Merge pull request 'feature/56-stats_endpoint' (#60) from feature/56-stats_endpoint into dev
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				continuous-integration/drone/push Build is passing
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	continuous-integration/drone/push Build is passing
				
			Reviewed-on: #60 closes #56
This commit is contained in:
		| @@ -1,9 +1,9 @@ | ||||
| import consola from "consola"; | ||||
| import "reflect-metadata"; | ||||
| import { createExpressServer } from "routing-controllers"; | ||||
| import authchecker from "./authchecker"; | ||||
| import { config, e as errors } from './config'; | ||||
| import loaders from "./loaders/index"; | ||||
| import authchecker from "./middlewares/authchecker"; | ||||
| import { ErrorHandler } from './middlewares/ErrorHandler'; | ||||
|  | ||||
| const CONTROLLERS_FILE_EXTENSION = process.env.NODE_ENV === 'production' ? 'js' : 'ts'; | ||||
|   | ||||
							
								
								
									
										75
									
								
								src/controllers/StatsClientController.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/controllers/StatsClientController.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | ||||
| import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post } from 'routing-controllers'; | ||||
| import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; | ||||
| import { getConnectionManager, Repository } from 'typeorm'; | ||||
| import { StatsClientNotFoundError } from '../errors/StatsClientErrors'; | ||||
| import { TrackNotFoundError } from "../errors/TrackErrors"; | ||||
| import { CreateStatsClient } from '../models/actions/CreateStatsClient'; | ||||
| import { StatsClient } from '../models/entities/StatsClient'; | ||||
| import { ResponseEmpty } from '../models/responses/ResponseEmpty'; | ||||
| import { ResponseStatsClient } from '../models/responses/ResponseStatsClient'; | ||||
|  | ||||
| @JsonController('/statsclients') | ||||
| @OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) | ||||
| export class StatsClientController { | ||||
| 	private clientRepository: Repository<StatsClient>; | ||||
|  | ||||
| 	/** | ||||
| 	 * Gets the repository of this controller's model/entity. | ||||
| 	 */ | ||||
| 	constructor() { | ||||
| 		this.clientRepository = getConnectionManager().get().getRepository(StatsClient); | ||||
| 	} | ||||
|  | ||||
| 	@Get() | ||||
| 	@Authorized("STATSCLIENT:GET") | ||||
| 	@ResponseSchema(ResponseStatsClient, { isArray: true }) | ||||
| 	@OpenAPI({ description: 'Lists all stats clients. Please remember that the key can only be viewed on creation.' }) | ||||
| 	async getAll() { | ||||
| 		let responseClients: ResponseStatsClient[] = new Array<ResponseStatsClient>(); | ||||
| 		const clients = await this.clientRepository.find(); | ||||
| 		clients.forEach(clients => { | ||||
| 			responseClients.push(new ResponseStatsClient(clients)); | ||||
| 		}); | ||||
| 		return responseClients; | ||||
| 	} | ||||
|  | ||||
| 	@Get('/:id') | ||||
| 	@Authorized("STATSCLIENT:GET") | ||||
| 	@ResponseSchema(ResponseStatsClient) | ||||
| 	@ResponseSchema(StatsClientNotFoundError, { statusCode: 404 }) | ||||
| 	@OnUndefined(StatsClientNotFoundError) | ||||
| 	@OpenAPI({ description: "Lists all information about the stats client whose id got provided. Please remember that the key can only be viewed on creation." }) | ||||
| 	async getOne(@Param('id') id: number) { | ||||
| 		let client = await this.clientRepository.findOne({ id: id }); | ||||
| 		if (!client) { throw new TrackNotFoundError(); } | ||||
| 		return new ResponseStatsClient(client); | ||||
| 	} | ||||
|  | ||||
| 	@Post() | ||||
| 	@Authorized("STATSCLIENT:CREATE") | ||||
| 	@ResponseSchema(ResponseStatsClient) | ||||
| 	@OpenAPI({ description: "Create a new stats client. <br> Please remember that the client\'s key will be generated automaticly and that it can only be viewed on creation." }) | ||||
| 	async post( | ||||
| 		@Body({ validate: true }) | ||||
| 		client: CreateStatsClient | ||||
| 	) { | ||||
| 		let newClient = await this.clientRepository.save(await client.toStatsClient()); | ||||
| 		let responseClient = new ResponseStatsClient(newClient); | ||||
| 		responseClient.key = newClient.cleartextkey; | ||||
| 		return responseClient; | ||||
| 	} | ||||
|  | ||||
| 	@Delete('/:id') | ||||
| 	@Authorized("STATSCLIENT:DELETE") | ||||
| 	@ResponseSchema(ResponseStatsClient) | ||||
| 	@ResponseSchema(ResponseEmpty, { statusCode: 204 }) | ||||
| 	@OnUndefined(204) | ||||
| 	@OpenAPI({ description: "Delete the stats client whose id you provided. <br> If no client with this id exists it will just return 204(no content)." }) | ||||
| 	async remove(@Param("id") id: number) { | ||||
| 		let client = await this.clientRepository.findOne({ id: id }); | ||||
| 		if (!client) { return null; } | ||||
|  | ||||
| 		await this.clientRepository.delete(client); | ||||
| 		return new ResponseStatsClient(client); | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										124
									
								
								src/controllers/StatsController.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								src/controllers/StatsController.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,124 @@ | ||||
| import { Get, JsonController, UseBefore } from 'routing-controllers'; | ||||
| import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; | ||||
| import { getConnection } from 'typeorm'; | ||||
| import StatsAuth from '../middlewares/StatsAuth'; | ||||
| import { Donation } from '../models/entities/Donation'; | ||||
| import { Runner } from '../models/entities/Runner'; | ||||
| import { RunnerOrganisation } from '../models/entities/RunnerOrganisation'; | ||||
| import { RunnerTeam } from '../models/entities/RunnerTeam'; | ||||
| import { Scan } from '../models/entities/Scan'; | ||||
| import { User } from '../models/entities/User'; | ||||
| import { ResponseStats } from '../models/responses/ResponseStats'; | ||||
| import { ResponseStatsOrgnisation } from '../models/responses/ResponseStatsOrganisation'; | ||||
| import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner'; | ||||
| import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam'; | ||||
|  | ||||
| @JsonController('/stats') | ||||
| export class StatsController { | ||||
|  | ||||
|     @Get() | ||||
|     @ResponseSchema(ResponseStats) | ||||
|     @OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" }) | ||||
|     async get() { | ||||
|         let connection = getConnection(); | ||||
|         let runners = await connection.getRepository(Runner).find({ relations: ['scans', 'scans.track'] }); | ||||
|         let teams = await connection.getRepository(RunnerTeam).find(); | ||||
|         let orgs = await connection.getRepository(RunnerOrganisation).find(); | ||||
|         let users = await connection.getRepository(User).find(); | ||||
|         let scans = await connection.getRepository(Scan).find(); | ||||
|         let donations = await connection.getRepository(Donation).find({ relations: ['runner', 'runner.scans', 'runner.scans.track'] }); | ||||
|         return new ResponseStats(runners, teams, orgs, users, scans, donations) | ||||
|     } | ||||
|  | ||||
|     @Get("/runners/distance") | ||||
|     @UseBefore(StatsAuth) | ||||
|     @ResponseSchema(ResponseStatsRunner, { isArray: true }) | ||||
|     @OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) | ||||
|     async getTopRunnersByDistance() { | ||||
|         let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] }); | ||||
|         let topRunners = runners.sort((runner1, runner2) => runner1.distance - runner2.distance).slice(0, 9); | ||||
|         let responseRunners: ResponseStatsRunner[] = new Array<ResponseStatsRunner>(); | ||||
|         topRunners.forEach(runner => { | ||||
|             responseRunners.push(new ResponseStatsRunner(runner)); | ||||
|         }); | ||||
|         return responseRunners; | ||||
|     } | ||||
|  | ||||
|     @Get("/runners/donations") | ||||
|     @UseBefore(StatsAuth) | ||||
|     @ResponseSchema(ResponseStatsRunner, { isArray: true }) | ||||
|     @OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) | ||||
|     async getTopRunnersByDonations() { | ||||
|         let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] }); | ||||
|         let topRunners = runners.sort((runner1, runner2) => runner1.distanceDonationAmount - runner2.distanceDonationAmount).slice(0, 9); | ||||
|         let responseRunners: ResponseStatsRunner[] = new Array<ResponseStatsRunner>(); | ||||
|         topRunners.forEach(runner => { | ||||
|             responseRunners.push(new ResponseStatsRunner(runner)); | ||||
|         }); | ||||
|         return responseRunners; | ||||
|     } | ||||
|  | ||||
|     @Get("/scans") | ||||
|     @UseBefore(StatsAuth) | ||||
|     @ResponseSchema(ResponseStatsRunner, { isArray: true }) | ||||
|     @OpenAPI({ description: "Returns the top ten fastest track times (with their runner and the runner's group).", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) | ||||
|     async getTopRunnersByTrackTime() { | ||||
|         throw new Error("Not implemented yet.") | ||||
|     } | ||||
|  | ||||
|     @Get("/teams/distance") | ||||
|     @UseBefore(StatsAuth) | ||||
|     @ResponseSchema(ResponseStatsTeam, { isArray: true }) | ||||
|     @OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) | ||||
|     async getTopTeamsByDistance() { | ||||
|         let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] }); | ||||
|         let topTeams = teams.sort((team1, team2) => team1.distance - team2.distance).slice(0, 9); | ||||
|         let responseTeams: ResponseStatsTeam[] = new Array<ResponseStatsTeam>(); | ||||
|         topTeams.forEach(team => { | ||||
|             responseTeams.push(new ResponseStatsTeam(team)); | ||||
|         }); | ||||
|         return responseTeams; | ||||
|     } | ||||
|  | ||||
|     @Get("/teams/donations") | ||||
|     @UseBefore(StatsAuth) | ||||
|     @ResponseSchema(ResponseStatsTeam, { isArray: true }) | ||||
|     @OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) | ||||
|     async getTopTeamsByDonations() { | ||||
|         let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] }); | ||||
|         let topTeams = teams.sort((team1, team2) => team1.distanceDonationAmount - team2.distanceDonationAmount).slice(0, 9); | ||||
|         let responseTeams: ResponseStatsTeam[] = new Array<ResponseStatsTeam>(); | ||||
|         topTeams.forEach(team => { | ||||
|             responseTeams.push(new ResponseStatsTeam(team)); | ||||
|         }); | ||||
|         return responseTeams; | ||||
|     } | ||||
|  | ||||
|     @Get("/organisations/distance") | ||||
|     @UseBefore(StatsAuth) | ||||
|     @ResponseSchema(ResponseStatsOrgnisation, { isArray: true }) | ||||
|     @OpenAPI({ description: "Returns the top ten organisations by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) | ||||
|     async getTopOrgsByDistance() { | ||||
|         let orgs = await getConnection().getRepository(RunnerOrganisation).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] }); | ||||
|         let topOrgs = orgs.sort((org1, org2) => org1.distance - org2.distance).slice(0, 9); | ||||
|         let responseOrgs: ResponseStatsOrgnisation[] = new Array<ResponseStatsOrgnisation>(); | ||||
|         topOrgs.forEach(org => { | ||||
|             responseOrgs.push(new ResponseStatsOrgnisation(org)); | ||||
|         }); | ||||
|         return responseOrgs; | ||||
|     } | ||||
|  | ||||
|     @Get("/organisations/donations") | ||||
|     @UseBefore(StatsAuth) | ||||
|     @ResponseSchema(ResponseStatsOrgnisation, { isArray: true }) | ||||
|     @OpenAPI({ description: "Returns the top ten organisations by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) | ||||
|     async getTopOrgsByDonations() { | ||||
|         let orgs = await getConnection().getRepository(RunnerOrganisation).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] }); | ||||
|         let topOrgs = orgs.sort((org1, org2) => org1.distanceDonationAmount - org2.distanceDonationAmount).slice(0, 9); | ||||
|         let responseOrgs: ResponseStatsOrgnisation[] = new Array<ResponseStatsOrgnisation>(); | ||||
|         topOrgs.forEach(org => { | ||||
|             responseOrgs.push(new ResponseStatsOrgnisation(org)); | ||||
|         }); | ||||
|         return responseOrgs; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										25
									
								
								src/errors/StatsClientErrors.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/errors/StatsClientErrors.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| import { IsString } from 'class-validator'; | ||||
| import { NotAcceptableError, NotFoundError } from 'routing-controllers'; | ||||
|  | ||||
| /** | ||||
|  * Error to throw, when a non-existant stats client get's loaded. | ||||
|  */ | ||||
| export class StatsClientNotFoundError extends NotFoundError { | ||||
| 	@IsString() | ||||
| 	name = "StatsClientNotFoundError" | ||||
|  | ||||
| 	@IsString() | ||||
| 	message = "The stats client you provided couldn't be located in the system. \n Please check your request." | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Error to throw when two stats clients' ids don't match. | ||||
|  * Usually occurs when a user tries to change a stats client's id. | ||||
|  */ | ||||
| export class StatsClientIdsNotMatchingError extends NotAcceptableError { | ||||
| 	@IsString() | ||||
| 	name = "StatsClientIdsNotMatchingError" | ||||
|  | ||||
| 	@IsString() | ||||
| 	message = "The ids don't match! \n And if you wanted to change a stats client's id: This isn't allowed!" | ||||
| } | ||||
| @@ -35,6 +35,11 @@ export default async (app: Application) => { | ||||
|             "in": "cookie", | ||||
|             "name": "lfk_backend__refresh_token", | ||||
|             description: "A cookie containing a JWT based refreh token. Attention: Doesn't work in swagger-ui. Use /api/auth/login or /api/auth/refresh to get one." | ||||
|           }, | ||||
|           "StatsApiToken": { | ||||
|             "type": "http", | ||||
|             "scheme": "bearer", | ||||
|             description: "Api token that can be obtained by creating a new stats client (post to /api/statsclients)." | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|   | ||||
							
								
								
									
										65
									
								
								src/middlewares/StatsAuth.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								src/middlewares/StatsAuth.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| import * as argon2 from "argon2"; | ||||
| import { Request, Response } from 'express'; | ||||
| import { getConnectionManager } from 'typeorm'; | ||||
| import { StatsClient } from '../models/entities/StatsClient'; | ||||
| import authchecker from './authchecker'; | ||||
|  | ||||
| /** | ||||
|  * This middleware handels the authentification of stats client api tokens. | ||||
|  * The tokens have to be provided via Bearer auth header. | ||||
|  * @param req Express request object. | ||||
|  * @param res Express response object. | ||||
|  * @param next Next function to call on success. | ||||
|  */ | ||||
| const StatsAuth = async (req: Request, res: Response, next: () => void) => { | ||||
|     let provided_token: string = req.headers["authorization"]; | ||||
|     if (provided_token == "" || provided_token === undefined || provided_token === null) { | ||||
|         res.status(401).send("No api token provided."); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|         provided_token = provided_token.replace("Bearer ", ""); | ||||
|     } catch (error) { | ||||
|         res.status(401).send("No valid jwt or api token provided."); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     let prefix = ""; | ||||
|     try { | ||||
|         prefix = provided_token.split(".")[0]; | ||||
|     } | ||||
|     finally { | ||||
|         if (prefix == "" || prefix == undefined || prefix == null) { | ||||
|             res.status(401).send("Api token non-existant or invalid syntax."); | ||||
|             return; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     const client = await getConnectionManager().get().getRepository(StatsClient).findOne({ prefix: prefix }); | ||||
|     if (!client) { | ||||
|         let user_authorized = false; | ||||
|         try { | ||||
|             let action = { request: req, response: res, context: null, next: next } | ||||
|             user_authorized = await authchecker(action, ["RUNNER:GET", "TEAM:GET", "ORGANISATION:GET"]); | ||||
|         } | ||||
|         finally { | ||||
|             if (user_authorized == false) { | ||||
|                 res.status(401).send("Api token non-existant or invalid syntax."); | ||||
|                 return; | ||||
|             } | ||||
|             else { | ||||
|                 next(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|     else { | ||||
|         if (!(await argon2.verify(client.key, provided_token))) { | ||||
|             res.status(401).send("Api token invalid."); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         next(); | ||||
|     } | ||||
| } | ||||
| export default StatsAuth; | ||||
| @@ -2,10 +2,10 @@ import cookie from "cookie"; | ||||
| import * as jwt from "jsonwebtoken"; | ||||
| import { Action } from "routing-controllers"; | ||||
| import { getConnectionManager } from 'typeorm'; | ||||
| import { config } from './config'; | ||||
| import { IllegalJWTError, NoPermissionError, UserDisabledError, UserNonexistantOrRefreshtokenInvalidError } from './errors/AuthError'; | ||||
| import { JwtCreator, JwtUser } from './jwtcreator'; | ||||
| import { User } from './models/entities/User'; | ||||
| import { config } from '../config'; | ||||
| import { IllegalJWTError, NoPermissionError, UserDisabledError, UserNonexistantOrRefreshtokenInvalidError } from '../errors/AuthError'; | ||||
| import { JwtCreator, JwtUser } from '../jwtcreator'; | ||||
| import { User } from '../models/entities/User'; | ||||
| 
 | ||||
| /** | ||||
|  * Handels authorisation verification via jwt's for all api endpoints using the @Authorized decorator. | ||||
							
								
								
									
										33
									
								
								src/models/actions/CreateStatsClient.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								src/models/actions/CreateStatsClient.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| import * as argon2 from "argon2"; | ||||
| import { IsOptional, IsString } from 'class-validator'; | ||||
| import crypto from 'crypto'; | ||||
| import * as uuid from 'uuid'; | ||||
| import { StatsClient } from '../entities/StatsClient'; | ||||
|  | ||||
| /** | ||||
|  * This classed is used to create a new StatsClient entity from a json body (post request). | ||||
|  */ | ||||
| export class CreateStatsClient { | ||||
|     /** | ||||
|      * The new client's description. | ||||
|      */ | ||||
|     @IsString() | ||||
|     @IsOptional() | ||||
|     description?: string; | ||||
|  | ||||
|     /** | ||||
|      * Converts this to a StatsClient entity. | ||||
|      */ | ||||
|     public async toStatsClient(): Promise<StatsClient> { | ||||
|         let newClient: StatsClient = new StatsClient(); | ||||
|  | ||||
|         newClient.description = this.description; | ||||
|  | ||||
|         let newUUID = uuid.v4().toUpperCase(); | ||||
|         newClient.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase(); | ||||
|         newClient.key = await argon2.hash(newClient.prefix + "." + newUUID); | ||||
|         newClient.cleartextkey = newClient.prefix + "." + newUUID; | ||||
|  | ||||
|         return newClient; | ||||
|     } | ||||
| } | ||||
| @@ -31,5 +31,5 @@ export abstract class Donation { | ||||
|    * The donation's amount in cents (or whatever your currency's smallest unit is.). | ||||
|    * The exact implementation may differ for each type of donation. | ||||
|    */ | ||||
|   abstract amount: number | Promise<number>; | ||||
|   abstract amount: number; | ||||
| } | ||||
| @@ -58,4 +58,12 @@ export class Runner extends Participant { | ||||
|   public get distance(): number { | ||||
|     return this.validScans.reduce((sum, current) => sum + current.distance, 0); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Returns the total donations a runner has collected based on his linked donations and distance ran. | ||||
|   */ | ||||
|   @IsInt() | ||||
|   public get distanceDonationAmount(): number { | ||||
|     return this.distanceDonations.reduce((sum, current) => sum + current.amountPerDistance, 0) * this.distance; | ||||
|   } | ||||
| } | ||||
| @@ -44,4 +44,20 @@ export abstract class RunnerGroup { | ||||
|    */ | ||||
|   @OneToMany(() => Runner, runner => runner.group, { nullable: true }) | ||||
|   runners: Runner[]; | ||||
|  | ||||
|   /** | ||||
|    * Returns the total distance ran by this group's runners based on all their valid scans. | ||||
|   */ | ||||
|   @IsInt() | ||||
|   public get distance(): number { | ||||
|     return this.runners.reduce((sum, current) => sum + current.distance, 0); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Returns the total donations a runner has collected based on his linked donations and distance ran. | ||||
|   */ | ||||
|   @IsInt() | ||||
|   public get distanceDonationAmount(): number { | ||||
|     return this.runners.reduce((sum, current) => sum + current.distanceDonationAmount, 0); | ||||
|   } | ||||
| } | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { IsOptional } from "class-validator"; | ||||
| import { IsInt, IsOptional } from "class-validator"; | ||||
| import { ChildEntity, ManyToOne, OneToMany } from "typeorm"; | ||||
| import { Address } from "./Address"; | ||||
| import { Runner } from './Runner'; | ||||
| import { RunnerGroup } from "./RunnerGroup"; | ||||
| import { RunnerTeam } from "./RunnerTeam"; | ||||
|  | ||||
| @@ -24,4 +25,32 @@ export class RunnerOrganisation extends RunnerGroup { | ||||
|    */ | ||||
|   @OneToMany(() => RunnerTeam, team => team.parentGroup, { nullable: true }) | ||||
|   teams: RunnerTeam[]; | ||||
|  | ||||
|   /** | ||||
|    * Returns all runners associated with this organisation (directly or indirectly via teams). | ||||
|    */ | ||||
|   public get allRunners(): Runner[] { | ||||
|     let returnRunners: Runner[] = new Array<Runner>(); | ||||
|     returnRunners.push(...this.runners); | ||||
|     for (let team of this.teams) { | ||||
|       returnRunners.push(...team.runners) | ||||
|     } | ||||
|     return returnRunners; | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Returns the total distance ran by this group's runners based on all their valid scans. | ||||
|   */ | ||||
|   @IsInt() | ||||
|   public get distance(): number { | ||||
|     return this.allRunners.reduce((sum, current) => sum + current.distance, 0); | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Returns the total donations a runner has collected based on his linked donations and distance ran. | ||||
|   */ | ||||
|   @IsInt() | ||||
|   public get distanceDonationAmount(): number { | ||||
|     return this.allRunners.reduce((sum, current) => sum + current.distanceDonationAmount, 0); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										48
									
								
								src/models/entities/StatsClient.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								src/models/entities/StatsClient.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| import { IsInt, IsOptional, IsString } from "class-validator"; | ||||
| import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; | ||||
| /** | ||||
|  * Defines the StatsClient entity. | ||||
|  * StatsClients can be used to access the protected parts of the stats api (top runners, donators and so on). | ||||
| */ | ||||
| @Entity() | ||||
| export class StatsClient { | ||||
|     /** | ||||
|      * Autogenerated unique id (primary key). | ||||
|      */ | ||||
|     @PrimaryGeneratedColumn() | ||||
|     @IsInt() | ||||
|     id: number; | ||||
|  | ||||
|     /** | ||||
|      * The clients's description. | ||||
|      * Mostly for better UX when traceing back stuff. | ||||
|      */ | ||||
|     @Column({ nullable: true }) | ||||
|     @IsOptional() | ||||
|     @IsString() | ||||
|     description?: string; | ||||
|  | ||||
|     /** | ||||
|      * The client's api key prefix. | ||||
|      * This is used identitfy a client by it's api key. | ||||
|      */ | ||||
|     @Column({ unique: true }) | ||||
|     @IsString() | ||||
|     prefix: string; | ||||
|  | ||||
|     /** | ||||
|      * The client's api key hash. | ||||
|      * The api key can be used to authenticate against the /stats/** routes. | ||||
|      */ | ||||
|     @Column() | ||||
|     @IsString() | ||||
|     key: string; | ||||
|  | ||||
|     /** | ||||
|      * The client's api key in plain text. | ||||
|      * This will only be used to display the full key on creation and updates. | ||||
|      */ | ||||
|     @IsString() | ||||
|     @IsOptional() | ||||
|     cleartextkey?: string; | ||||
| } | ||||
| @@ -8,5 +8,6 @@ export enum PermissionTarget { | ||||
|     TRACK = 'TRACK', | ||||
|     USER = 'USER', | ||||
|     USERGROUP = 'USERGROUP', | ||||
|     PERMISSION = 'PERMISSION' | ||||
|     PERMISSION = 'PERMISSION', | ||||
|     STATSCLIENT = 'STATSCLIENT' | ||||
| } | ||||
							
								
								
									
										83
									
								
								src/models/responses/ResponseStats.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								src/models/responses/ResponseStats.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,83 @@ | ||||
| import { | ||||
|     IsInt | ||||
| } from "class-validator"; | ||||
| import { Donation } from '../entities/Donation'; | ||||
| import { Runner } from '../entities/Runner'; | ||||
| import { RunnerOrganisation } from '../entities/RunnerOrganisation'; | ||||
| import { RunnerTeam } from '../entities/RunnerTeam'; | ||||
| import { Scan } from '../entities/Scan'; | ||||
| import { User } from '../entities/User'; | ||||
|  | ||||
| /** | ||||
|  * Defines the stats response. | ||||
|  * The stats response calculates some basic stats for a dashboard or public display. | ||||
| */ | ||||
| export class ResponseStats { | ||||
|     /** | ||||
|      * The amount of runners registered in the system. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     total_runners: number; | ||||
|  | ||||
|     /** | ||||
|      * The amount of teams registered in the system. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     total_teams: number; | ||||
|  | ||||
|     /** | ||||
|      * The amount of organisations registered in the system. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     total_orgs: number; | ||||
|  | ||||
|     /** | ||||
|      * The amount of users registered in the system. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     total_users: number; | ||||
|  | ||||
|     /** | ||||
|      * The amount of valid scans registered in the system. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     total_scans: number; | ||||
|  | ||||
|     /** | ||||
|      * The total distance that all runners ran combined. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     total_distance: number; | ||||
|  | ||||
|     /** | ||||
|      * The total donation amount. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     total_donation: number; | ||||
|  | ||||
|     /** | ||||
|      * The average distance ran per runner. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     average_distance: number; | ||||
|  | ||||
|     /** | ||||
|      * Creates a new stats response containing some basic statistics for a dashboard or public display. | ||||
|      * @param runners Array containing all runners - the following relations have to be resolved: scans, scans.track | ||||
|      * @param teams Array containing all teams - no relations have to be resolved. | ||||
|      * @param orgs Array containing all orgs - no relations have to be resolved. | ||||
|      * @param users Array containing all users - no relations have to be resolved. | ||||
|      * @param scans Array containing all scans - no relations have to be resolved. | ||||
|      * @param donations Array containing all donations - the following relations have to be resolved: runner, runner.scans, runner.scans.track | ||||
|      */ | ||||
|     public constructor(runners: Runner[], teams: RunnerTeam[], orgs: RunnerOrganisation[], users: User[], scans: Scan[], donations: Donation[]) { | ||||
|         this.total_runners = runners.length; | ||||
|         this.total_teams = teams.length; | ||||
|         this.total_orgs = orgs.length; | ||||
|         this.total_users = users.length; | ||||
|         this.total_scans = scans.filter(scan => { scan.valid === true }).length; | ||||
|         this.total_distance = runners.reduce((sum, current) => sum + current.distance, 0); | ||||
|         this.total_donation = donations.reduce((sum, current) => sum + current.amount, 0); | ||||
|         this.average_distance = this.total_distance / this.total_runners; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										54
									
								
								src/models/responses/ResponseStatsClient.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/models/responses/ResponseStatsClient.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| import { | ||||
|  | ||||
|     IsInt, | ||||
|  | ||||
|     IsNotEmpty, | ||||
|  | ||||
|     IsOptional, | ||||
|     IsString | ||||
| } from "class-validator"; | ||||
| import { StatsClient } from '../entities/StatsClient'; | ||||
|  | ||||
| /** | ||||
|  * Defines the statsClient response. | ||||
| */ | ||||
| export class ResponseStatsClient { | ||||
|     /** | ||||
|      * The client's id. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     id: number; | ||||
|  | ||||
|     /** | ||||
|      * The client's description. | ||||
|      */ | ||||
|     @IsString() | ||||
|     @IsOptional() | ||||
|     description?: string; | ||||
|  | ||||
|     /** | ||||
|      * The client's api key. | ||||
|      * Only visible on creation or regeneration. | ||||
|      */ | ||||
|     @IsString() | ||||
|     @IsOptional() | ||||
|     key: string; | ||||
|  | ||||
|     /** | ||||
|      * The client's api key prefix. | ||||
|      */ | ||||
|     @IsString() | ||||
|     @IsNotEmpty() | ||||
|     prefix: string; | ||||
|  | ||||
|     /** | ||||
|      * Creates a ResponseStatsClient object from a statsClient. | ||||
|      * @param client The statsClient the response shall be build for. | ||||
|      */ | ||||
|     public constructor(client: StatsClient) { | ||||
|         this.id = client.id; | ||||
|         this.description = client.description; | ||||
|         this.prefix = client.prefix; | ||||
|         this.key = "Only visible on creation."; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										47
									
								
								src/models/responses/ResponseStatsOrganisation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/models/responses/ResponseStatsOrganisation.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| import { | ||||
|     IsInt, | ||||
|  | ||||
|     IsString | ||||
| } from "class-validator"; | ||||
| import { RunnerOrganisation } from '../entities/RunnerOrganisation'; | ||||
|  | ||||
| /** | ||||
|  * Defines the org stats response. | ||||
|  * This differs from the normal org responce. | ||||
| */ | ||||
| export class ResponseStatsOrgnisation { | ||||
|     /** | ||||
|      * The orgs's id. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     id: number; | ||||
|  | ||||
|     /** | ||||
|      * The orgs's name. | ||||
|      */ | ||||
|     @IsString() | ||||
|     name: string; | ||||
|  | ||||
|     /** | ||||
|      * The orgs's runner's currently ran distance in meters. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     distance: number; | ||||
|  | ||||
|     /** | ||||
|      * The orgs's currently collected donations. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     donationAmount: number; | ||||
|  | ||||
|     /** | ||||
|      * Creates a new organisation stats response from a organisation | ||||
|      * @param org The organisation whoes response shall be generated - the following relations have to be resolved: runners, runners.scans, runners.distanceDonations, runners.scans.track, teams, teams.runners, teams.runners.scans, teams.runners.distanceDonations, teams.runners.scans.track | ||||
|      */ | ||||
|     public constructor(org: RunnerOrganisation) { | ||||
|         this.name = org.name; | ||||
|         this.id = org.id; | ||||
|         this.distance = org.distance; | ||||
|         this.donationAmount = org.distanceDonationAmount; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										69
									
								
								src/models/responses/ResponseStatsRunner.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								src/models/responses/ResponseStatsRunner.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| import { | ||||
|     IsInt, | ||||
|     IsObject, | ||||
|     IsString | ||||
| } from "class-validator"; | ||||
| import { Runner } from '../entities/Runner'; | ||||
| import { RunnerGroup } from '../entities/RunnerGroup'; | ||||
|  | ||||
| /** | ||||
|  * Defines the runner stats response. | ||||
|  * This differs from the normal runner responce. | ||||
| */ | ||||
| export class ResponseStatsRunner { | ||||
|     /** | ||||
|      * The runner's id. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     id: number; | ||||
|  | ||||
|     /** | ||||
|      * The runner's first name. | ||||
|      */ | ||||
|     @IsString() | ||||
|     firstname: string; | ||||
|  | ||||
|     /** | ||||
|      * The runner's middle name. | ||||
|      */ | ||||
|     @IsString() | ||||
|     middlename?: string; | ||||
|  | ||||
|     /** | ||||
|      * The runner's last name. | ||||
|      */ | ||||
|     @IsString() | ||||
|     lastname: string; | ||||
|  | ||||
|     /** | ||||
|      * The runner's currently ran distance in meters. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     distance: number; | ||||
|  | ||||
|     /** | ||||
|      * The runner's currently collected donations. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     donationAmount: number; | ||||
|  | ||||
|     /** | ||||
|      * The runner's group. | ||||
|      */ | ||||
|     @IsObject() | ||||
|     group: RunnerGroup; | ||||
|  | ||||
|     /** | ||||
|      * Creates a new runner stats response from a runner | ||||
|      * @param runner The runner whoes response shall be generated - the following relations have to be resolved: scans, group, distanceDonations, scans.track | ||||
|      */ | ||||
|     public constructor(runner: Runner) { | ||||
|         this.id = runner.id; | ||||
|         this.firstname = runner.firstname; | ||||
|         this.middlename = runner.middlename; | ||||
|         this.lastname = runner.lastname; | ||||
|         this.distance = runner.distance; | ||||
|         this.donationAmount = runner.distanceDonationAmount; | ||||
|         this.group = runner.group; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										55
									
								
								src/models/responses/ResponseStatsTeam.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/models/responses/ResponseStatsTeam.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | ||||
| import { | ||||
|     IsInt, | ||||
|     IsObject, | ||||
|     IsString | ||||
| } from "class-validator"; | ||||
| import { RunnerGroup } from '../entities/RunnerGroup'; | ||||
| import { RunnerTeam } from '../entities/RunnerTeam'; | ||||
|  | ||||
| /** | ||||
|  * Defines the team stats response. | ||||
|  * This differs from the normal team responce. | ||||
| */ | ||||
| export class ResponseStatsTeam { | ||||
|     /** | ||||
|      * The team's id. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     id: number; | ||||
|  | ||||
|     /** | ||||
|      * The team's name. | ||||
|      */ | ||||
|     @IsString() | ||||
|     name: string; | ||||
|  | ||||
|     /** | ||||
|      * The teams's currently ran distance in meters. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     distance: number; | ||||
|  | ||||
|     /** | ||||
|      * The teams's currently collected donations. | ||||
|      */ | ||||
|     @IsInt() | ||||
|     donationAmount: number; | ||||
|  | ||||
|     /** | ||||
|      * The teams's parent group. | ||||
|      */ | ||||
|     @IsObject() | ||||
|     parent: RunnerGroup; | ||||
|  | ||||
|     /** | ||||
|      * Creates a new team stats response from a team | ||||
|      * @param team The team whoes response shall be generated - the following relations have to be resolved: runners, runners.scans, runners.distanceDonations, runners.scans.track | ||||
|      */ | ||||
|     public constructor(team: RunnerTeam) { | ||||
|         this.name = team.name; | ||||
|         this.id = team.id; | ||||
|         this.parent = team.parentGroup; | ||||
|         this.distance = team.distance; | ||||
|         this.donationAmount = team.distanceDonationAmount; | ||||
|     } | ||||
| } | ||||
| @@ -4,8 +4,8 @@ import fs from "fs"; | ||||
| import "reflect-metadata"; | ||||
| import { createExpressServer, getMetadataArgsStorage } from "routing-controllers"; | ||||
| import { routingControllersToSpec } from 'routing-controllers-openapi'; | ||||
| import authchecker from "./authchecker"; | ||||
| import { config } from './config'; | ||||
| import authchecker from "./middlewares/authchecker"; | ||||
| import { ErrorHandler } from './middlewares/ErrorHandler'; | ||||
|  | ||||
| const CONTROLLERS_FILE_EXTENSION = process.env.NODE_ENV === 'production' ? 'js' : 'ts'; | ||||
| @@ -44,6 +44,11 @@ const spec = routingControllersToSpec( | ||||
|                     "in": "cookie", | ||||
|                     "name": "lfk_backend__refresh_token", | ||||
|                     description: "A cookie containing a JWT based refreh token. Attention: Doesn't work in swagger-ui. Use /api/auth/login or /api/auth/refresh to get one." | ||||
|                 }, | ||||
|                 "StatsApiToken": { | ||||
|                     "type": "http", | ||||
|                     "scheme": "bearer", | ||||
|                     description: "Api token that can be obtained by creating a new stats client (post to /api/statsclients)." | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user