mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 07:16:17 +08:00
parent
6a24dbb986
commit
259894ab9f
|
@ -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
|
||||
|
|
|
@ -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".',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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".',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -323,7 +323,7 @@ function lbBests(
|
|||
return result;
|
||||
}
|
||||
|
||||
function pb(
|
||||
export function pb(
|
||||
wpm: number,
|
||||
acc: number = 90,
|
||||
timestamp: number = 1
|
||||
|
|
|
@ -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<SharedTypes.Config.Mode>
|
||||
>;
|
||||
|
||||
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", () => {
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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<SharedTypes.Config.Mode>;
|
||||
} as unknown as Result<Mode>;
|
||||
|
||||
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<SharedTypes.Config.Mode>;
|
||||
} as unknown as Result<Mode>;
|
||||
|
||||
const run = pb.checkAndUpdatePb(userPbs, undefined, result);
|
||||
|
||||
|
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -35,7 +35,7 @@ features.openapi:
|
|||
http:
|
||||
delete: "#da3333"
|
||||
post: "#004D94"
|
||||
patch: "#e2b714"
|
||||
patch: "#af8d0f"
|
||||
get: "#009400"
|
||||
sidebar:
|
||||
backgroundColor: "#323437"
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<void> {
|
||||
let verified = false;
|
||||
|
@ -56,8 +102,8 @@ async function verifyCaptcha(captcha: string): Promise<void> {
|
|||
}
|
||||
|
||||
export async function createNewUser(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, CreateUserRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, ForgotPasswordEmailRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, UpdateUserNameRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, undefined, CheckNamePathParameters>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, UpdateEmailRequestSchema>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, UpdatePasswordRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyTypes.DBUser> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetUserResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetDiscordOauthLinkResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, LinkDiscordRequest>
|
||||
): Promise<LinkDiscordResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, AddResultFilterPresetRequest>
|
||||
): Promise<AddResultFilterPresetResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<
|
||||
undefined,
|
||||
undefined,
|
||||
RemoveResultFilterPresetPathParams
|
||||
>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, AddTagRequest>
|
||||
): Promise<AddTagResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, undefined, TagIdPathParams>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, EditTagRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, undefined, TagIdPathParams>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetTagsResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, UpdateLeaderboardMemoryRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { mode, language, rank } = req.body;
|
||||
const mode2 = req.body.mode2 as Mode2<Mode>;
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetCustomThemesResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, AddCustomThemeRequest>
|
||||
): Promise<AddCustomThemeResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, DeleteCustomThemeRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, EditCustomThemeRequst>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<GetPersonalBestsQuery>
|
||||
): Promise<GetPersonalBestsResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetStatsResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetFavoriteQuotesResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, AddFavoriteQuoteRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, RemoveFavoriteQuoteRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<GetProfileQuery, undefined, GetProfilePathParams>
|
||||
): Promise<GetProfileResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, UpdateUserProfileRequest>
|
||||
): Promise<UpdateUserProfileResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetUserInboxResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, UpdateUserInboxRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, ReportUserRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<undefined, SetStreakHourOffsetRequest>
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<MonkeyResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<MonkeyResponse2> {
|
||||
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<AllTimeLbs> {
|
||||
|
@ -1010,18 +1066,18 @@ async function getAllTimeLbs(uid: string): Promise<AllTimeLbs> {
|
|||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetTestActivityResponse> {
|
||||
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<void> {
|
||||
|
@ -1102,23 +1161,26 @@ async function firebaseDeleteUserIgnoreError(uid: string): Promise<void> {
|
|||
}
|
||||
|
||||
export async function getCurrentTestActivity(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetCurrentTestActivityResponse> {
|
||||
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<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetStreakResponseSchema> {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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"] &&
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<User, "name" | "email" | "discordId">;
|
||||
// Export for use in tests
|
||||
|
|
|
@ -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<MonkeyTypes.DBUser["favoriteQuotes"]> {
|
||||
): Promise<NonNullable<MonkeyTypes.DBUser["favoriteQuotes"]>> {
|
||||
const user = await getPartialUser(uid, "get favorite quotes", [
|
||||
"favoriteQuotes",
|
||||
]);
|
||||
|
@ -896,7 +889,7 @@ export async function updateProfile(
|
|||
|
||||
export async function getInbox(
|
||||
uid: string
|
||||
): Promise<MonkeyTypes.DBUser["inbox"]> {
|
||||
): Promise<NonNullable<MonkeyTypes.DBUser["inbox"]>> {
|
||||
const user = await getPartialUser(uid, "get inbox", ["inbox"]);
|
||||
return user.inbox ?? [];
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<T extends AppRouter | AppRoute>(
|
||||
export function withApeRateLimiter2(
|
||||
defaultRateLimiter: RateLimitRequestHandler,
|
||||
apeRateLimiterOverride?: RateLimitRequestHandler
|
||||
): TsRestRequestHandler<T> {
|
||||
): MonkeyTypes.RequestHandler {
|
||||
return (req: TsRestRequestWithCtx, res: Response, next: NextFunction) => {
|
||||
if (req.ctx.decodedToken.type === "ApeKey") {
|
||||
const rateLimiter = apeRateLimiterOverride ?? apeRateLimiter;
|
||||
|
|
|
@ -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<T> = {
|
||||
criteria: (data: T) => boolean;
|
||||
|
@ -13,13 +14,13 @@ export type ValidationOptions<T> = {
|
|||
*/
|
||||
export function validate(
|
||||
options: ValidationOptions<Configuration>
|
||||
): 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);
|
||||
|
|
|
@ -52,7 +52,7 @@ export function recordClientVersion(): RequestHandler {
|
|||
};
|
||||
}
|
||||
|
||||
export function onlyAvailableOnDev(): RequestHandler {
|
||||
export function onlyAvailableOnDev(): MonkeyTypes.RequestHandler {
|
||||
return validate({
|
||||
criteria: () => {
|
||||
return isDevEnvironment();
|
||||
|
|
17
backend/src/types/types.d.ts
vendored
17
backend/src/types/types.d.ts
vendored
|
@ -30,8 +30,11 @@ declare namespace MonkeyTypes {
|
|||
raw: Readonly<TsRestRequest>;
|
||||
};
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
type RequestHandler = import("@ts-rest/core").TsRestRequestHandler<any>;
|
||||
|
||||
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<import("@monkeytype/shared-types").UserTag>;
|
||||
type DBUserTag = WithObjectId<
|
||||
import("@monkeytype/contracts/schemas/users").UserTag
|
||||
>;
|
||||
|
||||
type LbPersonalBests = {
|
||||
time: Record<
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { MonkeyMail } from "@monkeytype/shared-types";
|
||||
import { MonkeyMail } from "@monkeytype/contracts/schemas/users";
|
||||
import { v4 } from "uuid";
|
||||
|
||||
type MonkeyMailOptions = Partial<Omit<MonkeyMail, "id" | "read">>;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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<SharedTypes.Config>): Formatting {
|
||||
const target: SharedTypes.Config = { ...DefaultConfig, ...config };
|
||||
function getInstance(config?: Partial<Config>): Formatting {
|
||||
const target: Config = { ...DefaultConfig, ...config };
|
||||
return new Formatting(target);
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<AxiosResponse>;
|
||||
|
||||
type AxiosClientDataMethod = (
|
||||
endpoint: string,
|
||||
data: unknown,
|
||||
config: AxiosRequestConfig
|
||||
) => Promise<AxiosResponse>;
|
||||
|
||||
async function adaptRequestOptions<TQuery, TPayload>(
|
||||
options: Ape.RequestOptionsWithPayload<TQuery, TPayload>
|
||||
): Promise<AxiosRequestConfig> {
|
||||
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 <TQuery, TPayload, TData>(
|
||||
endpoint: string,
|
||||
options: Ape.RequestOptionsWithPayload<TQuery, TPayload> = {}
|
||||
): Ape.EndpointResponse<TData> {
|
||||
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"),
|
||||
};
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
import Users from "./users";
|
||||
|
||||
export default {
|
||||
Users,
|
||||
};
|
|
@ -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<Ape.Users.GetUser> {
|
||||
return await this.httpClient.get(BASE_PATH);
|
||||
}
|
||||
|
||||
async create(
|
||||
name: string,
|
||||
captcha: string,
|
||||
email?: string,
|
||||
uid?: string
|
||||
): Ape.EndpointResponse<null> {
|
||||
const payload = {
|
||||
email,
|
||||
name,
|
||||
uid,
|
||||
captcha,
|
||||
};
|
||||
|
||||
return await this.httpClient.post(`${BASE_PATH}/signup`, { payload });
|
||||
}
|
||||
|
||||
async getNameAvailability(name: string): Ape.EndpointResponse<null> {
|
||||
const encoded = encodeURIComponent(name);
|
||||
return await this.httpClient.get(`${BASE_PATH}/checkName/${encoded}`);
|
||||
}
|
||||
|
||||
async delete(): Ape.EndpointResponse<null> {
|
||||
return await this.httpClient.delete(BASE_PATH);
|
||||
}
|
||||
|
||||
async reset(): Ape.EndpointResponse<null> {
|
||||
return await this.httpClient.patch(`${BASE_PATH}/reset`);
|
||||
}
|
||||
|
||||
async optOutOfLeaderboards(): Ape.EndpointResponse<null> {
|
||||
return await this.httpClient.post(`${BASE_PATH}/optOutOfLeaderboards`);
|
||||
}
|
||||
|
||||
async updateName(name: string): Ape.EndpointResponse<null> {
|
||||
return await this.httpClient.patch(`${BASE_PATH}/name`, {
|
||||
payload: { name },
|
||||
});
|
||||
}
|
||||
|
||||
async updateLeaderboardMemory<M extends Mode>(
|
||||
mode: string,
|
||||
mode2: Mode2<M>,
|
||||
language: string,
|
||||
rank: number
|
||||
): Ape.EndpointResponse<null> {
|
||||
const payload = {
|
||||
mode,
|
||||
mode2,
|
||||
language,
|
||||
rank,
|
||||
};
|
||||
|
||||
return await this.httpClient.patch(`${BASE_PATH}/leaderboardMemory`, {
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
async updateEmail(
|
||||
newEmail: string,
|
||||
previousEmail: string
|
||||
): Ape.EndpointResponse<null> {
|
||||
const payload = {
|
||||
newEmail,
|
||||
previousEmail,
|
||||
};
|
||||
|
||||
return await this.httpClient.patch(`${BASE_PATH}/email`, { payload });
|
||||
}
|
||||
|
||||
async updatePassword(newPassword: string): Ape.EndpointResponse<null> {
|
||||
return await this.httpClient.patch(`${BASE_PATH}/password`, {
|
||||
payload: { newPassword },
|
||||
});
|
||||
}
|
||||
|
||||
async deletePersonalBests(): Ape.EndpointResponse<null> {
|
||||
return await this.httpClient.delete(`${BASE_PATH}/personalBests`);
|
||||
}
|
||||
|
||||
async addResultFilterPreset(
|
||||
filter: ResultFilters
|
||||
): Ape.EndpointResponse<string> {
|
||||
return await this.httpClient.post(`${BASE_PATH}/resultFilterPresets`, {
|
||||
payload: filter,
|
||||
});
|
||||
}
|
||||
|
||||
async removeResultFilterPreset(id: string): Ape.EndpointResponse<null> {
|
||||
const encoded = encodeURIComponent(id);
|
||||
return await this.httpClient.delete(
|
||||
`${BASE_PATH}/resultFilterPresets/${encoded}`
|
||||
);
|
||||
}
|
||||
|
||||
async createTag(tagName: string): Ape.EndpointResponse<UserTag> {
|
||||
return await this.httpClient.post(`${BASE_PATH}/tags`, {
|
||||
payload: { tagName },
|
||||
});
|
||||
}
|
||||
|
||||
async editTag(tagId: string, newName: string): Ape.EndpointResponse<null> {
|
||||
const payload = {
|
||||
tagId,
|
||||
newName,
|
||||
};
|
||||
|
||||
return await this.httpClient.patch(`${BASE_PATH}/tags`, { payload });
|
||||
}
|
||||
|
||||
async deleteTag(tagId: string): Ape.EndpointResponse<null> {
|
||||
const encoded = encodeURIComponent(tagId);
|
||||
return await this.httpClient.delete(`${BASE_PATH}/tags/${encoded}`);
|
||||
}
|
||||
|
||||
async deleteTagPersonalBest(tagId: string): Ape.EndpointResponse<null> {
|
||||
const encoded = encodeURIComponent(tagId);
|
||||
return await this.httpClient.delete(
|
||||
`${BASE_PATH}/tags/${encoded}/personalBest`
|
||||
);
|
||||
}
|
||||
|
||||
async getCustomThemes(): Ape.EndpointResponse<CustomTheme[]> {
|
||||
return await this.httpClient.get(`${BASE_PATH}/customThemes`);
|
||||
}
|
||||
|
||||
async editCustomTheme(
|
||||
themeId: string,
|
||||
newTheme: Partial<MonkeyTypes.CustomTheme>
|
||||
): Ape.EndpointResponse<null> {
|
||||
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<null> {
|
||||
const payload = {
|
||||
themeId: themeId,
|
||||
};
|
||||
return await this.httpClient.delete(`${BASE_PATH}/customThemes`, {
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
async addCustomTheme(
|
||||
newTheme: Partial<MonkeyTypes.CustomTheme>
|
||||
): Ape.EndpointResponse<CustomTheme> {
|
||||
const payload = { name: newTheme.name, colors: newTheme.colors };
|
||||
return await this.httpClient.post(`${BASE_PATH}/customThemes`, { payload });
|
||||
}
|
||||
|
||||
async getOauthLink(): Ape.EndpointResponse<Ape.Users.GetOauthLink> {
|
||||
return await this.httpClient.get(`${BASE_PATH}/discord/oauth`);
|
||||
}
|
||||
|
||||
async linkDiscord(
|
||||
tokenType: string,
|
||||
accessToken: string,
|
||||
state: string
|
||||
): Ape.EndpointResponse<Ape.Users.LinkDiscord> {
|
||||
return await this.httpClient.post(`${BASE_PATH}/discord/link`, {
|
||||
payload: { tokenType, accessToken, state },
|
||||
});
|
||||
}
|
||||
|
||||
async unlinkDiscord(): Ape.EndpointResponse<null> {
|
||||
return await this.httpClient.post(`${BASE_PATH}/discord/unlink`);
|
||||
}
|
||||
|
||||
async addQuoteToFavorites(
|
||||
language: string,
|
||||
quoteId: string
|
||||
): Ape.EndpointResponse<null> {
|
||||
const payload = { language, quoteId };
|
||||
return await this.httpClient.post(`${BASE_PATH}/favoriteQuotes`, {
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
async removeQuoteFromFavorites(
|
||||
language: string,
|
||||
quoteId: string
|
||||
): Ape.EndpointResponse<null> {
|
||||
const payload = { language, quoteId };
|
||||
return await this.httpClient.delete(`${BASE_PATH}/favoriteQuotes`, {
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
async getProfileByUid(uid: string): Ape.EndpointResponse<UserProfile> {
|
||||
const encoded = encodeURIComponent(uid);
|
||||
return await this.httpClient.get(`${BASE_PATH}/${encoded}/profile?isUid`);
|
||||
}
|
||||
|
||||
async getProfileByName(name: string): Ape.EndpointResponse<UserProfile> {
|
||||
const encoded = encodeURIComponent(name);
|
||||
return await this.httpClient.get(`${BASE_PATH}/${encoded}/profile`);
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
profileUpdates: Partial<UserProfileDetails>,
|
||||
selectedBadgeId?: number
|
||||
): Ape.EndpointResponse<UserProfileDetails> {
|
||||
return await this.httpClient.patch(`${BASE_PATH}/profile`, {
|
||||
payload: {
|
||||
...profileUpdates,
|
||||
selectedBadgeId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getInbox(): Ape.EndpointResponse<Ape.Users.GetInbox> {
|
||||
return await this.httpClient.get(`${BASE_PATH}/inbox`);
|
||||
}
|
||||
|
||||
async updateInbox(options: {
|
||||
mailIdsToDelete?: string[];
|
||||
mailIdsToMarkRead?: string[];
|
||||
}): Ape.EndpointResponse<null> {
|
||||
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<null> {
|
||||
const payload = {
|
||||
uid,
|
||||
reason,
|
||||
comment,
|
||||
captcha,
|
||||
};
|
||||
|
||||
return await this.httpClient.post(`${BASE_PATH}/report`, { payload });
|
||||
}
|
||||
|
||||
async verificationEmail(): Ape.EndpointResponse<null> {
|
||||
return await this.httpClient.get(`${BASE_PATH}/verificationEmail`);
|
||||
}
|
||||
|
||||
async forgotPasswordEmail(email: string): Ape.EndpointResponse<null> {
|
||||
return await this.httpClient.post(`${BASE_PATH}/forgotPasswordEmail`, {
|
||||
payload: { email },
|
||||
});
|
||||
}
|
||||
|
||||
async setStreakHourOffset(hourOffset: number): Ape.EndpointResponse<null> {
|
||||
return await this.httpClient.post(`${BASE_PATH}/setStreakHourOffset`, {
|
||||
payload: { hourOffset },
|
||||
});
|
||||
}
|
||||
|
||||
async revokeAllTokens(): Ape.EndpointResponse<null> {
|
||||
return await this.httpClient.post(`${BASE_PATH}/revokeAllTokens`);
|
||||
}
|
||||
|
||||
async getTestActivity(): Ape.EndpointResponse<CountByYearAndDay> {
|
||||
return await this.httpClient.get(`${BASE_PATH}/testActivity`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
40
frontend/src/ts/ape/types/ape.d.ts
vendored
40
frontend/src/ts/ape/types/ape.d.ts
vendored
|
@ -1,40 +0,0 @@
|
|||
declare namespace Ape {
|
||||
type RequestOptions<TQuery> = {
|
||||
headers?: Record<string, string>;
|
||||
searchQuery?: Record<string, TQuery>;
|
||||
};
|
||||
|
||||
type HttpClientMethod = <TQuery, TData>(
|
||||
endpoint: string,
|
||||
options?: Ape.RequestOptions<TQuery>
|
||||
) => Ape.EndpointResponse<TData>;
|
||||
|
||||
type RequestOptionsWithPayload<TQuery, TPayload> = {
|
||||
headers?: Record<string, string>;
|
||||
searchQuery?: Record<string, TQuery>;
|
||||
payload?: TPayload;
|
||||
};
|
||||
|
||||
type HttpClientMethodWithPayload = <TQuery, TPayload, TData>(
|
||||
endpoint: string,
|
||||
options?: Ape.RequestOptionsWithPayload<TQuery, TPayload>
|
||||
) => Ape.EndpointResponse<TData>;
|
||||
|
||||
type HttpClientResponse<TData> = {
|
||||
status: number;
|
||||
message: string;
|
||||
data: TData | null;
|
||||
};
|
||||
|
||||
type EndpointResponse<TData> = Promise<HttpClientResponse<TData>>;
|
||||
|
||||
type HttpClient = {
|
||||
get: HttpClientMethod;
|
||||
post: HttpClientMethodWithPayload;
|
||||
put: HttpClientMethodWithPayload;
|
||||
patch: HttpClientMethodWithPayload;
|
||||
delete: HttpClientMethodWithPayload;
|
||||
};
|
||||
|
||||
type HttpMethodTypes = keyof HttpClient;
|
||||
}
|
18
frontend/src/ts/ape/types/users.d.ts
vendored
18
frontend/src/ts/ape/types/users.d.ts
vendored
|
@ -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;
|
||||
};
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
type ShouldRetryCallback<ResponseDataType> = (
|
||||
statusCode: number,
|
||||
response?: Ape.HttpClientResponse<ResponseDataType>
|
||||
) => boolean;
|
||||
|
||||
type RetryOptions<ResponseDataType = unknown> = {
|
||||
shouldRetry?: ShouldRetryCallback<ResponseDataType>;
|
||||
retryAttempts?: number;
|
||||
retryDelayMs?: number;
|
||||
};
|
||||
|
||||
const wait = async (delay: number): Promise<number> =>
|
||||
new Promise((resolve) => window.setTimeout(resolve, delay));
|
||||
|
||||
const DEFAULT_RETRY_OPTIONS: Required<RetryOptions> = {
|
||||
shouldRetry: (statusCode: number): boolean =>
|
||||
statusCode >= 500 && statusCode !== 503,
|
||||
retryAttempts: 3,
|
||||
retryDelayMs: 3000,
|
||||
};
|
||||
|
||||
export async function withRetry<ResponseDataType>(
|
||||
fn: () => Ape.EndpointResponse<ResponseDataType>,
|
||||
opts?: RetryOptions<ResponseDataType>
|
||||
): Ape.EndpointResponse<ResponseDataType> {
|
||||
const retry = async (
|
||||
previousData: Ape.HttpClientResponse<ResponseDataType>,
|
||||
completeOpts: Required<RetryOptions<ResponseDataType>>
|
||||
): Promise<Ape.HttpClientResponse<ResponseDataType>> => {
|
||||
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,
|
||||
});
|
||||
}
|
|
@ -66,7 +66,7 @@ async function sendVerificationEmail(): Promise<void> {
|
|||
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<void> {
|
|||
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 });
|
||||
|
|
|
@ -53,7 +53,7 @@ async function lookupProfile(): Promise<void> {
|
|||
|
||||
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<void> {
|
|||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<boolean> {
|
|||
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<M extends Mode>(
|
|||
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
|
||||
|
|
|
@ -183,15 +183,20 @@ function addFilterPresetToSnapshot(filter: ResultFilters): void {
|
|||
export async function createFilterPreset(name: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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<void> {
|
|||
} else if (inboxResponse.status !== 200) {
|
||||
$("#alertsPopup .accountAlerts .list").html(`
|
||||
<div class="nothing">
|
||||
Error getting inbox: ${inboxResponse.message} Please try again later
|
||||
Error getting inbox: ${inboxResponse.body.message} Please try again later
|
||||
</div>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
const inboxData = inboxResponse.data as {
|
||||
inbox: MonkeyTypes.MonkeyMail[];
|
||||
maxMail: number;
|
||||
};
|
||||
const inboxData = inboxResponse.body.data;
|
||||
|
||||
accountAlerts = inboxData.inbox;
|
||||
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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<void> {
|
|||
// 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<void> {
|
|||
|
||||
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<void> {
|
|||
}
|
||||
|
||||
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;
|
||||
|
|
|
@ -86,15 +86,16 @@ async function apply(): Promise<void> {
|
|||
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<void> {
|
|||
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> {
|
|||
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> {
|
|||
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> {
|
|||
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) => {
|
||||
|
|
|
@ -77,9 +77,9 @@ async function apply(): Promise<void> {
|
|||
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;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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<Mode> | null,
|
||||
CustomTextData | null,
|
||||
MonkeyTypes.CustomTextData | null,
|
||||
boolean | null,
|
||||
boolean | null,
|
||||
string | null,
|
||||
|
|
|
@ -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<ExecReturn> => {
|
||||
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<ExecReturn> => {
|
||||
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<ExecReturn> => {
|
||||
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,
|
||||
},
|
||||
|
|
|
@ -82,11 +82,13 @@ async function apply(): Promise<void> {
|
|||
|
||||
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 {
|
||||
|
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<void> {
|
|||
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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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");
|
||||
|
|
21
frontend/src/ts/types/types.d.ts
vendored
21
frontend/src/ts/types/types.d.ts
vendored
|
@ -209,13 +209,13 @@ declare namespace MonkeyTypes {
|
|||
|
||||
type QuoteRatings = Record<string, Record<number, number>>;
|
||||
|
||||
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<import("@monkeytype/shared-types").Badge>;
|
||||
item: import("@monkeytype/contracts/schemas/users").Badge;
|
||||
} & Reward<import("@monkeytype/contracts/schemas/users").Badge>;
|
||||
|
||||
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[];
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<void> {
|
||||
if (!hashOverride) return;
|
||||
|
@ -25,25 +24,27 @@ export async function linkDiscord(hashOverride: string): Promise<void> {
|
|||
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<Mode> | null,
|
||||
CustomTextData | null,
|
||||
MonkeyTypes.CustomTextData | null,
|
||||
boolean | null,
|
||||
boolean | null,
|
||||
string | null,
|
||||
|
|
12
packages/contracts/__test__/tsconfig.json
Normal file
12
packages/contracts/__test__/tsconfig.json
Normal file
|
@ -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"]
|
||||
}
|
42
packages/contracts/__test__/validation/validation.spec.ts
Normal file
42
packages/contracts/__test__/validation/validation.spec.ts
Normal file
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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": {
|
||||
".": {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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<typeof MonkeyResponseSchema>;
|
||||
|
||||
export const MonkeyValidationErrorSchema = MonkeyResponseSchema.extend({
|
||||
validationErrors: z.array(z.string()).nonempty(),
|
||||
validationErrors: z.array(z.string()),
|
||||
});
|
||||
export type MonkeyValidationError = z.infer<typeof MonkeyValidationErrorSchema>;
|
||||
|
||||
|
|
|
@ -25,8 +25,14 @@ export type PersonalBest = z.infer<typeof PersonalBestSchema>;
|
|||
|
||||
//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".',
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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<typeof ResultFiltersSchema>;
|
||||
|
||||
export const StreakHourOffsetSchema = z.number().int().min(-11).max(12);
|
||||
export type StreakHourOffset = z.infer<typeof StreakHourOffsetSchema>;
|
||||
|
||||
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<typeof UserStreakSchema>;
|
||||
|
||||
export const UserTagSchema = z
|
||||
.object({
|
||||
_id: IdSchema,
|
||||
name: z.string(),
|
||||
personalBests: PersonalBestsSchema,
|
||||
})
|
||||
.strict();
|
||||
export type UserTag = z.infer<typeof UserTagSchema>;
|
||||
|
||||
function profileDetailsBase(
|
||||
schema: ZodString
|
||||
): ZodEffects<ZodOptional<ZodEffects<ZodString>>> {
|
||||
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<typeof UserProfileDetailsSchema>;
|
||||
|
||||
export const CustomThemeNameSchema = z
|
||||
.string()
|
||||
.regex(/^[0-9a-zA-Z_-]+$/)
|
||||
.max(16);
|
||||
export type CustomThemeName = z.infer<typeof CustomThemeNameSchema>;
|
||||
|
||||
export const CustomThemeSchema = z
|
||||
.object({
|
||||
_id: IdSchema,
|
||||
name: CustomThemeNameSchema,
|
||||
colors: CustomThemeColorsSchema,
|
||||
})
|
||||
.strict();
|
||||
export type CustomTheme = z.infer<typeof CustomThemeSchema>;
|
||||
|
||||
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<typeof PremiumInfoSchema>;
|
||||
|
||||
export const UserQuoteRatingsSchema = z.record(
|
||||
LanguageSchema,
|
||||
z.record(
|
||||
StringNumberSchema.describe("quoteId as string"),
|
||||
z.number().nonnegative()
|
||||
)
|
||||
);
|
||||
export type UserQuoteRatings = z.infer<typeof UserQuoteRatingsSchema>;
|
||||
|
||||
export const UserLbMemorySchema = z.record(
|
||||
ModeSchema,
|
||||
z.record(
|
||||
Mode2Schema,
|
||||
z.record(LanguageSchema, z.number().int().nonnegative())
|
||||
)
|
||||
);
|
||||
export type UserLbMemory = z.infer<typeof UserLbMemorySchema>;
|
||||
|
||||
export const RankAndCountSchema = z.object({
|
||||
rank: z.number().int().nonnegative().optional(),
|
||||
count: z.number().int().nonnegative(),
|
||||
});
|
||||
export type RankAndCount = z.infer<typeof RankAndCountSchema>;
|
||||
|
||||
export const AllTimeLbsSchema = z.object({
|
||||
time: z.record(
|
||||
Mode2Schema,
|
||||
z.record(LanguageSchema, RankAndCountSchema.optional())
|
||||
),
|
||||
});
|
||||
export type AllTimeLbs = z.infer<typeof AllTimeLbsSchema>;
|
||||
|
||||
export const BadgeSchema = z
|
||||
.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
selected: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
export type Badge = z.infer<typeof BadgeSchema>;
|
||||
|
||||
export const UserInventorySchema = z
|
||||
.object({
|
||||
badges: z.array(BadgeSchema),
|
||||
})
|
||||
.strict();
|
||||
export type UserInventory = z.infer<typeof UserInventorySchema>;
|
||||
|
||||
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<typeof QuoteModSchema>;
|
||||
|
||||
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<typeof TestActivitySchema>;
|
||||
|
||||
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<typeof CountByYearAndDaySchema>;
|
||||
|
||||
//Record<language, array with quoteIds as string
|
||||
export const FavoriteQuotesSchema = z.record(
|
||||
LanguageSchema,
|
||||
z.array(StringNumberSchema)
|
||||
);
|
||||
export type FavoriteQuotes = z.infer<typeof FavoriteQuotesSchema>;
|
||||
|
||||
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<typeof UserSchema>;
|
||||
|
||||
export type ResultFiltersGroup = keyof ResultFilters;
|
||||
|
||||
export type ResultFiltersGroupItem<T extends ResultFiltersGroup> =
|
||||
keyof ResultFilters[T];
|
||||
|
||||
export const TagNameSchema = z
|
||||
.string()
|
||||
.regex(/^[0-9a-zA-Z_.-]+$/)
|
||||
.max(16);
|
||||
export type TagName = z.infer<typeof TagNameSchema>;
|
||||
|
||||
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<typeof TypingStatsSchema>;
|
||||
|
||||
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<typeof UserProfileSchema>;
|
||||
|
||||
export const RewardTypeSchema = z.enum(["xp", "badge"]);
|
||||
export type RewardType = z.infer<typeof RewardTypeSchema>;
|
||||
|
||||
export const XpRewardSchema = z.object({
|
||||
type: z.literal(RewardTypeSchema.enum.xp),
|
||||
item: z.number().int(),
|
||||
});
|
||||
export type XpReward = z.infer<typeof XpRewardSchema>;
|
||||
|
||||
export const BadgeRewardSchema = z.object({
|
||||
type: z.literal(RewardTypeSchema.enum.badge),
|
||||
item: BadgeSchema,
|
||||
});
|
||||
export type BadgeReward = z.infer<typeof BadgeRewardSchema>;
|
||||
|
||||
export const AllRewardsSchema = XpRewardSchema.or(BadgeRewardSchema);
|
||||
export type AllRewards = z.infer<typeof AllRewardsSchema>;
|
||||
|
||||
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<typeof MonkeyMailSchema>;
|
||||
|
||||
export const ReportUserReasonSchema = z.enum([
|
||||
"Inappropriate name",
|
||||
"Inappropriate bio",
|
||||
"Inappropriate social links",
|
||||
"Suspected cheating",
|
||||
]);
|
||||
export type ReportUserReason = z.infer<typeof ReportUserReasonSchema>;
|
||||
|
|
807
packages/contracts/src/users.ts
Normal file
807
packages/contracts/src/users.ts
Normal file
|
@ -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<typeof GetUserResponseSchema>;
|
||||
|
||||
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<typeof CreateUserRequestSchema>;
|
||||
|
||||
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<typeof UpdateUserNameRequestSchema>;
|
||||
|
||||
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<typeof UpdateEmailRequestSchema>;
|
||||
|
||||
export const UpdatePasswordRequestSchema = z.object({
|
||||
newPassword: z.string().min(6),
|
||||
});
|
||||
export type UpdatePasswordRequest = z.infer<typeof UpdatePasswordRequestSchema>;
|
||||
|
||||
export const GetPersonalBestsQuerySchema = z.object({
|
||||
mode: ModeSchema,
|
||||
mode2: Mode2Schema,
|
||||
});
|
||||
export type GetPersonalBestsQuery = z.infer<typeof GetPersonalBestsQuerySchema>;
|
||||
|
||||
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<typeof GetTagsResponseSchema>;
|
||||
|
||||
export const AddTagRequestSchema = z.object({
|
||||
tagName: TagNameSchema,
|
||||
});
|
||||
export type AddTagRequest = z.infer<typeof AddTagRequestSchema>;
|
||||
|
||||
export const AddTagResponseSchema = responseWithData(UserTagSchema);
|
||||
export type AddTagResponse = z.infer<typeof AddTagResponseSchema>;
|
||||
|
||||
export const EditTagRequestSchema = z.object({
|
||||
tagId: IdSchema,
|
||||
newName: TagNameSchema,
|
||||
});
|
||||
export type EditTagRequest = z.infer<typeof EditTagRequestSchema>;
|
||||
|
||||
export const TagIdPathParamsSchema = z.object({
|
||||
tagId: IdSchema,
|
||||
});
|
||||
export type TagIdPathParams = z.infer<typeof TagIdPathParamsSchema>;
|
||||
|
||||
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<typeof AddCustomThemeRequestSchema>;
|
||||
|
||||
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<typeof EditCustomThemeRequstSchema>;
|
||||
|
||||
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<typeof LinkDiscordRequestSchema>;
|
||||
|
||||
export const LinkDiscordResponseSchema = responseWithData(
|
||||
UserSchema.pick({ discordId: true, discordAvatar: true })
|
||||
);
|
||||
export type LinkDiscordResponse = z.infer<typeof LinkDiscordResponseSchema>;
|
||||
|
||||
export const GetStatsResponseSchema = responseWithData(
|
||||
UserSchema.pick({
|
||||
completedTests: true,
|
||||
startedTests: true,
|
||||
timeTyping: true,
|
||||
})
|
||||
);
|
||||
export type GetStatsResponse = z.infer<typeof GetStatsResponseSchema>;
|
||||
|
||||
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<typeof GetProfilePathParamsSchema>;
|
||||
|
||||
//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<typeof GetProfileQuerySchema>;
|
||||
|
||||
export const GetProfileResponseSchema = responseWithData(UserProfileSchema);
|
||||
export type GetProfileResponse = z.infer<typeof GetProfileResponseSchema>;
|
||||
|
||||
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<typeof GetUserInboxResponseSchema>;
|
||||
|
||||
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<typeof ReportUserRequestSchema>;
|
||||
|
||||
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<typeof GetStreakResponseSchema>;
|
||||
|
||||
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,
|
||||
}
|
||||
);
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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<ZodString> {
|
||||
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 ?? [];
|
||||
}
|
|
@ -6,7 +6,8 @@
|
|||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"moduleResolution": "Node",
|
||||
"module": "ES6"
|
||||
"module": "ES6",
|
||||
"target": "ES2015"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
|
11
packages/contracts/vitest.config.js
Normal file
11
packages/contracts/vitest.config.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
coverage: {
|
||||
include: ["**/*.ts"],
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,5 +0,0 @@
|
|||
/** @type {import("eslint").Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["@monkeytype/eslint-config"],
|
||||
};
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Language, Record<QuoteIdString, Rating>>
|
||||
export type UserQuoteRatings = Record<string, Record<string, number>>;
|
||||
|
||||
export type UserLbMemory = Record<
|
||||
string,
|
||||
Record<string, Record<string, number>>
|
||||
>;
|
||||
|
||||
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<string, string[]>;
|
||||
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<T> = {
|
||||
type: string;
|
||||
item: T;
|
||||
};
|
||||
|
||||
export type XpReward = {
|
||||
type: "xp";
|
||||
item: number;
|
||||
} & Reward<number>;
|
||||
|
||||
export type BadgeReward = {
|
||||
type: "badge";
|
||||
item: Badge;
|
||||
} & Reward<Badge>;
|
||||
|
||||
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<Record<`${number}`, PersonalBest[]>, "15" | "30" | "60" | "120">;
|
||||
words: Pick<
|
||||
Record<`${number}`, PersonalBest[]>,
|
||||
"10" | "25" | "50" | "100"
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
export type AllTimeLbs = {
|
||||
time: Record<string, Record<string, RankAndCount | undefined>>;
|
||||
};
|
||||
|
||||
export type RankAndCount = {
|
||||
rank?: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type TestActivity = {
|
||||
testsByDays: (number | null)[];
|
||||
lastDay: number;
|
||||
};
|
||||
|
||||
export type CountByYearAndDay = { [key: string]: (number | 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"]
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue