Merge branch 'backend' into develop
This commit is contained in:
commit
b477cad232
30 changed files with 2600 additions and 0 deletions
7
.env.example
Normal file
7
.env.example
Normal 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
44
backend/README.md
Normal 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.
|
||||||
2
backend/api/.dockerignore
Normal file
2
backend/api/.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
build/
|
||||||
|
node_modules/
|
||||||
11
backend/api/Dockerfile
Normal file
11
backend/api/Dockerfile
Normal 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
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
28
backend/api/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
backend/api/src/Database.ts
Normal file
12
backend/api/src/Database.ts
Normal 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
27
backend/api/src/app.ts
Normal 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`);
|
||||||
|
})
|
||||||
50
backend/api/src/gameRoute.ts
Normal file
50
backend/api/src/gameRoute.ts
Normal 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"}]})
|
||||||
|
}
|
||||||
|
})
|
||||||
32
backend/api/src/leaderboardRoute.ts
Normal file
32
backend/api/src/leaderboardRoute.ts
Normal 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"}]})
|
||||||
|
}
|
||||||
|
})
|
||||||
7
backend/api/src/model/Game.ts
Normal file
7
backend/api/src/model/Game.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface Game {
|
||||||
|
id?: number,
|
||||||
|
score: number,
|
||||||
|
playtime: string,
|
||||||
|
date: Date,
|
||||||
|
userId: number,
|
||||||
|
}
|
||||||
10
backend/api/src/model/Leaderboard.ts
Normal file
10
backend/api/src/model/Leaderboard.ts
Normal 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,
|
||||||
|
}
|
||||||
4
backend/api/src/model/User.ts
Normal file
4
backend/api/src/model/User.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface User {
|
||||||
|
id?: number,
|
||||||
|
name: string,
|
||||||
|
}
|
||||||
10
backend/api/src/model/UserScores.ts
Normal file
10
backend/api/src/model/UserScores.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
|
||||||
|
|
||||||
|
export interface UserScores {
|
||||||
|
userId: number,
|
||||||
|
highscore: number,
|
||||||
|
totalScore: number,
|
||||||
|
totalPlaytime: string,
|
||||||
|
averageScore: number,
|
||||||
|
gamesPlayed: number,
|
||||||
|
}
|
||||||
5
backend/api/src/repositories/GameRepository.ts
Normal file
5
backend/api/src/repositories/GameRepository.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {Game} from "../model/Game.js";
|
||||||
|
|
||||||
|
export abstract class GameRepository {
|
||||||
|
abstract insert(game: Game): Promise<Game>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {HighscoreLeaderboard} from "../model/Leaderboard.js";
|
||||||
|
|
||||||
|
export abstract class HighscoreLeaderboardRepository {
|
||||||
|
abstract getAll(): Promise<HighscoreLeaderboard>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {TimeLeaderboard} from "../model/Leaderboard.js";
|
||||||
|
|
||||||
|
export abstract class TimeLeaderboardRepository {
|
||||||
|
abstract getAll(): Promise<TimeLeaderboard>;
|
||||||
|
}
|
||||||
9
backend/api/src/repositories/UserRepository.ts
Normal file
9
backend/api/src/repositories/UserRepository.ts
Normal 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>;
|
||||||
|
}
|
||||||
5
backend/api/src/repositories/UserScoresRepository.ts
Normal file
5
backend/api/src/repositories/UserScoresRepository.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {UserScores} from "../model/UserScores.js";
|
||||||
|
|
||||||
|
export abstract class UserScoresRepository {
|
||||||
|
abstract getById(userId: number): Promise<UserScores>;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
72
backend/api/src/userRoute.ts
Normal file
72
backend/api/src/userRoute.ts
Normal 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
11
backend/api/tsconfig.json
Normal 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
3
backend/db/Dockerfile
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
FROM postgres:15-alpine
|
||||||
|
|
||||||
|
COPY initScripts/* /docker-entrypoint-initdb.d/
|
||||||
81
backend/db/initScripts/_createSchema.sql
Normal file
81
backend/db/initScripts/_createSchema.sql
Normal 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();
|
||||||
40
backend/db/initScripts/loadMockData.sql
Normal file
40
backend/db/initScripts/loadMockData.sql
Normal 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
23
docker-compose.yml
Normal 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"
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue