LETS GO BABY
This commit is contained in:
4
projects/backend/.gitignore
vendored
4
projects/backend/.gitignore
vendored
@ -39,4 +39,6 @@ yarn-error.log*
|
||||
**/*.tgz
|
||||
**/*.log
|
||||
package-lock.json
|
||||
**/*.bun
|
||||
**/*.bun
|
||||
|
||||
.env
|
@ -2,21 +2,24 @@
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"start": "bun run src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bogeychan/elysia-etag": "^0.0.6",
|
||||
"@dotenvx/dotenvx": "^1.16.1",
|
||||
"@elysiajs/cors": "^1.1.1",
|
||||
"@elysiajs/cron": "^1.1.1",
|
||||
"@elysiajs/swagger": "^1.1.3",
|
||||
"@ssr/common": "workspace:common",
|
||||
"@tqman/nice-logger": "^1.0.1",
|
||||
"@typegoose/typegoose": "^12.8.0",
|
||||
"elysia": "latest",
|
||||
"elysia-autoroutes": "^0.5.0",
|
||||
"elysia-decorators": "^1.0.2",
|
||||
"elysia-helmet": "^2.0.0",
|
||||
"elysia-rate-limit": "^4.1.0"
|
||||
"elysia-rate-limit": "^4.1.0",
|
||||
"mongoose": "^8.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest"
|
||||
|
3
projects/backend/src/common/config.ts
Normal file
3
projects/backend/src/common/config.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const Config = {
|
||||
mongoUri: process.env.MONGO_URI,
|
||||
}
|
54
projects/backend/src/controller/player.controller.ts
Normal file
54
projects/backend/src/controller/player.controller.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { Controller, Get } from "elysia-decorators";
|
||||
import { PlayerService } from "../service/player.service";
|
||||
import { t } from "elysia";
|
||||
import { PlayerHistory } from "@ssr/common/types/player/player-history";
|
||||
import { PlayerTrackedSince } from "@ssr/common/types/player/player-tracked-since";
|
||||
|
||||
@Controller("/player")
|
||||
export default class PlayerController {
|
||||
@Get("/history/:id", {
|
||||
config: {},
|
||||
params: t.Object({
|
||||
id: t.String({ required: true }),
|
||||
}),
|
||||
query: t.Object({
|
||||
createIfMissing: t.Boolean({ default: false, required: false }),
|
||||
}),
|
||||
})
|
||||
public async getPlayer({
|
||||
params: { id },
|
||||
query: { createIfMissing },
|
||||
}: {
|
||||
params: { id: string };
|
||||
query: { createIfMissing: boolean };
|
||||
}): Promise<{ statistics: Record<string, PlayerHistory> }> {
|
||||
const player = await PlayerService.getPlayer(id, createIfMissing);
|
||||
return { statistics: player.getHistoryPreviousDays(50) };
|
||||
}
|
||||
|
||||
@Get("/tracked/:id", {
|
||||
config: {},
|
||||
params: t.Object({
|
||||
id: t.String({ required: true }),
|
||||
}),
|
||||
})
|
||||
public async getTrackedStatus({
|
||||
params: { id },
|
||||
query: { createIfMissing },
|
||||
}: {
|
||||
params: { id: string };
|
||||
query: { createIfMissing: boolean };
|
||||
}): Promise<PlayerTrackedSince> {
|
||||
try {
|
||||
const player = await PlayerService.getPlayer(id, createIfMissing);
|
||||
return {
|
||||
tracked: true,
|
||||
daysTracked: player.getDaysTracked(),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
tracked: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
10
projects/backend/src/error/not-found-error.ts
Normal file
10
projects/backend/src/error/not-found-error.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { HttpCode } from "../common/http-codes";
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
constructor(
|
||||
public message: string = "not-found",
|
||||
public status: number = HttpCode.NOT_FOUND.code
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
@ -2,10 +2,9 @@ import { HttpCode } from "../common/http-codes";
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
constructor(
|
||||
public message: string = 'rate-limited',
|
||||
public detail: string = '',
|
||||
public message: string = "rate-limited",
|
||||
public status: number = HttpCode.TOO_MANY_REQUESTS.code
|
||||
) {
|
||||
super(message)
|
||||
super(message);
|
||||
}
|
||||
}
|
@ -2,14 +2,29 @@ import { Elysia } from "elysia";
|
||||
import cors from "@elysiajs/cors";
|
||||
import { decorators } from "elysia-decorators";
|
||||
import { logger } from "@tqman/nice-logger";
|
||||
import { swagger } from '@elysiajs/swagger'
|
||||
import { rateLimit } from 'elysia-rate-limit'
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
import { rateLimit } from "elysia-rate-limit";
|
||||
import { RateLimitError } from "./error/rate-limit-error";
|
||||
import { helmet } from 'elysia-helmet';
|
||||
import { etag } from '@bogeychan/elysia-etag'
|
||||
import { helmet } from "elysia-helmet";
|
||||
import { etag } from "@bogeychan/elysia-etag";
|
||||
import AppController from "./controller/app.controller";
|
||||
import * as dotenv from "@dotenvx/dotenvx";
|
||||
import mongoose from "mongoose";
|
||||
import { Config } from "./common/config";
|
||||
import { setLogLevel } from "@typegoose/typegoose";
|
||||
import PlayerController from "./controller/player.controller";
|
||||
import { PlayerService } from "./service/player.service";
|
||||
|
||||
const app = new Elysia();
|
||||
// Load .env file
|
||||
dotenv.config({
|
||||
logLevel: "success",
|
||||
path: ".env",
|
||||
override: true,
|
||||
});
|
||||
|
||||
await mongoose.connect(Config.mongoUri!); // Connect to MongoDB
|
||||
setLogLevel("DEBUG");
|
||||
export const app = new Elysia();
|
||||
|
||||
/**
|
||||
* Custom error handler
|
||||
@ -50,33 +65,37 @@ app.use(
|
||||
/**
|
||||
* Rate limit (100 requests per minute)
|
||||
*/
|
||||
app.use(rateLimit({
|
||||
scoping: "global",
|
||||
duration: 60 * 1000,
|
||||
max: 100,
|
||||
skip: (request) => {
|
||||
let [ _, path ] = request.url.split("/"); // Get the url parts
|
||||
path === "" || path === undefined && (path = "/"); // If we're on /, the path is undefined, so we set it to /
|
||||
return path === "/"; // ignore all requests to /
|
||||
},
|
||||
errorResponse: new RateLimitError("Too many requests, please try again later"),
|
||||
}))
|
||||
app.use(
|
||||
rateLimit({
|
||||
scoping: "global",
|
||||
duration: 60 * 1000,
|
||||
max: 100,
|
||||
skip: request => {
|
||||
let [_, path] = request.url.split("/"); // Get the url parts
|
||||
path === "" || (path === undefined && (path = "/")); // If we're on /, the path is undefined, so we set it to /
|
||||
return path === "/"; // ignore all requests to /
|
||||
},
|
||||
errorResponse: new RateLimitError("Too many requests, please try again later"),
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Security settings
|
||||
*/
|
||||
app.use(helmet({
|
||||
hsts: false, // Disable HSTS
|
||||
contentSecurityPolicy: false, // Disable CSP
|
||||
dnsPrefetchControl: true, // Enable DNS prefetch
|
||||
}))
|
||||
app.use(
|
||||
helmet({
|
||||
hsts: false, // Disable HSTS
|
||||
contentSecurityPolicy: false, // Disable CSP
|
||||
dnsPrefetchControl: true, // Enable DNS prefetch
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Controllers
|
||||
*/
|
||||
app.use(
|
||||
decorators({
|
||||
controllers: [AppController],
|
||||
controllers: [AppController, PlayerController],
|
||||
})
|
||||
);
|
||||
|
||||
@ -89,4 +108,9 @@ app.onStart(() => {
|
||||
console.log("Listening on port http://localhost:8080");
|
||||
});
|
||||
|
||||
/**
|
||||
* Start cronjobs
|
||||
*/
|
||||
PlayerService.initCronjobs();
|
||||
|
||||
app.listen(8080);
|
||||
|
108
projects/backend/src/model/player.ts
Normal file
108
projects/backend/src/model/player.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { getModelForClass, prop, ReturnModelType } from "@typegoose/typegoose";
|
||||
import { Document } from "mongoose";
|
||||
import { PlayerHistory } from "@ssr/common/types/player/player-history";
|
||||
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils";
|
||||
|
||||
/**
|
||||
* The model for a player.
|
||||
*/
|
||||
export class Player {
|
||||
/**
|
||||
* The id of the player.
|
||||
*/
|
||||
@prop()
|
||||
public _id!: string;
|
||||
|
||||
/**
|
||||
* The player's statistic history.
|
||||
*/
|
||||
@prop()
|
||||
private statisticHistory?: Record<string, PlayerHistory>;
|
||||
|
||||
@prop()
|
||||
public lastTracked?: Date;
|
||||
|
||||
/**
|
||||
* Gets the player's statistic history.
|
||||
*/
|
||||
public getStatisticHistory(): Record<string, PlayerHistory> {
|
||||
if (this.statisticHistory === undefined) {
|
||||
this.statisticHistory = {};
|
||||
}
|
||||
return this.statisticHistory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the player's history for a specific date.
|
||||
*
|
||||
* @param date the date to get the history for.
|
||||
*/
|
||||
public getHistoryByDate(date: Date): PlayerHistory {
|
||||
if (this.statisticHistory === undefined) {
|
||||
this.statisticHistory = {};
|
||||
}
|
||||
return this.getStatisticHistory()[formatDateMinimal(getMidnightAlignedDate(date))] || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the player's history for the previous X days.
|
||||
*
|
||||
* @param days the number of days to get the history for.
|
||||
*/
|
||||
public getHistoryPreviousDays(days: number): Record<string, PlayerHistory> {
|
||||
if (this.statisticHistory === undefined) {
|
||||
this.statisticHistory = {};
|
||||
}
|
||||
const history: Record<string, PlayerHistory> = {};
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = formatDateMinimal(getMidnightAlignedDate(getDaysAgoDate(i)));
|
||||
const playerHistory = this.getStatisticHistory()[date];
|
||||
if (playerHistory === undefined || Object.keys(playerHistory).length === 0) {
|
||||
continue;
|
||||
}
|
||||
history[date] = playerHistory;
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the player's statistic history.
|
||||
*
|
||||
* @param date the date to set it for.
|
||||
* @param history the history to set.
|
||||
*/
|
||||
public setStatisticHistory(date: Date, history: PlayerHistory) {
|
||||
if (this.statisticHistory === undefined) {
|
||||
this.statisticHistory = {};
|
||||
}
|
||||
this.getStatisticHistory()[formatDateMinimal(getMidnightAlignedDate(date))] = history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts the player's statistic history by
|
||||
* date in descending order. (oldest to newest)
|
||||
*/
|
||||
public sortStatisticHistory() {
|
||||
if (this.statisticHistory === undefined) {
|
||||
this.statisticHistory = {};
|
||||
}
|
||||
return Object.entries(this.getStatisticHistory())
|
||||
.sort((a, b) => Date.parse(b[0]) - Date.parse(a[0]))
|
||||
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of days tracked.
|
||||
*
|
||||
* @returns the number of days tracked.
|
||||
*/
|
||||
public getDaysTracked(): number {
|
||||
return Object.keys(this.getStatisticHistory()).length;
|
||||
}
|
||||
}
|
||||
|
||||
// This type defines a Mongoose document based on Player.
|
||||
export type PlayerDocument = Player & Document;
|
||||
|
||||
// This type ensures that PlayerModel returns Mongoose documents (PlayerDocument) that have Mongoose methods (save, remove, etc.)
|
||||
export const PlayerModel: ReturnModelType<typeof Player> = getModelForClass(Player);
|
126
projects/backend/src/service/player.service.ts
Normal file
126
projects/backend/src/service/player.service.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { PlayerDocument, PlayerModel } from "../model/player";
|
||||
import { NotFoundError } from "../error/not-found-error";
|
||||
import { cron } from "@elysiajs/cron";
|
||||
import { app } from "../index";
|
||||
import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils";
|
||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||
|
||||
export class PlayerService {
|
||||
/**
|
||||
* Initialize the cron jobs
|
||||
*/
|
||||
public static initCronjobs() {
|
||||
app.use(
|
||||
cron({
|
||||
name: "player-statistics-tracker-cron",
|
||||
pattern: "0 1 * * *", // Every day at 00:01 (midnight)
|
||||
run: async () => {
|
||||
const players: PlayerDocument[] = await PlayerModel.find({});
|
||||
for (const player of players) {
|
||||
await PlayerService.trackScoreSaberPlayer(getMidnightAlignedDate(new Date()), player);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a player from the database.
|
||||
*
|
||||
* @param id the player to fetch
|
||||
* @param create if true, create the player if it doesn't exist
|
||||
* @returns the player
|
||||
* @throws NotFoundError if the player is not found
|
||||
*/
|
||||
public static async getPlayer(id: string, create: boolean = false): Promise<PlayerDocument> {
|
||||
console.log(`Fetching player "${id}"...`);
|
||||
let player: PlayerDocument | null = await PlayerModel.findById(id);
|
||||
if (player === null && !create) {
|
||||
console.log(`Player "${id}" not found.`);
|
||||
throw new NotFoundError(`Player "${id}" not found`);
|
||||
}
|
||||
if (player === null) {
|
||||
const playerToken = await scoresaberService.lookupPlayer(id);
|
||||
if (playerToken === undefined) {
|
||||
throw new NotFoundError(`Player "${id}" not found`);
|
||||
}
|
||||
|
||||
console.log(`Creating player "${id}"...`);
|
||||
player = (await PlayerModel.create({ _id: id })) as any;
|
||||
if (player === null) {
|
||||
throw new NotFoundError(`Player "${id}" not found`);
|
||||
}
|
||||
await this.seedPlayerHistory(player, playerToken);
|
||||
console.log(`Created player "${id}".`);
|
||||
} else {
|
||||
console.log(`Found player "${id}".`);
|
||||
}
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the player's history using data from
|
||||
* the ScoreSaber API.
|
||||
*
|
||||
* @param player the player to seed
|
||||
* @param playerToken the SoreSaber player token
|
||||
*/
|
||||
public static async seedPlayerHistory(player: PlayerDocument, playerToken: ScoreSaberPlayerToken): Promise<void> {
|
||||
// Loop through rankHistory in reverse, from current day backwards
|
||||
const playerRankHistory = playerToken.histories.split(",").map((value: string) => {
|
||||
return parseInt(value);
|
||||
});
|
||||
playerRankHistory.push(playerToken.rank);
|
||||
|
||||
let daysAgo = 1; // Start from yesterday
|
||||
for (let i = playerRankHistory.length - daysAgo - 1; i >= 0; i--) {
|
||||
const rank = playerRankHistory[i];
|
||||
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
|
||||
player.setStatisticHistory(date, {
|
||||
rank: rank,
|
||||
});
|
||||
daysAgo += 1; // Increment daysAgo for each earlier rank
|
||||
}
|
||||
await player.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a players statistics
|
||||
*
|
||||
* @param dateToday the date to track
|
||||
* @param foundPlayer the player to track
|
||||
*/
|
||||
public static async trackScoreSaberPlayer(dateToday: Date, foundPlayer: PlayerDocument) {
|
||||
const player = await scoresaberService.lookupPlayer(foundPlayer.id);
|
||||
if (player == undefined) {
|
||||
console.log(`Player "${foundPlayer.id}" not found on ScoreSaber`);
|
||||
return;
|
||||
}
|
||||
if (player.inactive) {
|
||||
console.log(`Player "${foundPlayer.id}" is inactive on ScoreSaber`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Seed the history with ScoreSaber data if no history exists
|
||||
if (foundPlayer.getDaysTracked() === 0) {
|
||||
await this.seedPlayerHistory(foundPlayer, player);
|
||||
}
|
||||
|
||||
// Update current day's statistics
|
||||
let history = foundPlayer.getHistoryByDate(dateToday);
|
||||
if (history == undefined) {
|
||||
history = {}; // Initialize if history is not found
|
||||
}
|
||||
// Set the history data
|
||||
history.pp = player.pp;
|
||||
history.countryRank = player.countryRank;
|
||||
history.rank = player.rank;
|
||||
foundPlayer.setStatisticHistory(dateToday, history);
|
||||
foundPlayer.sortStatisticHistory();
|
||||
foundPlayer.lastTracked = new Date();
|
||||
await foundPlayer.save();
|
||||
|
||||
console.log(`Tracked player "${foundPlayer.id}"!`);
|
||||
}
|
||||
}
|
@ -1,12 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["bun-types"],
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user