impr: use tsrest for user endpoints (@fehmer) (#5815)

!nuf
This commit is contained in:
Christian Fehmer 2024-09-05 17:28:19 +02:00 committed by GitHub
parent 6a24dbb986
commit 259894ab9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
81 changed files with 5255 additions and 3290 deletions

View file

@ -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

View file

@ -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".',
],
});
});

View file

@ -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

View file

@ -323,7 +323,7 @@ function lbBests(
return result;
}
function pb(
export function pb(
wpm: number,
acc: number = 90,
timestamp: number = 1

View file

@ -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", () => {

View file

@ -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({

View file

@ -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"]
}

View file

@ -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);

View file

@ -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 = [
{

View file

@ -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",

View file

@ -35,7 +35,7 @@ features.openapi:
http:
delete: "#da3333"
post: "#004D94"
patch: "#e2b714"
patch: "#af8d0f"
get: "#009400"
sidebar:
backgroundColor: "#323437"

View file

@ -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) {

View file

@ -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);
}

View file

@ -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 {

View file

@ -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,
});

View file

@ -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"] &&

View file

@ -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;

View file

@ -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

View file

@ -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 ?? [];
}

View file

@ -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"
}
}
}
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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;

View file

@ -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);

View file

@ -52,7 +52,7 @@ export function recordClientVersion(): RequestHandler {
};
}
export function onlyAvailableOnDev(): RequestHandler {
export function onlyAvailableOnDev(): MonkeyTypes.RequestHandler {
return validate({
criteria: () => {
return isDevEnvironment();

View file

@ -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<

View file

@ -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 = {

View file

@ -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">>;

View file

@ -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;

View file

@ -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"]

View file

@ -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"]
}

View file

@ -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);
}

View file

@ -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",

View file

@ -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"),
};
}

View file

@ -1,5 +0,0 @@
import Users from "./users";
export default {
Users,
};

View file

@ -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`);
}
}

View file

@ -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,
};

View file

@ -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;
}

View file

@ -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;
};
}

View file

@ -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,
});
}

View file

@ -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 });

View file

@ -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,
});
}

View file

@ -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);
}
}
}

View file

@ -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

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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");

View file

@ -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;

View file

@ -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) => {

View file

@ -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;
}
});

View file

@ -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,

View file

@ -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,
},

View file

@ -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 {

View file

@ -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;
}

View file

@ -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
);
}
});
});

View file

@ -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
);
}

View file

@ -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);

View file

@ -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,

View file

@ -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 = {

View file

@ -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");

View file

@ -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[];
};
}

View file

@ -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);

View file

@ -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,

View 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"]
}

View 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
);
});
});
});

View file

@ -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": {
".": {

View file

@ -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,

View file

@ -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,
});

View file

@ -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>;

View file

@ -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".',
}),
}
);

View file

@ -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>;

View 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,
}
);

View file

@ -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);
});
}

View file

@ -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 ?? [];
}

View file

@ -6,7 +6,8 @@
"declaration": true,
"declarationMap": true,
"moduleResolution": "Node",
"module": "ES6"
"module": "ES6",
"target": "ES2015"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]

View file

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
coverage: {
include: ["**/*.ts"],
},
},
});

View file

@ -1,5 +0,0 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@monkeytype/eslint-config"],
};

View file

@ -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"
}
}
}

View file

@ -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)[] };

View file

@ -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"]
}

View file

@ -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