Merge branch 'backend' into develop

This commit is contained in:
j-weissen 2023-01-10 08:09:47 +01:00
commit b477cad232
30 changed files with 2600 additions and 0 deletions

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
POSTGRES_PORT=5432
EXPRESS_PORT=3000
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=rr

44
backend/README.md Normal file
View file

@ -0,0 +1,44 @@
## Database Schema
```mermaid
erDiagram
user {
serial id PK
string name
}
game {
serial game_id PK
int score
time playtime
date date
int user_id FK
}
user_scores {
int user_id PK
int highscore
int total_score
int total_playtime
int average_score
int games_played
}
lb_highscore {
int rank
int user_id
int highscore
}
lb_total_playtime {
int rank
int user_id
time total_playtime
}
user ||--O{ game : "played"
user ||--|| user_scores : ""
```
`lb_highscore` and `lb_total_playtime` are views querying the `user_scores` table.
A trigger function on insert to the `user` table creates a new row in `user_scores`. Everytime a new `game` is inserted, the row is updated.

View file

@ -0,0 +1,2 @@
build/
node_modules/

11
backend/api/Dockerfile Normal file
View file

@ -0,0 +1,11 @@
FROM node:18
COPY . /app
WORKDIR /app
RUN npm install
EXPOSE 3000
ENTRYPOINT ["npm", "run"]
CMD ["prod"]

1955
backend/api/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
backend/api/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "express-api",
"version": "1.0.0",
"description": "",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "ts-node-esm src/app.ts",
"prod": "tsc && node build/app.js"
},
"author": "jweissen",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"express-validator": "^6.14.2",
"helmet": "^6.0.1",
"morgan": "^1.10.0",
"pg-promise": "^10.15.4",
"ts-node": "^10.9.1",
"typescript": "^4.9.3"
},
"devDependencies": {
"@types/express": "^4.17.14",
"@types/morgan": "^1.9.3",
"@types/node": "^18.11.9"
}
}

View file

@ -0,0 +1,12 @@
import pgPromise from "pg-promise";
export abstract class Database {
private static _db = null;
static get db() {
if (Database._db == null) {
Database._db = pgPromise({})('postgres://postgres:postgres@db:5432/rr')
}
return Database._db;
}
}

27
backend/api/src/app.ts Normal file
View file

@ -0,0 +1,27 @@
import express from 'express';
import helmet from "helmet";
import morgan from 'morgan';
import cors from 'cors';
import {leaderboardRoute} from "./leaderboardRoute.js";
import {userRoute} from "./userRoute.js";
import {gameRoute} from "./gameRoute.js";
const app = express()
const port = 3000
app.use(helmet())
app.use(cors())
// configure & use logger
let morganFormatted = morgan('[:date[iso]] :method :url - :status')
app.use(morganFormatted);
app.use('/leaderboard', leaderboardRoute)
app.use('/user', userRoute)
app.use('/game', gameRoute)
app.listen(port, () => {
console.log(`Server started at http://localhost:3000`);
})

View file

@ -0,0 +1,50 @@
import express from "express";
import {GameRepository} from "./repositories/GameRepository.js";
import {GamePgPromiseRepository} from "./repositories/pgPromise/GamePgPromiseRepository.js";
import {Game} from "./model/Game.js";
import {body, CustomValidator, validationResult} from "express-validator";
import {UserRepository} from "./repositories/UserRepository.js";
import {UserPgPromiseRepository} from "./repositories/pgPromise/UserPgPromiseRepository.js";
export const gameRoute = express.Router()
gameRoute.use(express.json())
const userWithIdExists: CustomValidator = userId => {
try {
const userRepo: UserRepository = new UserPgPromiseRepository;
return userRepo.withIdExists(userId).then(exists => {
if (!exists) return Promise.reject("User does not exist");
});
} catch (error) {
console.log(error);
}
}
gameRoute.post(
'/add',
body('playtime')
.matches("([0-5]\\d:)?[0-5]\\d:[0-5]\\d"),
body('date')
.isDate(),
body('userId')
.isInt({min: 1})
.custom(userWithIdExists),
async (req, res) => {
try {
//region validate parameters
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
//endregion
let game: Game = req.body;
const gameRepo: GameRepository = new GamePgPromiseRepository;
const inserted: Game = await gameRepo.insert(game);
res.send(inserted);
} catch (error) {
// handle errors
console.log(error)
res.status(500).json({ errors: [{msg: "Internal server error"}]})
}
})

View file

@ -0,0 +1,32 @@
import express from 'express';
import {TimeLeaderboardRepository} from "./repositories/TimeLeaderboardRepository.js";
import {TimeLeaderboardPgPromiseRepository} from "./repositories/pgPromise/TimeLeaderboardPgPromiseRepository.js";
import {HighscoreLeaderboardPgPromiseRepository} from "./repositories/pgPromise/HighscoreLeaderboardPgPromiseRepository.js";
import {HighscoreLeaderboardRepository} from "./repositories/HighscoreLeaderboardRepository.js";
import {HighscoreLeaderboard, TimeLeaderboard} from "./model/Leaderboard.js";
export const leaderboardRoute = express.Router()
leaderboardRoute.get('/highscore', async (req, res) => {
try {
const highscoreLeaderboardManager: HighscoreLeaderboardRepository = new HighscoreLeaderboardPgPromiseRepository;
const highscoreLeaderboard: HighscoreLeaderboard = await highscoreLeaderboardManager.getAll();
res.send(highscoreLeaderboard);
} catch (error) {
// handle errors
console.log(error)
res.status(500).json({ errors: [{msg: "Internal server error"}]})
}
})
leaderboardRoute.get('/totalplaytime', async (req, res) => {
try {
const timeLeaderboardManager: TimeLeaderboardRepository = new TimeLeaderboardPgPromiseRepository;
const timeLeaderboard: TimeLeaderboard = await timeLeaderboardManager.getAll();
res.send(timeLeaderboard);
} catch (error) {
// handle errors
console.log(error)
res.status(500).json({ errors: [{msg: "Internal server error"}]})
}
})

View file

@ -0,0 +1,7 @@
export interface Game {
id?: number,
score: number,
playtime: string,
date: Date,
userId: number,
}

View file

@ -0,0 +1,10 @@
export type Leaderboard<T> = LeaderboardEntry<T>[];
export type HighscoreLeaderboard = Leaderboard<number>;
export type TimeLeaderboard = Leaderboard<string>;
export interface LeaderboardEntry<T> {
username: number,
rank: number,
score: T,
}

View file

@ -0,0 +1,4 @@
export interface User {
id?: number,
name: string,
}

View file

@ -0,0 +1,10 @@
export interface UserScores {
userId: number,
highscore: number,
totalScore: number,
totalPlaytime: string,
averageScore: number,
gamesPlayed: number,
}

View file

@ -0,0 +1,5 @@
import {Game} from "../model/Game.js";
export abstract class GameRepository {
abstract insert(game: Game): Promise<Game>;
}

View file

@ -0,0 +1,5 @@
import {HighscoreLeaderboard} from "../model/Leaderboard.js";
export abstract class HighscoreLeaderboardRepository {
abstract getAll(): Promise<HighscoreLeaderboard>;
}

View file

@ -0,0 +1,5 @@
import {TimeLeaderboard} from "../model/Leaderboard.js";
export abstract class TimeLeaderboardRepository {
abstract getAll(): Promise<TimeLeaderboard>;
}

View file

@ -0,0 +1,9 @@
import {User} from "../model/User.js";
export abstract class UserRepository {
abstract getById(id: number): Promise<User>;
abstract getByName(name: string): Promise<User>;
abstract withIdExists(userId: number): Promise<boolean>;
abstract withNameExists(username: string): Promise<boolean>
abstract insert(user: Omit<User, 'id'>): Promise<User>;
}

View file

@ -0,0 +1,5 @@
import {UserScores} from "../model/UserScores.js";
export abstract class UserScoresRepository {
abstract getById(userId: number): Promise<UserScores>;
}

View file

@ -0,0 +1,24 @@
import {GameRepository} from "../GameRepository.js";
import {Game} from "../../model/Game.js";
import {Database} from "../../Database.js";
export class GamePgPromiseRepository extends GameRepository{
public async insert(game: Game): Promise<Game> {
const raw: any = await Database.db.oneOrNone(
'INSERT INTO game (score, playtime, date, user_id) VALUES ($(score), $(playtime), $(date), $(userId)) RETURNING *;',
game
);
return this.serialize(raw);
}
serialize(raw: any): Game {
return {
id: raw.id,
score: raw.score,
playtime: raw.playtime,
date: raw.date,
userId: raw.userId,
};
}
}

View file

@ -0,0 +1,23 @@
import {HighscoreLeaderboardRepository} from "../HighscoreLeaderboardRepository.js";
import {HighscoreLeaderboard, LeaderboardEntry} from "../../model/Leaderboard.js";
import {Database} from "../../Database.js";
export class HighscoreLeaderboardPgPromiseRepository extends HighscoreLeaderboardRepository {
async getAll(): Promise<HighscoreLeaderboard> {
const raw: any = await Database.db.manyOrNone(
'SELECT * FROM lb_highscore INNER JOIN "user" ON user_id = id ORDER BY RANK;'
);
return this.serialize(raw);
}
protected serialize(raw: any): HighscoreLeaderboard {
return raw.map((item) => {
let newItem: LeaderboardEntry<number> = {
rank: item.rank,
username: item.name,
score: item.highscore,
}
return newItem;
});
}
}

View file

@ -0,0 +1,25 @@
import {TimeLeaderboardRepository} from "../TimeLeaderboardRepository.js";
import {LeaderboardEntry, TimeLeaderboard} from "../../model/Leaderboard.js";
import {Database} from "../../Database.js";
export class TimeLeaderboardPgPromiseRepository extends TimeLeaderboardRepository {
async getAll(): Promise<TimeLeaderboard> {
const raw: any = await Database.db.manyOrNone(
'SELECT * FROM lb_total_playtime INNER JOIN "user" ON user_id = id ORDER BY RANK;'
);
return this.serialize(raw);
}
//region serialization
protected serialize(raw: any): TimeLeaderboard {
return raw.map((item) => {
let newItem: LeaderboardEntry<string> = {
rank: item.rank,
username: item.name,
score: item.total_playtime,
}
return newItem
});
}
//endregion
}

View file

@ -0,0 +1,47 @@
import {UserRepository} from "../UserRepository.js";
import {User} from "../../model/User.js";
import {Database} from "../../Database.js";
export class UserPgPromiseRepository extends UserRepository {
async getById(id: number): Promise<User> {
const raw = await Database.db.oneOrNone(
'SELECT * FROM "user" WHERE id = $1;', id
);
return this.serialize(raw);
}
async getByName(name: string): Promise<User> {
const raw = await Database.db.oneOrNone(
'SELECT * FROM "user" WHERE name = $1;', name
);
return this.serialize(raw);
}
async withIdExists(id: number): Promise<boolean> {
const response = await Database.db.oneOrNone(
'SELECT count(*) AS row_count FROM "user" WHERE id = $1;', id
);
return response.row_count > 0;
}
async withNameExists(name: string): Promise<boolean> {
const response = await Database.db.oneOrNone(
'SELECT count(*) AS row_count FROM "user" WHERE name = $1;', name
);
return response.row_count > 0;
}
async insert(user: Omit<User, 'id'>): Promise<User> {
const raw = await Database.db.oneOrNone(
'INSERT INTO "user" (name) VALUES (${name}) RETURNING *;', user
);
return this.serialize(raw);
}
protected serialize(raw: any): User {
return {
id: raw.id,
name: raw.name
};
}
}

View file

@ -0,0 +1,23 @@
import {UserScoresRepository} from "../UserScoresRepository.js";
import {UserScores} from "../../model/UserScores.js";
import {Database} from "../../Database.js";
export class UserScoresPgPromiseRepository extends UserScoresRepository {
public async getById(id: number): Promise<UserScores> {
const raw = await Database.db.oneOrNone(
'SELECT * FROM user_scores WHERE user_id = $1;', id
);
return this.serialize(raw);
}
protected serialize(raw: any): UserScores {
return {
userId: raw.user_id,
highscore: raw.highscore,
totalScore: raw.total_score,
totalPlaytime: raw.total_playtime,
averageScore: raw.average_score,
gamesPlayed: raw.games_played,
};
}
}

View file

@ -0,0 +1,72 @@
import express from "express";
import { body, param, validationResult } from 'express-validator';
import {UserScoresPgPromiseRepository} from "./repositories/pgPromise/UserScoresPgPromiseRepository.js";
import {UserPgPromiseRepository} from "./repositories/pgPromise/UserPgPromiseRepository.js";
import {UserRepository} from "./repositories/UserRepository.js";
import {UserScoresRepository} from "./repositories/UserScoresRepository.js";
import {User} from "./model/User.js";
export const userRoute = express.Router()
userRoute.use(express.json())
userRoute.post(
'/register',
body('name')
.isString()
.isLength({min: 3, max: 32})
.matches('[a-zA-Z0-9_.\\- ]*'),
async (req, res) => {
try {
//region validate parameters
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
//endregion
const username: string = req.body.name;
const userManager: UserRepository = new UserPgPromiseRepository();
// check if username already exists
if (await userManager.withNameExists(username)) {
return res.status(400).json({ errors: [{msg: `User with name '${username}' already exists.`}] })
}
// insert & return user
const inserted: User = await userManager.insert({name: username});
res.json(inserted);
} catch (error) {
// handle errors
console.log(error)
res.status(500).json({ errors: [{msg: "Internal server error"}]})
}
}
)
userRoute.get('/:userId/scores',
param('userId').isInt({min: 1}),
async (req, res) => {
//region validate parameters
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
//endregion
const userId: number = req.params.userId;
const userManager: UserRepository = new UserPgPromiseRepository;
try {
// check if user with given id exists
if (!await userManager.withIdExists(userId)) {
return res.status(400).json({ errors: [{msg: `User with id ${userId} does not exist.`}] })
}
// get & return data
const userScoresManager: UserScoresRepository = new UserScoresPgPromiseRepository;
const userScores = await userScoresManager.getById(userId);
res.json(userScores);
} catch (error) {
// handle errors
console.log(error)
res.status(500).json({ errors: [{msg: "Internal server error"}]})
}
}
)

11
backend/api/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"compilerOptions": {
"esModuleInterop": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2020",
"sourceMap": true,
"outDir": "build/"
},
"include": ["src/**/*"]
}

3
backend/db/Dockerfile Normal file
View file

@ -0,0 +1,3 @@
FROM postgres:15-alpine
COPY initScripts/* /docker-entrypoint-initdb.d/

View file

@ -0,0 +1,81 @@
CREATE TABLE "user" (
id SERIAL PRIMARY KEY,
name VARCHAR(32) UNIQUE NOT NULL
);
CREATE TABLE user_scores (
user_id INT PRIMARY KEY,
highscore INT NOT NULL DEFAULT 0,
total_score INT NOT NULL DEFAULT 0,
total_playtime TIME NOT NULL DEFAULT '00:00:00',
average_score INT NOT NULL DEFAULT 0,
games_played INT NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES "user"
);
CREATE TABLE game (
id SERIAL PRIMARY KEY,
score INTEGER NOT NULL,
playtime TIME NOT NULL,
date DATE NOT NULL,
user_id INT NOT NULL,
FOREIGN KEY (user_id) REFERENCES "user"
);
CREATE VIEW lb_highscore AS (
SELECT row_number() OVER (ORDER BY highscore DESC) AS rank, user_id, highscore FROM user_scores ORDER BY rank
);
CREATE VIEW lb_total_playtime AS (
SELECT row_number() OVER (ORDER BY total_playtime DESC) AS rank, user_id, total_playtime AS total_playtime FROM user_scores ORDER BY rank
);
CREATE OR REPLACE FUNCTION insert_new_user_scores()
RETURNS TRIGGER
LANGUAGE plpgsql AS
$$
BEGIN
INSERT INTO user_scores (user_id) VALUES (NEW.id);
RETURN NEW;
END;
$$;
CREATE TRIGGER user_added
AFTER INSERT ON "user"
FOR EACH ROW
EXECUTE FUNCTION insert_new_user_scores();
CREATE OR REPLACE FUNCTION update_user_scores()
RETURNS TRIGGER
LANGUAGE plpgsql AS
$$
DECLARE
row user_scores%ROWTYPE;
BEGIN
SELECT
user_id,
max(score) AS highscore,
sum(score) AS total_score,
sum(playtime) AS total_playtime,
avg(score) AS average_score,
count(*) AS games_played
INTO row
FROM game
WHERE user_id = NEW.user_id
GROUP BY user_id;
UPDATE user_scores SET
highscore = row.highscore,
total_score = row.total_score,
total_playtime = row.total_playtime,
average_score = row.average_score,
games_played = row.games_played
WHERE user_id = row.user_id;
RETURN NEW;
END
$$;
CREATE TRIGGER game_played
AFTER INSERT ON game
FOR EACH ROW
EXECUTE FUNCTION update_user_scores();

View file

@ -0,0 +1,40 @@
insert into "user" (name) values ('dpettus0');
insert into "user" (name) values ('egreetland1');
insert into "user" (name) values ('smontford2');
insert into "user" (name) values ('idagwell3');
insert into "user" (name) values ('lgagan4');
insert into "user" (name) values ('acarmont5');
insert into "user" (name) values ('kjermyn6');
insert into "user" (name) values ('dokieran7');
insert into "user" (name) values ('pdrinkel8');
insert into game (user_id, score, playtime, date) values ('1', 74, '19:59', '2022-07-19');
insert into game (user_id, score, playtime, date) values ('1', 86, '20:32', '2022-11-24');
insert into game (user_id, score, playtime, date) values ('1', 68, '10:41', '2022-03-24');
insert into game (user_id, score, playtime, date) values ('2', 39, '5:55', '2022-06-01');
insert into game (user_id, score, playtime, date) values ('2', 20, '9:23', '2022-03-12');
insert into game (user_id, score, playtime, date) values ('2', 28, '23:45', '2022-04-01');
insert into game (user_id, score, playtime, date) values ('2', 44, '18:43', '2022-06-24');
insert into game (user_id, score, playtime, date) values ('3', 92, '14:54', '2022-11-06');
insert into game (user_id, score, playtime, date) values ('3', 73, '0:45', '2022-07-26');
insert into game (user_id, score, playtime, date) values ('3', 27, '2:49', '2022-02-03');
insert into game (user_id, score, playtime, date) values ('4', 26, '2:32', '2022-07-19');
insert into game (user_id, score, playtime, date) values ('4', 12, '17:03', '2022-04-25');
insert into game (user_id, score, playtime, date) values ('4', 6, '8:49', '2021-12-03');
insert into game (user_id, score, playtime, date) values ('4', 22, '22:27', '2022-03-02');
insert into game (user_id, score, playtime, date) values ('5', 94, '1:04', '2022-10-19');
insert into game (user_id, score, playtime, date) values ('5', 2, '2:14', '2022-04-06');
insert into game (user_id, score, playtime, date) values ('5', 21, '17:18', '2022-06-03');
insert into game (user_id, score, playtime, date) values ('6', 33, '16:01', '2022-02-02');
insert into game (user_id, score, playtime, date) values ('6', 27, '7:03', '2022-02-06');
insert into game (user_id, score, playtime, date) values ('6', 62, '0:45', '2022-11-15');
insert into game (user_id, score, playtime, date) values ('7', 12, '8:54', '2022-06-29');
insert into game (user_id, score, playtime, date) values ('7', 63, '16:01', '2022-11-05');
insert into game (user_id, score, playtime, date) values ('7', 29, '0:46', '2022-10-01');
insert into game (user_id, score, playtime, date) values ('8', 67, '1:27', '2022-09-29');
insert into game (user_id, score, playtime, date) values ('8', 84, '10:37', '2021-12-18');
insert into game (user_id, score, playtime, date) values ('8', 14, '19:14', '2022-01-31');
insert into game (user_id, score, playtime, date) values ('9', 21, '19:04', '2022-03-08');
insert into game (user_id, score, playtime, date) values ('9', 46, '2:34', '2022-04-18');
insert into game (user_id, score, playtime, date) values ('9', 78, '9:33', '2022-09-10');
insert into game (user_id, score, playtime, date) values ('9', 82, '11:19', '2022-11-29');

23
docker-compose.yml Normal file
View file

@ -0,0 +1,23 @@
version: '3.1'
services:
db:
build: backend/db
container_name: postgres-db
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${POSTGRES_PORT}:5432"
volumes:
- ./backend/pgdata:/var/lib/postgresql/data
api:
build: backend/api
depends_on:
- db
container_name: express-api
ports:
- "${EXPRESS_PORT}:3000"