impr: use tsrest for leaderboard endpoints (@fehmer) (#5717)

!nuf
This commit is contained in:
Christian Fehmer 2024-08-12 14:08:17 +02:00 committed by GitHub
parent d5b243cf57
commit c6e8f413fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1708 additions and 746 deletions

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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. */

View file

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

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

View file

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

View file

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

View file

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