diff --git a/src/app.ts b/src/app.ts index 9adc9b3..44060da 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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'; diff --git a/src/controllers/StatsClientController.ts b/src/controllers/StatsClientController.ts new file mode 100644 index 0000000..1aa46a2 --- /dev/null +++ b/src/controllers/StatsClientController.ts @@ -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; + + /** + * 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(); + 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.
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.
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); + } +} \ No newline at end of file diff --git a/src/controllers/StatsController.ts b/src/controllers/StatsController.ts new file mode 100644 index 0000000..127949d --- /dev/null +++ b/src/controllers/StatsController.ts @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + topOrgs.forEach(org => { + responseOrgs.push(new ResponseStatsOrgnisation(org)); + }); + return responseOrgs; + } +} \ No newline at end of file diff --git a/src/errors/StatsClientErrors.ts b/src/errors/StatsClientErrors.ts new file mode 100644 index 0000000..ab7f1ef --- /dev/null +++ b/src/errors/StatsClientErrors.ts @@ -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!" +} \ No newline at end of file diff --git a/src/loaders/openapi.ts b/src/loaders/openapi.ts index afe2165..5ab892c 100644 --- a/src/loaders/openapi.ts +++ b/src/loaders/openapi.ts @@ -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)." } } }, diff --git a/src/middlewares/StatsAuth.ts b/src/middlewares/StatsAuth.ts new file mode 100644 index 0000000..990206b --- /dev/null +++ b/src/middlewares/StatsAuth.ts @@ -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; \ No newline at end of file diff --git a/src/authchecker.ts b/src/middlewares/authchecker.ts similarity index 93% rename from src/authchecker.ts rename to src/middlewares/authchecker.ts index 54ef4d7..61ca231 100644 --- a/src/authchecker.ts +++ b/src/middlewares/authchecker.ts @@ -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. diff --git a/src/models/actions/CreateStatsClient.ts b/src/models/actions/CreateStatsClient.ts new file mode 100644 index 0000000..40172e5 --- /dev/null +++ b/src/models/actions/CreateStatsClient.ts @@ -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 { + 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; + } +} \ No newline at end of file diff --git a/src/models/entities/Donation.ts b/src/models/entities/Donation.ts index 100f0bb..3ddc272 100644 --- a/src/models/entities/Donation.ts +++ b/src/models/entities/Donation.ts @@ -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; + abstract amount: number; } \ No newline at end of file diff --git a/src/models/entities/Runner.ts b/src/models/entities/Runner.ts index def167d..1c509d6 100644 --- a/src/models/entities/Runner.ts +++ b/src/models/entities/Runner.ts @@ -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; + } } \ No newline at end of file diff --git a/src/models/entities/RunnerGroup.ts b/src/models/entities/RunnerGroup.ts index 4256f6e..b5bcfcd 100644 --- a/src/models/entities/RunnerGroup.ts +++ b/src/models/entities/RunnerGroup.ts @@ -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); + } } \ No newline at end of file diff --git a/src/models/entities/RunnerOrganisation.ts b/src/models/entities/RunnerOrganisation.ts index 8d24077..877784b 100644 --- a/src/models/entities/RunnerOrganisation.ts +++ b/src/models/entities/RunnerOrganisation.ts @@ -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(); + 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); + } } \ No newline at end of file diff --git a/src/models/entities/StatsClient.ts b/src/models/entities/StatsClient.ts new file mode 100644 index 0000000..493a8da --- /dev/null +++ b/src/models/entities/StatsClient.ts @@ -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; +} \ No newline at end of file diff --git a/src/models/enums/PermissionTargets.ts b/src/models/enums/PermissionTargets.ts index d31ce37..526aaa3 100644 --- a/src/models/enums/PermissionTargets.ts +++ b/src/models/enums/PermissionTargets.ts @@ -8,5 +8,6 @@ export enum PermissionTarget { TRACK = 'TRACK', USER = 'USER', USERGROUP = 'USERGROUP', - PERMISSION = 'PERMISSION' + PERMISSION = 'PERMISSION', + STATSCLIENT = 'STATSCLIENT' } \ No newline at end of file diff --git a/src/models/responses/ResponseStats.ts b/src/models/responses/ResponseStats.ts new file mode 100644 index 0000000..a691306 --- /dev/null +++ b/src/models/responses/ResponseStats.ts @@ -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; + } +} diff --git a/src/models/responses/ResponseStatsClient.ts b/src/models/responses/ResponseStatsClient.ts new file mode 100644 index 0000000..4028e2a --- /dev/null +++ b/src/models/responses/ResponseStatsClient.ts @@ -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."; + } +} diff --git a/src/models/responses/ResponseStatsOrganisation.ts b/src/models/responses/ResponseStatsOrganisation.ts new file mode 100644 index 0000000..338a8b3 --- /dev/null +++ b/src/models/responses/ResponseStatsOrganisation.ts @@ -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; + } +} diff --git a/src/models/responses/ResponseStatsRunner.ts b/src/models/responses/ResponseStatsRunner.ts new file mode 100644 index 0000000..8b55983 --- /dev/null +++ b/src/models/responses/ResponseStatsRunner.ts @@ -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; + } +} diff --git a/src/models/responses/ResponseStatsTeam.ts b/src/models/responses/ResponseStatsTeam.ts new file mode 100644 index 0000000..362971a --- /dev/null +++ b/src/models/responses/ResponseStatsTeam.ts @@ -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; + } +} diff --git a/src/openapi_export.ts b/src/openapi_export.ts index d5465a7..49647b5 100644 --- a/src/openapi_export.ts +++ b/src/openapi_export.ts @@ -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)." } } },