diff --git a/.vscode/settings.json b/.vscode/settings.json index 812953b..fd68d0b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,14 @@ }, "prettier.enable": false, "[typescript]": { - "editor.defaultFormatter": "vscode.typescript-language-features" - } + "editor.defaultFormatter": "vscode.typescript-language-features", + "editor.codeActionsOnSave": { + "source.organizeImports": true, + // "source.fixAll": true + } + }, + "javascript.preferences.quoteStyle": "single", + "javascript.preferences.importModuleSpecifierEnding": "minimal", + "typescript.preferences.importModuleSpecifierEnding": "minimal", + "typescript.preferences.includePackageJsonAutoImports": "on" } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index da54cd5..089c4de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM node:alpine WORKDIR /app COPY ./package.json ./ -RUN npm i +RUN yarn COPY ./ ./ ENTRYPOINT [ "yarn","dev" ] \ No newline at end of file diff --git a/README.md b/README.md index 077f888..f974523 100644 --- a/README.md +++ b/README.md @@ -53,9 +53,11 @@ docker-compose up --build ## File Structure -- src/models/\* - database models (typeorm entities) +- src/models/entities\* - database models (typeorm entities) +- src/models/actions\* - actions models +- src/models/responses\* - response models - src/controllers/\* - routing-controllers - src/loaders/\* - loaders for the different init steps of the api server -- src/routes/\* - express routes for everything we don't do via routing-controllers (shouldn't be much) - src/middlewares/\* - express middlewares (mainly auth r/n) - src/errors/* - our custom (http) errors +- src/routes/\* - express routes for everything we don't do via routing-controllers (depreciated) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 1efd5ba..7ca0bb8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,9 +12,12 @@ services: DB_USER: lfk DB_PASSWORD: changeme DB_NAME: lfk + NODE_ENV: production backend_db: image: postgres:11-alpine environment: POSTGRES_DB: lfk POSTGRES_PASSWORD: changeme - POSTGRES_USER: lfk \ No newline at end of file + POSTGRES_USER: lfk + ports: + - 5432:5432 \ No newline at end of file diff --git a/ormconfig.ts b/ormconfig.ts index b3b99c3..bf9be07 100644 --- a/ormconfig.ts +++ b/ormconfig.ts @@ -1,4 +1,4 @@ -import { config } from 'dotenv-safe'; +import { config } from 'dotenv'; config(); export default { @@ -8,5 +8,5 @@ export default { username: process.env.DB_USER, password: process.env.DB_PASSWORD, database: process.env.DB_NAME, - entities: ["src/models/*.ts"] + entities: ["src/models/entities/*.ts"] }; diff --git a/package.json b/package.json index 335f5ce..baae298 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,14 @@ ], "license": "CC-BY-NC-SA-4.0", "dependencies": { + "argon2": "^0.27.0", "body-parser": "^1.19.0", "class-transformer": "^0.3.1", "class-validator": "^0.12.2", "class-validator-jsonschema": "^2.0.3", "consola": "^2.15.0", "cors": "^2.8.5", + "dotenv": "^8.2.0", "express": "^4.17.1", "helmet": "^4.2.0", "jsonwebtoken": "^8.5.1", @@ -39,17 +41,18 @@ "routing-controllers-openapi": "^2.1.0", "swagger-ui-express": "^4.1.5", "typeorm": "^0.2.29", - "typeorm-routing-controllers-extensions": "^0.2.0" + "typeorm-routing-controllers-extensions": "^0.2.0", + "uuid": "^8.3.1" }, "devDependencies": { "@types/cors": "^2.8.8", - "@types/dotenv-safe": "^8.1.1", + "@types/dotenv": "^8.2.0", "@types/express": "^4.17.9", "@types/jsonwebtoken": "^8.5.0", "@types/multer": "^1.4.4", "@types/node": "^14.14.9", "@types/swagger-ui-express": "^4.1.2", - "dotenv-safe": "^8.2.0", + "@types/uuid": "^8.3.0", "nodemon": "^2.0.6", "sqlite3": "^5.0.0", "ts-node": "^9.0.0", @@ -61,4 +64,4 @@ "build": "tsc", "docs": "typedoc --out docs src" } -} +} \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 9e15de1..664ec6f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,18 +1,15 @@ -import "reflect-metadata"; -import * as dotenvSafe from "dotenv-safe"; -import { createExpressServer } from "routing-controllers"; import consola from "consola"; -import loaders from "./loaders/index"; +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 { ErrorHandler } from './middlewares/ErrorHandler'; -dotenvSafe.config(); -const PORT = process.env.APP_PORT || 4010; - const app = createExpressServer({ authorizationChecker: authchecker, middlewares: [ErrorHandler], - development: process.env.NODE_ENV === "production", + development: config.development, cors: true, routePrefix: "/api", controllers: [__dirname + "/controllers/*.ts"], @@ -20,10 +17,15 @@ const app = createExpressServer({ async function main() { await loaders(app); - app.listen(PORT, () => { + app.listen(config.internal_port, () => { consola.success( - `⚡️[server]: Server is running at http://localhost:${PORT}` + `⚡️[server]: Server is running at http://localhost:${config.internal_port}` ); }); } -main(); +if (errors === 0) { + main(); +} else { + console.log("error"); + // something's wrong +} diff --git a/src/authchecker.ts b/src/authchecker.ts index 9b62dfa..869e0da 100644 --- a/src/authchecker.ts +++ b/src/authchecker.ts @@ -1,13 +1,9 @@ import * as jwt from "jsonwebtoken"; -import { Action, HttpError } from "routing-controllers"; -// ----------- -const sampletoken = jwt.sign({ - "permissions": { - "TRACKS": ["read", "update", "delete", "add"] - // "TRACKS": [] - } -}, process.env.JWT_SECRET || "secretjwtsecret") -console.log(`sampletoken: ${sampletoken}`); +import { Action } from "routing-controllers"; +import { getConnectionManager } from 'typeorm'; +import { config } from './config'; +import { IllegalJWTError, NoPermissionError, UserNonexistantOrRefreshtokenInvalidError } from './errors/AuthError'; +import { User } from './models/entities/User'; // ----------- const authchecker = async (action: Action, permissions: string | string[]) => { let required_permissions = undefined @@ -20,9 +16,14 @@ const authchecker = async (action: Action, permissions: string | string[]) => { const provided_token = action.request.query["auth"]; let jwtPayload = undefined try { - jwtPayload = jwt.verify(provided_token, process.env.JWT_SECRET || "secretjwtsecret"); + jwtPayload = jwt.verify(provided_token, config.jwt_secret); } catch (error) { - throw new HttpError(401, "jwt_illegal") + console.log(error); + throw new IllegalJWTError() + } + const count = await getConnectionManager().get().getRepository(User).count({ id: jwtPayload["userdetails"]["id"], refreshTokenCount: jwtPayload["userdetails"]["refreshTokenCount"] }) + if (count !== 1) { + throw new UserNonexistantOrRefreshtokenInvalidError() } if (jwtPayload.permissions) { action.response.local = {} @@ -34,15 +35,15 @@ const authchecker = async (action: Action, permissions: string | string[]) => { if (actual_accesslevel_for_permission.includes(permission_access_level)) { return true; } else { - throw new HttpError(403, "no") + throw new NoPermissionError() } }); } else { - throw new HttpError(403, "no") + throw new NoPermissionError() } // try { - jwt.verify(provided_token, process.env.JWT_SECRET || "secretjwtsecret"); + jwt.verify(provided_token, config.jwt_secret); return true } catch (error) { return false diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..567e35b --- /dev/null +++ b/src/config.ts @@ -0,0 +1,22 @@ +import { config as configDotenv } from 'dotenv'; +configDotenv(); +export const config = { + internal_port: parseInt(process.env.APP_PORT) || 4010, + development: process.env.NODE_ENV === "production", + jwt_secret: process.env.JWT_SECRET || "secretjwtsecret", + phone_validation_countrycode: process.env.PHONE_COUNTRYCODE || "ZZ" +} +let errors = 0 +if (typeof config.internal_port !== "number") { + errors++ +} +if (typeof config.phone_validation_countrycode !== "string") { + errors++ +} +if (config.phone_validation_countrycode.length !== 2) { + errors++ +} +if (typeof config.development !== "boolean") { + errors++ +} +export let e = errors \ No newline at end of file diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts new file mode 100644 index 0000000..c2002a3 --- /dev/null +++ b/src/controllers/AuthController.ts @@ -0,0 +1,68 @@ +import { Body, JsonController, Post } from 'routing-controllers'; +import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; +import { IllegalJWTError, InvalidCredentialsError, JwtNotProvidedError, PasswordNeededError, RefreshTokenCountInvalidError, UsernameOrEmailNeededError } from '../errors/AuthError'; +import { UserNotFoundError } from '../errors/UserErrors'; +import { CreateAuth } from '../models/actions/CreateAuth'; +import { HandleLogout } from '../models/actions/HandleLogout'; +import { RefreshAuth } from '../models/actions/RefreshAuth'; +import { Auth } from '../models/responses/ResponseAuth'; +import { Logout } from '../models/responses/ResponseLogout'; + +@JsonController('/auth') +export class AuthController { + constructor() { + } + + @Post("/login") + @ResponseSchema(Auth) + @ResponseSchema(InvalidCredentialsError) + @ResponseSchema(UserNotFoundError) + @ResponseSchema(UsernameOrEmailNeededError) + @ResponseSchema(PasswordNeededError) + @ResponseSchema(InvalidCredentialsError) + @OpenAPI({ description: 'Create a new access token object' }) + async login(@Body({ validate: true }) createAuth: CreateAuth) { + let auth; + try { + auth = await createAuth.toAuth(); + } catch (error) { + throw error; + } + return auth + } + + @Post("/logout") + @ResponseSchema(Logout) + @ResponseSchema(InvalidCredentialsError) + @ResponseSchema(UserNotFoundError) + @ResponseSchema(UsernameOrEmailNeededError) + @ResponseSchema(PasswordNeededError) + @ResponseSchema(InvalidCredentialsError) + @OpenAPI({ description: 'Create a new access token object' }) + async logout(@Body({ validate: true }) handleLogout: HandleLogout) { + let logout; + try { + logout = await handleLogout.logout() + } catch (error) { + return error; + } + return logout + } + + @Post("/refresh") + @ResponseSchema(Auth) + @ResponseSchema(JwtNotProvidedError) + @ResponseSchema(IllegalJWTError) + @ResponseSchema(UserNotFoundError) + @ResponseSchema(RefreshTokenCountInvalidError) + @OpenAPI({ description: 'refresh a access token' }) + async refresh(@Body({ validate: true }) refreshAuth: RefreshAuth) { + let auth; + try { + auth = await refreshAuth.toAuth(); + } catch (error) { + return error; + } + return auth + } +} diff --git a/src/controllers/RunnerController.ts b/src/controllers/RunnerController.ts new file mode 100644 index 0000000..53b869d --- /dev/null +++ b/src/controllers/RunnerController.ts @@ -0,0 +1,102 @@ +import { Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; +import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; +import { getConnectionManager, Repository } from 'typeorm'; +import { EntityFromBody } from 'typeorm-routing-controllers-extensions'; +import { RunnerGroupNeededError, RunnerIdsNotMatchingError, RunnerNotFoundError } from '../errors/RunnerErrors'; +import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors'; +import { CreateRunner } from '../models/actions/CreateRunner'; +import { Runner } from '../models/entities/Runner'; +import { ResponseEmpty } from '../models/responses/ResponseEmpty'; +import { ResponseRunner } from '../models/responses/ResponseRunner'; + +@JsonController('/runners') +//@Authorized('RUNNERS:read') +export class RunnerController { + private runnerRepository: Repository; + + /** + * Gets the repository of this controller's model/entity. + */ + constructor() { + this.runnerRepository = getConnectionManager().get().getRepository(Runner); + } + + @Get() + @ResponseSchema(ResponseRunner, { isArray: true }) + @OpenAPI({ description: 'Lists all runners.' }) + async getAll() { + let responseRunners: ResponseRunner[] = new Array(); + const runners = await this.runnerRepository.find({ relations: ['scans', 'group'] }); + console.log(runners); + runners.forEach(runner => { + responseRunners.push(new ResponseRunner(runner)); + }); + return responseRunners; + } + + @Get('/:id') + @ResponseSchema(ResponseRunner) + @ResponseSchema(RunnerNotFoundError, { statusCode: 404 }) + @OnUndefined(RunnerNotFoundError) + @OpenAPI({ description: 'Returns a runner of a specified id (if it exists)' }) + async getOne(@Param('id') id: number) { + let runner = await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group'] }) + if (!runner) { throw new RunnerNotFoundError(); } + return new ResponseRunner(runner); + } + + @Post() + @ResponseSchema(ResponseRunner) + @ResponseSchema(RunnerGroupNeededError) + @ResponseSchema(RunnerGroupNotFoundError) + @OpenAPI({ description: 'Create a new runner object (id will be generated automagicly).' }) + async post(@Body({ validate: true }) createRunner: CreateRunner) { + let runner; + try { + runner = await createRunner.toRunner(); + } catch (error) { + throw error; + } + + runner = await this.runnerRepository.save(runner) + return new ResponseRunner(await this.runnerRepository.findOne(runner, { relations: ['scans', 'group'] })); + } + + @Put('/:id') + @ResponseSchema(ResponseRunner) + @ResponseSchema(RunnerNotFoundError, { statusCode: 404 }) + @ResponseSchema(RunnerIdsNotMatchingError, { statusCode: 406 }) + @OpenAPI({ description: "Update a runner object (id can't be changed)." }) + async put(@Param('id') id: number, @EntityFromBody() runner: Runner) { + let oldRunner = await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group'] }); + + if (!oldRunner) { + throw new RunnerNotFoundError(); + } + + if (oldRunner.id != runner.id) { + throw new RunnerIdsNotMatchingError(); + } + + await this.runnerRepository.update(oldRunner, runner); + return new ResponseRunner(runner); + } + + @Delete('/:id') + @ResponseSchema(ResponseRunner) + @ResponseSchema(ResponseEmpty, { statusCode: 204 }) + @OnUndefined(204) + @OpenAPI({ description: 'Delete a specified runner (if it exists).' }) + async remove(@Param("id") id: number, @QueryParam("force") force: boolean) { + let runner = await this.runnerRepository.findOne({ id: id }); + if (!runner) { return null; } + const responseRunner = await this.runnerRepository.findOne(runner, { relations: ['scans', 'group'] }); + + if (!runner) { + throw new RunnerNotFoundError(); + } + + await this.runnerRepository.delete(runner); + return new ResponseRunner(responseRunner); + } +} diff --git a/src/controllers/RunnerOrganisationController.ts b/src/controllers/RunnerOrganisationController.ts new file mode 100644 index 0000000..8471e90 --- /dev/null +++ b/src/controllers/RunnerOrganisationController.ts @@ -0,0 +1,124 @@ +import { Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; +import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; +import { getConnectionManager, Repository } from 'typeorm'; +import { EntityFromBody } from 'typeorm-routing-controllers-extensions'; +import { RunnerOrganisationHasRunnersError, RunnerOrganisationHasTeamsError, RunnerOrganisationIdsNotMatchingError, RunnerOrganisationNotFoundError } from '../errors/RunnerOrganisationErrors'; +import { CreateRunnerOrganisation } from '../models/actions/CreateRunnerOrganisation'; +import { RunnerOrganisation } from '../models/entities/RunnerOrganisation'; +import { ResponseEmpty } from '../models/responses/ResponseEmpty'; +import { ResponseRunnerOrganisation } from '../models/responses/ResponseRunnerOrganisation'; +import { RunnerController } from './RunnerController'; +import { RunnerTeamController } from './RunnerTeamController'; + + +@JsonController('/organisations') +//@Authorized('RUNNERS:read') +export class RunnerOrganisationController { + private runnerOrganisationRepository: Repository; + + /** + * Gets the repository of this controller's model/entity. + */ + constructor() { + this.runnerOrganisationRepository = getConnectionManager().get().getRepository(RunnerOrganisation); + } + + @Get() + @ResponseSchema(ResponseRunnerOrganisation, { isArray: true }) + @OpenAPI({ description: 'Lists all runnerOrganisations.' }) + async getAll() { + let responseTeams: ResponseRunnerOrganisation[] = new Array(); + const runners = await this.runnerOrganisationRepository.find({ relations: ['address', 'contact', 'teams'] }); + console.log(runners); + runners.forEach(runner => { + responseTeams.push(new ResponseRunnerOrganisation(runner)); + }); + return responseTeams; + } + + @Get('/:id') + @ResponseSchema(ResponseRunnerOrganisation) + @ResponseSchema(RunnerOrganisationNotFoundError, { statusCode: 404 }) + @OnUndefined(RunnerOrganisationNotFoundError) + @OpenAPI({ description: 'Returns a runnerOrganisation of a specified id (if it exists)' }) + async getOne(@Param('id') id: number) { + let runnerOrg = await this.runnerOrganisationRepository.findOne({ id: id }, { relations: ['address', 'contact', 'teams'] }); + if (!runnerOrg) { throw new RunnerOrganisationNotFoundError(); } + return new ResponseRunnerOrganisation(runnerOrg); + } + + @Post() + @ResponseSchema(ResponseRunnerOrganisation) + @OpenAPI({ description: 'Create a new runnerOrganisation object (id will be generated automagicly).' }) + async post(@Body({ validate: true }) createRunnerOrganisation: CreateRunnerOrganisation) { + let runnerOrganisation; + try { + runnerOrganisation = await createRunnerOrganisation.toRunnerOrganisation(); + } catch (error) { + throw error; + } + + runnerOrganisation = await this.runnerOrganisationRepository.save(runnerOrganisation); + + return new ResponseRunnerOrganisation(await this.runnerOrganisationRepository.findOne(runnerOrganisation, { relations: ['address', 'contact', 'teams'] })); + } + + @Put('/:id') + @ResponseSchema(ResponseRunnerOrganisation) + @ResponseSchema(RunnerOrganisationNotFoundError, { statusCode: 404 }) + @ResponseSchema(RunnerOrganisationIdsNotMatchingError, { statusCode: 406 }) + @OpenAPI({ description: "Update a runnerOrganisation object (id can't be changed)." }) + async put(@Param('id') id: number, @EntityFromBody() runnerOrganisation: RunnerOrganisation) { + let oldRunnerOrganisation = await this.runnerOrganisationRepository.findOne({ id: id }); + + if (!oldRunnerOrganisation) { + throw new RunnerOrganisationNotFoundError(); + } + + if (oldRunnerOrganisation.id != runnerOrganisation.id) { + throw new RunnerOrganisationIdsNotMatchingError(); + } + + await this.runnerOrganisationRepository.update(oldRunnerOrganisation, runnerOrganisation); + + runnerOrganisation = await this.runnerOrganisationRepository.findOne(runnerOrganisation, { relations: ['address', 'contact', 'teams'] }); + return new ResponseRunnerOrganisation(runnerOrganisation); + } + + @Delete('/:id') + @ResponseSchema(ResponseRunnerOrganisation) + @ResponseSchema(ResponseEmpty, { statusCode: 204 }) + @ResponseSchema(RunnerOrganisationHasTeamsError, { statusCode: 406 }) + @ResponseSchema(RunnerOrganisationHasRunnersError, { statusCode: 406 }) + @OnUndefined(204) + @OpenAPI({ description: 'Delete a specified runnerOrganisation (if it exists).' }) + async remove(@Param("id") id: number, @QueryParam("force") force: boolean) { + let organisation = await this.runnerOrganisationRepository.findOne({ id: id }); + if (!organisation) { return null; } + let runnerOrganisation = await this.runnerOrganisationRepository.findOne(organisation, { relations: ['address', 'contact', 'runners', 'teams'] }); + + if (!force) { + if (runnerOrganisation.teams.length != 0) { + throw new RunnerOrganisationHasTeamsError(); + } + } + const teamController = new RunnerTeamController() + for (let team of runnerOrganisation.teams) { + await teamController.remove(team.id, true); + } + + if (!force) { + if (runnerOrganisation.runners.length != 0) { + throw new RunnerOrganisationHasRunnersError(); + } + } + const runnerController = new RunnerController() + for (let runner of runnerOrganisation.runners) { + await runnerController.remove(runner.id, true); + } + + const responseOrganisation = new ResponseRunnerOrganisation(runnerOrganisation); + await this.runnerOrganisationRepository.delete(organisation); + return responseOrganisation; + } +} diff --git a/src/controllers/RunnerTeamController.ts b/src/controllers/RunnerTeamController.ts new file mode 100644 index 0000000..2eeae3e --- /dev/null +++ b/src/controllers/RunnerTeamController.ts @@ -0,0 +1,113 @@ +import { Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; +import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; +import { getConnectionManager, Repository } from 'typeorm'; +import { EntityFromBody } from 'typeorm-routing-controllers-extensions'; +import { RunnerTeamHasRunnersError, RunnerTeamIdsNotMatchingError, RunnerTeamNotFoundError } from '../errors/RunnerTeamErrors'; +import { CreateRunnerTeam } from '../models/actions/CreateRunnerTeam'; +import { RunnerTeam } from '../models/entities/RunnerTeam'; +import { ResponseEmpty } from '../models/responses/ResponseEmpty'; +import { ResponseRunnerTeam } from '../models/responses/ResponseRunnerTeam'; +import { RunnerController } from './RunnerController'; + + +@JsonController('/teams') +//@Authorized('RUNNERS:read') +export class RunnerTeamController { + private runnerTeamRepository: Repository; + + /** + * Gets the repository of this controller's model/entity. + */ + constructor() { + this.runnerTeamRepository = getConnectionManager().get().getRepository(RunnerTeam); + } + + @Get() + @ResponseSchema(ResponseRunnerTeam, { isArray: true }) + @OpenAPI({ description: 'Lists all runnerTeams.' }) + async getAll() { + let responseTeams: ResponseRunnerTeam[] = new Array(); + const runners = await this.runnerTeamRepository.find({ relations: ['parentGroup', 'contact'] }); + console.log(runners); + runners.forEach(runner => { + responseTeams.push(new ResponseRunnerTeam(runner)); + }); + return responseTeams; + } + + @Get('/:id') + @ResponseSchema(ResponseRunnerTeam) + @ResponseSchema(RunnerTeamNotFoundError, { statusCode: 404 }) + @OnUndefined(RunnerTeamNotFoundError) + @OpenAPI({ description: 'Returns a runnerTeam of a specified id (if it exists)' }) + async getOne(@Param('id') id: number) { + let runnerTeam = await this.runnerTeamRepository.findOne({ id: id }, { relations: ['parentGroup', 'contact'] }); + if (!runnerTeam) { throw new RunnerTeamNotFoundError(); } + return new ResponseRunnerTeam(runnerTeam); + } + + @Post() + @ResponseSchema(ResponseRunnerTeam) + @OpenAPI({ description: 'Create a new runnerTeam object (id will be generated automagicly).' }) + async post(@Body({ validate: true }) createRunnerTeam: CreateRunnerTeam) { + let runnerTeam; + try { + runnerTeam = await createRunnerTeam.toRunnerTeam(); + } catch (error) { + throw error; + } + + runnerTeam = await this.runnerTeamRepository.save(runnerTeam); + runnerTeam = await this.runnerTeamRepository.findOne(runnerTeam, { relations: ['parentGroup', 'contact'] }); + + return new ResponseRunnerTeam(runnerTeam); + } + + @Put('/:id') + @ResponseSchema(ResponseRunnerTeam) + @ResponseSchema(RunnerTeamNotFoundError, { statusCode: 404 }) + @ResponseSchema(RunnerTeamIdsNotMatchingError, { statusCode: 406 }) + @OpenAPI({ description: "Update a runnerTeam object (id can't be changed)." }) + async put(@Param('id') id: number, @EntityFromBody() runnerTeam: RunnerTeam) { + let oldRunnerTeam = await this.runnerTeamRepository.findOne({ id: id }, { relations: ['parentGroup', 'contact'] }); + + if (!oldRunnerTeam) { + throw new RunnerTeamNotFoundError(); + } + + if (oldRunnerTeam.id != runnerTeam.id) { + throw new RunnerTeamIdsNotMatchingError(); + } + + await this.runnerTeamRepository.update(oldRunnerTeam, runnerTeam); + + runnerTeam = await this.runnerTeamRepository.findOne(runnerTeam, { relations: ['parentGroup', 'contact'] }); + return new ResponseRunnerTeam(runnerTeam); + } + + @Delete('/:id') + @ResponseSchema(ResponseRunnerTeam) + @ResponseSchema(ResponseEmpty, { statusCode: 204 }) + @ResponseSchema(RunnerTeamHasRunnersError, { statusCode: 406 }) + @OnUndefined(204) + @OpenAPI({ description: 'Delete a specified runnerTeam (if it exists).' }) + async remove(@Param("id") id: number, @QueryParam("force") force: boolean) { + let team = await this.runnerTeamRepository.findOne({ id: id }); + if (!team) { return null; } + let runnerTeam = await this.runnerTeamRepository.findOne(team, { relations: ['parentGroup', 'contact', 'runners'] }); + + if (!force) { + if (runnerTeam.runners.length != 0) { + throw new RunnerTeamHasRunnersError(); + } + } + const runnerController = new RunnerController() + for (let runner of runnerTeam.runners) { + await runnerController.remove(runner.id, true); + } + + const responseTeam = new ResponseRunnerTeam(runnerTeam); + await this.runnerTeamRepository.delete(team); + return responseTeam; + } +} diff --git a/src/controllers/TrackController.ts b/src/controllers/TrackController.ts index d0b3a0c..e17e098 100644 --- a/src/controllers/TrackController.ts +++ b/src/controllers/TrackController.ts @@ -1,23 +1,15 @@ -import { JsonController, Param, Body, Get, Post, Put, Delete, NotFoundError, OnUndefined, NotAcceptableError, Authorized } from 'routing-controllers'; +import { Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put } from 'routing-controllers'; +import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { getConnectionManager, Repository } from 'typeorm'; import { EntityFromBody } from 'typeorm-routing-controllers-extensions'; -import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; -import { Track } from '../models/Track'; -import { IsInt, IsNotEmpty, IsPositive, IsString } from 'class-validator'; -import {TrackIdsNotMatchingError, TrackNotFoundError} from "../errors/TrackErrors"; - -class CreateTrack { - @IsString() - @IsNotEmpty() - name: string; - - @IsInt() - @IsPositive() - length: number; -} +import { TrackIdsNotMatchingError, TrackNotFoundError } from "../errors/TrackErrors"; +import { CreateTrack } from '../models/actions/CreateTrack'; +import { Track } from '../models/entities/Track'; +import { ResponseEmpty } from '../models/responses/ResponseEmpty'; +import { ResponseTrack } from '../models/responses/ResponseTrack'; @JsonController('/tracks') -@Authorized("TRACKS:read") +//@Authorized("TRACKS:read") export class TrackController { private trackRepository: Repository; @@ -29,36 +21,43 @@ export class TrackController { } @Get() - @ResponseSchema(Track, { isArray: true }) + @ResponseSchema(ResponseTrack, { isArray: true }) @OpenAPI({ description: "Lists all tracks." }) - getAll() { - return this.trackRepository.find(); + async getAll() { + let responseTracks: ResponseTrack[] = new Array(); + const tracks = await this.trackRepository.find(); + tracks.forEach(track => { + responseTracks.push(new ResponseTrack(track)); + }); + return responseTracks; } @Get('/:id') - @ResponseSchema(Track) - @ResponseSchema(TrackNotFoundError, {statusCode: 404}) + @ResponseSchema(ResponseTrack) + @ResponseSchema(TrackNotFoundError, { statusCode: 404 }) @OnUndefined(TrackNotFoundError) @OpenAPI({ description: "Returns a track of a specified id (if it exists)" }) - getOne(@Param('id') id: number) { - return this.trackRepository.findOne({ id: id }); + async getOne(@Param('id') id: number) { + let track = await this.trackRepository.findOne({ id: id }); + if (!track) { throw new TrackNotFoundError(); } + return new ResponseTrack(track); } @Post() - @ResponseSchema(Track) + @ResponseSchema(ResponseTrack) @OpenAPI({ description: "Create a new track object (id will be generated automagicly)." }) - post( + async post( @Body({ validate: true }) track: CreateTrack ) { - return this.trackRepository.save(track); + return new ResponseTrack(await this.trackRepository.save(track.toTrack())); } @Put('/:id') - @ResponseSchema(Track) - @ResponseSchema(TrackNotFoundError, {statusCode: 404}) - @ResponseSchema(TrackIdsNotMatchingError, {statusCode: 406}) - @OpenAPI({description: "Update a track object (id can't be changed)."}) + @ResponseSchema(ResponseTrack) + @ResponseSchema(TrackNotFoundError, { statusCode: 404 }) + @ResponseSchema(TrackIdsNotMatchingError, { statusCode: 406 }) + @OpenAPI({ description: "Update a track object (id can't be changed)." }) async put(@Param('id') id: number, @EntityFromBody() track: Track) { let oldTrack = await this.trackRepository.findOne({ id: id }); @@ -66,26 +65,24 @@ export class TrackController { throw new TrackNotFoundError(); } - if(oldTrack.id != track.id){ + if (oldTrack.id != track.id) { throw new TrackIdsNotMatchingError(); } await this.trackRepository.update(oldTrack, track); - return track; + return new ResponseTrack(track); } @Delete('/:id') - @ResponseSchema(Track) - @ResponseSchema(TrackNotFoundError, {statusCode: 404}) - @OpenAPI({description: "Delete a specified track (if it exists)."}) - async remove(@Param('id') id: number) { + @ResponseSchema(ResponseTrack) + @ResponseSchema(ResponseEmpty, { statusCode: 204 }) + @OnUndefined(204) + @OpenAPI({ description: "Delete a specified track (if it exists)." }) + async remove(@Param("id") id: number) { let track = await this.trackRepository.findOne({ id: id }); - - if (!track) { - throw new TrackNotFoundError(); - } + if (!track) { return null; } await this.trackRepository.delete(track); - return track; + return new ResponseTrack(track); } -} +} \ No newline at end of file diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts new file mode 100644 index 0000000..f71fd60 --- /dev/null +++ b/src/controllers/UserController.ts @@ -0,0 +1,88 @@ +import { Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put } from 'routing-controllers'; +import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; +import { getConnectionManager, Repository } from 'typeorm'; +import { EntityFromBody } from 'typeorm-routing-controllers-extensions'; +import { UserIdsNotMatchingError, UserNotFoundError } from '../errors/UserErrors'; +import { UserGroupNotFoundError } from '../errors/UserGroupErrors'; +import { CreateUser } from '../models/actions/CreateUser'; +import { User } from '../models/entities/User'; +import { ResponseEmpty } from '../models/responses/ResponseEmpty'; + + +@JsonController('/users') +export class UserController { + private userRepository: Repository; + + /** + * Gets the repository of this controller's model/entity. + */ + constructor() { + this.userRepository = getConnectionManager().get().getRepository(User); + } + + @Get() + @ResponseSchema(User, { isArray: true }) + @OpenAPI({ description: 'Lists all users.' }) + getAll() { + return this.userRepository.find(); + } + + @Get('/:id') + @ResponseSchema(User) + @ResponseSchema(UserNotFoundError, { statusCode: 404 }) + @OnUndefined(UserNotFoundError) + @OpenAPI({ description: 'Returns a user of a specified id (if it exists)' }) + getOne(@Param('id') id: number) { + return this.userRepository.findOne({ id: id }); + } + + @Post() + @ResponseSchema(User) + @ResponseSchema(UserGroupNotFoundError) + @OpenAPI({ description: 'Create a new user object (id will be generated automagicly).' }) + async post(@Body({ validate: true }) createUser: CreateUser) { + let user; + try { + user = await createUser.toUser(); + } catch (error) { + throw error; + } + + return this.userRepository.save(user); + } + + @Put('/:id') + @ResponseSchema(User) + @ResponseSchema(UserNotFoundError, { statusCode: 404 }) + @ResponseSchema(UserIdsNotMatchingError, { statusCode: 406 }) + @OpenAPI({ description: "Update a user object (id can't be changed)." }) + async put(@Param('id') id: number, @EntityFromBody() user: User) { + let oldUser = await this.userRepository.findOne({ id: id }); + + if (!oldUser) { + throw new UserNotFoundError(); + } + + if (oldUser.id != user.id) { + throw new UserIdsNotMatchingError(); + } + + await this.userRepository.update(oldUser, user); + return user; + } + + @Delete('/:id') + @ResponseSchema(User) + @ResponseSchema(ResponseEmpty, { statusCode: 204 }) + @OnUndefined(204) + @OpenAPI({ description: 'Delete a specified runner (if it exists).' }) + async remove(@Param("id") id: number) { + let user = await this.userRepository.findOne({ id: id }); + if (!user) { + return null; + } + + await this.userRepository.delete(user); + return user; + } +} diff --git a/src/controllers/UserGroupController.ts b/src/controllers/UserGroupController.ts new file mode 100644 index 0000000..a317366 --- /dev/null +++ b/src/controllers/UserGroupController.ts @@ -0,0 +1,87 @@ +import { Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put } from 'routing-controllers'; +import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; +import { getConnectionManager, Repository } from 'typeorm'; +import { EntityFromBody } from 'typeorm-routing-controllers-extensions'; +import { UserGroupIdsNotMatchingError, UserGroupNotFoundError } from '../errors/UserGroupErrors'; +import { CreateUserGroup } from '../models/actions/CreateUserGroup'; +import { UserGroup } from '../models/entities/UserGroup'; +import { ResponseEmpty } from '../models/responses/ResponseEmpty'; + + +@JsonController('/usergroups') +export class UserGroupController { + private userGroupsRepository: Repository; + + /** + * Gets the repository of this controller's model/entity. + */ + constructor() { + this.userGroupsRepository = getConnectionManager().get().getRepository(UserGroup); + } + + @Get() + @ResponseSchema(UserGroup, { isArray: true }) + @OpenAPI({ description: 'Lists all usergroups.' }) + getAll() { + return this.userGroupsRepository.find(); + } + + @Get('/:id') + @ResponseSchema(UserGroup) + @ResponseSchema(UserGroupNotFoundError, { statusCode: 404 }) + @OnUndefined(UserGroupNotFoundError) + @OpenAPI({ description: 'Returns a usergroup of a specified id (if it exists)' }) + getOne(@Param('id') id: number) { + return this.userGroupsRepository.findOne({ id: id }); + } + + @Post() + @ResponseSchema(UserGroup) + @ResponseSchema(UserGroupNotFoundError) + @OpenAPI({ description: 'Create a new usergroup object (id will be generated automagicly).' }) + async post(@Body({ validate: true }) createUserGroup: CreateUserGroup) { + let userGroup; + try { + userGroup = await createUserGroup.toUserGroup(); + } catch (error) { + throw error; + } + + return this.userGroupsRepository.save(userGroup); + } + + @Put('/:id') + @ResponseSchema(UserGroup) + @ResponseSchema(UserGroupNotFoundError, { statusCode: 404 }) + @ResponseSchema(UserGroupIdsNotMatchingError, { statusCode: 406 }) + @OpenAPI({ description: "Update a usergroup object (id can't be changed)." }) + async put(@Param('id') id: number, @EntityFromBody() userGroup: UserGroup) { + let oldUserGroup = await this.userGroupsRepository.findOne({ id: id }); + + if (!oldUserGroup) { + throw new UserGroupNotFoundError() + } + + if (oldUserGroup.id != userGroup.id) { + throw new UserGroupIdsNotMatchingError(); + } + + await this.userGroupsRepository.update(oldUserGroup, userGroup); + return userGroup; + } + + @Delete('/:id') + @ResponseSchema(UserGroup) + @ResponseSchema(ResponseEmpty, { statusCode: 204 }) + @OnUndefined(204) + @OpenAPI({ description: 'Delete a specified usergroup (if it exists).' }) + async remove(@Param("id") id: number) { + let group = await this.userGroupsRepository.findOne({ id: id }); + if (!group) { + return null; + } + + await this.userGroupsRepository.delete(group); + return group; + } +} diff --git a/src/errors/AddressErrors.ts b/src/errors/AddressErrors.ts new file mode 100644 index 0000000..18cf624 --- /dev/null +++ b/src/errors/AddressErrors.ts @@ -0,0 +1,24 @@ +import { IsString } from 'class-validator'; +import { NotAcceptableError, NotFoundError } from 'routing-controllers'; + +/** + * Error to throw, when to provided address doesn't belong to the accepted types. + */ +export class AddressWrongTypeError extends NotAcceptableError { + @IsString() + name = "AddressWrongTypeError" + + @IsString() + message = "The address must be an existing adress's id. \n You provided a object of another type." +} + +/** + * Error to throw, when a non-existant address get's loaded. + */ +export class AddressNotFoundError extends NotFoundError { + @IsString() + name = "AddressNotFoundError" + + @IsString() + message = "The address you provided couldn't be located in the system. \n Please check your request." +} \ No newline at end of file diff --git a/src/errors/AuthError.ts b/src/errors/AuthError.ts new file mode 100644 index 0000000..7687371 --- /dev/null +++ b/src/errors/AuthError.ts @@ -0,0 +1,123 @@ +import { IsString } from 'class-validator'; +import { ForbiddenError, NotAcceptableError, NotFoundError, UnauthorizedError } from 'routing-controllers'; + +/** + * Error to throw when a jwt is expired. + */ +export class ExpiredJWTError extends UnauthorizedError { + @IsString() + name = "ExpiredJWTError" + + @IsString() + message = "your provided jwt is expired" +} + +/** + * Error to throw when a jwt could not be parsed. + */ +export class IllegalJWTError extends UnauthorizedError { + @IsString() + name = "IllegalJWTError" + + @IsString() + message = "your provided jwt could not be parsed" +} + +/** + * Error to throw when user is nonexistant or refreshtoken is invalid. + */ +export class UserNonexistantOrRefreshtokenInvalidError extends UnauthorizedError { + @IsString() + name = "UserNonexistantOrRefreshtokenInvalidError" + + @IsString() + message = "user is nonexistant or refreshtoken is invalid" +} + +/** + * Error to throw when provided credentials are invalid. + */ +export class InvalidCredentialsError extends UnauthorizedError { + @IsString() + name = "InvalidCredentialsError" + + @IsString() + message = "your provided credentials are invalid" +} + +/** + * Error to throw when a jwt does not have permission for this route/action. + */ +export class NoPermissionError extends ForbiddenError { + @IsString() + name = "NoPermissionError" + + @IsString() + message = "your provided jwt does not have permission for this route/ action" +} + +/** + * Error to throw when no username and no email is set. + */ +export class UsernameOrEmailNeededError extends NotAcceptableError { + @IsString() + name = "UsernameOrEmailNeededError" + + @IsString() + message = "Auth needs to have email or username set! \n You provided neither." +} + +/** + * Error to throw when no password is provided. + */ +export class PasswordNeededError extends NotAcceptableError { + @IsString() + name = "PasswordNeededError" + + @IsString() + message = "no password is provided - you need to provide it" +} + +/** + * Error to throw when no user could be found mating the provided credential. + */ +export class UserNotFoundError extends NotFoundError { + @IsString() + name = "UserNotFoundError" + + @IsString() + message = "no user could be found for provided credential" +} + +/** + * Error to throw when no jwt token was provided (but one had to be). + */ +export class JwtNotProvidedError extends NotAcceptableError { + @IsString() + name = "JwtNotProvidedError" + + @IsString() + message = "no jwt token was provided" +} + +/** + * Error to throw when user was not found or refresh token count was invalid. + */ +export class UserNotFoundOrRefreshTokenCountInvalidError extends NotAcceptableError { + @IsString() + name = "UserNotFoundOrRefreshTokenCountInvalidError" + + @IsString() + message = "user was not found or refresh token count was invalid" +} + +/** + * Error to throw when refresh token count was invalid + */ +export class RefreshTokenCountInvalidError extends NotAcceptableError { + @IsString() + name = "RefreshTokenCountInvalidError" + + @IsString() + message = "refresh token count was invalid" +} \ No newline at end of file diff --git a/src/errors/GroupContactErrors.ts b/src/errors/GroupContactErrors.ts new file mode 100644 index 0000000..7dc19e8 --- /dev/null +++ b/src/errors/GroupContactErrors.ts @@ -0,0 +1,24 @@ +import { IsString } from 'class-validator'; +import { NotAcceptableError, NotFoundError } from 'routing-controllers'; + +/** + * Error to throw, when a provided groupContact doesn't belong to the accepted types. + */ +export class GroupContactWrongTypeError extends NotAcceptableError { + @IsString() + name = "GroupContactWrongTypeError" + + @IsString() + message = "The groupContact must be an existing groupContact's id. \n You provided a object of another type." +} + +/** + * Error to throw, when a non-existant groupContact get's loaded. + */ +export class GroupContactNotFoundError extends NotFoundError { + @IsString() + name = "GroupContactNotFoundError" + + @IsString() + message = "The groupContact you provided couldn't be located in the system. \n Please check your request." +} \ No newline at end of file diff --git a/src/errors/RunnerErrors.ts b/src/errors/RunnerErrors.ts new file mode 100644 index 0000000..06dc78f --- /dev/null +++ b/src/errors/RunnerErrors.ts @@ -0,0 +1,38 @@ +import { IsString } from 'class-validator'; +import { NotAcceptableError, NotFoundError } from 'routing-controllers'; + +/** + * Error to throw when a runner couldn't be found. + * Implemented this ways to work with the json-schema conversion for openapi. + */ +export class RunnerNotFoundError extends NotFoundError { + @IsString() + name = "RunnerNotFoundError" + + @IsString() + message = "Runner not found!" +} + +/** + * Error to throw when two runners' ids don't match. + * Usually occurs when a user tries to change a runner's id. + * Implemented this ways to work with the json-schema conversion for openapi. + */ +export class RunnerIdsNotMatchingError extends NotAcceptableError { + @IsString() + name = "RunnerIdsNotMatchingError" + + @IsString() + message = "The id's don't match!! \n And if you wanted to change a runner's id: This isn't allowed" +} + +/** + * Error to throw when a runner is missing his group association. + */ +export class RunnerGroupNeededError extends NotAcceptableError { + @IsString() + name = "RunnerGroupNeededError" + + @IsString() + message = "Runner's need to be part of one group (team or organisiation)! \n You provided neither." +} \ No newline at end of file diff --git a/src/errors/RunnerGroupErrors.ts b/src/errors/RunnerGroupErrors.ts new file mode 100644 index 0000000..ed9624c --- /dev/null +++ b/src/errors/RunnerGroupErrors.ts @@ -0,0 +1,14 @@ +import { IsString } from 'class-validator'; +import { NotFoundError } from 'routing-controllers'; + +/** + * Error to throw when a runner group couldn't be found. + * Implemented this ways to work with the json-schema conversion for openapi. + */ +export class RunnerGroupNotFoundError extends NotFoundError { + @IsString() + name = "RunnerGroupNotFoundError" + + @IsString() + message = "RunnerGroup not found!" +} \ No newline at end of file diff --git a/src/errors/RunnerOrganisationErrors.ts b/src/errors/RunnerOrganisationErrors.ts new file mode 100644 index 0000000..040befb --- /dev/null +++ b/src/errors/RunnerOrganisationErrors.ts @@ -0,0 +1,62 @@ +import { IsString } from 'class-validator'; +import { NotAcceptableError, NotFoundError } from 'routing-controllers'; + +/** + * Error to throw when a runner organisation couldn't be found. + * Implemented this ways to work with the json-schema conversion for openapi. + */ +export class RunnerOrganisationNotFoundError extends NotFoundError { + @IsString() + name = "RunnerOrganisationNotFoundError" + + @IsString() + message = "RunnerOrganisation not found!" +} + +/** + * Error to throw when two runner organisations' ids don't match. + * Usually occurs when a user tries to change a runner's id. + * Implemented this way to work with the json-schema conversion for openapi. + */ +export class RunnerOrganisationIdsNotMatchingError extends NotAcceptableError { + @IsString() + name = "RunnerOrganisationIdsNotMatchingError" + + @IsString() + message = "The id's don't match!! \n And if you wanted to change a runner's id: This isn't allowed" +} + +/** + * Error to throw when a organisation still has runners associated. + * Implemented this waysto work with the json-schema conversion for openapi. + */ +export class RunnerOrganisationHasRunnersError extends NotAcceptableError { + @IsString() + name = "RunnerOrganisationHasRunnersError" + + @IsString() + message = "This organisation still has runners associated with it. \n If you want to delete this organisation with all it's runners and teams ass `?force` to your query." +} + +/** + * Error to throw when a organisation still has runners associated. + * Implemented this waysto work with the json-schema conversion for openapi. + */ +export class RunnerOrganisationHasTeamsError extends NotAcceptableError { + @IsString() + name = "RunnerOrganisationHasTeamsError" + + @IsString() + message = "This organisation still has teams associated with it. \n If you want to delete this organisation with all it's runners and teams ass `?force` to your query." +} + +/** + * Error to throw, when a provided runnerOrganisation doesn't belong to the accepted types. + */ +export class RunnerOrganisationWrongTypeError extends NotAcceptableError { + @IsString() + name = "RunnerOrganisationWrongTypeError" + + @IsString() + message = "The runner organisation must be an existing organisation's id. \n You provided a object of another type." +} diff --git a/src/errors/RunnerTeamErrors.ts b/src/errors/RunnerTeamErrors.ts new file mode 100644 index 0000000..33fbea5 --- /dev/null +++ b/src/errors/RunnerTeamErrors.ts @@ -0,0 +1,51 @@ +import { IsString } from 'class-validator'; +import { NotAcceptableError, NotFoundError } from 'routing-controllers'; + +/** + * Error to throw when a runner team couldn't be found. + * Implemented this ways to work with the json-schema conversion for openapi. + */ +export class RunnerTeamNotFoundError extends NotFoundError { + @IsString() + name = "RunnerTeamNotFoundError" + + @IsString() + message = "RunnerTeam not found!" +} + +/** + * Error to throw when two runner teams' ids don't match. + * Usually occurs when a user tries to change a runner's id. + * Implemented this way to work with the json-schema conversion for openapi. + */ +export class RunnerTeamIdsNotMatchingError extends NotAcceptableError { + @IsString() + name = "RunnerTeamIdsNotMatchingError" + + @IsString() + message = "The id's don't match!! \n And if you wanted to change a runner's id: This isn't allowed" +} + +/** + * Error to throw when a team still has runners associated. + * Implemented this waysto work with the json-schema conversion for openapi. + */ +export class RunnerTeamHasRunnersError extends NotAcceptableError { + @IsString() + name = "RunnerTeamHasRunnersError" + + @IsString() + message = "This team still has runners associated with it. \n If you want to delete this team with all it's runners and teams ass `?force` to your query." +} + +/** + * Error to throw when a team still has runners associated. + * Implemented this waysto work with the json-schema conversion for openapi. + */ +export class RunnerTeamNeedsParentError extends NotAcceptableError { + @IsString() + name = "RunnerTeamNeedsParentError" + + @IsString() + message = "You provided no runner organisation as this team's parent group." +} \ No newline at end of file diff --git a/src/errors/UserErrors.ts b/src/errors/UserErrors.ts new file mode 100644 index 0000000..cfceff1 --- /dev/null +++ b/src/errors/UserErrors.ts @@ -0,0 +1,37 @@ +import { IsString } from 'class-validator'; +import { NotAcceptableError, NotFoundError } from 'routing-controllers'; + + +/** + * Error to throw when no username or email is set + */ +export class UsernameOrEmailNeededError extends NotFoundError { + @IsString() + name = "UsernameOrEmailNeededError" + + @IsString() + message = "no username or email is set!" +} + +/** + * Error to throw when a user couldn't be found. + */ +export class UserNotFoundError extends NotFoundError { + @IsString() + name = "UserNotFoundError" + + @IsString() + message = "User not found!" +} + +/** + * Error to throw when two users' ids don't match. + * Usually occurs when a user tries to change a user's id. + */ +export class UserIdsNotMatchingError extends NotAcceptableError { + @IsString() + name = "UserIdsNotMatchingError" + + @IsString() + message = "The id's don't match!! \n And if you wanted to change a user's id: This isn't allowed" +} \ No newline at end of file diff --git a/src/errors/UserGroupErrors.ts b/src/errors/UserGroupErrors.ts new file mode 100644 index 0000000..15495b9 --- /dev/null +++ b/src/errors/UserGroupErrors.ts @@ -0,0 +1,36 @@ +import { IsString } from 'class-validator'; +import { NotAcceptableError, NotFoundError } from 'routing-controllers'; + +/** + * Error to throw when no groupname is set + */ +export class GroupNameNeededError extends NotFoundError { + @IsString() + name = "GroupNameNeededError" + + @IsString() + message = "no groupname is set!" +} + +/** + * Error to throw when a usergroup couldn't be found. + */ +export class UserGroupNotFoundError extends NotFoundError { + @IsString() + name = "UserGroupNotFoundError" + + @IsString() + message = "User Group not found!" +} + +/** + * Error to throw when two usergroups' ids don't match. + * Usually occurs when a user tries to change a usergroups's id. + */ +export class UserGroupIdsNotMatchingError extends NotAcceptableError { + @IsString() + name = "UserGroupIdsNotMatchingError" + + @IsString() + message = "The id's don't match!! \n If you wanted to change a usergroup's id: This isn't allowed" +} \ No newline at end of file diff --git a/src/loaders/database.ts b/src/loaders/database.ts index b8e9607..ee331ae 100644 --- a/src/loaders/database.ts +++ b/src/loaders/database.ts @@ -1,5 +1,8 @@ import { createConnection } from "typeorm"; +/** + * Loader for the database that creates the database connection and initializes the database tabels. + */ export default async () => { const connection = await createConnection(); connection.synchronize(); diff --git a/src/loaders/express.ts b/src/loaders/express.ts index 787497a..32a28cd 100644 --- a/src/loaders/express.ts +++ b/src/loaders/express.ts @@ -1,7 +1,9 @@ import { Application } from "express"; -import bodyParser from 'body-parser'; -import cors from 'cors'; +/** + * Loader for express related configurations. + * Currently only enables the proxy trust. + */ export default async (app: Application) => { app.enable('trust proxy'); return app; diff --git a/src/loaders/index.ts b/src/loaders/index.ts index 704ea47..802a732 100644 --- a/src/loaders/index.ts +++ b/src/loaders/index.ts @@ -1,8 +1,11 @@ +import { Application } from "express"; +import databaseLoader from "./database"; import expressLoader from "./express"; import openapiLoader from "./openapi"; -import databaseLoader from "./database"; -import { Application } from "express"; +/** + * Index Loader that executes the other loaders in the right order. + */ export default async (app: Application) => { await databaseLoader(); await openapiLoader(app); diff --git a/src/loaders/openapi.ts b/src/loaders/openapi.ts index 9a0ff85..9c270ed 100644 --- a/src/loaders/openapi.ts +++ b/src/loaders/openapi.ts @@ -1,14 +1,19 @@ +import { validationMetadatasToSchemas } from "class-validator-jsonschema"; import { Application } from "express"; -import * as swaggerUiExpress from "swagger-ui-express"; import { getMetadataArgsStorage } from "routing-controllers"; import { routingControllersToSpec } from "routing-controllers-openapi"; -import { validationMetadatasToSchemas } from "class-validator-jsonschema"; +import * as swaggerUiExpress from "swagger-ui-express"; +/** + * Loader for everything openapi related - from creating the schema to serving it via a static route. + */ export default async (app: Application) => { const storage = getMetadataArgsStorage(); const schemas = validationMetadatasToSchemas({ refPointerPrefix: "#/components/schemas/", }); + + //Spec creation based on the previously created schemas const spec = routingControllersToSpec( storage, { @@ -17,6 +22,13 @@ export default async (app: Application) => { { components: { schemas, + "securitySchemes": { + "AuthToken": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } }, info: { description: "The the backend API for the LfK! runner system.", @@ -25,6 +37,8 @@ export default async (app: Application) => { }, } ); + + //Options for swaggerUiExpress const options = { explorer: true, }; diff --git a/src/middlewares/ErrorHandler.ts b/src/middlewares/ErrorHandler.ts index 7f45073..40c1ff6 100644 --- a/src/middlewares/ErrorHandler.ts +++ b/src/middlewares/ErrorHandler.ts @@ -1,20 +1,14 @@ -import { - Middleware, - ExpressErrorMiddlewareInterface -} from "routing-controllers"; +import { ExpressErrorMiddlewareInterface, Middleware } from "routing-controllers"; +/** + * Our Error handling middlware that returns our custom httperrors to the user + */ @Middleware({ type: "after" }) export class ErrorHandler implements ExpressErrorMiddlewareInterface { - public error( - error: any, - request: any, - response: any, - next: (err: any) => any - ) { + public error(error: any, request: any, response: any, next: (err: any) => any) { if (response.headersSent) { return; } - response.json(error); } } diff --git a/src/middlewares/jwtauth.ts b/src/middlewares/jwtauth.ts deleted file mode 100644 index 81c28c3..0000000 --- a/src/middlewares/jwtauth.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -// import bodyParser from 'body-parser'; -// import cors from 'cors'; -import * as jwt from "jsonwebtoken"; - -export default (req: Request, res: Response, next: NextFunction) => { - const token = req.headers["auth"]; - try { - const jwtPayload = jwt.verify(token, "secretjwtsecret"); - // const jwtPayload = jwt.verify(token, process.env.JWT_SECRET); - res.locals.jwtPayload = jwtPayload; - } catch (error) { - console.log(error); - return res.status(401).send(); - } - next(); -}; diff --git a/src/models/Track.ts b/src/models/Track.ts deleted file mode 100644 index ea09063..0000000 --- a/src/models/Track.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"; -import { - IsInt, - IsNotEmpty, - IsOptional, - IsPositive, - IsString, -} from "class-validator"; - -/** - * Defines a track of given length. -*/ -@Entity() -export class Track { - /** - * Autogenerated unique id (primary key). - */ - @PrimaryGeneratedColumn() - @IsOptional() - @IsInt() - id: number; - - /** - * The track's name. - */ - @Column() - @IsString() - @IsNotEmpty() - name: string; - - /** - * The track's length in meters. - */ - @Column() - @IsInt() - @IsPositive() - length: number; -} diff --git a/src/models/actions/CreateAddress.ts b/src/models/actions/CreateAddress.ts new file mode 100644 index 0000000..ef78410 --- /dev/null +++ b/src/models/actions/CreateAddress.ts @@ -0,0 +1,64 @@ +import { IsNotEmpty, IsOptional, IsPostalCode, IsString } from 'class-validator'; +import { Address } from '../entities/Address'; + +export class CreateAddress { + /** + * The address's description. + */ + @IsString() + @IsOptional() + description?: string; + + /** + * The address's first line. + * Containing the street and house number. + */ + @IsString() + @IsNotEmpty() + address1: string; + + /** + * The address's second line. + * Containing optional information. + */ + @IsString() + @IsOptional() + address2?: string; + + /** + * The address's postal code. + */ + @IsString() + @IsNotEmpty() + @IsPostalCode("DE") + postalcode: string; + + /** + * The address's city. + */ + @IsString() + @IsNotEmpty() + city: string; + + /** + * The address's country. + */ + @IsString() + @IsNotEmpty() + country: string; + + /** + * Creates a Address object based on this. + */ + public toAddress(): Address { + let newAddress: Address = new Address(); + + newAddress.address1 = this.address1; + newAddress.address2 = this.address2; + newAddress.postalcode = this.postalcode; + newAddress.city = this.city; + newAddress.country = this.country; + + return newAddress; + } +} \ No newline at end of file diff --git a/src/models/actions/CreateAuth.ts b/src/models/actions/CreateAuth.ts new file mode 100644 index 0000000..b31bee6 --- /dev/null +++ b/src/models/actions/CreateAuth.ts @@ -0,0 +1,59 @@ +import * as argon2 from "argon2"; +import { IsEmail, IsOptional, IsString } from 'class-validator'; +import * as jsonwebtoken from 'jsonwebtoken'; +import { getConnectionManager } from 'typeorm'; +import { config } from '../../config'; +import { InvalidCredentialsError, PasswordNeededError, UserNotFoundError } from '../../errors/AuthError'; +import { UsernameOrEmailNeededError } from '../../errors/UserErrors'; +import { User } from '../entities/User'; +import { Auth } from '../responses/ResponseAuth'; + +export class CreateAuth { + @IsOptional() + @IsString() + username?: string; + @IsString() + password: string; + @IsOptional() + @IsEmail() + @IsString() + email?: string; + + public async toAuth(): Promise { + let newAuth: Auth = new Auth(); + + if (this.email === undefined && this.username === undefined) { + throw new UsernameOrEmailNeededError(); + } + if (!this.password) { + throw new PasswordNeededError() + } + const found_users = await getConnectionManager().get().getRepository(User).find({ relations: ['groups', 'permissions'], where: [{ username: this.username }, { email: this.email }] }); + if (found_users.length === 0) { + throw new UserNotFoundError() + } else { + const found_user = found_users[0] + if (await argon2.verify(found_user.password, this.password + found_user.uuid)) { + const timestamp_accesstoken_expiry = Math.floor(Date.now() / 1000) + 5 * 60 + found_user.permissions = found_user.permissions || [] + delete found_user.password; + newAuth.access_token = jsonwebtoken.sign({ + userdetails: found_user, + exp: timestamp_accesstoken_expiry + }, config.jwt_secret) + newAuth.access_token_expires_at = timestamp_accesstoken_expiry + // + const timestamp_refresh_expiry = Math.floor(Date.now() / 1000) + 10 * 36000 + newAuth.refresh_token = jsonwebtoken.sign({ + refreshtokencount: found_user.refreshTokenCount, + userid: found_user.id, + exp: timestamp_refresh_expiry + }, config.jwt_secret) + newAuth.refresh_token_expires_at = timestamp_refresh_expiry + } else { + throw new InvalidCredentialsError() + } + } + return newAuth; + } +} \ No newline at end of file diff --git a/src/models/actions/CreateGroupContact.ts b/src/models/actions/CreateGroupContact.ts new file mode 100644 index 0000000..d8aa815 --- /dev/null +++ b/src/models/actions/CreateGroupContact.ts @@ -0,0 +1,84 @@ +import { IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString } from 'class-validator'; +import { getConnectionManager } from 'typeorm'; +import { config } from '../../config'; +import { AddressNotFoundError, AddressWrongTypeError } from '../../errors/AddressErrors'; +import { Address } from '../entities/Address'; +import { GroupContact } from '../entities/GroupContact'; + +export class CreateGroupContact { + /** + * The contact's first name. + */ + @IsNotEmpty() + @IsString() + firstname: string; + + /** + * The contact's middle name. + * Optional + */ + @IsOptional() + @IsString() + middlename?: string; + + /** + * The contact's last name. + */ + @IsNotEmpty() + @IsString() + lastname: string; + + /** + * The contact's address. + * Optional + */ + @IsInt() + @IsOptional() + address?: number; + + /** + * The contact's phone number. + * Optional + */ + @IsOptional() + @IsPhoneNumber(config.phone_validation_countrycode) + phone?: string; + + /** + * The contact's email address. + * Optional + */ + @IsOptional() + @IsEmail() + email?: string; + + /** + * Get's this participant's address from this.address. + */ + public async getAddress(): Promise
{ + if (this.address === undefined) { + return null; + } + if (!isNaN(this.address)) { + let address = await getConnectionManager().get().getRepository(Address).findOne({ id: this.address }); + if (!address) { throw new AddressNotFoundError; } + return address; + } + + throw new AddressWrongTypeError; + } + + /** + * Creates a Address object based on this. + */ + public async toGroupContact(): Promise { + let contact: GroupContact = new GroupContact(); + contact.firstname = this.firstname; + contact.middlename = this.middlename; + contact.lastname = this.lastname; + contact.email = this.email; + contact.phone = this.phone; + contact.address = await this.getAddress(); + return null; + } +} \ No newline at end of file diff --git a/src/models/actions/CreateParticipant.ts b/src/models/actions/CreateParticipant.ts new file mode 100644 index 0000000..9cd51ce --- /dev/null +++ b/src/models/actions/CreateParticipant.ts @@ -0,0 +1,72 @@ +import { IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString } from 'class-validator'; +import { getConnectionManager } from 'typeorm'; +import { config } from '../../config'; +import { AddressNotFoundError, AddressWrongTypeError } from '../../errors/AddressErrors'; +import { Address } from '../entities/Address'; + +export abstract class CreateParticipant { + /** + * The new participant's first name. + */ + @IsString() + @IsNotEmpty() + firstname: string; + + /** + * The new participant's middle name. + * Optional. + */ + @IsString() + @IsNotEmpty() + middlename?: string; + + /** + * The new participant's last name. + */ + @IsString() + @IsNotEmpty() + lastname: string; + + /** + * The new participant's phone number. + * Optional. + */ + @IsString() + @IsOptional() + @IsPhoneNumber(config.phone_validation_countrycode) + phone?: string; + + /** + * The new participant's e-mail address. + * Optional. + */ + @IsString() + @IsOptional() + @IsEmail() + email?: string; + + /** + * The new participant's address. + * Must be of type number (address id), createAddress (new address) or address (existing address) + * Optional. + */ + @IsInt() + @IsOptional() + address?: number; + + /** + * Get's this participant's address from this.address. + */ + public async getAddress(): Promise
{ + if (this.address === undefined) { + return null; + } + if (!isNaN(this.address)) { + let address = await getConnectionManager().get().getRepository(Address).findOne({ id: this.address }); + if (!address) { throw new AddressNotFoundError; } + return address; + } + + throw new AddressWrongTypeError; + } +} \ No newline at end of file diff --git a/src/models/actions/CreateRunner.ts b/src/models/actions/CreateRunner.ts new file mode 100644 index 0000000..f3611ce --- /dev/null +++ b/src/models/actions/CreateRunner.ts @@ -0,0 +1,51 @@ +import { IsInt } from 'class-validator'; +import { getConnectionManager } from 'typeorm'; +import { RunnerGroupNotFoundError } from '../../errors/RunnerGroupErrors'; +import { RunnerOrganisationWrongTypeError } from '../../errors/RunnerOrganisationErrors'; +import { RunnerTeamNeedsParentError } from '../../errors/RunnerTeamErrors'; +import { Runner } from '../entities/Runner'; +import { RunnerGroup } from '../entities/RunnerGroup'; +import { CreateParticipant } from './CreateParticipant'; + +export class CreateRunner extends CreateParticipant { + + /** + * The new runner's team's id. + * Either provide this or his organisation's id. + */ + @IsInt() + group: number; + + /** + * Creates a Runner entity from this. + */ + public async toRunner(): Promise { + let newRunner: Runner = new Runner(); + + newRunner.firstname = this.firstname; + newRunner.middlename = this.middlename; + newRunner.lastname = this.lastname; + newRunner.phone = this.phone; + newRunner.email = this.email; + newRunner.group = await this.getGroup(); + newRunner.address = await this.getAddress(); + + return newRunner; + } + + /** + * Manages all the different ways a group can be provided. + */ + public async getGroup(): Promise { + if (this.group === undefined) { + throw new RunnerTeamNeedsParentError(); + } + if (!isNaN(this.group)) { + let group = await getConnectionManager().get().getRepository(RunnerGroup).findOne({ id: this.group }); + if (!group) { throw new RunnerGroupNotFoundError; } + return group; + } + + throw new RunnerOrganisationWrongTypeError; + } +} \ No newline at end of file diff --git a/src/models/actions/CreateRunnerGroup.ts b/src/models/actions/CreateRunnerGroup.ts new file mode 100644 index 0000000..76552ac --- /dev/null +++ b/src/models/actions/CreateRunnerGroup.ts @@ -0,0 +1,37 @@ +import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { getConnectionManager } from 'typeorm'; +import { GroupContactNotFoundError, GroupContactWrongTypeError } from '../../errors/GroupContactErrors'; +import { GroupContact } from '../entities/GroupContact'; + +export abstract class CreateRunnerGroup { + /** + * The group's name. + */ + @IsNotEmpty() + @IsString() + name: string; + + /** + * The group's contact. + * Optional + */ + @IsInt() + @IsOptional() + contact?: number; + + /** + * Deals with the contact for groups this. + */ + public async getContact(): Promise { + if (this.contact === undefined) { + return null; + } + if (!isNaN(this.contact)) { + let address = await getConnectionManager().get().getRepository(GroupContact).findOne({ id: this.contact }); + if (!address) { throw new GroupContactNotFoundError; } + return address; + } + + throw new GroupContactWrongTypeError; + } +} \ No newline at end of file diff --git a/src/models/actions/CreateRunnerOrganisation.ts b/src/models/actions/CreateRunnerOrganisation.ts new file mode 100644 index 0000000..01c3732 --- /dev/null +++ b/src/models/actions/CreateRunnerOrganisation.ts @@ -0,0 +1,46 @@ +import { IsInt, IsOptional } from 'class-validator'; +import { getConnectionManager } from 'typeorm'; +import { AddressNotFoundError, AddressWrongTypeError } from '../../errors/AddressErrors'; +import { Address } from '../entities/Address'; +import { RunnerOrganisation } from '../entities/RunnerOrganisation'; +import { CreateRunnerGroup } from './CreateRunnerGroup'; + +export class CreateRunnerOrganisation extends CreateRunnerGroup { + /** + * The new organisation's address. + * Must be of type number (address id), createAddress (new address) or address (existing address) + * Optional. + */ + @IsInt() + @IsOptional() + address?: number; + + /** + * Creates a Participant entity from this. + */ + public async getAddress(): Promise
{ + if (this.address === undefined) { + return null; + } + if (!isNaN(this.address)) { + let address = await getConnectionManager().get().getRepository(Address).findOne({ id: this.address }); + if (!address) { throw new AddressNotFoundError; } + return address; + } + + throw new AddressWrongTypeError; + } + + /** + * Creates a RunnerOrganisation entity from this. + */ + public async toRunnerOrganisation(): Promise { + let newRunnerOrganisation: RunnerOrganisation = new RunnerOrganisation(); + + newRunnerOrganisation.name = this.name; + newRunnerOrganisation.contact = await this.getContact(); + newRunnerOrganisation.address = await this.getAddress(); + + return newRunnerOrganisation; + } +} \ No newline at end of file diff --git a/src/models/actions/CreateRunnerTeam.ts b/src/models/actions/CreateRunnerTeam.ts new file mode 100644 index 0000000..0a4941f --- /dev/null +++ b/src/models/actions/CreateRunnerTeam.ts @@ -0,0 +1,44 @@ +import { IsInt, IsNotEmpty } from 'class-validator'; +import { getConnectionManager } from 'typeorm'; +import { RunnerOrganisationNotFoundError, RunnerOrganisationWrongTypeError } from '../../errors/RunnerOrganisationErrors'; +import { RunnerTeamNeedsParentError } from '../../errors/RunnerTeamErrors'; +import { RunnerOrganisation } from '../entities/RunnerOrganisation'; +import { RunnerTeam } from '../entities/RunnerTeam'; +import { CreateRunnerGroup } from './CreateRunnerGroup'; + +export class CreateRunnerTeam extends CreateRunnerGroup { + + /** + * The team's parent group (organisation). + */ + @IsInt() + @IsNotEmpty() + parentGroup: number; + + public async getParent(): Promise { + if (this.parentGroup === undefined) { + throw new RunnerTeamNeedsParentError(); + } + if (!isNaN(this.parentGroup)) { + let parentGroup = await getConnectionManager().get().getRepository(RunnerOrganisation).findOne({ id: this.parentGroup }); + if (!parentGroup) { throw new RunnerOrganisationNotFoundError();; } + return parentGroup; + } + + throw new RunnerOrganisationWrongTypeError; + } + + /** + * Creates a RunnerTeam entity from this. + */ + public async toRunnerTeam(): Promise { + let newRunnerTeam: RunnerTeam = new RunnerTeam(); + + newRunnerTeam.name = this.name; + newRunnerTeam.parentGroup = await this.getParent(); + + newRunnerTeam.contact = await this.getContact() + + return newRunnerTeam; + } +} \ No newline at end of file diff --git a/src/models/actions/CreateTrack.ts b/src/models/actions/CreateTrack.ts new file mode 100644 index 0000000..42bf384 --- /dev/null +++ b/src/models/actions/CreateTrack.ts @@ -0,0 +1,30 @@ +import { IsInt, IsNotEmpty, IsPositive, IsString } from 'class-validator'; +import { Track } from '../entities/Track'; + +export class CreateTrack { + /** + * The track's name. + */ + @IsString() + @IsNotEmpty() + name: string; + + /** + * The track's distance in meters (must be greater 0). + */ + @IsInt() + @IsPositive() + distance: number; + + /** + * Converts a Track object based on this. + */ + public toTrack(): Track { + let newTrack: Track = new Track(); + + newTrack.name = this.name; + newTrack.distance = this.distance; + + return newTrack; + } +} \ No newline at end of file diff --git a/src/models/actions/CreateUser.ts b/src/models/actions/CreateUser.ts new file mode 100644 index 0000000..d3c6ef6 --- /dev/null +++ b/src/models/actions/CreateUser.ts @@ -0,0 +1,120 @@ +import * as argon2 from "argon2"; +import { IsEmail, IsOptional, IsPhoneNumber, IsString } from 'class-validator'; +import { getConnectionManager } from 'typeorm'; +import * as uuid from 'uuid'; +import { config } from '../../config'; +import { UsernameOrEmailNeededError } from '../../errors/UserErrors'; +import { UserGroupNotFoundError } from '../../errors/UserGroupErrors'; +import { User } from '../entities/User'; +import { UserGroup } from '../entities/UserGroup'; + +export class CreateUser { + /** + * The new user's first name. + */ + @IsString() + firstname: string; + + /** + * The new user's middle name. + * Optinal. + */ + @IsString() + @IsOptional() + middlename?: string; + + /** + * The new user's last name. + */ + @IsString() + lastname: string; + + /** + * The new user's username. + * You have to provide at least one of: {email, username}. + */ + @IsOptional() + @IsString() + username?: string; + + /** + * The new user's email address. + * You have to provide at least one of: {email, username}. + */ + @IsEmail() + @IsString() + @IsOptional() + email?: string; + + /** + * The new user's phone number. + * Optional + */ + @IsPhoneNumber(config.phone_validation_countrycode) + @IsOptional() + phone?: string; + + /** + * The new user's password. + * This will of course not be saved in plaintext :) + */ + @IsString() + password: string; + + /** + * The new user's groups' id(s). + * You can provide either one groupId or an array of groupIDs. + * Optional. + */ + @IsOptional() + groupId?: number[] | number + + //TODO: ProfilePics + + /** + * Converts this to a User Entity. + */ + public async toUser(): Promise { + let newUser: User = new User(); + + if (this.email === undefined && this.username === undefined) { + throw new UsernameOrEmailNeededError(); + } + + if (this.groupId) { + if (!Array.isArray(this.groupId)) { + this.groupId = [this.groupId] + } + const groupIDs: number[] = this.groupId + let errors = 0 + const validateusergroups = async () => { + let foundgroups = [] + for (const g of groupIDs) { + const found = await getConnectionManager().get().getRepository(UserGroup).find({ id: g }); + if (found.length === 0) { + errors++ + } else { + foundgroups.push(found[0]) + } + } + newUser.groups = foundgroups + } + await validateusergroups() + if (errors !== 0) { + throw new UserGroupNotFoundError(); + } + } + + newUser.email = this.email + newUser.username = this.username + newUser.firstname = this.firstname + newUser.middlename = this.middlename + newUser.lastname = this.lastname + newUser.uuid = uuid.v4() + newUser.phone = this.phone + newUser.password = await argon2.hash(this.password + newUser.uuid); + //TODO: ProfilePics + + return newUser; + } +} \ No newline at end of file diff --git a/src/models/actions/CreateUserGroup.ts b/src/models/actions/CreateUserGroup.ts new file mode 100644 index 0000000..959bea5 --- /dev/null +++ b/src/models/actions/CreateUserGroup.ts @@ -0,0 +1,30 @@ +import { IsOptional, IsString } from 'class-validator'; +import { UserGroup } from '../entities/UserGroup'; + +export class CreateUserGroup { + /** + * The new group's name. + */ + @IsString() + name: string; + + /** + * The new group's description. + * Optinal. + */ + @IsOptional() + @IsString() + description?: string; + + /** + * Converts this to a UserGroup entity. + */ + public async toUserGroup(): Promise { + let newUserGroup: UserGroup = new UserGroup(); + + newUserGroup.name = this.name; + newUserGroup.description = this.description; + + return newUserGroup; + } +} \ No newline at end of file diff --git a/src/models/actions/HandleLogout.ts b/src/models/actions/HandleLogout.ts new file mode 100644 index 0000000..5ecab38 --- /dev/null +++ b/src/models/actions/HandleLogout.ts @@ -0,0 +1,36 @@ +import { IsString } from 'class-validator'; +import * as jsonwebtoken from 'jsonwebtoken'; +import { getConnectionManager } from 'typeorm'; +import { config } from '../../config'; +import { IllegalJWTError, JwtNotProvidedError, RefreshTokenCountInvalidError, UserNotFoundError } from '../../errors/AuthError'; +import { User } from '../entities/User'; +import { Logout } from '../responses/ResponseLogout'; + +export class HandleLogout { + @IsString() + token: string; + + public async logout(): Promise { + let logout: Logout = new Logout(); + if (!this.token || this.token === undefined) { + throw new JwtNotProvidedError() + } + let decoded; + try { + decoded = jsonwebtoken.verify(this.token, config.jwt_secret) + } catch (error) { + throw new IllegalJWTError() + } + logout.timestamp = Math.floor(Date.now() / 1000) + let found_user: User = await getConnectionManager().get().getRepository(User).findOne({ id: decoded["userid"] }); + if (!found_user) { + throw new UserNotFoundError() + } + if (found_user.refreshTokenCount !== decoded["refreshtokencount"]) { + throw new RefreshTokenCountInvalidError() + } + found_user.refreshTokenCount++; + await getConnectionManager().get().getRepository(User).update({ id: found_user.id }, found_user) + return logout; + } +} \ No newline at end of file diff --git a/src/models/actions/RefreshAuth.ts b/src/models/actions/RefreshAuth.ts new file mode 100644 index 0000000..dacca59 --- /dev/null +++ b/src/models/actions/RefreshAuth.ts @@ -0,0 +1,51 @@ +import { IsString } from 'class-validator'; +import * as jsonwebtoken from 'jsonwebtoken'; +import { getConnectionManager } from 'typeorm'; +import { config } from '../../config'; +import { IllegalJWTError, JwtNotProvidedError, RefreshTokenCountInvalidError, UserNotFoundError } from '../../errors/AuthError'; +import { User } from '../entities/User'; +import { Auth } from '../responses/ResponseAuth'; + +export class RefreshAuth { + @IsString() + token: string; + + public async toAuth(): Promise { + let newAuth: Auth = new Auth(); + if (!this.token || this.token === undefined) { + throw new JwtNotProvidedError() + } + let decoded + try { + decoded = jsonwebtoken.verify(this.token, config.jwt_secret) + } catch (error) { + throw new IllegalJWTError() + } + const found_user = await getConnectionManager().get().getRepository(User).findOne({ id: decoded["userid"] }, { relations: ['groups', 'permissions'] }); + if (!found_user) { + throw new UserNotFoundError() + } + if (found_user.refreshTokenCount !== decoded["refreshtokencount"]) { + throw new RefreshTokenCountInvalidError() + } + found_user.permissions = found_user.permissions || [] + delete found_user.password; + const timestamp_accesstoken_expiry = Math.floor(Date.now() / 1000) + 5 * 60 + delete found_user.password; + newAuth.access_token = jsonwebtoken.sign({ + userdetails: found_user, + exp: timestamp_accesstoken_expiry + }, config.jwt_secret) + newAuth.access_token_expires_at = timestamp_accesstoken_expiry + // + const timestamp_refresh_expiry = Math.floor(Date.now() / 1000) + 10 * 36000 + newAuth.refresh_token = jsonwebtoken.sign({ + refreshtokencount: found_user.refreshTokenCount, + userid: found_user.id, + exp: timestamp_refresh_expiry + }, config.jwt_secret) + newAuth.refresh_token_expires_at = timestamp_refresh_expiry + + return newAuth; + } +} \ No newline at end of file diff --git a/src/models/entities/Address.ts b/src/models/entities/Address.ts new file mode 100644 index 0000000..6cc1d11 --- /dev/null +++ b/src/models/entities/Address.ts @@ -0,0 +1,86 @@ +import { + IsInt, + IsNotEmpty, + IsOptional, + IsPostalCode, + IsString +} from "class-validator"; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import { Participant } from "./Participant"; +import { RunnerOrganisation } from "./RunnerOrganisation"; + +/** + * Defines a address (to be used for contact information). +*/ +@Entity() +export class Address { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * The address's description. + */ + @Column({ nullable: true }) + @IsString() + @IsOptional() + description?: string; + + /** + * The address's first line. + * Containing the street and house number. + */ + @Column() + @IsString() + @IsNotEmpty() + address1: string; + + /** + * The address's second line. + * Containing optional information. + */ + @Column({ nullable: true }) + @IsString() + @IsOptional() + address2?: string; + + /** + * The address's postal code. + */ + @Column() + @IsString() + @IsNotEmpty() + @IsPostalCode("DE") + postalcode: string; + + /** + * The address's city. + */ + @Column() + @IsString() + @IsNotEmpty() + city: string; + + /** + * The address's country. + */ + @Column() + @IsString() + @IsNotEmpty() + country: string; + + /** + * Used to link the address to participants. + */ + @OneToMany(() => Participant, participant => participant.address, { nullable: true }) + participants: Participant[]; + + /** + * Used to link the address to runner groups. + */ + @OneToMany(() => RunnerOrganisation, group => group.address, { nullable: true }) + groups: RunnerOrganisation[]; +} diff --git a/src/models/entities/DistanceDonation.ts b/src/models/entities/DistanceDonation.ts new file mode 100644 index 0000000..3103865 --- /dev/null +++ b/src/models/entities/DistanceDonation.ts @@ -0,0 +1,40 @@ +import { IsInt, IsNotEmpty, IsPositive } from "class-validator"; +import { ChildEntity, Column, ManyToOne } from "typeorm"; +import { Donation } from "./Donation"; +import { Runner } from "./Runner"; + +/** + * Defines a distance based donation. + * Here people donate a certain amout per kilometer +*/ +@ChildEntity() +export class DistanceDonation extends Donation { + /** + * The runner associated. + */ + @IsNotEmpty() + @ManyToOne(() => Runner, runner => runner.distanceDonations) + runner: Runner; + + /** + * The amount the donor set to be donated per kilometer that the runner ran. + */ + @Column() + @IsInt() + @IsPositive() + amountPerDistance: number; + + /** + * The donation's amount in cents (or whatever your currency's smallest unit is.). + * The exact implementation may differ for each type of donation. + */ + public get amount(): number { + let calculatedAmount = -1; + try { + calculatedAmount = this.amountPerDistance * this.runner.distance; + } catch (error) { + throw error; + } + return calculatedAmount; + } +} diff --git a/src/models/entities/Donation.ts b/src/models/entities/Donation.ts new file mode 100644 index 0000000..4af3485 --- /dev/null +++ b/src/models/entities/Donation.ts @@ -0,0 +1,33 @@ +import { + IsInt, + IsNotEmpty +} from "class-validator"; +import { Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm"; +import { Participant } from "./Participant"; + +/** + * Defines the donation interface. +*/ +@Entity() +@TableInheritance({ column: { name: "type", type: "varchar" } }) +export abstract class Donation { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * The donations's donor. + */ + @IsNotEmpty() + @ManyToOne(() => Participant, donor => donor.donations) + donor: Participant; + + /** + * 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; +} \ No newline at end of file diff --git a/src/models/entities/Donor.ts b/src/models/entities/Donor.ts new file mode 100644 index 0000000..b92b06a --- /dev/null +++ b/src/models/entities/Donor.ts @@ -0,0 +1,17 @@ +import { IsBoolean } from "class-validator"; +import { ChildEntity, Column } from "typeorm"; +import { Participant } from "./Participant"; + +/** + * Defines a donor. +*/ +@ChildEntity() +export class Donor extends Participant { + /** + * Does this donor need a receipt?. + * Default: false + */ + @Column() + @IsBoolean() + receiptNeeded: boolean = false; +} \ No newline at end of file diff --git a/src/models/entities/FixedDonation.ts b/src/models/entities/FixedDonation.ts new file mode 100644 index 0000000..8ab366d --- /dev/null +++ b/src/models/entities/FixedDonation.ts @@ -0,0 +1,18 @@ +import { IsInt, IsPositive } from "class-validator"; +import { ChildEntity, Column } from "typeorm"; +import { Donation } from "./Donation"; + +/** + * Defines a fixed donation. +*/ +@ChildEntity() +export class FixedDonation extends Donation { + + /** + * The donation's amount in cents (or whatever your currency's smallest unit is.). + */ + @Column() + @IsInt() + @IsPositive() + amount: number; +} \ No newline at end of file diff --git a/src/models/entities/GroupContact.ts b/src/models/entities/GroupContact.ts new file mode 100644 index 0000000..08c5db3 --- /dev/null +++ b/src/models/entities/GroupContact.ts @@ -0,0 +1,83 @@ +import { + IsEmail, + IsInt, + IsNotEmpty, + IsOptional, + IsPhoneNumber, + + IsString +} from "class-validator"; +import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import { config } from '../../config'; +import { Address } from "./Address"; +import { RunnerGroup } from "./RunnerGroup"; + +/** + * Defines a group's contact. +*/ +@Entity() +export class GroupContact { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * The contact's first name. + */ + @Column() + @IsNotEmpty() + @IsString() + firstname: string; + + /** + * The contact's middle name. + * Optional + */ + @Column({ nullable: true }) + @IsOptional() + @IsString() + middlename?: string; + + /** + * The contact's last name. + */ + @Column() + @IsNotEmpty() + @IsString() + lastname: string; + + /** + * The contact's address. + * Optional + */ + @IsOptional() + @ManyToOne(() => Address, address => address.participants, { nullable: true }) + address?: Address; + + /** + * The contact's phone number. + * Optional + */ + @Column({ nullable: true }) + @IsOptional() + @IsPhoneNumber(config.phone_validation_countrycode) + phone?: string; + + /** + * The contact's email address. + * Optional + */ + @Column({ nullable: true }) + @IsOptional() + @IsEmail() + email?: string; + + /** + * Used to link contacts to groups. + */ + @OneToMany(() => RunnerGroup, group => group.contact, { nullable: true }) + groups: RunnerGroup[]; +} \ No newline at end of file diff --git a/src/models/entities/Participant.ts b/src/models/entities/Participant.ts new file mode 100644 index 0000000..791b30d --- /dev/null +++ b/src/models/entities/Participant.ts @@ -0,0 +1,83 @@ +import { + IsEmail, + IsInt, + IsNotEmpty, + IsOptional, + IsPhoneNumber, + + IsString +} from "class-validator"; +import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, TableInheritance } from "typeorm"; +import { config } from '../../config'; +import { Address } from "./Address"; +import { Donation } from "./Donation"; + +/** + * Defines the participant interface. +*/ +@Entity() +@TableInheritance({ column: { name: "type", type: "varchar" } }) +export abstract class Participant { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * The participant's first name. + */ + @Column() + @IsNotEmpty() + @IsString() + firstname: string; + + /** + * The participant's middle name. + * Optional + */ + @Column({ nullable: true }) + @IsOptional() + @IsString() + middlename?: string; + + /** + * The participant's last name. + */ + @Column() + @IsNotEmpty() + @IsString() + lastname: string; + + /** + * The participant's address. + * Optional + */ + @ManyToOne(() => Address, address => address.participants, { nullable: true }) + address?: Address; + + /** + * The participant's phone number. + * Optional + */ + @Column({ nullable: true }) + @IsOptional() + @IsPhoneNumber(config.phone_validation_countrycode) + phone?: string; + + /** + * The participant's email address. + * Optional + */ + @Column({ nullable: true }) + @IsOptional() + @IsEmail() + email?: string; + + /** + * Used to link the participant as the donor of a donation. + */ + @OneToMany(() => Donation, donation => donation.donor, { nullable: true }) + donations: Donation[]; +} \ No newline at end of file diff --git a/src/models/entities/Permission.ts b/src/models/entities/Permission.ts new file mode 100644 index 0000000..b9e2777 --- /dev/null +++ b/src/models/entities/Permission.ts @@ -0,0 +1,49 @@ +import { + IsInt, + IsNotEmpty, + + IsString +} from "class-validator"; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import { User } from './User'; +import { UserGroup } from './UserGroup'; +/** + * Defines the Permission interface. +*/ +@Entity() +export abstract class Permission { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * users + */ + @OneToMany(() => User, user => user.permissions, { nullable: true }) + users: User[] + + /** + * groups + */ + @OneToMany(() => UserGroup, group => group.permissions, { nullable: true }) + groups: UserGroup[] + + /** + * The target + */ + @Column() + @IsNotEmpty() + @IsString() + target: string; + + /** + * The action type + */ + @Column() + @IsNotEmpty() + @IsString() + action: string; +} \ No newline at end of file diff --git a/src/models/entities/Runner.ts b/src/models/entities/Runner.ts new file mode 100644 index 0000000..6f4033a --- /dev/null +++ b/src/models/entities/Runner.ts @@ -0,0 +1,53 @@ +import { IsInt, IsNotEmpty } from "class-validator"; +import { ChildEntity, ManyToOne, OneToMany } from "typeorm"; +import { DistanceDonation } from "./DistanceDonation"; +import { Participant } from "./Participant"; +import { RunnerCard } from "./RunnerCard"; +import { RunnerGroup } from "./RunnerGroup"; +import { Scan } from "./Scan"; + +/** + * Defines a runner. +*/ +@ChildEntity() +export class Runner extends Participant { + /** + * The runner's associated group. + */ + @IsNotEmpty() + @ManyToOne(() => RunnerGroup, group => group.runners, { nullable: false }) + group: RunnerGroup; + + /** + * Used to link runners to donations. + */ + @OneToMany(() => DistanceDonation, distanceDonation => distanceDonation.runner, { nullable: true }) + distanceDonations: DistanceDonation[]; + + /** + * Used to link runners to cards. + */ + @OneToMany(() => RunnerCard, card => card.runner, { nullable: true }) + cards: RunnerCard[]; + + /** + * Used to link runners to a scans + */ + @OneToMany(() => Scan, scan => scan.runner, { nullable: true }) + scans: Scan[]; + + /** + * Returns all valid scans associated with this runner. + */ + public get validScans(): Scan[] { + return this.scans.filter(scan => { scan.valid === true }); + } + + /** + * Returns the total distance ran by this runner. + */ + @IsInt() + public get distance(): number { + return this.validScans.reduce((sum, current) => sum + current.distance, 0); + } +} \ No newline at end of file diff --git a/src/models/entities/RunnerCard.ts b/src/models/entities/RunnerCard.ts new file mode 100644 index 0000000..49ea0da --- /dev/null +++ b/src/models/entities/RunnerCard.ts @@ -0,0 +1,56 @@ +import { + IsBoolean, + IsEAN, + IsInt, + IsNotEmpty, + IsOptional, + IsString +} from "class-validator"; +import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import { Runner } from "./Runner"; +import { TrackScan } from "./TrackScan"; + +/** + * Defines a card that can be scanned via a scanner station. +*/ +@Entity() +export class RunnerCard { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * The runner that is currently associated with this card. + */ + @IsOptional() + @ManyToOne(() => Runner, runner => runner.cards, { nullable: true }) + runner: Runner; + + /** + * The card's code. + * This has to be able to being converted to something barcode compatible. + * could theoretically be autogenerated + */ + @Column() + @IsEAN() + @IsString() + @IsNotEmpty() + code: string; + + /** + * Is the card enabled (for fraud reasons)? + * Default: true + */ + @Column() + @IsBoolean() + enabled: boolean = true; + + /** + * Used to link cards to a track scans. + */ + @OneToMany(() => TrackScan, scan => scan.track, { nullable: true }) + scans: TrackScan[]; +} diff --git a/src/models/entities/RunnerGroup.ts b/src/models/entities/RunnerGroup.ts new file mode 100644 index 0000000..a2bd585 --- /dev/null +++ b/src/models/entities/RunnerGroup.ts @@ -0,0 +1,45 @@ +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString +} from "class-validator"; +import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, TableInheritance } from "typeorm"; +import { GroupContact } from "./GroupContact"; +import { Runner } from "./Runner"; + +/** + * Defines the runnerGroup interface. +*/ +@Entity() +@TableInheritance({ column: { name: "type", type: "varchar" } }) +export abstract class RunnerGroup { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * The group's name. + */ + @Column() + @IsNotEmpty() + @IsString() + name: string; + + /** + * The group's contact. + * Optional + */ + @IsOptional() + @ManyToOne(() => GroupContact, contact => contact.groups, { nullable: true }) + contact?: GroupContact; + + /** + * Used to link runners to a runner group. + */ + @OneToMany(() => Runner, runner => runner.group, { nullable: true }) + runners: Runner[]; +} \ No newline at end of file diff --git a/src/models/entities/RunnerOrganisation.ts b/src/models/entities/RunnerOrganisation.ts new file mode 100644 index 0000000..28b90d8 --- /dev/null +++ b/src/models/entities/RunnerOrganisation.ts @@ -0,0 +1,26 @@ +import { IsOptional } from "class-validator"; +import { ChildEntity, ManyToOne, OneToMany } from "typeorm"; +import { Address } from "./Address"; +import { RunnerGroup } from "./RunnerGroup"; +import { RunnerTeam } from "./RunnerTeam"; + +/** + * Defines a runner organisation (business or school for example). +*/ +@ChildEntity() +export class RunnerOrganisation extends RunnerGroup { + + /** + * The organisations's address. + * Optional + */ + @IsOptional() + @ManyToOne(() => Address, address => address.groups, { nullable: true }) + address?: Address; + + /** + * Used to link teams to runner groups. + */ + @OneToMany(() => RunnerTeam, team => team.parentGroup, { nullable: true }) + teams: RunnerTeam[]; +} \ No newline at end of file diff --git a/src/models/entities/RunnerTeam.ts b/src/models/entities/RunnerTeam.ts new file mode 100644 index 0000000..bcae3fd --- /dev/null +++ b/src/models/entities/RunnerTeam.ts @@ -0,0 +1,19 @@ +import { IsNotEmpty } from "class-validator"; +import { ChildEntity, ManyToOne } from "typeorm"; +import { RunnerGroup } from "./RunnerGroup"; +import { RunnerOrganisation } from "./RunnerOrganisation"; + +/** + * Defines a runner team (class or deparment for example). +*/ +@ChildEntity() +export class RunnerTeam extends RunnerGroup { + + /** + * The team's parent group. + * Optional + */ + @IsNotEmpty() + @ManyToOne(() => RunnerOrganisation, org => org.teams, { nullable: true }) + parentGroup?: RunnerOrganisation; +} \ No newline at end of file diff --git a/src/models/entities/Scan.ts b/src/models/entities/Scan.ts new file mode 100644 index 0000000..b8fc079 --- /dev/null +++ b/src/models/entities/Scan.ts @@ -0,0 +1,45 @@ +import { + IsBoolean, + IsInt, + IsNotEmpty, + + IsPositive +} from "class-validator"; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm"; +import { Runner } from "./Runner"; + +/** + * Defines the scan interface. +*/ +@Entity() +@TableInheritance({ column: { name: "type", type: "varchar" } }) +export abstract class Scan { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * The associated runner. + */ + @IsNotEmpty() + @ManyToOne(() => Runner, runner => runner.scans, { nullable: false }) + runner: Runner; + + /** + * The scan's distance in meters. + */ + @IsInt() + @IsPositive() + abstract distance: number; + + /** + * Is the scan valid (for fraud reasons). + * Default: true + */ + @Column() + @IsBoolean() + valid: boolean = true; +} \ No newline at end of file diff --git a/src/models/entities/ScanStation.ts b/src/models/entities/ScanStation.ts new file mode 100644 index 0000000..f014da8 --- /dev/null +++ b/src/models/entities/ScanStation.ts @@ -0,0 +1,60 @@ +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsOptional, + IsString +} from "class-validator"; +import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import { Track } from "./Track"; +import { TrackScan } from "./TrackScan"; + +/** + * ScannerStations have the ability to create scans for specific tracks. +*/ +@Entity() +export class ScanStation { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * The station's description. + */ + @Column({ nullable: true }) + @IsOptional() + @IsString() + description?: string; + + /** + * The track this station is associated with. + */ + @IsNotEmpty() + @ManyToOne(() => Track, track => track.stations, { nullable: false }) + track: Track; + + /** + * The station's api key. + */ + @Column() + @IsNotEmpty() + @IsString() + key: string; + + /** + * Is the station enabled (for fraud reasons)? + * Default: true + */ + @Column() + @IsBoolean() + enabled: boolean = true; + + /** + * Used to link track scans to a scan station. + */ + @OneToMany(() => TrackScan, scan => scan.track, { nullable: true }) + scans: TrackScan[]; +} diff --git a/src/models/entities/Track.ts b/src/models/entities/Track.ts new file mode 100644 index 0000000..34f3bb6 --- /dev/null +++ b/src/models/entities/Track.ts @@ -0,0 +1,51 @@ +import { + IsInt, + IsNotEmpty, + + IsPositive, + IsString +} from "class-validator"; +import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import { ScanStation } from "./ScanStation"; +import { TrackScan } from "./TrackScan"; + +/** + * Defines a track of given length. +*/ +@Entity() +export class Track { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number;; + + /** + * The track's name. + */ + @Column() + @IsString() + @IsNotEmpty() + name: string; + + /** + * The track's length/distance in meters. + */ + @Column() + @IsInt() + @IsPositive() + distance: number; + + /** + * Used to link scan stations to track. + */ + @OneToMany(() => ScanStation, station => station.track, { nullable: true }) + stations: ScanStation[]; + + /** + * Used to link track scans to a track. + */ + @OneToMany(() => TrackScan, scan => scan.track, { nullable: true }) + scans: TrackScan[]; +} diff --git a/src/models/entities/TrackScan.ts b/src/models/entities/TrackScan.ts new file mode 100644 index 0000000..759c3d7 --- /dev/null +++ b/src/models/entities/TrackScan.ts @@ -0,0 +1,56 @@ +import { + IsDateString, + IsInt, + IsNotEmpty, + + IsPositive +} from "class-validator"; +import { ChildEntity, Column, ManyToOne } from "typeorm"; +import { RunnerCard } from "./RunnerCard"; +import { Scan } from "./Scan"; +import { ScanStation } from "./ScanStation"; +import { Track } from "./Track"; + +/** + * Defines the scan interface. +*/ +@ChildEntity() +export class TrackScan extends Scan { + /** + * The associated track. + */ + @IsNotEmpty() + @ManyToOne(() => Track, track => track.scans, { nullable: true }) + track: Track; + + /** + * The associated card. + */ + @IsNotEmpty() + @ManyToOne(() => RunnerCard, card => card.scans, { nullable: true }) + card: RunnerCard; + + /** + * The scanning station. + */ + @IsNotEmpty() + @ManyToOne(() => ScanStation, station => station.scans, { nullable: true }) + station: ScanStation; + + /** + * The scan's distance in meters. + */ + @IsInt() + @IsPositive() + public get distance(): number { + return this.track.distance; + } + + /** + * The scan's creation timestamp. + */ + @Column() + @IsDateString() + @IsNotEmpty() + timestamp: string; +} diff --git a/src/models/entities/User.ts b/src/models/entities/User.ts new file mode 100644 index 0000000..ebc62b1 --- /dev/null +++ b/src/models/entities/User.ts @@ -0,0 +1,142 @@ +import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUUID } from "class-validator"; +import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm"; +import { config } from '../../config'; +import { Permission } from './Permission'; +import { UserAction } from './UserAction'; +import { UserGroup } from './UserGroup'; + +/** + * Defines a admin user. +*/ +@Entity() +export class User { + /** + * autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * uuid + */ + @Column({ unique: true }) + @IsUUID(4) + uuid: string; + + /** + * user email + */ + @Column({ nullable: true, unique: true }) + @IsEmail() + email?: string; + + /** + * user phone + */ + @Column({ nullable: true }) + @IsOptional() + @IsPhoneNumber(config.phone_validation_countrycode) + phone?: string; + + /** + * username + */ + @Column({ nullable: true, unique: true }) + @IsString() + username?: string; + + /** + * firstname + */ + @Column() + @IsString() + @IsNotEmpty() + firstname: string; + + /** + * middlename + */ + @Column({ nullable: true }) + @IsString() + @IsOptional() + middlename?: string; + + /** + * lastname + */ + @Column() + @IsString() + @IsNotEmpty() + lastname: string; + + /** + * password + */ + @Column() + @IsString() + @IsNotEmpty() + password: string; + + /** + * permissions + */ + @IsOptional() + @ManyToOne(() => Permission, permission => permission.users, { nullable: true }) + permissions?: Permission[]; + + /** + * groups + */ + @IsOptional() + @ManyToMany(() => UserGroup, { nullable: true }) + @JoinTable() + groups: UserGroup[]; + + /** + * is user enabled? + */ + @Column() + @IsBoolean() + enabled: boolean = true; + + /** + * jwt refresh count + */ + @IsInt() + @Column({ default: 1 }) + refreshTokenCount?: number; + + /** + * profilepic + */ + @Column({ nullable: true, unique: true }) + @IsString() + @IsOptional() + profilePic?: string; + + /** + * actions + */ + @IsOptional() + @OneToMany(() => UserAction, action => action.user, { nullable: true }) + actions: UserAction[] + + /** + * calculate all permissions + */ + public get calc_permissions(): Permission[] { + let final_permissions = [] + this.groups.forEach((permission) => { + if (!final_permissions.includes(permission)) { + final_permissions.push(permission) + } + }) + this.permissions.forEach((permission) => { + if (!final_permissions.includes(permission)) { + final_permissions.push(permission) + } + }) + return final_permissions + } +} diff --git a/src/models/entities/UserAction.ts b/src/models/entities/UserAction.ts new file mode 100644 index 0000000..92c41f0 --- /dev/null +++ b/src/models/entities/UserAction.ts @@ -0,0 +1,51 @@ +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString +} from "class-validator"; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { User } from './User'; + +/** + * Defines the UserAction interface. +*/ +@Entity() +export class UserAction { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * user + */ + @ManyToOne(() => User, user => user.actions) + user: User + + /** + * The actions's target (e.g. Track#2) + */ + @Column() + @IsNotEmpty() + @IsString() + target: string; + + /** + * The actions's action (e.g. UPDATE) + */ + @Column() + @IsNotEmpty() + @IsString() + action: string; + + /** + * The description of change (before-> after; e.g. distance:15->17) + */ + @Column({ nullable: true }) + @IsOptional() + @IsString() + changed: string; +} \ No newline at end of file diff --git a/src/models/entities/UserGroup.ts b/src/models/entities/UserGroup.ts new file mode 100644 index 0000000..e86b2a6 --- /dev/null +++ b/src/models/entities/UserGroup.ts @@ -0,0 +1,43 @@ +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString +} from "class-validator"; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; +import { Permission } from "./Permission"; + +/** + * Defines the UserGroup interface. +*/ +@Entity() +export class UserGroup { + /** + * Autogenerated unique id (primary key). + */ + @PrimaryGeneratedColumn() + @IsInt() + id: number; + + /** + * permissions + */ + @ManyToOne(() => Permission, permission => permission.groups, { nullable: true }) + permissions: Permission[]; + + /** + * The group's name + */ + @Column() + @IsNotEmpty() + @IsString() + name: string; + + /** + * The group's description + */ + @Column({ nullable: true }) + @IsOptional() + @IsString() + description?: string; +} \ No newline at end of file diff --git a/src/models/responses/ResponseAuth.ts b/src/models/responses/ResponseAuth.ts new file mode 100644 index 0000000..39a16ef --- /dev/null +++ b/src/models/responses/ResponseAuth.ts @@ -0,0 +1,27 @@ +import { IsInt, IsString } from 'class-validator'; + +/** + * Defines a auth object +*/ +export class Auth { + /** + * access_token - JWT shortterm access token + */ + @IsString() + access_token: string; + /** + * refresh_token - longterm refresh token (used for requesting new access tokens) + */ + @IsString() + refresh_token: string; + /** + * access_token_expires_at - unix timestamp of access token expiry + */ + @IsInt() + access_token_expires_at: number; + /** + * refresh_token_expires_at - unix timestamp of access token expiry + */ + @IsInt() + refresh_token_expires_at: number; +} diff --git a/src/models/responses/ResponseEmpty.ts b/src/models/responses/ResponseEmpty.ts new file mode 100644 index 0000000..e1e3b41 --- /dev/null +++ b/src/models/responses/ResponseEmpty.ts @@ -0,0 +1,6 @@ + +/** + * Defines a empty response object +*/ +export class ResponseEmpty { +} diff --git a/src/models/responses/ResponseLogout.ts b/src/models/responses/ResponseLogout.ts new file mode 100644 index 0000000..f0c109d --- /dev/null +++ b/src/models/responses/ResponseLogout.ts @@ -0,0 +1,12 @@ +import { IsString } from 'class-validator'; + +/** + * Defines a Logout object +*/ +export class Logout { + /** + * timestamp of logout + */ + @IsString() + timestamp: number; +} diff --git a/src/models/responses/ResponseParticipant.ts b/src/models/responses/ResponseParticipant.ts new file mode 100644 index 0000000..22ec2a3 --- /dev/null +++ b/src/models/responses/ResponseParticipant.ts @@ -0,0 +1,61 @@ +import { + IsInt, + + + + IsString +} from "class-validator"; +import { Participant } from '../entities/Participant'; + +/** + * Defines a participant response. +*/ +export abstract class ResponseParticipant { + /** + * Autogenerated unique id (primary key). + */ + @IsInt() + id: number;; + + /** + * The participant's first name. + */ + @IsString() + firstname: string; + + /** + * The participant's middle name. + * Optional. + */ + @IsString() + middlename?: string; + + /** + * The participant's last name. + */ + @IsString() + lastname: string; + + /** + * The participant's phone number. + * Optional. + */ + @IsString() + phone?: string; + + /** + * The participant's e-mail address. + * Optional. + */ + @IsString() + email?: string; + + public constructor(participant: Participant) { + this.id = participant.id; + this.firstname = participant.firstname; + this.middlename = participant.middlename; + this.lastname = participant.lastname; + this.phone = participant.phone; + this.email = participant.email; + } +} diff --git a/src/models/responses/ResponseRunner.ts b/src/models/responses/ResponseRunner.ts new file mode 100644 index 0000000..01327c2 --- /dev/null +++ b/src/models/responses/ResponseRunner.ts @@ -0,0 +1,32 @@ +import { + IsInt, + IsObject +} from "class-validator"; +import { Runner } from '../entities/Runner'; +import { RunnerGroup } from '../entities/RunnerGroup'; +import { ResponseParticipant } from './ResponseParticipant'; + +/** + * Defines RunnerTeam's response class. +*/ +export class ResponseRunner extends ResponseParticipant { + + /** + * The runner's currently ran distance in meters. + * Optional. + */ + @IsInt() + distance: number; + + /** + * The runner's group. + */ + @IsObject() + group: RunnerGroup; + + public constructor(runner: Runner) { + super(runner); + this.distance = runner.scans.filter(scan => { scan.valid === true }).reduce((sum, current) => sum + current.distance, 0); + this.group = runner.group; + } +} diff --git a/src/models/responses/ResponseRunnerGroup.ts b/src/models/responses/ResponseRunnerGroup.ts new file mode 100644 index 0000000..922f141 --- /dev/null +++ b/src/models/responses/ResponseRunnerGroup.ts @@ -0,0 +1,55 @@ +import { + IsInt, + + + + IsNotEmpty, + + + + IsObject, + + + + IsOptional, + + + + IsString +} from "class-validator"; +import { GroupContact } from '../entities/GroupContact'; +import { RunnerGroup } from '../entities/RunnerGroup'; + +/** + * Defines a track of given length. +*/ +export abstract class ResponseRunnerGroup { + /** + * Autogenerated unique id (primary key). + */ + @IsInt() + @IsNotEmpty() + id: number;; + + /** + * The groups's name. + */ + @IsString() + @IsNotEmpty() + name: string; + + + /** + * The group's contact. + * Optional. + */ + @IsObject() + @IsOptional() + contact?: GroupContact; + + public constructor(group: RunnerGroup) { + this.id = group.id; + this.name = group.name; + this.contact = group.contact; + } +} diff --git a/src/models/responses/ResponseRunnerOrganisation.ts b/src/models/responses/ResponseRunnerOrganisation.ts new file mode 100644 index 0000000..2aecb75 --- /dev/null +++ b/src/models/responses/ResponseRunnerOrganisation.ts @@ -0,0 +1,37 @@ +import { + IsArray, + IsNotEmpty, + IsObject +} from "class-validator"; +import { Address } from '../entities/Address'; +import { RunnerOrganisation } from '../entities/RunnerOrganisation'; +import { RunnerTeam } from '../entities/RunnerTeam'; +import { ResponseRunnerGroup } from './ResponseRunnerGroup'; + +/** + * Defines RunnerOrgs's response class. +*/ +export class ResponseRunnerOrganisation extends ResponseRunnerGroup { + + /** + * The orgs's address. + * Optional. + */ + @IsObject() + @IsNotEmpty() + address?: Address; + + /** + * The orgs associated teams. + */ + @IsObject() + @IsArray() + teams: RunnerTeam[]; + + + public constructor(org: RunnerOrganisation) { + super(org); + this.address = org.address; + this.teams = org.teams; + } +} diff --git a/src/models/responses/ResponseRunnerTeam.ts b/src/models/responses/ResponseRunnerTeam.ts new file mode 100644 index 0000000..b26e2c2 --- /dev/null +++ b/src/models/responses/ResponseRunnerTeam.ts @@ -0,0 +1,26 @@ +import { + IsNotEmpty, + IsObject +} from "class-validator"; +import { RunnerOrganisation } from '../entities/RunnerOrganisation'; +import { RunnerTeam } from '../entities/RunnerTeam'; +import { ResponseRunnerGroup } from './ResponseRunnerGroup'; + +/** + * Defines RunnerTeam's response class. +*/ +export class ResponseRunnerTeam extends ResponseRunnerGroup { + + /** + * The team's parent group (organisation). + * Optional. + */ + @IsObject() + @IsNotEmpty() + parentGroup: RunnerOrganisation; + + public constructor(team: RunnerTeam) { + super(team); + this.parentGroup = team.parentGroup; + } +} diff --git a/src/models/responses/ResponseTrack.ts b/src/models/responses/ResponseTrack.ts new file mode 100644 index 0000000..ce1d74d --- /dev/null +++ b/src/models/responses/ResponseTrack.ts @@ -0,0 +1,35 @@ +import { + IsInt, + + IsString +} from "class-validator"; +import { Track } from '../entities/Track'; + +/** + * Defines a track of given length. +*/ +export class ResponseTrack { + /** + * Autogenerated unique id (primary key). + */ + @IsInt() + id: number;; + + /** + * The track's name. + */ + @IsString() + name: string; + + /** + * The track's length/distance in meters. + */ + @IsInt() + distance: number; + + public constructor(track: Track) { + this.id = track.id; + this.name = track.name; + this.distance = track.distance; + } +} diff --git a/src/routes/v1/test.ts b/src/routes/v1/test.ts deleted file mode 100644 index 7f000f5..0000000 --- a/src/routes/v1/test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Router } from "express"; -import jwtauth from "../../middlewares/jwtauth"; - -const router = Router(); - -router.use("*", jwtauth, async (req, res, next) => { - return res.send("ok"); -}); - -export default router; diff --git a/tsconfig.json b/tsconfig.json index 0f2192a..dfd4d15 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,20 @@ { "compilerOptions": { - "target": "es2017", + "target": "ES2020", "module": "commonjs", "rootDir": "./src", "outDir": "./build", "esModuleInterop": true, "strict": false, "experimentalDecorators": true, - "emitDecoratorMetadata": true - } + "emitDecoratorMetadata": true, + "sourceMap": false + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "**/*.spec.ts" + ] } \ No newline at end of file