mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 07:16:17 +08:00
parent
d5b243cf57
commit
c6e8f413fc
37
backend/__tests__/__testData__/auth.ts
Normal file
37
backend/__tests__/__testData__/auth.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { Configuration } from "@monkeytype/shared-types";
|
||||
import { randomBytes } from "crypto";
|
||||
import { hash } from "bcrypt";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { base64UrlEncode } from "../../src/utils/misc";
|
||||
import * as ApeKeyDal from "../../src/dal/ape-keys";
|
||||
|
||||
export async function mockAuthenticateWithApeKey(
|
||||
uid: string,
|
||||
config: Configuration
|
||||
): Promise<string> {
|
||||
if (!config.apeKeys.acceptKeys)
|
||||
throw Error("config.apeKeys.acceptedKeys needs to be set to true");
|
||||
const { apeKeyBytes, apeKeySaltRounds } = config.apeKeys;
|
||||
|
||||
const apiKey = randomBytes(apeKeyBytes).toString("base64url");
|
||||
const saltyHash = await hash(apiKey, apeKeySaltRounds);
|
||||
|
||||
const apeKey: MonkeyTypes.ApeKeyDB = {
|
||||
_id: new ObjectId(),
|
||||
name: "bob",
|
||||
enabled: true,
|
||||
uid,
|
||||
hash: saltyHash,
|
||||
createdOn: Date.now(),
|
||||
modifiedOn: Date.now(),
|
||||
lastUsedOn: -1,
|
||||
useCount: 0,
|
||||
};
|
||||
|
||||
const apeKeyId = new ObjectId().toHexString();
|
||||
|
||||
vi.spyOn(ApeKeyDal, "getApeKey").mockResolvedValue(apeKey);
|
||||
vi.spyOn(ApeKeyDal, "updateLastUsedOn").mockResolvedValue();
|
||||
|
||||
return base64UrlEncode(`${apeKeyId}.${apiKey}`);
|
||||
}
|
File diff suppressed because it is too large
Load diff
|
@ -89,9 +89,9 @@ describe("PublicController", () => {
|
|||
expect(body).toEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: [
|
||||
'"language" Invalid',
|
||||
'"language" Can only contain letters [a-zA-Z0-9_+]',
|
||||
`"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`,
|
||||
'"mode2" Needs to be either a number, "zen" or "custom."',
|
||||
'"mode2" Needs to be a number or a number represented as a string e.g. "10".',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,6 +4,8 @@ import * as UserDal from "../../src/dal/user";
|
|||
import * as LeaderboardsDal from "../../src/dal/leaderboards";
|
||||
import * as PublicDal from "../../src/dal/public";
|
||||
import * as Configuration from "../../src/init/configuration";
|
||||
import type { DBLeaderboardEntry } from "../../src/dal/leaderboards";
|
||||
import type { PersonalBest } from "@monkeytype/contracts/schemas/shared";
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
|
||||
import * as DB from "../../src/init/db";
|
||||
|
@ -29,7 +31,9 @@ describe("LeaderboardsDal", () => {
|
|||
|
||||
//THEN
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveProperty("uid", applicableUser.uid);
|
||||
expect(
|
||||
(result as LeaderboardsDal.DBLeaderboardEntry[])[0]
|
||||
).toHaveProperty("uid", applicableUser.uid);
|
||||
});
|
||||
|
||||
it("should create leaderboard time english 15", async () => {
|
||||
|
@ -46,7 +50,7 @@ describe("LeaderboardsDal", () => {
|
|||
"15",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = result.map((it) => _.omit(it, ["_id"]));
|
||||
|
@ -72,7 +76,7 @@ describe("LeaderboardsDal", () => {
|
|||
"60",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as LeaderboardsDal.DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = result.map((it) => _.omit(it, ["_id"]));
|
||||
|
@ -98,7 +102,7 @@ describe("LeaderboardsDal", () => {
|
|||
"60",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
expect(lb[0]).not.toHaveProperty("discordId");
|
||||
|
@ -121,7 +125,7 @@ describe("LeaderboardsDal", () => {
|
|||
"15",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
expect(lb[0]).not.toHaveProperty("consistency");
|
||||
|
@ -183,7 +187,7 @@ describe("LeaderboardsDal", () => {
|
|||
"15",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = result.map((it) => _.omit(it, ["_id"]));
|
||||
|
@ -219,7 +223,7 @@ describe("LeaderboardsDal", () => {
|
|||
"15",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
const lb = result.map((it) => _.omit(it, ["_id"]));
|
||||
|
@ -251,7 +255,7 @@ describe("LeaderboardsDal", () => {
|
|||
"15",
|
||||
"english",
|
||||
0
|
||||
)) as SharedTypes.LeaderboardEntry[];
|
||||
)) as DBLeaderboardEntry[];
|
||||
|
||||
//THEN
|
||||
expect(result[0]?.isPremium).toBeUndefined();
|
||||
|
@ -263,8 +267,10 @@ function expectedLbEntry(
|
|||
time: string,
|
||||
{ rank, user, badgeId, isPremium }: ExpectedLbEntry
|
||||
) {
|
||||
const lbBest: SharedTypes.PersonalBest =
|
||||
user.lbPersonalBests?.time[time].english;
|
||||
// @ts-expect-error
|
||||
const lbBest: PersonalBest =
|
||||
// @ts-expect-error
|
||||
user.lbPersonalBests?.time[Number.parseInt(time)].english;
|
||||
|
||||
return {
|
||||
rank,
|
||||
|
@ -308,10 +314,10 @@ async function createUser(
|
|||
}
|
||||
|
||||
function lbBests(
|
||||
pb15?: SharedTypes.PersonalBest,
|
||||
pb60?: SharedTypes.PersonalBest
|
||||
pb15?: PersonalBest,
|
||||
pb60?: PersonalBest
|
||||
): MonkeyTypes.LbPersonalBests {
|
||||
const result = { time: {} };
|
||||
const result: MonkeyTypes.LbPersonalBests = { time: {} };
|
||||
if (pb15) result.time["15"] = { english: pb15 };
|
||||
if (pb60) result.time["60"] = { english: pb60 };
|
||||
return result;
|
||||
|
@ -321,7 +327,7 @@ function pb(
|
|||
wpm: number,
|
||||
acc: number = 90,
|
||||
timestamp: number = 1
|
||||
): SharedTypes.PersonalBest {
|
||||
): PersonalBest {
|
||||
return {
|
||||
acc,
|
||||
consistency: 100,
|
||||
|
@ -335,7 +341,7 @@ function pb(
|
|||
};
|
||||
}
|
||||
|
||||
function premium(expirationDeltaSeconds) {
|
||||
function premium(expirationDeltaSeconds: number) {
|
||||
return {
|
||||
premium: {
|
||||
startTimestamp: 0,
|
||||
|
|
|
@ -65,7 +65,7 @@
|
|||
"@monkeytype/eslint-config": "workspace:*",
|
||||
"@monkeytype/shared-types": "workspace:*",
|
||||
"@monkeytype/typescript-config": "workspace:*",
|
||||
"@redocly/cli": "1.18.1",
|
||||
"@redocly/cli": "1.19.0",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/cors": "2.8.12",
|
||||
"@types/cron": "1.7.3",
|
||||
|
|
|
@ -71,6 +71,11 @@ export function getOpenApi(): OpenAPIObject {
|
|||
description: "Public endpoints such as typing stats.",
|
||||
"x-displayName": "public",
|
||||
},
|
||||
{
|
||||
name: "leaderboards",
|
||||
description: "All-time and daily leaderboards of the fastest typers.",
|
||||
"x-displayName": "Leaderboards",
|
||||
},
|
||||
{
|
||||
name: "psas",
|
||||
description: "Public service announcements.",
|
||||
|
|
|
@ -4,85 +4,81 @@ import {
|
|||
MILLISECONDS_IN_DAY,
|
||||
getCurrentWeekTimestamp,
|
||||
} from "../../utils/misc";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import { MonkeyResponse2 } from "../../utils/monkey-response";
|
||||
import * as LeaderboardsDAL from "../../dal/leaderboards";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import * as DailyLeaderboards from "../../utils/daily-leaderboards";
|
||||
import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard";
|
||||
import {
|
||||
GetDailyLeaderboardQuery,
|
||||
GetDailyLeaderboardRankQuery,
|
||||
GetLeaderboardDailyRankResponse,
|
||||
GetLeaderboardQuery,
|
||||
GetLeaderboardRankResponse,
|
||||
GetLeaderboardResponse as GetLeaderboardResponse,
|
||||
GetWeeklyXpLeaderboardQuery,
|
||||
GetWeeklyXpLeaderboardRankResponse,
|
||||
GetWeeklyXpLeaderboardResponse,
|
||||
LanguageAndModeQuery,
|
||||
} from "@monkeytype/contracts/leaderboards";
|
||||
import { Configuration } from "@monkeytype/shared-types";
|
||||
|
||||
export async function getLeaderboard(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
const { language, mode, mode2, skip, limit = 50 } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const queryLimit = Math.min(parseInt(limit as string, 10), 50);
|
||||
req: MonkeyTypes.Request2<GetLeaderboardQuery>
|
||||
): Promise<GetLeaderboardResponse> {
|
||||
const { language, mode, mode2, skip = 0, limit = 50 } = req.query;
|
||||
|
||||
const leaderboard = await LeaderboardsDAL.get(
|
||||
mode as string,
|
||||
mode2 as string,
|
||||
language as string,
|
||||
parseInt(skip as string, 10),
|
||||
queryLimit
|
||||
mode,
|
||||
mode2,
|
||||
language,
|
||||
skip,
|
||||
limit
|
||||
);
|
||||
|
||||
if (leaderboard === false) {
|
||||
return new MonkeyResponse(
|
||||
"Leaderboard is currently updating. Please try again in a few seconds.",
|
||||
null,
|
||||
503
|
||||
throw new MonkeyError(
|
||||
503,
|
||||
"Leaderboard is currently updating. Please try again in a few seconds."
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedLeaderboard = _.map(leaderboard, (entry) => {
|
||||
return uid && entry.uid === uid
|
||||
? entry
|
||||
: _.omit(entry, ["_id", "difficulty", "language"]);
|
||||
});
|
||||
const normalizedLeaderboard = leaderboard.map((it) => _.omit(it, ["_id"]));
|
||||
|
||||
return new MonkeyResponse("Leaderboard retrieved", normalizedLeaderboard);
|
||||
return new MonkeyResponse2("Leaderboard retrieved", normalizedLeaderboard);
|
||||
}
|
||||
|
||||
export async function getRankFromLeaderboard(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<LanguageAndModeQuery>
|
||||
): Promise<GetLeaderboardRankResponse> {
|
||||
const { language, mode, mode2 } = req.query;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const data = await LeaderboardsDAL.getRank(
|
||||
mode as string,
|
||||
mode2 as string,
|
||||
language as string,
|
||||
uid
|
||||
);
|
||||
const data = await LeaderboardsDAL.getRank(mode, mode2, language, uid);
|
||||
if (data === false) {
|
||||
return new MonkeyResponse(
|
||||
"Leaderboard is currently updating. Please try again in a few seconds.",
|
||||
null,
|
||||
503
|
||||
throw new MonkeyError(
|
||||
503,
|
||||
"Leaderboard is currently updating. Please try again in a few seconds."
|
||||
);
|
||||
}
|
||||
|
||||
return new MonkeyResponse("Rank retrieved", data);
|
||||
return new MonkeyResponse2("Rank retrieved", data);
|
||||
}
|
||||
|
||||
function getDailyLeaderboardWithError(
|
||||
req: MonkeyTypes.Request
|
||||
{ language, mode, mode2, daysBefore }: GetDailyLeaderboardRankQuery,
|
||||
config: Configuration["dailyLeaderboards"]
|
||||
): DailyLeaderboards.DailyLeaderboard {
|
||||
const { language, mode, mode2, daysBefore } = req.query;
|
||||
|
||||
const normalizedDayBefore = parseInt(daysBefore as string, 10);
|
||||
const currentDayTimestamp = getCurrentDayTimestamp();
|
||||
const dayBeforeTimestamp =
|
||||
currentDayTimestamp - normalizedDayBefore * MILLISECONDS_IN_DAY;
|
||||
|
||||
const customTimestamp = _.isNil(daysBefore) ? -1 : dayBeforeTimestamp;
|
||||
const customTimestamp =
|
||||
daysBefore === undefined
|
||||
? -1
|
||||
: getCurrentDayTimestamp() - daysBefore * MILLISECONDS_IN_DAY;
|
||||
|
||||
const dailyLeaderboard = DailyLeaderboards.getDailyLeaderboard(
|
||||
language as string,
|
||||
mode as string,
|
||||
mode2 as string,
|
||||
req.ctx.configuration.dailyLeaderboards,
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
config,
|
||||
customTimestamp
|
||||
);
|
||||
if (!dailyLeaderboard) {
|
||||
|
@ -93,14 +89,17 @@ function getDailyLeaderboardWithError(
|
|||
}
|
||||
|
||||
export async function getDailyLeaderboard(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<GetDailyLeaderboardQuery>
|
||||
): Promise<GetLeaderboardResponse> {
|
||||
const { skip = 0, limit = 50 } = req.query;
|
||||
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(req);
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(
|
||||
req.query,
|
||||
req.ctx.configuration.dailyLeaderboards
|
||||
);
|
||||
|
||||
const minRank = parseInt(skip as string, 10);
|
||||
const maxRank = minRank + parseInt(limit as string, 10) - 1;
|
||||
const minRank = skip;
|
||||
const maxRank = minRank + limit - 1;
|
||||
|
||||
const topResults = await dailyLeaderboard.getResults(
|
||||
minRank,
|
||||
|
@ -109,40 +108,37 @@ export async function getDailyLeaderboard(
|
|||
req.ctx.configuration.users.premium.enabled
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Daily leaderboard retrieved", topResults);
|
||||
return new MonkeyResponse2("Daily leaderboard retrieved", topResults);
|
||||
}
|
||||
|
||||
export async function getDailyLeaderboardRank(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<GetDailyLeaderboardRankQuery>
|
||||
): Promise<GetLeaderboardDailyRankResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(req);
|
||||
const dailyLeaderboard = getDailyLeaderboardWithError(
|
||||
req.query,
|
||||
req.ctx.configuration.dailyLeaderboards
|
||||
);
|
||||
|
||||
const rank = await dailyLeaderboard.getRank(
|
||||
uid,
|
||||
req.ctx.configuration.dailyLeaderboards
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Daily leaderboard rank retrieved", rank);
|
||||
return new MonkeyResponse2("Daily leaderboard rank retrieved", rank);
|
||||
}
|
||||
|
||||
function getWeeklyXpLeaderboardWithError(
|
||||
req: MonkeyTypes.Request
|
||||
{ weeksBefore }: GetWeeklyXpLeaderboardQuery,
|
||||
config: Configuration["leaderboards"]["weeklyXp"]
|
||||
): WeeklyXpLeaderboard.WeeklyXpLeaderboard {
|
||||
const { weeksBefore } = req.query;
|
||||
const customTimestamp =
|
||||
weeksBefore === undefined
|
||||
? -1
|
||||
: getCurrentWeekTimestamp() - weeksBefore * MILLISECONDS_IN_DAY * 7;
|
||||
|
||||
const normalizedWeeksBefore = parseInt(weeksBefore as string, 10);
|
||||
const currentWeekTimestamp = getCurrentWeekTimestamp();
|
||||
const weekBeforeTimestamp =
|
||||
currentWeekTimestamp - normalizedWeeksBefore * MILLISECONDS_IN_DAY * 7;
|
||||
|
||||
const customTimestamp = _.isNil(weeksBefore) ? -1 : weekBeforeTimestamp;
|
||||
|
||||
const weeklyXpLeaderboard = WeeklyXpLeaderboard.get(
|
||||
req.ctx.configuration.leaderboards.weeklyXp,
|
||||
customTimestamp
|
||||
);
|
||||
const weeklyXpLeaderboard = WeeklyXpLeaderboard.get(config, customTimestamp);
|
||||
if (!weeklyXpLeaderboard) {
|
||||
throw new MonkeyError(404, "XP leaderboard for this week not found.");
|
||||
}
|
||||
|
@ -151,33 +147,39 @@ function getWeeklyXpLeaderboardWithError(
|
|||
}
|
||||
|
||||
export async function getWeeklyXpLeaderboardResults(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2<GetWeeklyXpLeaderboardQuery>
|
||||
): Promise<GetWeeklyXpLeaderboardResponse> {
|
||||
const { skip = 0, limit = 50 } = req.query;
|
||||
|
||||
const minRank = parseInt(skip as string, 10);
|
||||
const maxRank = minRank + parseInt(limit as string, 10) - 1;
|
||||
const minRank = skip;
|
||||
const maxRank = minRank + limit - 1;
|
||||
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(req);
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(
|
||||
req.query,
|
||||
req.ctx.configuration.leaderboards.weeklyXp
|
||||
);
|
||||
const results = await weeklyXpLeaderboard.getResults(
|
||||
minRank,
|
||||
maxRank,
|
||||
req.ctx.configuration.leaderboards.weeklyXp
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Weekly xp leaderboard retrieved", results);
|
||||
return new MonkeyResponse2("Weekly xp leaderboard retrieved", results);
|
||||
}
|
||||
|
||||
export async function getWeeklyXpLeaderboardRank(
|
||||
req: MonkeyTypes.Request
|
||||
): Promise<MonkeyResponse> {
|
||||
req: MonkeyTypes.Request2
|
||||
): Promise<GetWeeklyXpLeaderboardRankResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(req);
|
||||
const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(
|
||||
{},
|
||||
req.ctx.configuration.leaderboards.weeklyXp
|
||||
);
|
||||
const rankEntry = await weeklyXpLeaderboard.getRank(
|
||||
uid,
|
||||
req.ctx.configuration.leaderboards.weeklyXp
|
||||
);
|
||||
|
||||
return new MonkeyResponse("Weekly xp leaderboard rank retrieved", rankEntry);
|
||||
return new MonkeyResponse2("Weekly xp leaderboard rank retrieved", rankEntry);
|
||||
}
|
||||
|
|
|
@ -42,7 +42,6 @@ const APP_START_TIME = Date.now();
|
|||
const API_ROUTE_MAP = {
|
||||
"/users": users,
|
||||
"/results": results,
|
||||
"/leaderboards": leaderboards,
|
||||
"/quotes": quotes,
|
||||
"/webhooks": webhooks,
|
||||
"/docs": docs,
|
||||
|
@ -56,6 +55,7 @@ const router = s.router(contract, {
|
|||
presets,
|
||||
psas,
|
||||
public: publicStats,
|
||||
leaderboards,
|
||||
});
|
||||
|
||||
export function addApiRoutes(app: Application): void {
|
||||
|
|
|
@ -1,41 +1,11 @@
|
|||
import joi from "joi";
|
||||
import { Router } from "express";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import { withApeRateLimiter } from "../../middlewares/ape-rate-limit";
|
||||
import { authenticateRequest } from "../../middlewares/auth";
|
||||
import * as LeaderboardController from "../controllers/leaderboard";
|
||||
import { validate } from "../../middlewares/configuration";
|
||||
import { validateRequest } from "../../middlewares/validation";
|
||||
import { asyncHandler } from "../../middlewares/utility";
|
||||
import * as RateLimit from "../../middlewares/rate-limit";
|
||||
import * as LeaderboardController from "../controllers/leaderboard";
|
||||
|
||||
const BASE_LEADERBOARD_VALIDATION_SCHEMA = {
|
||||
language: joi
|
||||
.string()
|
||||
.max(50)
|
||||
.pattern(/^[a-zA-Z0-9_+]+$/)
|
||||
.required(),
|
||||
mode: joi
|
||||
.string()
|
||||
.valid("time", "words", "quote", "zen", "custom")
|
||||
.required(),
|
||||
mode2: joi
|
||||
.string()
|
||||
.regex(/^(\d)+|custom|zen/)
|
||||
.required(),
|
||||
};
|
||||
|
||||
const LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT = {
|
||||
...BASE_LEADERBOARD_VALIDATION_SCHEMA,
|
||||
skip: joi.number().min(0),
|
||||
limit: joi.number().min(0).max(50),
|
||||
};
|
||||
|
||||
const DAILY_LEADERBOARD_VALIDATION_SCHEMA = {
|
||||
...LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT,
|
||||
daysBefore: joi.number().min(1).max(1),
|
||||
};
|
||||
|
||||
const router = Router();
|
||||
import { leaderboardsContract } from "@monkeytype/contracts/leaderboards";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
const requireDailyLeaderboardsEnabled = validate({
|
||||
criteria: (configuration) => {
|
||||
|
@ -44,58 +14,6 @@ const requireDailyLeaderboardsEnabled = validate({
|
|||
invalidMessage: "Daily leaderboards are not available at this time.",
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/",
|
||||
authenticateRequest({ isPublic: true }),
|
||||
withApeRateLimiter(RateLimit.leaderboardsGet),
|
||||
validateRequest({
|
||||
query: LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT,
|
||||
}),
|
||||
asyncHandler(LeaderboardController.getLeaderboard)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/rank",
|
||||
authenticateRequest({ acceptApeKeys: true }),
|
||||
withApeRateLimiter(RateLimit.leaderboardsGet),
|
||||
validateRequest({
|
||||
query: BASE_LEADERBOARD_VALIDATION_SCHEMA,
|
||||
}),
|
||||
asyncHandler(LeaderboardController.getRankFromLeaderboard)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/daily",
|
||||
requireDailyLeaderboardsEnabled,
|
||||
authenticateRequest({ isPublic: true }),
|
||||
RateLimit.leaderboardsGet,
|
||||
validateRequest({
|
||||
query: DAILY_LEADERBOARD_VALIDATION_SCHEMA,
|
||||
}),
|
||||
asyncHandler(LeaderboardController.getDailyLeaderboard)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/daily/rank",
|
||||
requireDailyLeaderboardsEnabled,
|
||||
authenticateRequest(),
|
||||
RateLimit.leaderboardsGet,
|
||||
validateRequest({
|
||||
query: DAILY_LEADERBOARD_VALIDATION_SCHEMA,
|
||||
}),
|
||||
asyncHandler(LeaderboardController.getDailyLeaderboardRank)
|
||||
);
|
||||
|
||||
const BASE_XP_LEADERBOARD_VALIDATION_SCHEMA = {
|
||||
skip: joi.number().min(0),
|
||||
limit: joi.number().min(0).max(50),
|
||||
};
|
||||
|
||||
const WEEKLY_XP_LEADERBOARD_VALIDATION_SCHEMA = {
|
||||
...BASE_XP_LEADERBOARD_VALIDATION_SCHEMA,
|
||||
weeksBefore: joi.number().min(1).max(1),
|
||||
};
|
||||
|
||||
const requireWeeklyXpLeaderboardEnabled = validate({
|
||||
criteria: (configuration) => {
|
||||
return configuration.leaderboards.weeklyXp.enabled;
|
||||
|
@ -103,23 +21,36 @@ const requireWeeklyXpLeaderboardEnabled = validate({
|
|||
invalidMessage: "Weekly XP leaderboards are not available at this time.",
|
||||
});
|
||||
|
||||
router.get(
|
||||
"/xp/weekly",
|
||||
requireWeeklyXpLeaderboardEnabled,
|
||||
authenticateRequest({ isPublic: true }),
|
||||
withApeRateLimiter(RateLimit.leaderboardsGet),
|
||||
validateRequest({
|
||||
query: WEEKLY_XP_LEADERBOARD_VALIDATION_SCHEMA,
|
||||
}),
|
||||
asyncHandler(LeaderboardController.getWeeklyXpLeaderboardResults)
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/xp/weekly/rank",
|
||||
requireWeeklyXpLeaderboardEnabled,
|
||||
authenticateRequest(),
|
||||
withApeRateLimiter(RateLimit.leaderboardsGet),
|
||||
asyncHandler(LeaderboardController.getWeeklyXpLeaderboardRank)
|
||||
);
|
||||
|
||||
export default router;
|
||||
const s = initServer();
|
||||
export default s.router(leaderboardsContract, {
|
||||
get: {
|
||||
middleware: [RateLimit.leaderboardsGet],
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getLeaderboard)(r),
|
||||
},
|
||||
getRank: {
|
||||
middleware: [withApeRateLimiter(RateLimit.leaderboardsGet)],
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getRankFromLeaderboard)(r),
|
||||
},
|
||||
getDaily: {
|
||||
middleware: [requireDailyLeaderboardsEnabled, RateLimit.leaderboardsGet],
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getDailyLeaderboard)(r),
|
||||
},
|
||||
getDailyRank: {
|
||||
middleware: [requireDailyLeaderboardsEnabled, RateLimit.leaderboardsGet],
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getDailyLeaderboardRank)(r),
|
||||
},
|
||||
getWeeklyXp: {
|
||||
middleware: [requireWeeklyXpLeaderboardEnabled, RateLimit.leaderboardsGet],
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getWeeklyXpLeaderboardResults)(r),
|
||||
},
|
||||
getWeeklyXpRank: {
|
||||
middleware: [requireWeeklyXpLeaderboardEnabled, RateLimit.leaderboardsGet],
|
||||
handler: async (r) =>
|
||||
callController(LeaderboardController.getWeeklyXpLeaderboardRank)(r),
|
||||
},
|
||||
});
|
||||
|
|
|
@ -4,8 +4,27 @@ import { performance } from "perf_hooks";
|
|||
import { setLeaderboard } from "../utils/prometheus";
|
||||
import { isDevEnvironment } from "../utils/misc";
|
||||
import { getCachedConfiguration } from "../init/configuration";
|
||||
import { LeaderboardEntry } from "@monkeytype/shared-types";
|
||||
|
||||
import { addLog } from "./logs";
|
||||
import { Collection } from "mongodb";
|
||||
import {
|
||||
LeaderboardEntry,
|
||||
LeaderboardRank,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { omit } from "lodash";
|
||||
|
||||
export type DBLeaderboardEntry = LeaderboardEntry & {
|
||||
_id: ObjectId;
|
||||
};
|
||||
|
||||
export const getCollection = (key: {
|
||||
language: string;
|
||||
mode: string;
|
||||
mode2: string;
|
||||
}): Collection<DBLeaderboardEntry> =>
|
||||
db.collection<DBLeaderboardEntry>(
|
||||
`leaderboards.${key.language}.${key.mode}.${key.mode2}`
|
||||
);
|
||||
|
||||
export async function get(
|
||||
mode: string,
|
||||
|
@ -13,14 +32,13 @@ export async function get(
|
|||
language: string,
|
||||
skip: number,
|
||||
limit = 50
|
||||
): Promise<LeaderboardEntry[] | false> {
|
||||
): Promise<DBLeaderboardEntry[] | false> {
|
||||
//if (leaderboardUpdating[`${language}_${mode}_${mode2}`]) return false;
|
||||
|
||||
if (limit > 50 || limit <= 0) limit = 50;
|
||||
if (skip < 0) skip = 0;
|
||||
try {
|
||||
const preset = await db
|
||||
.collection<LeaderboardEntry>(`leaderboards.${language}.${mode}.${mode2}`)
|
||||
const preset = await getCollection({ language, mode, mode2 })
|
||||
.find()
|
||||
.sort({ rank: 1 })
|
||||
.skip(skip)
|
||||
|
@ -31,8 +49,9 @@ export async function get(
|
|||
.premium.enabled;
|
||||
|
||||
if (!premiumFeaturesEnabled) {
|
||||
preset.forEach((it) => (it.isPremium = undefined));
|
||||
return preset.map((it) => omit(it, "isPremium"));
|
||||
}
|
||||
|
||||
return preset;
|
||||
} catch (e) {
|
||||
if (e.error === 175) {
|
||||
|
@ -43,30 +62,26 @@ export async function get(
|
|||
}
|
||||
}
|
||||
|
||||
type GetRankResponse = {
|
||||
count: number;
|
||||
rank: number | null;
|
||||
entry: LeaderboardEntry | null;
|
||||
};
|
||||
|
||||
export async function getRank(
|
||||
mode: string,
|
||||
mode2: string,
|
||||
language: string,
|
||||
uid: string
|
||||
): Promise<GetRankResponse | false> {
|
||||
): Promise<LeaderboardRank | false> {
|
||||
try {
|
||||
const entry = await db
|
||||
.collection<LeaderboardEntry>(`leaderboards.${language}.${mode}.${mode2}`)
|
||||
.findOne({ uid });
|
||||
const count = await db
|
||||
.collection(`leaderboards.${language}.${mode}.${mode2}`)
|
||||
.estimatedDocumentCount();
|
||||
const entry = await getCollection({ language, mode, mode2 }).findOne({
|
||||
uid,
|
||||
});
|
||||
const count = await getCollection({
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
}).estimatedDocumentCount();
|
||||
|
||||
return {
|
||||
count,
|
||||
rank: entry ? entry.rank : null,
|
||||
entry,
|
||||
rank: entry?.rank,
|
||||
entry: entry !== null ? entry : undefined,
|
||||
};
|
||||
} catch (e) {
|
||||
if (e.error === 175) {
|
||||
|
|
|
@ -23,10 +23,6 @@
|
|||
"name": "users",
|
||||
"description": "User data and related operations"
|
||||
},
|
||||
{
|
||||
"name": "leaderboards",
|
||||
"description": "Leaderboard data"
|
||||
},
|
||||
{
|
||||
"name": "results",
|
||||
"description": "Result data and related operations"
|
||||
|
@ -412,78 +408,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/leaderboards": {
|
||||
"get": {
|
||||
"tags": ["leaderboards"],
|
||||
"summary": "Gets a leaderboard",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "language",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "mode",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "mode2",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "skip",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "limit",
|
||||
"type": "number"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/leaderboards/rank": {
|
||||
"get": {
|
||||
"tags": ["leaderboards"],
|
||||
"summary": "Gets a user's rank from a leaderboard",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "language",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "mode",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"in": "query",
|
||||
"name": "mode2",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"default": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/Response"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/results": {
|
||||
"get": {
|
||||
"tags": ["results"],
|
||||
|
|
|
@ -20,10 +20,6 @@
|
|||
"name": "users",
|
||||
"description": "User data and related operations"
|
||||
},
|
||||
{
|
||||
"name": "leaderboards",
|
||||
"description": "Leaderboard data and related operations"
|
||||
},
|
||||
{
|
||||
"name": "results",
|
||||
"description": "User results data and related operations"
|
||||
|
@ -201,97 +197,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/leaderboards": {
|
||||
"get": {
|
||||
"tags": ["leaderboards"],
|
||||
"summary": "Gets global leaderboard data",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "language",
|
||||
"in": "query",
|
||||
"description": "The leaderboard's language (i.e., english)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"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": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "skip",
|
||||
"in": "query",
|
||||
"description": "How many leaderboard entries to skip",
|
||||
"required": false,
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "How many leaderboard entries to request",
|
||||
"required": false,
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 50
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/LeaderboardEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/leaderboards/rank": {
|
||||
"get": {
|
||||
"tags": ["leaderboards"],
|
||||
"summary": "Gets your qualifying rank from a leaderboard",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "language",
|
||||
"in": "query",
|
||||
"description": "The leaderboard's language (i.e., english)",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"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": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/LeaderboardEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
|
@ -606,70 +511,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"LeaderboardEntry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uid": {
|
||||
"type": "string",
|
||||
"example": "6226b17aebc27a4a8d1ce04b"
|
||||
},
|
||||
"acc": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 97.96
|
||||
},
|
||||
"consistency": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 83.29
|
||||
},
|
||||
"lazyMode": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Miodec"
|
||||
},
|
||||
"punctuation": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"rank": {
|
||||
"type": "integer",
|
||||
"example": 3506
|
||||
},
|
||||
"raw": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 145.18
|
||||
},
|
||||
"wpm": {
|
||||
"type": "number",
|
||||
"format": "double",
|
||||
"example": 141.18
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "integer",
|
||||
"example": 1644438189583
|
||||
},
|
||||
"discordId": {
|
||||
"type": "string",
|
||||
"example": "974761412044437307"
|
||||
},
|
||||
"discordAvatar": {
|
||||
"type": "string",
|
||||
"example": "6226b17aebc27a4a8d1ce04b"
|
||||
},
|
||||
"badgeIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Results": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
|
|
@ -2,20 +2,21 @@ import { CronJob } from "cron";
|
|||
import GeorgeQueue from "../queues/george-queue";
|
||||
import * as LeaderboardsDAL from "../dal/leaderboards";
|
||||
import { getCachedConfiguration } from "../init/configuration";
|
||||
import { LeaderboardEntry } from "@monkeytype/shared-types";
|
||||
|
||||
const CRON_SCHEDULE = "30 14/15 * * * *";
|
||||
const RECENT_AGE_MINUTES = 10;
|
||||
const RECENT_AGE_MILLISECONDS = RECENT_AGE_MINUTES * 60 * 1000;
|
||||
|
||||
async function getTop10(leaderboardTime: string): Promise<LeaderboardEntry[]> {
|
||||
async function getTop10(
|
||||
leaderboardTime: string
|
||||
): Promise<LeaderboardsDAL.DBLeaderboardEntry[]> {
|
||||
return (await LeaderboardsDAL.get(
|
||||
"time",
|
||||
leaderboardTime,
|
||||
"english",
|
||||
0,
|
||||
10
|
||||
)) as LeaderboardEntry[]; //can do that because gettop10 will not be called during an update
|
||||
)) as LeaderboardsDAL.DBLeaderboardEntry[]; //can do that because gettop10 will not be called during an update
|
||||
}
|
||||
|
||||
async function updateLeaderboardAndNotifyChanges(
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { LeaderboardEntry } from "@monkeytype/shared-types";
|
||||
import { type LbEntryWithRank } from "../utils/daily-leaderboards";
|
||||
import { LeaderboardEntry } from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { MonkeyQueue } from "./monkey-queue";
|
||||
|
||||
const QUEUE_NAME = "george-tasks";
|
||||
|
@ -62,7 +61,7 @@ class GeorgeQueue extends MonkeyQueue<GeorgeTask> {
|
|||
}
|
||||
|
||||
async announceLeaderboardUpdate(
|
||||
newRecords: LeaderboardEntry[],
|
||||
newRecords: Omit<LeaderboardEntry, "_id">[],
|
||||
leaderboardId: string
|
||||
): Promise<void> {
|
||||
const taskName = "announceLeaderboardUpdate";
|
||||
|
@ -90,7 +89,7 @@ class GeorgeQueue extends MonkeyQueue<GeorgeTask> {
|
|||
async announceDailyLeaderboardTopResults(
|
||||
leaderboardId: string,
|
||||
leaderboardTimestamp: number,
|
||||
topResults: LbEntryWithRank[]
|
||||
topResults: LeaderboardEntry[]
|
||||
): Promise<void> {
|
||||
const taskName = "announceDailyLeaderboardTopResults";
|
||||
|
||||
|
|
|
@ -2,25 +2,21 @@ import { Configuration } from "@monkeytype/shared-types";
|
|||
import * as RedisClient from "../init/redis";
|
||||
import LaterQueue from "../queues/later-queue";
|
||||
import { getCurrentWeekTimestamp } from "../utils/misc";
|
||||
|
||||
type InternalWeeklyXpLeaderboardEntry = {
|
||||
uid: string;
|
||||
name: string;
|
||||
discordAvatar?: string;
|
||||
discordId?: string;
|
||||
badgeId?: number;
|
||||
lastActivityTimestamp: number;
|
||||
};
|
||||
|
||||
type WeeklyXpLeaderboardEntry = {
|
||||
totalXp: number;
|
||||
rank: number;
|
||||
count?: number;
|
||||
timeTypedSeconds: number;
|
||||
} & InternalWeeklyXpLeaderboardEntry;
|
||||
import {
|
||||
XpLeaderboardEntry,
|
||||
XpLeaderboardRank,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
|
||||
type AddResultOpts = {
|
||||
entry: InternalWeeklyXpLeaderboardEntry;
|
||||
entry: Pick<
|
||||
XpLeaderboardEntry,
|
||||
| "uid"
|
||||
| "name"
|
||||
| "discordId"
|
||||
| "discordAvatar"
|
||||
| "badgeId"
|
||||
| "lastActivityTimestamp"
|
||||
>;
|
||||
xpGained: number;
|
||||
timeTypedSeconds: number;
|
||||
};
|
||||
|
@ -123,7 +119,7 @@ export class WeeklyXpLeaderboard {
|
|||
minRank: number,
|
||||
maxRank: number,
|
||||
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"]
|
||||
): Promise<WeeklyXpLeaderboardEntry[]> {
|
||||
): Promise<XpLeaderboardEntry[]> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !weeklyXpLeaderboardConfig.enabled) {
|
||||
return [];
|
||||
|
@ -154,10 +150,10 @@ export class WeeklyXpLeaderboard {
|
|||
);
|
||||
}
|
||||
|
||||
const resultsWithRanks: WeeklyXpLeaderboardEntry[] = results.map(
|
||||
const resultsWithRanks: XpLeaderboardEntry[] = results.map(
|
||||
(resultJSON: string, index: number) => {
|
||||
//TODO parse with zod?
|
||||
const parsed = JSON.parse(resultJSON) as WeeklyXpLeaderboardEntry;
|
||||
const parsed = JSON.parse(resultJSON) as XpLeaderboardEntry;
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
|
@ -173,7 +169,7 @@ export class WeeklyXpLeaderboard {
|
|||
public async getRank(
|
||||
uid: string,
|
||||
weeklyXpLeaderboardConfig: Configuration["leaderboards"]["weeklyXp"]
|
||||
): Promise<WeeklyXpLeaderboardEntry | null> {
|
||||
): Promise<XpLeaderboardRank | null> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !weeklyXpLeaderboardConfig.enabled) {
|
||||
return null;
|
||||
|
@ -201,7 +197,7 @@ export class WeeklyXpLeaderboard {
|
|||
|
||||
//TODO parse with zod?
|
||||
const parsed = JSON.parse(result ?? "null") as Omit<
|
||||
WeeklyXpLeaderboardEntry,
|
||||
XpLeaderboardEntry,
|
||||
"rank" | "count" | "totalXp"
|
||||
>;
|
||||
|
||||
|
|
|
@ -1,33 +1,13 @@
|
|||
import _ from "lodash";
|
||||
import _, { omit } from "lodash";
|
||||
import * as RedisClient from "../init/redis";
|
||||
import LaterQueue from "../queues/later-queue";
|
||||
import { getCurrentDayTimestamp, matchesAPattern, kogascore } from "./misc";
|
||||
import { Configuration, ValidModeRule } from "@monkeytype/shared-types";
|
||||
|
||||
type DailyLeaderboardEntry = {
|
||||
uid: string;
|
||||
name: string;
|
||||
wpm: number;
|
||||
raw: number;
|
||||
acc: number;
|
||||
consistency: number;
|
||||
timestamp: number;
|
||||
discordAvatar?: string;
|
||||
discordId?: string;
|
||||
badgeId?: number;
|
||||
isPremium?: boolean;
|
||||
};
|
||||
|
||||
type GetRankResponse = {
|
||||
minWpm: number;
|
||||
count: number;
|
||||
rank: number | null;
|
||||
entry: DailyLeaderboardEntry | null;
|
||||
};
|
||||
|
||||
export type LbEntryWithRank = {
|
||||
rank: number;
|
||||
} & DailyLeaderboardEntry;
|
||||
import {
|
||||
DailyLeaderboardRank,
|
||||
LeaderboardEntry,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import MonkeyError from "./error";
|
||||
|
||||
const dailyLeaderboardNamespace = "monkeytype:dailyleaderboard";
|
||||
const scoresNamespace = `${dailyLeaderboardNamespace}:scores`;
|
||||
|
@ -68,7 +48,7 @@ export class DailyLeaderboard {
|
|||
}
|
||||
|
||||
public async addResult(
|
||||
entry: DailyLeaderboardEntry,
|
||||
entry: Omit<LeaderboardEntry, "rank">,
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"]
|
||||
): Promise<number> {
|
||||
const connection = RedisClient.getConnection();
|
||||
|
@ -127,7 +107,7 @@ export class DailyLeaderboard {
|
|||
maxRank: number,
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"],
|
||||
premiumFeaturesEnabled: boolean
|
||||
): Promise<LbEntryWithRank[]> {
|
||||
): Promise<LeaderboardEntry[]> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !dailyLeaderboardsConfig.enabled) {
|
||||
return [];
|
||||
|
@ -152,10 +132,10 @@ export class DailyLeaderboard {
|
|||
);
|
||||
}
|
||||
|
||||
const resultsWithRanks: LbEntryWithRank[] = results.map(
|
||||
const resultsWithRanks: LeaderboardEntry[] = results.map(
|
||||
(resultJSON, index) => {
|
||||
// TODO: parse with zod?
|
||||
const parsed = JSON.parse(resultJSON) as LbEntryWithRank;
|
||||
const parsed = JSON.parse(resultJSON) as LeaderboardEntry;
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
|
@ -165,7 +145,7 @@ export class DailyLeaderboard {
|
|||
);
|
||||
|
||||
if (!premiumFeaturesEnabled) {
|
||||
resultsWithRanks.forEach((it) => (it.isPremium = undefined));
|
||||
return resultsWithRanks.map((it) => omit(it, "isPremium"));
|
||||
}
|
||||
|
||||
return resultsWithRanks;
|
||||
|
@ -174,10 +154,10 @@ export class DailyLeaderboard {
|
|||
public async getRank(
|
||||
uid: string,
|
||||
dailyLeaderboardsConfig: Configuration["dailyLeaderboards"]
|
||||
): Promise<GetRankResponse | null> {
|
||||
): Promise<DailyLeaderboardRank> {
|
||||
const connection = RedisClient.getConnection();
|
||||
if (!connection || !dailyLeaderboardsConfig.enabled) {
|
||||
return null;
|
||||
throw new MonkeyError(500, "Redis connnection is unavailable");
|
||||
}
|
||||
|
||||
const { leaderboardScoresKey, leaderboardResultsKey } =
|
||||
|
@ -198,8 +178,6 @@ export class DailyLeaderboard {
|
|||
return {
|
||||
minWpm,
|
||||
count: count ?? 0,
|
||||
rank: null,
|
||||
entry: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import Leaderboards from "./leaderboards";
|
||||
import Quotes from "./quotes";
|
||||
import Results from "./results";
|
||||
import Users from "./users";
|
||||
|
@ -6,7 +5,6 @@ import Configuration from "./configuration";
|
|||
import Dev from "./dev";
|
||||
|
||||
export default {
|
||||
Leaderboards,
|
||||
Quotes,
|
||||
Results,
|
||||
Users,
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
const BASE_PATH = "/leaderboards";
|
||||
|
||||
export default class Leaderboards {
|
||||
constructor(private httpClient: Ape.HttpClient) {
|
||||
this.httpClient = httpClient;
|
||||
}
|
||||
|
||||
async get(
|
||||
query: Ape.Leaderboards.QueryWithPagination
|
||||
): Ape.EndpointResponse<Ape.Leaderboards.GetLeaderboard> {
|
||||
const {
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
isDaily,
|
||||
skip = 0,
|
||||
limit = 50,
|
||||
daysBefore,
|
||||
} = query;
|
||||
const includeDaysBefore = (isDaily ?? false) && (daysBefore ?? 0) > 0;
|
||||
|
||||
const searchQuery = {
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
skip: Math.max(skip, 0),
|
||||
limit: Math.max(Math.min(limit, 50), 0),
|
||||
...(includeDaysBefore && { daysBefore }),
|
||||
};
|
||||
|
||||
const endpointPath = `${BASE_PATH}/${isDaily ? "daily" : ""}`;
|
||||
|
||||
return await this.httpClient.get(endpointPath, { searchQuery });
|
||||
}
|
||||
|
||||
async getRank(
|
||||
query: Ape.Leaderboards.Query
|
||||
): Ape.EndpointResponse<Ape.Leaderboards.GetRank> {
|
||||
const { language, mode, mode2, isDaily, daysBefore } = query;
|
||||
const includeDaysBefore = (isDaily ?? false) && (daysBefore ?? 0) > 0;
|
||||
|
||||
const searchQuery = {
|
||||
language,
|
||||
mode,
|
||||
mode2,
|
||||
...(includeDaysBefore && { daysBefore }),
|
||||
};
|
||||
|
||||
const endpointPath = `${BASE_PATH}${isDaily ? "/daily" : ""}/rank`;
|
||||
|
||||
return await this.httpClient.get(endpointPath, { searchQuery });
|
||||
}
|
||||
}
|
|
@ -17,7 +17,6 @@ const Ape = {
|
|||
users: new endpoints.Users(httpClient),
|
||||
results: new endpoints.Results(httpClient),
|
||||
quotes: new endpoints.Quotes(httpClient),
|
||||
leaderboards: new endpoints.Leaderboards(httpClient),
|
||||
configuration: new endpoints.Configuration(httpClient),
|
||||
dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)),
|
||||
};
|
||||
|
|
25
frontend/src/ts/ape/types/leaderboards.d.ts
vendored
25
frontend/src/ts/ape/types/leaderboards.d.ts
vendored
|
@ -1,25 +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.Leaderboards {
|
||||
type Query = {
|
||||
language: string;
|
||||
mode: Config.Mode;
|
||||
mode2: string;
|
||||
isDaily?: boolean;
|
||||
daysBefore?: number;
|
||||
};
|
||||
|
||||
type QueryWithPagination = {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
} & Query;
|
||||
|
||||
type GetLeaderboard = LeaderboardEntry[];
|
||||
|
||||
type GetRank = {
|
||||
minWpm: number;
|
||||
count: number;
|
||||
rank: number | null;
|
||||
entry: import("@monkeytype/shared-types").LeaderboardEntry | null;
|
||||
};
|
||||
}
|
|
@ -21,6 +21,7 @@ import * as ConfigSchemas from "@monkeytype/contracts/schemas/configs";
|
|||
import { Config } from "@monkeytype/contracts/schemas/configs";
|
||||
import { roundTo1 } from "./utils/numbers";
|
||||
import { Mode, ModeSchema } from "@monkeytype/contracts/schemas/shared";
|
||||
import { Language, LanguageSchema } from "@monkeytype/contracts/schemas/util";
|
||||
|
||||
export let localStorageConfig: Config;
|
||||
|
||||
|
@ -1565,12 +1566,8 @@ export function setCustomThemeColors(
|
|||
return true;
|
||||
}
|
||||
|
||||
export function setLanguage(
|
||||
language: ConfigSchemas.Language,
|
||||
nosave?: boolean
|
||||
): boolean {
|
||||
if (!isConfigValueValid("language", language, ConfigSchemas.LanguageSchema))
|
||||
return false;
|
||||
export function setLanguage(language: Language, nosave?: boolean): boolean {
|
||||
if (!isConfigValueValid("language", language, LanguageSchema)) return false;
|
||||
|
||||
config.language = language;
|
||||
void AnalyticsController.log("changedLanguage", { language });
|
||||
|
|
|
@ -17,7 +17,11 @@ import Format from "../utils/format";
|
|||
// @ts-expect-error TODO: update slim-select
|
||||
import SlimSelect from "slim-select";
|
||||
import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
|
||||
import { LeaderboardEntry } from "@monkeytype/shared-types";
|
||||
import {
|
||||
LeaderboardEntry,
|
||||
LeaderboardRank,
|
||||
} from "@monkeytype/contracts/schemas/leaderboards";
|
||||
import { Mode } from "@monkeytype/contracts/schemas/shared";
|
||||
|
||||
const wrapperId = "leaderboardsWrapper";
|
||||
|
||||
|
@ -35,7 +39,9 @@ let currentData: {
|
|||
};
|
||||
|
||||
let currentRank: {
|
||||
[_key in LbKey]: Ape.Leaderboards.GetRank | Record<string, never>;
|
||||
[_key in LbKey]:
|
||||
| (LeaderboardRank & { minWpm?: number }) //Daily LB rank has minWpm
|
||||
| Record<string, never>;
|
||||
} = {
|
||||
"15": {},
|
||||
"60": {},
|
||||
|
@ -425,10 +431,10 @@ function updateYesterdayButton(): void {
|
|||
}
|
||||
}
|
||||
|
||||
function getDailyLeaderboardQuery(): { isDaily: boolean; daysBefore: number } {
|
||||
function getDailyLeaderboardQuery(): { isDaily: boolean; daysBefore?: 1 } {
|
||||
const isDaily = currentTimeRange === "daily";
|
||||
const isViewingDailyAndButtonIsActive = isDaily && showingYesterday;
|
||||
const daysBefore = isViewingDailyAndButtonIsActive ? 1 : 0;
|
||||
const daysBefore = isViewingDailyAndButtonIsActive ? 1 : undefined;
|
||||
|
||||
return {
|
||||
isDaily,
|
||||
|
@ -443,57 +449,59 @@ async function update(): Promise<void> {
|
|||
showLoader("15");
|
||||
showLoader("60");
|
||||
|
||||
const timeModes = ["15", "60"];
|
||||
const { isDaily, daysBefore } = getDailyLeaderboardQuery();
|
||||
const requestData = isDaily
|
||||
? Ape.leaderboards.getDaily
|
||||
: Ape.leaderboards.get;
|
||||
const requestRank = isDaily
|
||||
? Ape.leaderboards.getDailyRank
|
||||
: Ape.leaderboards.getRank;
|
||||
|
||||
const lbDataRequests = timeModes.map(async (mode2) => {
|
||||
return Ape.leaderboards.get({
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2,
|
||||
...getDailyLeaderboardQuery(),
|
||||
});
|
||||
});
|
||||
const baseQuery = {
|
||||
language: currentLanguage,
|
||||
mode: "time" as Mode,
|
||||
daysBefore,
|
||||
};
|
||||
|
||||
const lbRankRequests: Promise<
|
||||
Ape.HttpClientResponse<Ape.Leaderboards.GetRank>
|
||||
>[] = [];
|
||||
if (isAuthenticated()) {
|
||||
lbRankRequests.push(
|
||||
...timeModes.map(async (mode2) => {
|
||||
return Ape.leaderboards.getRank({
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2,
|
||||
...getDailyLeaderboardQuery(),
|
||||
});
|
||||
})
|
||||
const fallbackResponse = { status: 200, body: { message: "", data: null } };
|
||||
|
||||
const lbRank15Request = isAuthenticated()
|
||||
? requestRank({ query: { ...baseQuery, mode2: "15" } })
|
||||
: fallbackResponse;
|
||||
|
||||
const lbRank60Request = isAuthenticated()
|
||||
? requestRank({ query: { ...baseQuery, mode2: "60" } })
|
||||
: fallbackResponse;
|
||||
const [lb15Data, lb60Data, lb15Rank, lb60Rank] = await Promise.all([
|
||||
requestData({ query: { ...baseQuery, mode2: "15" } }),
|
||||
requestData({ query: { ...baseQuery, mode2: "60" } }),
|
||||
lbRank15Request,
|
||||
lbRank60Request,
|
||||
]);
|
||||
|
||||
if (
|
||||
lb15Data.status !== 200 ||
|
||||
lb60Data.status !== 200 ||
|
||||
lb15Rank.status !== 200 ||
|
||||
lb60Rank.status !== 200
|
||||
) {
|
||||
const failedResponses = [lb15Data, lb60Data, lb15Rank, lb60Rank].filter(
|
||||
(it) => it.status !== 200
|
||||
);
|
||||
}
|
||||
|
||||
const responses = await Promise.all(lbDataRequests);
|
||||
const rankResponses = await Promise.all(lbRankRequests);
|
||||
|
||||
const failedResponses = [
|
||||
...(responses.filter((response) => response.status !== 200) ?? []),
|
||||
...(rankResponses.filter((response) => response.status !== 200) ?? []),
|
||||
];
|
||||
if (failedResponses.length > 0) {
|
||||
hideLoader("15");
|
||||
hideLoader("60");
|
||||
Notifications.add(
|
||||
"Failed to load leaderboards: " + failedResponses[0]?.message,
|
||||
"Failed to load leaderboards: " + failedResponses[0]?.body.message,
|
||||
-1
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const [lb15Data, lb60Data] = responses.map((response) => response.data);
|
||||
const [lb15Rank, lb60Rank] = rankResponses.map((response) => response.data);
|
||||
|
||||
if (lb15Data !== undefined && lb15Data !== null) currentData["15"] = lb15Data;
|
||||
if (lb60Data !== undefined && lb60Data !== null) currentData["60"] = lb60Data;
|
||||
if (lb15Rank !== undefined && lb15Rank !== null) currentRank["15"] = lb15Rank;
|
||||
if (lb60Rank !== undefined && lb60Rank !== null) currentRank["60"] = lb60Rank;
|
||||
if (lb15Data.body.data !== null) currentData["15"] = lb15Data.body.data;
|
||||
if (lb60Data.body.data !== null) currentData["60"] = lb60Data.body.data;
|
||||
if (lb15Rank.body.data !== null) currentRank["15"] = lb15Rank.body.data;
|
||||
if (lb60Rank.body.data !== null) currentRank["60"] = lb60Rank.body.data;
|
||||
|
||||
const leaderboardKeys: LbKey[] = ["15", "60"];
|
||||
|
||||
|
@ -541,21 +549,34 @@ async function requestMore(lb: LbKey, prepend = false): Promise<void> {
|
|||
skipVal = 0;
|
||||
}
|
||||
|
||||
const response = await Ape.leaderboards.get({
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2: lb,
|
||||
skip: skipVal,
|
||||
limit: limitVal,
|
||||
...getDailyLeaderboardQuery(),
|
||||
});
|
||||
const data = response.data;
|
||||
const { isDaily, daysBefore } = getDailyLeaderboardQuery();
|
||||
|
||||
if (response.status !== 200 || data === null || data.length === 0) {
|
||||
const requestData = isDaily
|
||||
? Ape.leaderboards.getDaily
|
||||
: Ape.leaderboards.get;
|
||||
|
||||
const response = await requestData({
|
||||
query: {
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2: lb,
|
||||
skip: skipVal,
|
||||
limit: limitVal,
|
||||
daysBefore,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
response.body.data === null ||
|
||||
response.body.data.length === 0
|
||||
) {
|
||||
hideLoader(lb);
|
||||
requesting[lb] = false;
|
||||
return;
|
||||
}
|
||||
const data = response.body.data;
|
||||
|
||||
if (prepend) {
|
||||
currentData[lb].unshift(...data);
|
||||
} else {
|
||||
|
@ -582,14 +603,21 @@ async function requestMore(lb: LbKey, prepend = false): Promise<void> {
|
|||
async function requestNew(lb: LbKey, skip: number): Promise<void> {
|
||||
showLoader(lb);
|
||||
|
||||
const response = await Ape.leaderboards.get({
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2: lb,
|
||||
skip,
|
||||
...getDailyLeaderboardQuery(),
|
||||
const { isDaily, daysBefore } = getDailyLeaderboardQuery();
|
||||
|
||||
const requestData = isDaily
|
||||
? Ape.leaderboards.getDaily
|
||||
: Ape.leaderboards.get;
|
||||
|
||||
const response = await requestData({
|
||||
query: {
|
||||
language: currentLanguage,
|
||||
mode: "time",
|
||||
mode2: lb,
|
||||
skip,
|
||||
daysBefore,
|
||||
},
|
||||
});
|
||||
const data = response.data;
|
||||
|
||||
if (response.status === 503) {
|
||||
Notifications.add(
|
||||
|
@ -602,10 +630,16 @@ async function requestNew(lb: LbKey, skip: number): Promise<void> {
|
|||
clearBody(lb);
|
||||
currentData[lb] = [];
|
||||
currentAvatars[lb] = [];
|
||||
if (response.status !== 200 || data === null || data.length === 0) {
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
response.body.data === null ||
|
||||
response.body.data.length === 0
|
||||
) {
|
||||
hideLoader(lb);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = response.body.data;
|
||||
currentData[lb] = data;
|
||||
await fillTable(lb);
|
||||
|
||||
|
@ -618,7 +652,7 @@ async function requestNew(lb: LbKey, skip: number): Promise<void> {
|
|||
}
|
||||
|
||||
async function getAvatarUrls(
|
||||
data: Ape.Leaderboards.GetLeaderboard
|
||||
data: LeaderboardEntry[]
|
||||
): Promise<(string | null)[]> {
|
||||
return Promise.allSettled(
|
||||
data.map(async (entry) =>
|
||||
|
|
6
frontend/src/ts/types/types.d.ts
vendored
6
frontend/src/ts/types/types.d.ts
vendored
|
@ -202,12 +202,6 @@ declare namespace MonkeyTypes {
|
|||
};
|
||||
};
|
||||
|
||||
type Leaderboards = {
|
||||
time: {
|
||||
[_key in 15 | 60]: import("@monkeytype/shared-types").LeaderboardEntry[];
|
||||
};
|
||||
};
|
||||
|
||||
type QuoteRatings = Record<string, Record<number, number>>;
|
||||
|
||||
type UserTag = import("@monkeytype/shared-types").UserTag & {
|
||||
|
|
|
@ -5,6 +5,7 @@ import { configsContract } from "./configs";
|
|||
import { presetsContract } from "./presets";
|
||||
import { psasContract } from "./psas";
|
||||
import { publicContract } from "./public";
|
||||
import { leaderboardsContract } from "./leaderboards";
|
||||
|
||||
const c = initContract();
|
||||
|
||||
|
@ -15,4 +16,5 @@ export const contract = c.router({
|
|||
presets: presetsContract,
|
||||
psas: psasContract,
|
||||
public: publicContract,
|
||||
leaderboards: leaderboardsContract,
|
||||
});
|
||||
|
|
174
packages/contracts/src/leaderboards.ts
Normal file
174
packages/contracts/src/leaderboards.ts
Normal file
|
@ -0,0 +1,174 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
CommonResponses,
|
||||
EndpointMetadata,
|
||||
responseWithData,
|
||||
responseWithNullableData,
|
||||
} from "./schemas/api";
|
||||
import {
|
||||
DailyLeaderboardRankSchema,
|
||||
LeaderboardEntrySchema,
|
||||
LeaderboardRankSchema,
|
||||
XpLeaderboardEntrySchema,
|
||||
XpLeaderboardRankSchema,
|
||||
} from "./schemas/leaderboards";
|
||||
import { LanguageSchema } from "./schemas/util";
|
||||
import { Mode2Schema, ModeSchema } from "./schemas/shared";
|
||||
import { initContract } from "@ts-rest/core";
|
||||
|
||||
export const LanguageAndModeQuerySchema = z.object({
|
||||
language: LanguageSchema,
|
||||
mode: ModeSchema,
|
||||
mode2: Mode2Schema,
|
||||
});
|
||||
export type LanguageAndModeQuery = z.infer<typeof LanguageAndModeQuerySchema>;
|
||||
const PaginationQuerySchema = z.object({
|
||||
skip: z.number().int().nonnegative().optional(),
|
||||
limit: z.number().int().nonnegative().max(50).optional(),
|
||||
});
|
||||
|
||||
export const GetLeaderboardQuerySchema = LanguageAndModeQuerySchema.merge(
|
||||
PaginationQuerySchema
|
||||
);
|
||||
export type GetLeaderboardQuery = z.infer<typeof GetLeaderboardQuerySchema>;
|
||||
export const GetLeaderboardResponseSchema = responseWithData(
|
||||
z.array(LeaderboardEntrySchema)
|
||||
);
|
||||
export type GetLeaderboardResponse = z.infer<
|
||||
typeof GetLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
export const GetLeaderboardRankResponseSchema = responseWithData(
|
||||
LeaderboardRankSchema
|
||||
);
|
||||
export type GetLeaderboardRankResponse = z.infer<
|
||||
typeof GetLeaderboardRankResponseSchema
|
||||
>;
|
||||
|
||||
export const GetDailyLeaderboardRankQuerySchema =
|
||||
LanguageAndModeQuerySchema.extend({
|
||||
daysBefore: z.literal(1).optional(),
|
||||
});
|
||||
export type GetDailyLeaderboardRankQuery = z.infer<
|
||||
typeof GetDailyLeaderboardRankQuerySchema
|
||||
>;
|
||||
|
||||
export const GetDailyLeaderboardQuerySchema =
|
||||
GetDailyLeaderboardRankQuerySchema.merge(PaginationQuerySchema);
|
||||
export type GetDailyLeaderboardQuery = z.infer<
|
||||
typeof GetDailyLeaderboardQuerySchema
|
||||
>;
|
||||
|
||||
export const GetLeaderboardDailyRankResponseSchema = responseWithData(
|
||||
DailyLeaderboardRankSchema
|
||||
);
|
||||
export type GetLeaderboardDailyRankResponse = z.infer<
|
||||
typeof GetLeaderboardDailyRankResponseSchema
|
||||
>;
|
||||
|
||||
export const GetWeeklyXpLeaderboardQuerySchema = PaginationQuerySchema.extend({
|
||||
weeksBefore: z.literal(1).optional(),
|
||||
});
|
||||
export type GetWeeklyXpLeaderboardQuery = z.infer<
|
||||
typeof GetWeeklyXpLeaderboardQuerySchema
|
||||
>;
|
||||
|
||||
export const GetWeeklyXpLeaderboardResponseSchema = responseWithData(
|
||||
z.array(XpLeaderboardEntrySchema)
|
||||
);
|
||||
export type GetWeeklyXpLeaderboardResponse = z.infer<
|
||||
typeof GetWeeklyXpLeaderboardResponseSchema
|
||||
>;
|
||||
|
||||
export const GetWeeklyXpLeaderboardRankResponseSchema =
|
||||
responseWithNullableData(XpLeaderboardRankSchema.partial());
|
||||
export type GetWeeklyXpLeaderboardRankResponse = z.infer<
|
||||
typeof GetWeeklyXpLeaderboardRankResponseSchema
|
||||
>;
|
||||
|
||||
const c = initContract();
|
||||
export const leaderboardsContract = c.router(
|
||||
{
|
||||
get: {
|
||||
summary: "get leaderboard",
|
||||
description: "Get all-time leaderboard.",
|
||||
method: "GET",
|
||||
path: "",
|
||||
query: GetLeaderboardQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetLeaderboardResponseSchema,
|
||||
},
|
||||
metadata: {
|
||||
authenticationOptions: { isPublic: true },
|
||||
} as EndpointMetadata,
|
||||
},
|
||||
getRank: {
|
||||
summary: "get leaderboard rank",
|
||||
description:
|
||||
"Get the rank of the current user on the all-time leaderboard",
|
||||
method: "GET",
|
||||
path: "/rank",
|
||||
query: LanguageAndModeQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetLeaderboardRankResponseSchema,
|
||||
},
|
||||
metadata: {
|
||||
authenticationOptions: { acceptApeKeys: true },
|
||||
} as EndpointMetadata,
|
||||
},
|
||||
getDaily: {
|
||||
summary: "get daily leaderboard",
|
||||
description: "Get daily leaderboard.",
|
||||
method: "GET",
|
||||
path: "/daily",
|
||||
query: GetDailyLeaderboardQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetLeaderboardResponseSchema,
|
||||
},
|
||||
metadata: {
|
||||
authenticationOptions: { isPublic: true },
|
||||
} as EndpointMetadata,
|
||||
},
|
||||
getDailyRank: {
|
||||
summary: "get daily leaderboard rank",
|
||||
description: "Get the rank of the current user on the daily leaderboard",
|
||||
method: "GET",
|
||||
path: "/daily/rank",
|
||||
query: GetDailyLeaderboardRankQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetLeaderboardDailyRankResponseSchema,
|
||||
},
|
||||
},
|
||||
getWeeklyXp: {
|
||||
summary: "get weekly xp leaderboard",
|
||||
description: "Get weekly xp leaderboard",
|
||||
method: "GET",
|
||||
path: "/xp/weekly",
|
||||
query: GetWeeklyXpLeaderboardQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetWeeklyXpLeaderboardResponseSchema,
|
||||
},
|
||||
metadata: {
|
||||
authenticationOptions: { isPublic: true },
|
||||
} as EndpointMetadata,
|
||||
},
|
||||
getWeeklyXpRank: {
|
||||
summary: "get weekly xp leaderboard rank",
|
||||
description:
|
||||
"Get the rank of the current user on the weekly xp leaderboard",
|
||||
method: "GET",
|
||||
path: "/xp/weekly/rank",
|
||||
responses: {
|
||||
200: GetWeeklyXpLeaderboardRankResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pathPrefix: "/leaderboards",
|
||||
strictStatusCodes: true,
|
||||
metadata: {
|
||||
openApiTags: "leaderboards",
|
||||
} as EndpointMetadata,
|
||||
commonResponses: CommonResponses,
|
||||
}
|
||||
);
|
|
@ -6,7 +6,8 @@ export type OpenApiTag =
|
|||
| "ape-keys"
|
||||
| "admin"
|
||||
| "psas"
|
||||
| "public";
|
||||
| "public"
|
||||
| "leaderboards";
|
||||
|
||||
export type EndpointMetadata = {
|
||||
/** Authentication options, by default a bearer token is required. */
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { token } from "./util";
|
||||
import { LanguageSchema, token } from "./util";
|
||||
import * as Shared from "./shared";
|
||||
|
||||
export const SmoothCaretSchema = z.enum(["off", "slow", "medium", "fast"]);
|
||||
|
@ -262,12 +262,6 @@ export type FontFamily = z.infer<typeof FontFamilySchema>;
|
|||
export const ThemeNameSchema = token().max(50);
|
||||
export type ThemeName = z.infer<typeof ThemeNameSchema>;
|
||||
|
||||
export const LanguageSchema = z
|
||||
.string()
|
||||
.max(50)
|
||||
.regex(/^[a-zA-Z0-9_+]+$/);
|
||||
export type Language = z.infer<typeof LanguageSchema>;
|
||||
|
||||
export const KeymapLayoutSchema = z
|
||||
.string()
|
||||
.max(50)
|
||||
|
|
47
packages/contracts/src/schemas/leaderboards.ts
Normal file
47
packages/contracts/src/schemas/leaderboards.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const LeaderboardEntrySchema = z.object({
|
||||
wpm: z.number().nonnegative(),
|
||||
acc: z.number().nonnegative().min(0).max(100),
|
||||
timestamp: z.number().int().nonnegative(),
|
||||
raw: z.number().nonnegative(),
|
||||
consistency: z.number().nonnegative().optional(),
|
||||
uid: z.string(),
|
||||
name: z.string(),
|
||||
discordId: z.string().optional(),
|
||||
discordAvatar: z.string().optional(),
|
||||
rank: z.number().nonnegative().int(),
|
||||
badgeId: z.number().int().optional(),
|
||||
isPremium: z.boolean().optional(),
|
||||
});
|
||||
export type LeaderboardEntry = z.infer<typeof LeaderboardEntrySchema>;
|
||||
|
||||
export const LeaderboardRankSchema = z.object({
|
||||
count: z.number().int().nonnegative(),
|
||||
rank: z.number().int().nonnegative().optional(),
|
||||
entry: LeaderboardEntrySchema.optional(),
|
||||
});
|
||||
export type LeaderboardRank = z.infer<typeof LeaderboardRankSchema>;
|
||||
|
||||
export const DailyLeaderboardRankSchema = LeaderboardRankSchema.extend({
|
||||
minWpm: z.number().nonnegative(),
|
||||
});
|
||||
export type DailyLeaderboardRank = z.infer<typeof DailyLeaderboardRankSchema>;
|
||||
|
||||
export const XpLeaderboardEntrySchema = z.object({
|
||||
uid: z.string(),
|
||||
name: z.string(),
|
||||
discordId: z.string().optional(),
|
||||
discordAvatar: z.string().optional(),
|
||||
badgeId: z.number().int().optional(),
|
||||
lastActivityTimestamp: z.number().int().nonnegative(),
|
||||
timeTypedSeconds: z.number().nonnegative(),
|
||||
rank: z.number().nonnegative().int(),
|
||||
totalXp: z.number().nonnegative().int(),
|
||||
});
|
||||
export type XpLeaderboardEntry = z.infer<typeof XpLeaderboardEntrySchema>;
|
||||
|
||||
export const XpLeaderboardRankSchema = XpLeaderboardEntrySchema.extend({
|
||||
count: z.number().int().nonnegative(),
|
||||
});
|
||||
export type XpLeaderboardRank = z.infer<typeof XpLeaderboardRankSchema>;
|
|
@ -1,13 +1,12 @@
|
|||
import { z, ZodString } from "zod";
|
||||
|
||||
export const StringNumberSchema = z
|
||||
|
||||
.custom<`${number}`>((val) => {
|
||||
if (typeof val === "number") val = val.toString();
|
||||
return typeof val === "string" ? /^\d+$/.test(val) : false;
|
||||
}, 'Needs to be a number or a number represented as a string e.g. "10".')
|
||||
.transform(String);
|
||||
|
||||
.string()
|
||||
.regex(
|
||||
/^\d+$/,
|
||||
'Needs to be a number or a number represented as a string e.g. "10".'
|
||||
)
|
||||
.or(z.number().transform(String));
|
||||
export type StringNumber = z.infer<typeof StringNumberSchema>;
|
||||
|
||||
export const token = (): ZodString => z.string().regex(/^[a-zA-Z0-9_]+$/);
|
||||
|
@ -21,5 +20,5 @@ export type Tag = z.infer<typeof TagSchema>;
|
|||
export const LanguageSchema = z
|
||||
.string()
|
||||
.max(50)
|
||||
.regex(/^[a-zA-Z0-9_+]+$/);
|
||||
.regex(/^[a-zA-Z0-9_+]+$/, "Can only contain letters [a-zA-Z0-9_+]");
|
||||
export type Language = z.infer<typeof LanguageSchema>;
|
||||
|
|
|
@ -301,22 +301,6 @@ export type ResultFilters = {
|
|||
} & Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type LeaderboardEntry = {
|
||||
_id: string;
|
||||
wpm: number;
|
||||
acc: number;
|
||||
timestamp: number;
|
||||
raw: number;
|
||||
consistency?: number;
|
||||
uid: string;
|
||||
name: string;
|
||||
discordId?: string;
|
||||
discordAvatar?: string;
|
||||
rank: number;
|
||||
badgeId?: number;
|
||||
isPremium?: boolean;
|
||||
};
|
||||
|
||||
export type PostResultResponse = {
|
||||
isPb: boolean;
|
||||
tagPbs: string[];
|
||||
|
|
|
@ -172,8 +172,8 @@ importers:
|
|||
specifier: workspace:*
|
||||
version: link:../packages/typescript-config
|
||||
'@redocly/cli':
|
||||
specifier: 1.18.1
|
||||
version: 1.18.1(encoding@0.1.13)(enzyme@3.11.0)
|
||||
specifier: 1.19.0
|
||||
version: 1.19.0(encoding@0.1.13)(enzyme@3.11.0)
|
||||
'@types/bcrypt':
|
||||
specifier: 5.0.2
|
||||
version: 5.0.2
|
||||
|
@ -2327,16 +2327,16 @@ packages:
|
|||
'@redocly/ajv@8.11.0':
|
||||
resolution: {integrity: sha512-9GWx27t7xWhDIR02PA18nzBdLcKQRgc46xNQvjFkrYk4UOmvKhJ/dawwiX0cCOeetN5LcaaiqQbVOWYK62SGHw==}
|
||||
|
||||
'@redocly/cli@1.18.1':
|
||||
resolution: {integrity: sha512-+bRKj46R9wvTzMdnoYfMueJ9/ek0NprEsQNowV7XcHgOXifeFFikRtBFcpkwqCNxaQ/nWAJn4LHZaFcssbcHow==}
|
||||
'@redocly/cli@1.19.0':
|
||||
resolution: {integrity: sha512-ev6J0eD+quprvW9PVCl9JmRFZbj6cuK+mnYPAjcrPvesy2RF752fflcpgQjGnyFaGb1Cj+DiwDi3dYr3EAp04A==}
|
||||
engines: {node: '>=14.19.0', npm: '>=7.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@redocly/config@0.7.0':
|
||||
resolution: {integrity: sha512-6GKxTo/9df0654Mtivvr4lQnMOp+pRj9neVywmI5+BwfZLTtkJnj2qB3D6d8FHTr4apsNOf6zTa5FojX0Evh4g==}
|
||||
|
||||
'@redocly/openapi-core@1.18.1':
|
||||
resolution: {integrity: sha512-y2ZR3aaVF80XRVoFP0Dp2z5DeCOilPTuS7V4HnHIYZdBTfsqzjkO169h5JqAaifnaLsLBhe3YArdgLb7W7wW6Q==}
|
||||
'@redocly/openapi-core@1.19.0':
|
||||
resolution: {integrity: sha512-ezK6qr80sXvjDgHNrk/zmRs9vwpIAeHa0T/qmo96S+ib4ThQ5a8f3qjwEqxMeVxkxCTbkaY9sYSJKOxv4ejg5w==}
|
||||
engines: {node: '>=14.19.0', npm: '>=7.0.0'}
|
||||
|
||||
'@rollup/plugin-babel@5.3.1':
|
||||
|
@ -11653,9 +11653,9 @@ snapshots:
|
|||
require-from-string: 2.0.2
|
||||
uri-js: 4.4.1
|
||||
|
||||
'@redocly/cli@1.18.1(encoding@0.1.13)(enzyme@3.11.0)':
|
||||
'@redocly/cli@1.19.0(encoding@0.1.13)(enzyme@3.11.0)':
|
||||
dependencies:
|
||||
'@redocly/openapi-core': 1.18.1(encoding@0.1.13)
|
||||
'@redocly/openapi-core': 1.19.0(encoding@0.1.13)
|
||||
abort-controller: 3.0.0
|
||||
chokidar: 3.6.0
|
||||
colorette: 1.4.0
|
||||
|
@ -11684,7 +11684,7 @@ snapshots:
|
|||
|
||||
'@redocly/config@0.7.0': {}
|
||||
|
||||
'@redocly/openapi-core@1.18.1(encoding@0.1.13)':
|
||||
'@redocly/openapi-core@1.19.0(encoding@0.1.13)':
|
||||
dependencies:
|
||||
'@redocly/ajv': 8.11.0
|
||||
'@redocly/config': 0.7.0
|
||||
|
@ -18231,7 +18231,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.18.1(encoding@0.1.13)
|
||||
'@redocly/openapi-core': 1.19.0(encoding@0.1.13)
|
||||
classnames: 2.5.1
|
||||
core-js: 3.37.1
|
||||
decko: 1.2.0
|
||||
|
|
Loading…
Reference in a new issue