Compare commits
127 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c9b8614f53
|
|||
|
cbf1da31c9
|
|||
|
fd18e56251
|
|||
|
3bb8b202b0
|
|||
|
d1c4744231
|
|||
|
fe90414dd9
|
|||
|
21ceb9fa26
|
|||
|
5081819281
|
|||
|
240bd9cba1
|
|||
|
53fb0389cd
|
|||
|
d230350027
|
|||
|
024e647295
|
|||
|
d3e0206a3c
|
|||
|
b0c6759813
|
|||
|
526738e487
|
|||
|
778f159405
|
|||
|
2da8247978
|
|||
|
bbf6ea6c0f
|
|||
|
3584b3facf
|
|||
|
e27e819609
|
|||
|
0f532b139c
|
|||
|
eebcc2e328
|
|||
|
284954d064
|
|||
|
401ca923a6
|
|||
|
bf1f6411e0
|
|||
|
f225cc4954
|
|||
|
728f8a14e9
|
|||
|
a4480589a0
|
|||
|
0ad9eeb52f
|
|||
|
4494afc64b
|
|||
|
f4747c51de
|
|||
|
07a0195f12
|
|||
|
7ac98229d1
|
|||
|
dd5b538783
|
|||
|
8e6d67428c
|
|||
|
7ffb7523aa
|
|||
|
f4bf309821
|
|||
|
02b1cb9904
|
|||
|
7697acff82
|
|||
|
bacfc437f9
|
|||
|
9875b4f392
|
|||
|
ce9b765b81
|
|||
|
2ab6e985e3
|
|||
|
d06f6a4407
|
|||
|
a50d72f2f5
|
|||
|
4723d9738e
|
|||
|
1a478bd784
|
|||
|
284cb0f8b3
|
|||
|
6e63c57936
|
|||
|
30b61db2c1
|
|||
|
8237d5f210
|
|||
|
03e0a29096
|
|||
|
a6afba93e2
|
|||
|
a41758cd9c
|
|||
|
d6755ed134
|
|||
|
599c75fc00
|
|||
|
bb213f001e
|
|||
|
5415cd38a7
|
|||
|
175ba52ffa
|
|||
|
5c5000a218
|
|||
|
d559d04031
|
|||
|
2af682d1dd
|
|||
|
30905e481c
|
|||
|
752d405bda
|
|||
|
8fa4ed7c33
|
|||
|
c4201e9a68
|
|||
|
78dcad0857
|
|||
|
93e0cdf577
|
|||
|
6efcd94726
|
|||
|
2e271bcd52
|
|||
|
ebde8c6ffd
|
|||
| a3639dd89b | |||
|
0a43f1bb5b
|
|||
|
8c6fdb2239
|
|||
|
c0d5af5d7a
|
|||
|
4008a5ee72
|
|||
|
07bf28b144
|
|||
|
6764bf80ea
|
|||
|
b3a73b25e8
|
|||
| bda1f971d1 | |||
|
765ef84903
|
|||
|
296ba8ddab
|
|||
|
6eff243803
|
|||
|
0f4c8b2051
|
|||
|
d842c14240
|
|||
|
a54cb287a4
|
|||
|
74d334f9b7
|
|||
|
cd3cd81360
|
|||
|
cf48c00ddb
|
|||
|
3192365793
|
|||
|
075d484f11
|
|||
|
5082b1b8b1
|
|||
|
50dd703a1b
|
|||
|
057a8ee699
|
|||
|
8d9418635d
|
|||
|
f2832a2dae
|
|||
|
0d21596e2b
|
|||
|
245827e9c6
|
|||
|
4608a36df6
|
|||
|
cb1305aa77
|
|||
|
12a9ae2493
|
|||
|
b9fe9f1c24
|
|||
|
b25b0db760
|
|||
|
fe59e3a557
|
|||
|
42c23a5883
|
|||
|
6ee5328dbc
|
|||
|
6f39ac42da
|
|||
|
301f334674
|
|||
|
fcee3909f4
|
|||
|
f0e20e4130
|
|||
|
80de188565
|
|||
|
2f305e127c
|
|||
|
513d7f6fba
|
|||
|
244da61892
|
|||
|
2a72aea10e
|
|||
|
71ebce6f8e
|
|||
|
f60025b6de
|
|||
|
0fa663a341
|
|||
|
538622aa18
|
|||
|
86a21dbfa4
|
|||
|
1e9e24d99d
|
|||
|
4493c0e3d9
|
|||
|
f5d48fc638
|
|||
|
b35a2dd2fa
|
|||
|
a28ffe06e5
|
|||
|
d873674819
|
|||
|
37b2ac974b
|
180
.drone.yml
180
.drone.yml
@@ -1,180 +0,0 @@
|
|||||||
---
|
|
||||||
kind: secret
|
|
||||||
name: docker_username
|
|
||||||
get:
|
|
||||||
path: odit-registry-builder
|
|
||||||
name: username
|
|
||||||
|
|
||||||
---
|
|
||||||
kind: secret
|
|
||||||
name: docker_password
|
|
||||||
get:
|
|
||||||
path: odit-registry-builder
|
|
||||||
name: password
|
|
||||||
|
|
||||||
---
|
|
||||||
kind: secret
|
|
||||||
name: git_ssh
|
|
||||||
get:
|
|
||||||
path: odit-git-bot
|
|
||||||
name: sshkey
|
|
||||||
|
|
||||||
---
|
|
||||||
kind: secret
|
|
||||||
name: ci_token
|
|
||||||
get:
|
|
||||||
path: odit-ci-bot
|
|
||||||
name: apikey
|
|
||||||
|
|
||||||
---
|
|
||||||
kind: secret
|
|
||||||
name: npm_url
|
|
||||||
get:
|
|
||||||
path: odit-npm-cache
|
|
||||||
name: url
|
|
||||||
|
|
||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
type: kubernetes
|
|
||||||
name: tests:node
|
|
||||||
clone:
|
|
||||||
disable: true
|
|
||||||
steps:
|
|
||||||
- name: checkout pr
|
|
||||||
image: alpine/git
|
|
||||||
commands:
|
|
||||||
- git clone $DRONE_REMOTE_URL .
|
|
||||||
- git checkout $DRONE_SOURCE_BRANCH
|
|
||||||
- name: run tests
|
|
||||||
image: registry.odit.services/hub/library/node:19.5.0-alpine3.16
|
|
||||||
commands:
|
|
||||||
- npm config set registry $NPM_REGISTRY_URL && npm i -g pnpm@8
|
|
||||||
- pnpm i
|
|
||||||
- pnpm test:ci
|
|
||||||
environment:
|
|
||||||
NPM_REGISTRY_URL:
|
|
||||||
from_secret: npm_url
|
|
||||||
trigger:
|
|
||||||
event:
|
|
||||||
- pull_request
|
|
||||||
|
|
||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
type: kubernetes
|
|
||||||
name: build:dev
|
|
||||||
clone:
|
|
||||||
disable: true
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: clone
|
|
||||||
image: alpine/git
|
|
||||||
commands:
|
|
||||||
- git clone $DRONE_REMOTE_URL .
|
|
||||||
- git checkout dev
|
|
||||||
- name: build dev
|
|
||||||
depends_on: ["clone"]
|
|
||||||
image: registry.odit.services/library/drone-kaniko
|
|
||||||
settings:
|
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
build_args:
|
|
||||||
- NPM_REGISTRY_URL:
|
|
||||||
from_secret: npm_url
|
|
||||||
repo: lfk/backend
|
|
||||||
tags:
|
|
||||||
- dev
|
|
||||||
cache: true
|
|
||||||
registry: registry.odit.services
|
|
||||||
|
|
||||||
trigger:
|
|
||||||
branch:
|
|
||||||
- dev
|
|
||||||
event:
|
|
||||||
- push
|
|
||||||
|
|
||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
type: kubernetes
|
|
||||||
name: build:latest
|
|
||||||
clone:
|
|
||||||
disable: true
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: clone
|
|
||||||
image: alpine/git
|
|
||||||
commands:
|
|
||||||
- git clone $DRONE_REMOTE_URL .
|
|
||||||
- git checkout dev
|
|
||||||
- git merge main
|
|
||||||
- git checkout main
|
|
||||||
- name: build latest
|
|
||||||
depends_on: ["clone"]
|
|
||||||
image: registry.odit.services/library/drone-kaniko
|
|
||||||
settings:
|
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
build_args:
|
|
||||||
- NPM_REGISTRY_URL:
|
|
||||||
from_secret: npm_url
|
|
||||||
repo: lfk/backend
|
|
||||||
tags:
|
|
||||||
- latest
|
|
||||||
cache: true
|
|
||||||
registry: registry.odit.services
|
|
||||||
- name: push merge to repo
|
|
||||||
depends_on: ["clone"]
|
|
||||||
image: appleboy/drone-git-push
|
|
||||||
settings:
|
|
||||||
branch: dev
|
|
||||||
commit: false
|
|
||||||
remote: git@git.odit.services:lfk/backend.git
|
|
||||||
ssh_key:
|
|
||||||
from_secret: git_ssh
|
|
||||||
|
|
||||||
trigger:
|
|
||||||
branch:
|
|
||||||
- main
|
|
||||||
event:
|
|
||||||
- push
|
|
||||||
|
|
||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
type: kubernetes
|
|
||||||
name: build:tags
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: build $DRONE_TAG
|
|
||||||
depends_on: ["clone"]
|
|
||||||
image: registry.odit.services/library/drone-kaniko
|
|
||||||
settings:
|
|
||||||
username:
|
|
||||||
from_secret: docker_username
|
|
||||||
password:
|
|
||||||
from_secret: docker_password
|
|
||||||
build_args:
|
|
||||||
- NPM_REGISTRY_URL:
|
|
||||||
from_secret: npm_url
|
|
||||||
repo: lfk/backend
|
|
||||||
tags:
|
|
||||||
- "${DRONE_TAG}"
|
|
||||||
cache: true
|
|
||||||
registry: registry.odit.services
|
|
||||||
- name: trigger node lib build
|
|
||||||
image: idcooldi/drone-webhook
|
|
||||||
settings:
|
|
||||||
urls: https://ci.odit.services/api/repos/lfk/lfk-client-node/builds?SOURCE_TAG=${DRONE_TAG}
|
|
||||||
bearer:
|
|
||||||
from_secret: ci_token
|
|
||||||
- name: trigger js lib build
|
|
||||||
image: idcooldi/drone-webhook
|
|
||||||
settings:
|
|
||||||
urls: https://ci.odit.services/api/repos/lfk/lfk-client-js/builds?SOURCE_TAG=${DRONE_TAG}
|
|
||||||
bearer:
|
|
||||||
from_secret: ci_token
|
|
||||||
trigger:
|
|
||||||
event:
|
|
||||||
- tag
|
|
||||||
@@ -7,4 +7,8 @@ DB_PASSWORD=bla
|
|||||||
DB_NAME=./test.sqlite
|
DB_NAME=./test.sqlite
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
POSTALCODE_COUNTRYCODE=DE
|
POSTALCODE_COUNTRYCODE=DE
|
||||||
SEED_TEST_DATA=false
|
SEED_TEST_DATA=false
|
||||||
|
SELFSERVICE_URL=bla
|
||||||
|
STATION_TOKEN_SECRET=<replace-with-random-secret-min-32-chars>
|
||||||
|
NATS_URL=nats://localhost:4222
|
||||||
|
NATS_PREWARM=false
|
||||||
30
.gitea/workflows/release.yml
Normal file
30
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Build release images
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "*.*.*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-container:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
- run: bun install --frozen-lockfile
|
||||||
|
- run: bun licenses:export
|
||||||
|
- name: Login to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: registry.odit.services
|
||||||
|
username: ${{ vars.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ vars.REGISTRY }}/lfk/backend:${{ github.ref_name }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -126,8 +126,12 @@ dist
|
|||||||
.yarn/build-state.yml
|
.yarn/build-state.yml
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
# Old package manager lockfiles (Bun migration - keep bun.lock)
|
||||||
yarn.lock
|
yarn.lock
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
build
|
build
|
||||||
|
|
||||||
*.sqlite
|
*.sqlite
|
||||||
@@ -136,4 +140,3 @@ build
|
|||||||
lib
|
lib
|
||||||
/oss-attribution
|
/oss-attribution
|
||||||
*.tmp
|
*.tmp
|
||||||
pnpm-lock.yaml
|
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -9,8 +9,7 @@
|
|||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "vscode.typescript-language-features",
|
"editor.defaultFormatter": "vscode.typescript-language-features",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.organizeImports": true,
|
"source.organizeImports": "explicit"
|
||||||
// "source.fixAll": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"javascript.preferences.quoteStyle": "single",
|
"javascript.preferences.quoteStyle": "single",
|
||||||
|
|||||||
282
AGENTS.md
Normal file
282
AGENTS.md
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
# AGENTS.md — LfK Backend
|
||||||
|
|
||||||
|
Guidance for agentic coding agents working in this repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Express + [`routing-controllers`](https://github.com/typestack/routing-controllers) REST API written in TypeScript. Uses TypeORM for database access (SQLite in dev/test, PostgreSQL or MySQL in production). OpenAPI docs are auto-generated from decorators at startup.
|
||||||
|
|
||||||
|
**Runtime & Package Manager**: Bun (replaces Node.js + npm/pnpm).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build / Run / Test Commands
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run dev # Start dev server with auto-reload (uses Bun's --watch)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auto-reload**: The `dev` script uses Bun's built-in `--watch` flag, which automatically restarts the server when TypeScript files in `src/` change. Bun runs TypeScript directly - no build step needed.
|
||||||
|
|
||||||
|
**Performance**: Bun delivers 8-15% better latency under concurrent load compared to Node.js. See `BUN_BENCHMARK_RESULTS.md` for details.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run build # rimraf dist && tsc && copy static assets → dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The build script exists for legacy compatibility and type-checking, but is **not required** for development or production. Bun runs TypeScript source files directly.
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun start # bun src/app.ts (runs TypeScript directly)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Tests are **integration tests** that hit a live running server via HTTP. The server must be started before Jest is invoked.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Full CI test flow (generates .env, starts server, runs jest):
|
||||||
|
bun run test:ci
|
||||||
|
|
||||||
|
# Run Jest directly (server must already be running):
|
||||||
|
bun test
|
||||||
|
|
||||||
|
# Watch mode:
|
||||||
|
bun run test:watch
|
||||||
|
|
||||||
|
# Run a single test file:
|
||||||
|
bunx jest src/tests/runners/runner_add.spec.ts
|
||||||
|
|
||||||
|
# Run tests matching a name pattern:
|
||||||
|
bunx jest --testNamePattern="POST /api/runners"
|
||||||
|
|
||||||
|
# Run all tests in a subdirectory:
|
||||||
|
bunx jest src/tests/runners/
|
||||||
|
```
|
||||||
|
|
||||||
|
# Run all tests in a subdirectory:
|
||||||
|
bunx jest src/tests/runners/
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Important:** `bun test` alone will fail unless the dev server is already running on `http://localhost:<config.internal_port>`. In CI, `start-server-and-test` handles this automatically via `bun run test:ci`.
|
||||||
|
|
||||||
|
### Other Utilities
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run seed # Sync DB schema and run seeders
|
||||||
|
bun run openapi:export # Export OpenAPI spec to file
|
||||||
|
bun run docs # Generate TypeDoc documentation
|
||||||
|
bun run licenses:export # Export third-party license report
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TypeScript Configuration
|
||||||
|
|
||||||
|
- **Target:** ES2020, **Module:** CommonJS
|
||||||
|
- **`strict: false`** — TypeScript strictness is disabled; types are used but not exhaustively enforced
|
||||||
|
- **`experimentalDecorators: true`** and **`emitDecoratorMetadata: true`** — required by `routing-controllers`, `TypeORM`, and `class-validator`
|
||||||
|
- Spec files (`**/*.spec.ts`) are excluded from compilation
|
||||||
|
- Source root: `src/`, output: `dist/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### No Linter / Formatter Configured
|
||||||
|
|
||||||
|
There is no ESLint or Prettier configuration. Follow the patterns already established in the codebase rather than introducing new tooling.
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
- Use named imports for decorator packages: `import { Get, JsonController, Param } from 'routing-controllers'`
|
||||||
|
- Use named imports for TypeORM: `import { Column, Entity, getConnectionManager } from 'typeorm'`
|
||||||
|
- Use named imports for class-validator: `import { IsInt, IsOptional, IsString } from 'class-validator'`
|
||||||
|
- Use `import * as X from 'module'` for modules without clean default exports (e.g., `import * as jwt from 'jsonwebtoken'`)
|
||||||
|
- Use default imports for simple modules (e.g., `import cookie from 'cookie'`)
|
||||||
|
- `reflect-metadata` is imported once at the top of `src/app.ts` — do not re-import it
|
||||||
|
- No barrel/index re-export files; import source files directly by path
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
| Construct | Convention | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| Classes | `PascalCase` | `RunnerController`, `CreateRunner` |
|
||||||
|
| Files | `PascalCase.ts` matching class name | `RunnerController.ts` |
|
||||||
|
| Local variables | `camelCase` (some `snake_case` in tests) | `accessToken`, `access_token` |
|
||||||
|
| DB entity fields | `snake_case` preferred | `created_at`, `updated_at` |
|
||||||
|
| Controller methods | REST-conventional | `getAll`, `getOne`, `post`, `put`, `remove` |
|
||||||
|
| Custom errors | `{Entity}{Issue}Error` | `RunnerNotFoundError`, `RunnerIdsNotMatchingError` |
|
||||||
|
| Response DTOs | `Response{Entity}` | `ResponseRunner`, `ResponseAuth` |
|
||||||
|
| Create DTOs | `Create{Entity}` | `CreateRunner` |
|
||||||
|
| Update DTOs | `Update{Entity}` | `UpdateRunner` |
|
||||||
|
| Enums | `PascalCase` | `ResponseObjectType`, `PermissionAction` |
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
|
||||||
|
- 4-space indentation (observed throughout the codebase)
|
||||||
|
- Single quotes for string literals in most files
|
||||||
|
- No trailing semicolons style inconsistency — follow what's already in the file you're editing
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- Add TypeScript types to all function parameters and return values
|
||||||
|
- Use `class-validator` decorators (`@IsString`, `@IsInt`, `@IsOptional`, `@IsUUID`, etc.) on every DTO and response class field — these drive both runtime validation and OpenAPI schema generation
|
||||||
|
- Use abstract classes for shared entity base types (e.g., `abstract class Participant`)
|
||||||
|
- Use interfaces for response contracts (e.g., `interface IResponse`)
|
||||||
|
- Use enums for typed string/number constants
|
||||||
|
- Avoid `any` where possible; when unavoidable, keep it localised
|
||||||
|
- `strict` is off — but still annotate types explicitly rather than relying on inference
|
||||||
|
|
||||||
|
### Controller Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Authorized, Body, Delete, Get, JsonController, Param, Post, Put } from 'routing-controllers';
|
||||||
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
|
|
||||||
|
@JsonController('/runners')
|
||||||
|
@Authorized()
|
||||||
|
export class RunnerController {
|
||||||
|
@Get('/')
|
||||||
|
@OpenAPI({ description: 'Returns all runners' })
|
||||||
|
@ResponseSchema(ResponseRunner, { isArray: true })
|
||||||
|
async getAll() { ... }
|
||||||
|
|
||||||
|
@Get('/:id')
|
||||||
|
@ResponseSchema(ResponseRunner)
|
||||||
|
async getOne(@Param('id') id: number) { ... }
|
||||||
|
|
||||||
|
@Post('/')
|
||||||
|
@ResponseSchema(ResponseRunner)
|
||||||
|
async post(@Body({ validate: true }) createRunner: CreateRunner) { ... }
|
||||||
|
|
||||||
|
@Put('/:id')
|
||||||
|
@ResponseSchema(ResponseRunner)
|
||||||
|
async put(@Param('id') id: number, @Body({ validate: true }) updateRunner: UpdateRunner) { ... }
|
||||||
|
|
||||||
|
@Delete('/:id')
|
||||||
|
@ResponseSchema(ResponseRunner)
|
||||||
|
async remove(@Param('id') id: number) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Define custom error classes in `src/errors/` extending `routing-controllers` error types (`NotFoundError`, `NotAcceptableError`, etc.)
|
||||||
|
- Every custom error class must include `@IsString()` decorated `name` and `message` fields for OpenAPI schema generation
|
||||||
|
- Throw custom errors directly in controllers: `throw new RunnerNotFoundError()`
|
||||||
|
- Use try/catch in controllers and re-throw meaningful errors; do not swallow errors silently
|
||||||
|
- The global `ErrorHandler` middleware (registered in `src/middlewares/`) catches all unhandled errors and serialises them as JSON — do not duplicate this logic in controllers
|
||||||
|
- Auth errors are thrown from `src/middlewares/authchecker.ts`, not from individual controllers
|
||||||
|
|
||||||
|
### Entity Pattern (TypeORM)
|
||||||
|
|
||||||
|
- Entities live in `src/models/entities/`
|
||||||
|
- Decorate every entity with `@Entity()` and every column with the appropriate `@Column`, `@PrimaryGeneratedColumn`, etc.
|
||||||
|
- Use `@CreateDateColumn()` / `@UpdateDateColumn()` for timestamp fields
|
||||||
|
- Use table inheritance (`@TableInheritance` + `@ChildEntity`) for polymorphic entities
|
||||||
|
- Access repositories via `getConnectionManager().get().getRepository(EntityClass)` — do not inject repositories as constructor dependencies
|
||||||
|
- Database schema is synchronised automatically on startup (`connection.synchronize()`) — no manual migration files
|
||||||
|
|
||||||
|
### DTO Pattern (Create / Update)
|
||||||
|
|
||||||
|
- Create DTOs in `src/models/actions/create/` and `src/models/actions/update/`
|
||||||
|
- Use `class-validator` decorators for every field
|
||||||
|
- `@IsOptional()` for fields that are not required on update; all fields on create DTOs should be mandatory unless explicitly optional in the API contract
|
||||||
|
- Response DTOs live in `src/models/responses/` and follow the `Response{Entity}` naming pattern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Style Guidelines
|
||||||
|
|
||||||
|
> **IMPORTANT: Do not run existing tests and do not create new tests.** The existing test suite in `src/tests/` is outdated and no longer reflects the current state of the codebase. Ignore all test files when working in this repository. Do not write new tests for any changes or additions.
|
||||||
|
|
||||||
|
All tests are integration tests in `src/tests/` organised by domain entity:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/tests/
|
||||||
|
auth/
|
||||||
|
auth_login.spec.ts
|
||||||
|
auth_refresh.spec.ts
|
||||||
|
runners/
|
||||||
|
runner_add.spec.ts
|
||||||
|
runner_get.spec.ts
|
||||||
|
runner_update.spec.ts
|
||||||
|
runner_delete.spec.ts
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test File Template
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import axios from 'axios';
|
||||||
|
import { config } from '../../config';
|
||||||
|
const base = "http://localhost:" + config.internal_port;
|
||||||
|
|
||||||
|
let access_token: string;
|
||||||
|
let axios_config: object;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
jest.setTimeout(20000);
|
||||||
|
const res = await axios.post(base + '/api/auth/login', { username: "demo", password: "demo" });
|
||||||
|
access_token = res.data["access_token"];
|
||||||
|
axios_config = {
|
||||||
|
headers: { "authorization": "Bearer " + access_token },
|
||||||
|
validateStatus: undefined // prevents axios from throwing on non-2xx responses
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/runners working', () => {
|
||||||
|
it('creating a runner with required params should return 200', async () => {
|
||||||
|
const res = await axios.post(base + '/api/runners', { ... }, axios_config);
|
||||||
|
expect(res.status).toEqual(200);
|
||||||
|
expect(res.headers['content-type']).toContain("application/json");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/runners failing', () => {
|
||||||
|
it('creating a runner without required params should return 400', async () => {
|
||||||
|
const res = await axios.post(base + '/api/runners', {}, axios_config);
|
||||||
|
expect(res.status).toEqual(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Always set `validateStatus: undefined` in `axios_config` to prevent axios throwing on error responses
|
||||||
|
- Group tests by HTTP verb + route in `describe()` blocks; separate "working" and "failing" cases
|
||||||
|
- Use `jest.setTimeout(20000)` in `beforeAll` for slow integration tests
|
||||||
|
- Assert both `res.status` and `res.headers['content-type']` on success paths
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
- Copy `.env.example` to `.env` and fill in values before running locally
|
||||||
|
- Database type is set via `DB_TYPE` env var (`sqlite`, `postgres`, or `mysql`)
|
||||||
|
- Server port is set via `INTERNAL_PORT` (accessed as `config.internal_port` in code)
|
||||||
|
- All config values are validated at startup in `src/config.ts`
|
||||||
|
- CI env is generated by `bun run test:ci:generate_env` (`scripts/create_testenv.ts`)
|
||||||
|
|
||||||
|
### NATS Configuration
|
||||||
|
|
||||||
|
The backend uses **NATS JetStream** as a KV cache for scan intake performance optimization.
|
||||||
|
|
||||||
|
- `NATS_URL` — connection URL for NATS server (default: `nats://localhost:4222`)
|
||||||
|
- `NATS_PREWARM` — if `true`, preloads all runner state into the KV cache at startup to eliminate DB reads from the first scan onward (default: `false`)
|
||||||
|
|
||||||
|
**KV buckets** (auto-created by `NatsClient` at startup):
|
||||||
|
- `station_state` — station token cache (1-hour TTL)
|
||||||
|
- `card_state` — card→runner mapping cache (1-hour TTL)
|
||||||
|
- `runner_state` — runner display name, total distance, latest scan timestamp (no TTL, CAS-based updates)
|
||||||
|
|
||||||
|
**Development**: NATS runs in Docker via `docker-compose.yml` (port 4222). The JetStream volume is persisted to `./nats-data/` to survive container restarts.
|
||||||
|
|
||||||
|
**Station intake hot path**: `POST /api/scans/trackscans` from scan stations uses a KV-first flow that eliminates DB reads on cache hits and prevents race conditions via compare-and-swap (CAS) updates. See `SCAN_NATS_PLAN.md` for full architecture details.
|
||||||
292
CHANGELOG.md
292
CHANGELOG.md
@@ -2,9 +2,301 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
||||||
|
|
||||||
|
#### [1.7.2](https://git.odit.services/lfk/backend/compare/1.7.1...1.7.2)
|
||||||
|
|
||||||
|
- fix(dev): We did it funky bun dev workarounds are no more [`3bb8b20`](https://git.odit.services/lfk/backend/commit/3bb8b202b00f8b7c52c700373ed09a92714528be)
|
||||||
|
- docs: Added agents file to support ai assisted coding [`cbf1da3`](https://git.odit.services/lfk/backend/commit/cbf1da31c9f02a810d8c85caae60ab9483f826c2)
|
||||||
|
- refactor(dev): Yeet the funky dev script out of this codebase [`fd18e56`](https://git.odit.services/lfk/backend/commit/fd18e562518f5b3437f11ceb68e69e50f042891e)
|
||||||
|
|
||||||
|
#### [1.7.1](https://git.odit.services/lfk/backend/compare/1.7.0...1.7.1)
|
||||||
|
|
||||||
|
> 20 February 2026
|
||||||
|
|
||||||
|
- fix(ci): Switch to bun in ci [`fe90414`](https://git.odit.services/lfk/backend/commit/fe90414dd910baff8107197408575b6af0cc4cbf)
|
||||||
|
- perf(db): Added indexes [`21ceb9f`](https://git.odit.services/lfk/backend/commit/21ceb9fa265df2f2193a6c4fb58080ead9c72bf8)
|
||||||
|
- chore(release): 1.7.1 [`d1c4744`](https://git.odit.services/lfk/backend/commit/d1c47442314508a95bfa66b83740c957b75f152a)
|
||||||
|
|
||||||
|
#### [1.7.0](https://git.odit.services/lfk/backend/compare/1.6.0...1.7.0)
|
||||||
|
|
||||||
|
> 20 February 2026
|
||||||
|
|
||||||
|
- refactor: Bun by default [`240bd9c`](https://git.odit.services/lfk/backend/commit/240bd9cba10636bfc100ea2732508d805639f105)
|
||||||
|
- chore(release): 1.7.0 [`5081819`](https://git.odit.services/lfk/backend/commit/5081819281eacd6beb8d4876f0a9df71c901e84e)
|
||||||
|
|
||||||
|
#### [1.6.0](https://git.odit.services/lfk/backend/compare/1.5.2...1.6.0)
|
||||||
|
|
||||||
|
> 20 February 2026
|
||||||
|
|
||||||
|
- feat(data): Added nats jetstream dependency [`bbf6ea6`](https://git.odit.services/lfk/backend/commit/bbf6ea6c0fdffa11dacdf4b9afb6160ce54e197d)
|
||||||
|
- chore(deps): Bump typescript and get rid of now legacy imports [`2da8247`](https://git.odit.services/lfk/backend/commit/2da8247978c5142eec194651a7520fa53396d762)
|
||||||
|
- chore(release): 1.6.0 [`53fb038`](https://git.odit.services/lfk/backend/commit/53fb0389cd1da2b71b82102e82fc3d30f0be3820)
|
||||||
|
- feat(nats): Implement caching for card, runner, and station entries with improved key management [`b0c6759`](https://git.odit.services/lfk/backend/commit/b0c67598132deffce697f19c83bd4826420abe76)
|
||||||
|
- feat(auth): Implement caching for scanauth [`526738e`](https://git.odit.services/lfk/backend/commit/526738e48722fffe4493102fad69f65b40fc3b49)
|
||||||
|
- refactor(scan): Implement KV-backed scan station submissions and response model [`d3e0206`](https://git.odit.services/lfk/backend/commit/d3e0206a3ccbff0e69024426bb2bf266cde30eeb)
|
||||||
|
- fix(types): Add custom Express request types for station authentication [`778f159`](https://git.odit.services/lfk/backend/commit/778f15940594d5c2e423ef001eddd2d505ebd5f5)
|
||||||
|
- perf(nats): Implement bulk cache prewarming for runners to optimize startup performance [`024e647`](https://git.odit.services/lfk/backend/commit/024e64729594237773f3819646bdbc806ee985bc)
|
||||||
|
- feat(auth): Switch scanstation auth from argon2 to sha256 to improve performance [`3584b3f`](https://git.odit.services/lfk/backend/commit/3584b3facf7641f18db6eafe7035f17de8c5086c)
|
||||||
|
- perf(nats): Implement bulk cache prewarming for runners to optimize startup performance [`d230350`](https://git.odit.services/lfk/backend/commit/d230350027dea4dcdad9feddd9408a866ed787df)
|
||||||
|
|
||||||
|
#### [1.5.2](https://git.odit.services/lfk/backend/compare/1.5.1...1.5.2)
|
||||||
|
|
||||||
|
> 26 May 2025
|
||||||
|
|
||||||
|
- feat(mailer): Add logging for selfservice forgotten mail requests [`eebcc2e`](https://git.odit.services/lfk/backend/commit/eebcc2e3284230135e3911b4edaecd1a9cfd2100)
|
||||||
|
- chore(release): 1.5.2 [`e27e819`](https://git.odit.services/lfk/backend/commit/e27e8196097da19e24af22368ca8be5a8d9ef6b9)
|
||||||
|
- feat(mailer): Log error message when sending selfservice forgotten mail fails [`0f532b1`](https://git.odit.services/lfk/backend/commit/0f532b139c2bc5cd89ca2dbff0867825a9363250)
|
||||||
|
|
||||||
|
#### [1.5.1](https://git.odit.services/lfk/backend/compare/1.5.0...1.5.1)
|
||||||
|
|
||||||
|
> 26 May 2025
|
||||||
|
|
||||||
|
- chore(release): 1.5.1 [`284954d`](https://git.odit.services/lfk/backend/commit/284954d064f09951c13584e9d50a83be2c4b9f72)
|
||||||
|
- feat(mailer): Log error when sending selfservice forgotten mail fails [`401ca92`](https://git.odit.services/lfk/backend/commit/401ca923a61bc5988e73209c086bc9a5a4fa04f9)
|
||||||
|
|
||||||
|
#### [1.5.0](https://git.odit.services/lfk/backend/compare/1.4.3...1.5.0)
|
||||||
|
|
||||||
|
> 6 May 2025
|
||||||
|
|
||||||
|
- feat(entities): Added created/updated at to all entities [`728f8a1`](https://git.odit.services/lfk/backend/commit/728f8a14e9fb7360fce92640bfa5658af8cadb4f)
|
||||||
|
- feat(responses): Added created_at/updated_at [`f225cc4`](https://git.odit.services/lfk/backend/commit/f225cc49548605de48cf6c6e6f7c86b163236545)
|
||||||
|
- feat(participants): Added created/updated at [`a448058`](https://git.odit.services/lfk/backend/commit/a4480589a0e23a4481332ab5efa0777c62bbab56)
|
||||||
|
- chore(release): 1.5.0 [`bf1f641`](https://git.odit.services/lfk/backend/commit/bf1f6411e0f5113842a537f5bcf632638bdf1048)
|
||||||
|
|
||||||
|
#### [1.4.3](https://git.odit.services/lfk/backend/compare/1.4.2...1.4.3)
|
||||||
|
|
||||||
|
> 1 May 2025
|
||||||
|
|
||||||
|
- feat(runners): Include collected distance donation amount in runner detail [`4494afc`](https://git.odit.services/lfk/backend/commit/4494afc64b433d26b54a293fe156d13c40faad95)
|
||||||
|
- chore(release): 1.4.3 [`0ad9eeb`](https://git.odit.services/lfk/backend/commit/0ad9eeb52f18af3ea7d86fe1bf15edb04f4cfd2d)
|
||||||
|
|
||||||
|
#### [1.4.2](https://git.odit.services/lfk/backend/compare/1.4.1...1.4.2)
|
||||||
|
|
||||||
|
> 1 May 2025
|
||||||
|
|
||||||
|
- fix(donations): Fixed creation bug [`07a0195`](https://git.odit.services/lfk/backend/commit/07a0195f125519f239d255a0cc081ddbde8f1da3)
|
||||||
|
- chore(release): 1.4.2 [`f4747c5`](https://git.odit.services/lfk/backend/commit/f4747c51de71d9b28cca1b00a91de3cfd6f0f56e)
|
||||||
|
|
||||||
|
#### [1.4.1](https://git.odit.services/lfk/backend/compare/1.4.0...1.4.1)
|
||||||
|
|
||||||
|
> 28 April 2025
|
||||||
|
|
||||||
|
- chore(release): 1.4.1 [`7ac9822`](https://git.odit.services/lfk/backend/commit/7ac98229d17e7cb019d5dcc5402870490a97f910)
|
||||||
|
- refactor(auth): Increased token timeouts to 24hrs/7days [`dd5b538`](https://git.odit.services/lfk/backend/commit/dd5b538783f9c806f0c883cd391754fb5c842ec8)
|
||||||
|
|
||||||
|
#### [1.4.0](https://git.odit.services/lfk/backend/compare/1.3.12...1.4.0)
|
||||||
|
|
||||||
|
> 28 April 2025
|
||||||
|
|
||||||
|
- feat(donations): Implement response type to indicate possible missing donor [`f4bf309`](https://git.odit.services/lfk/backend/commit/f4bf309821c140f2bc0ae8b6d96c7458fcc80978)
|
||||||
|
- wip [`9875b4f`](https://git.odit.services/lfk/backend/commit/9875b4f3926e04b502e7af64c17f54fd3c1d8e3e)
|
||||||
|
- refactor(donations): Make anon prepaid [`02b1cb9`](https://git.odit.services/lfk/backend/commit/02b1cb9904cc593faeac025ae302a8684f650f5e)
|
||||||
|
- chore(release): 1.4.0 [`8e6d674`](https://git.odit.services/lfk/backend/commit/8e6d67428c85b6ee504a379ff13a3a951f7b9543)
|
||||||
|
- fix(donations): Move donor over to the types that need it [`7697acf`](https://git.odit.services/lfk/backend/commit/7697acff82b23d0c05dbbd17fee6e70eb1b7061c)
|
||||||
|
|
||||||
|
#### [1.3.12](https://git.odit.services/lfk/backend/compare/1.3.11...1.3.12)
|
||||||
|
|
||||||
|
> 28 April 2025
|
||||||
|
|
||||||
|
- chore(release): 1.3.12 [`bacfc43`](https://git.odit.services/lfk/backend/commit/bacfc437f97cac6a20c32b79ae2d6391466f78a6)
|
||||||
|
- refactor: make Donation.donor optional [`2ab6e98`](https://git.odit.services/lfk/backend/commit/2ab6e985e356f0f3d8637d81630d191cc11b8806)
|
||||||
|
- refactor(config): improve consola error logs [`ce9b765`](https://git.odit.services/lfk/backend/commit/ce9b765b81b014623e79ce64d8d835f1f86cecf3)
|
||||||
|
|
||||||
|
#### [1.3.11](https://git.odit.services/lfk/backend/compare/1.3.10...1.3.11)
|
||||||
|
|
||||||
|
> 17 April 2025
|
||||||
|
|
||||||
|
- feat(RunnerController): add selfservice_links parameter to getRunners method [`a50d72f`](https://git.odit.services/lfk/backend/commit/a50d72f2f5281b8c28ca64a0970161a35a7af95a)
|
||||||
|
- chore(release): 1.3.11 [`d06f6a4`](https://git.odit.services/lfk/backend/commit/d06f6a44072971d1853411b255f9b49eb423b3a2)
|
||||||
|
|
||||||
|
#### [1.3.10](https://git.odit.services/lfk/backend/compare/1.3.9...1.3.10)
|
||||||
|
|
||||||
|
> 11 April 2025
|
||||||
|
|
||||||
|
- chore(release): 1.3.10 [`4723d97`](https://git.odit.services/lfk/backend/commit/4723d9738eacd63fb41f23c628fbe4181bd126de)
|
||||||
|
- feat(RunnerController.getAll): debug created_via query param filter [`1a478bd`](https://git.odit.services/lfk/backend/commit/1a478bd784e01b9d5a1c6635d1004a9535c9a0e9)
|
||||||
|
|
||||||
|
#### [1.3.9](https://git.odit.services/lfk/backend/compare/1.3.8...1.3.9)
|
||||||
|
|
||||||
|
> 9 April 2025
|
||||||
|
|
||||||
|
- feat(RunnerController.getAll): add created_via query param filter [`6e63c57`](https://git.odit.services/lfk/backend/commit/6e63c57936f06a29da5f1a94b1141d51b75df5f0)
|
||||||
|
- chore(release): 1.3.9 [`284cb0f`](https://git.odit.services/lfk/backend/commit/284cb0f8b3955d0d65c2b36d2ec427a39752ffe7)
|
||||||
|
|
||||||
|
#### [1.3.8](https://git.odit.services/lfk/backend/compare/1.3.7...1.3.8)
|
||||||
|
|
||||||
|
> 9 April 2025
|
||||||
|
|
||||||
|
- feat(RunnerCardController): putByCode [`8237d5f`](https://git.odit.services/lfk/backend/commit/8237d5f21067c0872a7eff7c8d1506edf44ec10c)
|
||||||
|
- chore(release): 1.3.8 [`30b61db`](https://git.odit.services/lfk/backend/commit/30b61db2c160c019bac381f26cefdc6524ea465e)
|
||||||
|
|
||||||
|
#### [1.3.7](https://git.odit.services/lfk/backend/compare/1.3.6...1.3.7)
|
||||||
|
|
||||||
|
> 8 April 2025
|
||||||
|
|
||||||
|
- feat(stats): Publish runners by kiosk stat [`a6afba9`](https://git.odit.services/lfk/backend/commit/a6afba93e243ca419c282a16cad023d06d864e0e)
|
||||||
|
- chore(release): 1.3.7 [`03e0a29`](https://git.odit.services/lfk/backend/commit/03e0a290965648579956ac1f8e8542c97a667ed8)
|
||||||
|
|
||||||
|
#### [1.3.6](https://git.odit.services/lfk/backend/compare/1.3.5...1.3.6)
|
||||||
|
|
||||||
|
> 8 April 2025
|
||||||
|
|
||||||
|
- chore(release): 1.3.6 [`a41758c`](https://git.odit.services/lfk/backend/commit/a41758cd9c83105c3a4b407744bafe2f0f6fb48a)
|
||||||
|
- feat(runners): Allow created via being set via api [`d6755ed`](https://git.odit.services/lfk/backend/commit/d6755ed134071df635bc9d5821ceb2396c0f1d22)
|
||||||
|
- fix(participant): Switch to correct type [`599c75f`](https://git.odit.services/lfk/backend/commit/599c75fc00217eaec3cc87c0de50d059bdde685f)
|
||||||
|
|
||||||
|
#### [1.3.5](https://git.odit.services/lfk/backend/compare/1.3.4...1.3.5)
|
||||||
|
|
||||||
|
> 8 April 2025
|
||||||
|
|
||||||
|
- feat(runners): Generate selfservice urls on runner if requested or create/update/get single [`5415cd3`](https://git.odit.services/lfk/backend/commit/5415cd38a727e76632a01a4d2634a1777df5542c)
|
||||||
|
- chore(release): 1.3.5 [`bb213f0`](https://git.odit.services/lfk/backend/commit/bb213f001eff2157abf8741128f624f9cc991afe)
|
||||||
|
|
||||||
|
#### [1.3.4](https://git.odit.services/lfk/backend/compare/1.3.3...1.3.4)
|
||||||
|
|
||||||
|
> 28 March 2025
|
||||||
|
|
||||||
|
- feat: add runnersViaSelfservice to statsControllerGet [`5c5000a`](https://git.odit.services/lfk/backend/commit/5c5000a218b47815e6846ac8b857dcd1995bfa6f)
|
||||||
|
- chore(release): 1.3.4 [`175ba52`](https://git.odit.services/lfk/backend/commit/175ba52ffae8e6ba1fdc1603ac2f5eba15602046)
|
||||||
|
|
||||||
|
#### [1.3.3](https://git.odit.services/lfk/backend/compare/v1.3.2...1.3.3)
|
||||||
|
|
||||||
|
> 28 March 2025
|
||||||
|
|
||||||
|
- chore(release): 1.3.3 [`d559d04`](https://git.odit.services/lfk/backend/commit/d559d0403191c703fd6da0e3f3dab53eec9258c0)
|
||||||
|
- ci: remove "v" prefix from tags [`2af682d`](https://git.odit.services/lfk/backend/commit/2af682d1dd09df496eb9f3a9111c50c0c4117356)
|
||||||
|
|
||||||
|
#### [v1.3.2](https://git.odit.services/lfk/backend/compare/v1.3.1...v1.3.2)
|
||||||
|
|
||||||
|
> 28 March 2025
|
||||||
|
|
||||||
|
- chore(release): v1.3.2 [`30905e4`](https://git.odit.services/lfk/backend/commit/30905e481c69cfe62b4261544b4277de3a1a43c2)
|
||||||
|
- ci: pnpm@10.7 [`752d405`](https://git.odit.services/lfk/backend/commit/752d405bda9129f3cd288a956d5444cab316c2af)
|
||||||
|
|
||||||
|
#### [v1.3.1](https://git.odit.services/lfk/backend/compare/1.3.0...v1.3.1)
|
||||||
|
|
||||||
|
> 28 March 2025
|
||||||
|
|
||||||
|
- fix: TypeError: Cannot read properties of undefined (reading 'filter') - when trying to delete a org/team with runners [`#210`](https://git.odit.services/lfk/backend/issues/210)
|
||||||
|
- pnpm@10.7, node@23, argon->@node-rs/argon2 [`78dcad0`](https://git.odit.services/lfk/backend/commit/78dcad085794c93829499dd550a786c38d6186f5)
|
||||||
|
- chore(release): v1.3.1 [`8fa4ed7`](https://git.odit.services/lfk/backend/commit/8fa4ed7c3319c3e56a71701ba266ceda64d2ef69)
|
||||||
|
|
||||||
|
#### [1.3.0](https://git.odit.services/lfk/backend/compare/1.2.1...1.3.0)
|
||||||
|
|
||||||
|
> 28 March 2025
|
||||||
|
|
||||||
|
- feat: created_via for tracking how runners got into the system [`#212`](https://git.odit.services/lfk/backend/pull/212)
|
||||||
|
- feat: created_via for tracking how runners got into the system (#212) [`#211`](https://git.odit.services/lfk/backend/issues/211)
|
||||||
|
- ci: move to gitea workflows [`ebde8c6`](https://git.odit.services/lfk/backend/commit/ebde8c6ffd8b17c6752da8c4d8eb3095105f6132)
|
||||||
|
- chore(release): v1.3.0 [`93e0cdf`](https://git.odit.services/lfk/backend/commit/93e0cdf577654898b2d63790d91598c458a2db59)
|
||||||
|
- build: docker "AS" casing [`0a43f1b`](https://git.odit.services/lfk/backend/commit/0a43f1bb5b26d3acb0d4d91648473f0dc55e8637)
|
||||||
|
- ci: change release commit message [`6efcd94`](https://git.odit.services/lfk/backend/commit/6efcd94726957b8c527820f1a9b0130151ce22f1)
|
||||||
|
- refactor(RunnerController.remove): only load necessary relations [`8c6fdb2`](https://git.odit.services/lfk/backend/commit/8c6fdb22390218e385780fadb3bdaf32148ac054)
|
||||||
|
- refactor(RunnerTeamController.remove): only load necessary relations [`c0d5af5`](https://git.odit.services/lfk/backend/commit/c0d5af5d7ab44cfdf19014e0d774fb560d08f6d7)
|
||||||
|
- fix: add .created_via to ResponseParticipant constructor [`2e271bc`](https://git.odit.services/lfk/backend/commit/2e271bcd52f02ab7449cd15916b0afc86e8b0a90)
|
||||||
|
|
||||||
|
#### [1.2.1](https://git.odit.services/lfk/backend/compare/1.2.0...1.2.1)
|
||||||
|
|
||||||
|
> 11 December 2024
|
||||||
|
|
||||||
|
- refactor: allow selfservice link every 30s [`07bf28b`](https://git.odit.services/lfk/backend/commit/07bf28b14458849930748ce041fb65e572759482)
|
||||||
|
- chore(release): 1.2.1 [`4008a5e`](https://git.odit.services/lfk/backend/commit/4008a5ee720b212bac9cba64417058bf4526060b)
|
||||||
|
|
||||||
|
#### [1.2.0](https://git.odit.services/lfk/backend/compare/v1.1.4...1.2.0)
|
||||||
|
|
||||||
|
> 11 December 2024
|
||||||
|
|
||||||
|
- refactor: move to new mailer [`0f4c8b2`](https://git.odit.services/lfk/backend/commit/0f4c8b2051cae17fbdd7e02017ad5b41c61e210c)
|
||||||
|
- refactor(ci): Switch to new woodpecker [`b3a73b2`](https://git.odit.services/lfk/backend/commit/b3a73b25e80a0466ff83e43481271fc0cd499a0d)
|
||||||
|
- feat: middlename [`6eff243`](https://git.odit.services/lfk/backend/commit/6eff2438035b368eb45931fad9402a6cb942b350)
|
||||||
|
- SELFSERVICE_URL [`765ef84`](https://git.odit.services/lfk/backend/commit/765ef849035ca4f8b2253bb76d15be8e9a3e6763)
|
||||||
|
- FRONTEND_URL env [`296ba8d`](https://git.odit.services/lfk/backend/commit/296ba8ddab1dba46f8201829d9a7e5fc1c88c0f8)
|
||||||
|
- chore: update readme [`d842c14`](https://git.odit.services/lfk/backend/commit/d842c14240fb4a7f70c66143bbe877f8168ef6d4)
|
||||||
|
- chore(release): 1.2.0 [`6764bf8`](https://git.odit.services/lfk/backend/commit/6764bf80eac832d186e688319d8a959543a1495f)
|
||||||
|
- Merge pull request 'refactor: move to new mailer' (#209) from refactor/new-mailer into dev [`bda1f97`](https://git.odit.services/lfk/backend/commit/bda1f971d1a14ea403439533c7ae31280c7df167)
|
||||||
|
|
||||||
|
#### [v1.1.4](https://git.odit.services/lfk/backend/compare/v1.1.3...v1.1.4)
|
||||||
|
|
||||||
|
> 20 November 2024
|
||||||
|
|
||||||
|
- build: package lock [`50dd703`](https://git.odit.services/lfk/backend/commit/50dd703a1bd276a607cc10a087c7e90fd880847a)
|
||||||
|
- fix(deps): Bump sqlite3 [`cd3cd81`](https://git.odit.services/lfk/backend/commit/cd3cd81360777e8bc4d78e861354e58c8da79cc7)
|
||||||
|
- feat(ci)!: Switch to woodpecker [`3192365`](https://git.odit.services/lfk/backend/commit/3192365793fae59f2b89e3231db298654f0a28e9)
|
||||||
|
- fix(deps): Bumped argon2 to latest version for arm support [`cf48c00`](https://git.odit.services/lfk/backend/commit/cf48c00ddb2ac33263549876928db50ae152c12d)
|
||||||
|
- fix: updated README for pnpm, typos [`5082b1b`](https://git.odit.services/lfk/backend/commit/5082b1b8b1c0ae9e8ffa9c71c4d7923fd9223c87)
|
||||||
|
- 🚀Bumped version to v1.1.4 [`a54cb28`](https://git.odit.services/lfk/backend/commit/a54cb287a4323ac8de77f51711cc6c52ec290859)
|
||||||
|
- ci: drop lfk-client-node [`075d484`](https://git.odit.services/lfk/backend/commit/075d484f1169bfc5c5b68cb9712116b0e270b471)
|
||||||
|
- fix(dependencies): Switch back to previous class-validator version to produce a working build [`74d334f`](https://git.odit.services/lfk/backend/commit/74d334f9b747a77115bd9b97729ef1120822e128)
|
||||||
|
|
||||||
|
#### [v1.1.3](https://git.odit.services/lfk/backend/compare/v1.1.2...v1.1.3)
|
||||||
|
|
||||||
|
> 10 May 2023
|
||||||
|
|
||||||
|
- 🚀Bumped version to v1.1.3 [`057a8ee`](https://git.odit.services/lfk/backend/commit/057a8ee699d08c0e4a80cb50a8820f819569c9ac)
|
||||||
|
- feat(orgs): Also resolve child-teams' distances and add them to org total [`8d94186`](https://git.odit.services/lfk/backend/commit/8d9418635d3e381c0f55a2521a3334ba497c169a)
|
||||||
|
- fix(orgs): Removed unused log [`f2832a2`](https://git.odit.services/lfk/backend/commit/f2832a2daecc7bc7bbee4d4fceeab8db194730cf)
|
||||||
|
|
||||||
|
#### [v1.1.2](https://git.odit.services/lfk/backend/compare/v1.1.1...v1.1.2)
|
||||||
|
|
||||||
|
> 10 May 2023
|
||||||
|
|
||||||
|
- 🚀Bumped version to v1.1.2 [`0d21596`](https://git.odit.services/lfk/backend/commit/0d21596e2b64a99258d4925ae2ad627d5cdbd984)
|
||||||
|
- feat(groups): Resolve the total group distance on group get single (aka get org and get team) [`245827e`](https://git.odit.services/lfk/backend/commit/245827e9c659cf76183dc33ab253becc22ddf032)
|
||||||
|
- chore(package): Formatting [`4608a36`](https://git.odit.services/lfk/backend/commit/4608a36df6b187520ca0c331b8dce615205257be)
|
||||||
|
|
||||||
|
#### [v1.1.1](https://git.odit.services/lfk/backend/compare/v1.1.0...v1.1.1)
|
||||||
|
|
||||||
|
> 19 April 2023
|
||||||
|
|
||||||
|
- feat(donors): Resolve donations with donors via pagination [`12a9ae2`](https://git.odit.services/lfk/backend/commit/12a9ae24933117acb3ff9815a7d72abca5eea7a7)
|
||||||
|
- 🚀Bumped version to v1.1.1 [`cb1305a`](https://git.odit.services/lfk/backend/commit/cb1305aa77c36aa9d7900f09e7413bc6d45f2c89)
|
||||||
|
|
||||||
|
#### [v1.1.0](https://git.odit.services/lfk/backend/compare/v1.0.1...v1.1.0)
|
||||||
|
|
||||||
|
> 19 April 2023
|
||||||
|
|
||||||
|
- feat(stats): Added donation count and donor count to stats [`6f39ac4`](https://git.odit.services/lfk/backend/commit/6f39ac42dafc2a589bbb2256b0417f3e774ae174)
|
||||||
|
- 🚀Bumped version to v1.1.0 [`b9fe9f1`](https://git.odit.services/lfk/backend/commit/b9fe9f1c24653b91255a6dbbdc32c30b1b411eeb)
|
||||||
|
- Added average donation per distance to stats [`fe59e3a`](https://git.odit.services/lfk/backend/commit/fe59e3a557903cf555d4c50098e935c49ca1fac4)
|
||||||
|
- Added hints [`b25b0db`](https://git.odit.services/lfk/backend/commit/b25b0db76071ef8d50cc60e950a399dc060a2a9f)
|
||||||
|
- Added calls to controller [`6ee5328`](https://git.odit.services/lfk/backend/commit/6ee5328dbc404603d19db3a5173ae4def560a9c9)
|
||||||
|
- Formatting [`42c23a5`](https://git.odit.services/lfk/backend/commit/42c23a5883dacda4e0147842d448b3ad35b197b1)
|
||||||
|
|
||||||
|
#### [v1.0.1](https://git.odit.services/lfk/backend/compare/v1.0.0...v1.0.1)
|
||||||
|
|
||||||
|
> 18 April 2023
|
||||||
|
|
||||||
|
- fix(pagination) page=0 resulted in false thx JS [`fcee390`](https://git.odit.services/lfk/backend/commit/fcee3909f4c4664115cc7ecb94f30e0dd8e78ce0)
|
||||||
|
- 🚀Bumped version to v1.0.1 [`301f334`](https://git.odit.services/lfk/backend/commit/301f33467489a8533bdac11fbd10efd1b791f5e3)
|
||||||
|
|
||||||
|
### [v1.0.0](https://git.odit.services/lfk/backend/compare/v0.15.4...v1.0.0)
|
||||||
|
|
||||||
|
> 18 April 2023
|
||||||
|
|
||||||
|
- 🚀Bumped version to v1.0.0 [`f0e20e4`](https://git.odit.services/lfk/backend/commit/f0e20e413014fe446c97754d2765cdad92c2cc3b)
|
||||||
|
- Merge pull request 'feature/205-pagination' (#206) from feature/205-pagination into dev [`80de188`](https://git.odit.services/lfk/backend/commit/80de188565523d642407612272432ef07672b890)
|
||||||
|
- Added pagination for runner orgs [`538622a`](https://git.odit.services/lfk/backend/commit/538622aa1841e27256f304e15b4204c2f6d24d76)
|
||||||
|
- RunnerTeam Pagination [`0fa663a`](https://git.odit.services/lfk/backend/commit/0fa663a34104d438dd8fc9ab02458fdf289329f8)
|
||||||
|
- users pagination [`244da61`](https://git.odit.services/lfk/backend/commit/244da618926377f58bb12dbbd89b7bb39d84596e)
|
||||||
|
- Track pagination [`2a72aea`](https://git.odit.services/lfk/backend/commit/2a72aea10ef940fbdd4a9e6137b22933fdec7734)
|
||||||
|
- usergroup pagination [`513d7f6`](https://git.odit.services/lfk/backend/commit/513d7f6fbaebe39beab6ec95e6e42eb10c62296d)
|
||||||
|
- statsclient pagination [`71ebce6`](https://git.odit.services/lfk/backend/commit/71ebce6f8eebf110bb973a53b91dd6a49e1def99)
|
||||||
|
- scanstation pagination [`f60025b`](https://git.odit.services/lfk/backend/commit/f60025b6de79b0f5f89995bf59260194f5de9af0)
|
||||||
|
- Get all pagination for permissions [`86a21db`](https://git.odit.services/lfk/backend/commit/86a21dbfa4b50d8e80c611ea6e3eabfc2b8ae365)
|
||||||
|
- Pagination for group contacts [`1e9e24d`](https://git.odit.services/lfk/backend/commit/1e9e24d99d75ce6dc846ff662e62c886646ea974)
|
||||||
|
- Added pagination for get all donors [`4493c0e`](https://git.odit.services/lfk/backend/commit/4493c0e3d9beebbf7f601b39e1a2579771b4d152)
|
||||||
|
- Added pagination for donations [`f5d48fc`](https://git.odit.services/lfk/backend/commit/f5d48fc638080c9333efe474d86f131794c809af)
|
||||||
|
- Added pagination for runnercards [`b35a2dd`](https://git.odit.services/lfk/backend/commit/b35a2dd2fab708253373b3326f11ab574be18371)
|
||||||
|
- Added pagination for runners [`d873674`](https://git.odit.services/lfk/backend/commit/d873674819e6cb33cf89da4f8fdc30a0b41707e4)
|
||||||
|
- Added pagination for get all scans [`37b2ac9`](https://git.odit.services/lfk/backend/commit/37b2ac974b2276efd13538c127ba5ddda2537fe3)
|
||||||
|
- Updated test for attribute [`2f305e1`](https://git.odit.services/lfk/backend/commit/2f305e127c75e9e6ff8e9fc0cfc10cc3db44759d)
|
||||||
|
- Formatting [`a28ffe0`](https://git.odit.services/lfk/backend/commit/a28ffe06e5f3f69e4af6fdf0c66c9a1dfda10cfa)
|
||||||
|
|
||||||
#### [v0.15.4](https://git.odit.services/lfk/backend/compare/v0.15.3...v0.15.4)
|
#### [v0.15.4](https://git.odit.services/lfk/backend/compare/v0.15.3...v0.15.4)
|
||||||
|
|
||||||
|
> 15 April 2023
|
||||||
|
|
||||||
- Fixed possible null [`0f0c3c7`](https://git.odit.services/lfk/backend/commit/0f0c3c7214f357d991518aafd015ffc4d387ce59)
|
- Fixed possible null [`0f0c3c7`](https://git.odit.services/lfk/backend/commit/0f0c3c7214f357d991518aafd015ffc4d387ce59)
|
||||||
|
- 🚀Bumped version to v0.15.4 [`81aed1d`](https://git.odit.services/lfk/backend/commit/81aed1de40166f4cefabdb478d7638017127b25c)
|
||||||
|
|
||||||
#### [v0.15.3](https://git.odit.services/lfk/backend/compare/v0.15.2...v0.15.3)
|
#### [v0.15.3](https://git.odit.services/lfk/backend/compare/v0.15.2...v0.15.3)
|
||||||
|
|
||||||
|
|||||||
20
Dockerfile
20
Dockerfile
@@ -1,23 +1,23 @@
|
|||||||
# Typescript Build
|
# Typescript Build
|
||||||
FROM registry.odit.services/hub/library/node:19.5.0-alpine3.16 as build
|
FROM registry.odit.services/hub/oven/bun:1.3.9-alpine AS build
|
||||||
ARG NPM_REGISTRY_URL=https://registry.npmjs.org
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package.json bun.lockb* ./
|
||||||
RUN npm config set registry $NPM_REGISTRY_URL && npm i -g pnpm@8
|
RUN bun install --frozen-lockfile
|
||||||
RUN mkdir /pnpm && pnpm config set store-dir /pnpm && pnpm i
|
|
||||||
|
|
||||||
COPY tsconfig.json ormconfig.js ./
|
COPY tsconfig.json ormconfig.js bunfig.toml ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN pnpm run build \
|
RUN bun run build \
|
||||||
&& rm -rf /app/node_modules \
|
&& rm -rf /app/node_modules \
|
||||||
&& pnpm i --production --prefer-offline
|
&& bun install --production --frozen-lockfile
|
||||||
|
|
||||||
# final image
|
# final image
|
||||||
FROM registry.odit.services/hub/library/node:19.5.0-alpine3.16 as final
|
FROM registry.odit.services/hub/oven/bun:1.3.9-alpine AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/package.json /app/package.json
|
COPY --from=build /app/package.json /app/package.json
|
||||||
|
COPY --from=build /app/bun.lockb* /app/
|
||||||
COPY --from=build /app/ormconfig.js /app/ormconfig.js
|
COPY --from=build /app/ormconfig.js /app/ormconfig.js
|
||||||
|
COPY --from=build /app/bunfig.toml /app/bunfig.toml
|
||||||
COPY --from=build /app/dist /app/dist
|
COPY --from=build /app/dist /app/dist
|
||||||
COPY --from=build /app/node_modules /app/node_modules
|
COPY --from=build /app/node_modules /app/node_modules
|
||||||
ENTRYPOINT ["node", "/app/dist/app.js"]
|
ENTRYPOINT ["bun", "/app/dist/app.js"]
|
||||||
589
PERFORMANCE_IDEAS.md
Normal file
589
PERFORMANCE_IDEAS.md
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
# Performance Optimization Ideas for LfK Backend
|
||||||
|
|
||||||
|
This document outlines potential performance improvements for the LfK backend API, organized by impact and complexity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Already Implemented
|
||||||
|
|
||||||
|
### 1. Bun Runtime Migration
|
||||||
|
**Status**: Complete
|
||||||
|
**Impact**: 8-15% latency improvement
|
||||||
|
**Details**: Migrated from Node.js to Bun runtime, achieving:
|
||||||
|
- Parallel throughput: +8.3% (306 → 331 scans/sec)
|
||||||
|
- Parallel p50 latency: -9.5% (21ms → 19ms)
|
||||||
|
|
||||||
|
### 2. NATS KV Cache for Scan Intake
|
||||||
|
**Status**: Complete (based on code analysis)
|
||||||
|
**Impact**: Significant reduction in DB reads for hot path
|
||||||
|
**Details**: `ScanController.stationIntake()` uses NATS JetStream KV store to cache:
|
||||||
|
- Station tokens (1-hour TTL)
|
||||||
|
- Card→Runner mappings (1-hour TTL)
|
||||||
|
- Runner state (no TTL, CAS-based updates)
|
||||||
|
- Eliminates DB reads on cache hits
|
||||||
|
- Prevents race conditions via compare-and-swap (CAS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 High Impact, Low-Medium Complexity
|
||||||
|
|
||||||
|
### 3. Add Database Indexes
|
||||||
|
**Priority**: HIGH
|
||||||
|
**Complexity**: Low
|
||||||
|
**Estimated Impact**: 30-70% query time reduction
|
||||||
|
|
||||||
|
**Problem**: TypeORM synchronize() doesn't automatically create indexes on foreign keys or commonly queried fields.
|
||||||
|
|
||||||
|
**Observations**:
|
||||||
|
- Heavy use of `find()` with complex nested relations (e.g., `['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track']`)
|
||||||
|
- No explicit `@Index()` decorators found in entity files
|
||||||
|
- Frequent filtering by foreign keys (runner_id, track_id, station_id, card_id)
|
||||||
|
|
||||||
|
**Recommended Indexes**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/models/entities/Scan.ts
|
||||||
|
@Index(['runner', 'timestamp']) // For runner scan history queries
|
||||||
|
@Index(['station', 'timestamp']) // For station-based queries
|
||||||
|
@Index(['card']) // For card lookup
|
||||||
|
|
||||||
|
// src/models/entities/Runner.ts
|
||||||
|
@Index(['email']) // For authentication/lookup
|
||||||
|
@Index(['group']) // For group-based queries
|
||||||
|
|
||||||
|
// src/models/entities/RunnerCard.ts
|
||||||
|
@Index(['runner']) // For card→runner lookups
|
||||||
|
@Index(['code']) // For barcode scans
|
||||||
|
|
||||||
|
// src/models/entities/Donation.ts
|
||||||
|
@Index(['runner']) // For runner donations
|
||||||
|
@Index(['donor']) // For donor contributions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation Steps**:
|
||||||
|
1. Audit all entities and add `@Index()` decorators
|
||||||
|
2. Test query performance with `EXPLAIN` before/after
|
||||||
|
3. Monitor index usage with database tools
|
||||||
|
4. Consider composite indexes for frequently combined filters
|
||||||
|
|
||||||
|
**Expected Results**:
|
||||||
|
- 50-70% faster JOIN operations
|
||||||
|
- 30-50% faster foreign key lookups
|
||||||
|
- Reduced database CPU usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Implement Query Result Caching
|
||||||
|
**Priority**: HIGH
|
||||||
|
**Complexity**: Medium
|
||||||
|
**Estimated Impact**: 50-90% latency reduction for repeated queries
|
||||||
|
|
||||||
|
**Problem**: Stats endpoints and frequently accessed data (org totals, team rankings, runner lists) are recalculated on every request.
|
||||||
|
|
||||||
|
**Observations**:
|
||||||
|
- `StatsController` methods load entire datasets with deep relations:
|
||||||
|
- `getRunnerStats()`: loads all runners with scans, groups, donations
|
||||||
|
- `getTeamStats()`: loads all teams with nested runner data
|
||||||
|
- `getOrgStats()`: loads all orgs with teams, runners, scans
|
||||||
|
- Many `find()` calls without any caching layer
|
||||||
|
- Data changes infrequently (only during scan intake)
|
||||||
|
|
||||||
|
**Solution Options**:
|
||||||
|
|
||||||
|
**Option A: NATS KV Cache (Recommended)**
|
||||||
|
```typescript
|
||||||
|
// src/nats/StatsKV.ts
|
||||||
|
export async function getOrgStatsCache(): Promise<ResponseOrgStats[] | null> {
|
||||||
|
const kv = await NatsClient.getKV('stats_cache', { ttl: 60 * 1000 }); // 60s TTL
|
||||||
|
const entry = await kv.get('org_stats');
|
||||||
|
return entry ? JSON.parse(entry.string()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setOrgStatsCache(stats: ResponseOrgStats[]): Promise<void> {
|
||||||
|
const kv = await NatsClient.getKV('stats_cache', { ttl: 60 * 1000 });
|
||||||
|
await kv.put('org_stats', JSON.stringify(stats));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate on scan creation
|
||||||
|
// src/controllers/ScanController.ts (after line 173)
|
||||||
|
await invalidateStatsCache(); // Clear stats on new scan
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option B: In-Memory Cache with TTL**
|
||||||
|
```typescript
|
||||||
|
// src/cache/MemoryCache.ts
|
||||||
|
import NodeCache from 'node-cache';
|
||||||
|
|
||||||
|
const cache = new NodeCache({ stdTTL: 60 }); // 60s TTL
|
||||||
|
|
||||||
|
export function getCached<T>(key: string): T | undefined {
|
||||||
|
return cache.get<T>(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCached<T>(key: string, value: T, ttl?: number): void {
|
||||||
|
cache.set(key, value, ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidatePattern(pattern: string): void {
|
||||||
|
const keys = cache.keys().filter(k => k.includes(pattern));
|
||||||
|
cache.del(keys);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Option C: Redis Cache** (if Redis is already in stack)
|
||||||
|
|
||||||
|
**Recommended Cache Strategy**:
|
||||||
|
- **TTL**: 30-60 seconds for stats endpoints
|
||||||
|
- **Invalidation**: On scan creation, runner updates, donation changes
|
||||||
|
- **Keys**: `stats:org`, `stats:team:${id}`, `stats:runner:${id}`
|
||||||
|
- **Warm on startup**: Pre-populate cache for critical endpoints
|
||||||
|
|
||||||
|
**Expected Results**:
|
||||||
|
- 80-90% latency reduction for stats endpoints (from ~500ms to ~50ms)
|
||||||
|
- 70-80% reduction in database load
|
||||||
|
- Improved user experience for dashboards and leaderboards
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Lazy Load Relations & DTOs
|
||||||
|
**Priority**: HIGH
|
||||||
|
**Complexity**: Medium
|
||||||
|
**Estimated Impact**: 40-60% query time reduction
|
||||||
|
|
||||||
|
**Problem**: Many queries eagerly load deeply nested relations that aren't always needed.
|
||||||
|
|
||||||
|
**Observations**:
|
||||||
|
```typescript
|
||||||
|
// Current: Loads everything
|
||||||
|
scan = await this.scanRepository.findOne(
|
||||||
|
{ id: scan.id },
|
||||||
|
{ relations: ['runner', 'track', 'runner.scans', 'runner.group',
|
||||||
|
'runner.scans.track', 'card', 'station'] }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
|
||||||
|
**A. Create Lightweight Response DTOs**
|
||||||
|
```typescript
|
||||||
|
// src/models/responses/ResponseScanLight.ts
|
||||||
|
export class ResponseScanLight {
|
||||||
|
@IsInt() id: number;
|
||||||
|
@IsInt() distance: number;
|
||||||
|
@IsInt() timestamp: number;
|
||||||
|
@IsBoolean() valid: boolean;
|
||||||
|
// Omit nested runner.scans, runner.group, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use for list views
|
||||||
|
@Get()
|
||||||
|
@ResponseSchema(ResponseScanLight, { isArray: true })
|
||||||
|
async getAll() {
|
||||||
|
const scans = await this.scanRepository.find({
|
||||||
|
relations: ['runner', 'track'] // Minimal relations
|
||||||
|
});
|
||||||
|
return scans.map(s => new ResponseScanLight(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep detailed DTO for single-item views
|
||||||
|
@Get('/:id')
|
||||||
|
@ResponseSchema(ResponseScan) // Full details
|
||||||
|
async getOne(@Param('id') id: number) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Use Query Builder for Selective Loading**
|
||||||
|
```typescript
|
||||||
|
// Instead of loading all scans with runner relations:
|
||||||
|
const scans = await this.scanRepository
|
||||||
|
.createQueryBuilder('scan')
|
||||||
|
.leftJoinAndSelect('scan.runner', 'runner')
|
||||||
|
.leftJoinAndSelect('scan.track', 'track')
|
||||||
|
.select([
|
||||||
|
'scan.id', 'scan.distance', 'scan.timestamp', 'scan.valid',
|
||||||
|
'runner.id', 'runner.firstname', 'runner.lastname',
|
||||||
|
'track.id', 'track.name'
|
||||||
|
])
|
||||||
|
.where('scan.id = :id', { id })
|
||||||
|
.getOne();
|
||||||
|
```
|
||||||
|
|
||||||
|
**C. Implement GraphQL-style Field Selection**
|
||||||
|
```typescript
|
||||||
|
@Get()
|
||||||
|
async getAll(@QueryParam('fields') fields?: string) {
|
||||||
|
const relations = [];
|
||||||
|
if (fields?.includes('runner')) relations.push('runner');
|
||||||
|
if (fields?.includes('track')) relations.push('track');
|
||||||
|
return this.scanRepository.find({ relations });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Results**:
|
||||||
|
- 40-60% faster list queries
|
||||||
|
- 50-70% reduction in data transfer size
|
||||||
|
- Reduced JOIN complexity and memory usage
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Pagination Optimization
|
||||||
|
**Priority**: MEDIUM
|
||||||
|
**Complexity**: Low
|
||||||
|
**Estimated Impact**: 20-40% improvement for large result sets
|
||||||
|
|
||||||
|
**Problem**: Current pagination uses `skip/take` which becomes slow with large offsets.
|
||||||
|
|
||||||
|
**Current Implementation**:
|
||||||
|
```typescript
|
||||||
|
// Inefficient for large page numbers (e.g., page=1000)
|
||||||
|
scans = await this.scanRepository.find({
|
||||||
|
skip: page * page_size, // Scans 100,000 rows to skip them
|
||||||
|
take: page_size
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
|
||||||
|
**A. Cursor-Based Pagination (Recommended)**
|
||||||
|
```typescript
|
||||||
|
@Get()
|
||||||
|
async getAll(
|
||||||
|
@QueryParam('cursor') cursor?: number, // Last ID from previous page
|
||||||
|
@QueryParam('page_size') page_size: number = 100
|
||||||
|
) {
|
||||||
|
const query = this.scanRepository.createQueryBuilder('scan')
|
||||||
|
.orderBy('scan.id', 'ASC')
|
||||||
|
.take(page_size + 1); // Get 1 extra to determine if more pages exist
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
query.where('scan.id > :cursor', { cursor });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scans = await query.getMany();
|
||||||
|
const hasMore = scans.length > page_size;
|
||||||
|
const results = scans.slice(0, page_size);
|
||||||
|
const nextCursor = hasMore ? results[results.length - 1].id : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: results.map(s => s.toResponse()),
|
||||||
|
pagination: { nextCursor, hasMore }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Add Total Count Caching**
|
||||||
|
```typescript
|
||||||
|
// Cache total counts to avoid expensive COUNT(*) queries
|
||||||
|
const totalCache = new Map<string, { count: number, expires: number }>();
|
||||||
|
|
||||||
|
async function getTotalCount(repo: Repository<any>): Promise<number> {
|
||||||
|
const cacheKey = repo.metadata.tableName;
|
||||||
|
const cached = totalCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached && cached.expires > Date.now()) {
|
||||||
|
return cached.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = await repo.count();
|
||||||
|
totalCache.set(cacheKey, { count, expires: Date.now() + 60000 }); // 60s TTL
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Results**:
|
||||||
|
- 60-80% faster pagination for large page numbers
|
||||||
|
- Consistent query performance regardless of offset
|
||||||
|
- Better mobile app experience with cursor-based loading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Medium Impact, Medium Complexity
|
||||||
|
|
||||||
|
### 7. Database Connection Pooling Optimization
|
||||||
|
**Priority**: MEDIUM
|
||||||
|
**Complexity**: Medium
|
||||||
|
**Estimated Impact**: 10-20% improvement under load
|
||||||
|
|
||||||
|
**Current**: Default TypeORM connection pooling (likely 10 connections)
|
||||||
|
|
||||||
|
**Recommendations**:
|
||||||
|
```typescript
|
||||||
|
// ormconfig.js
|
||||||
|
module.exports = {
|
||||||
|
// ... existing config
|
||||||
|
extra: {
|
||||||
|
// PostgreSQL specific
|
||||||
|
max: 20, // Max pool size (adjust based on load)
|
||||||
|
min: 5, // Min pool size
|
||||||
|
idleTimeoutMillis: 30000, // Close idle connections after 30s
|
||||||
|
connectionTimeoutMillis: 2000,
|
||||||
|
|
||||||
|
// MySQL specific
|
||||||
|
connectionLimit: 20,
|
||||||
|
waitForConnections: true,
|
||||||
|
queueLimit: 0
|
||||||
|
},
|
||||||
|
|
||||||
|
// Enable query logging in dev to identify slow queries
|
||||||
|
logging: process.env.NODE_ENV !== 'production' ? ['query', 'error'] : ['error'],
|
||||||
|
maxQueryExecutionTime: 1000, // Log queries taking >1s
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Monitor**:
|
||||||
|
- Connection pool exhaustion
|
||||||
|
- Query execution times
|
||||||
|
- Active connection count
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Bulk Operations for Import
|
||||||
|
**Priority**: MEDIUM
|
||||||
|
**Complexity**: Medium
|
||||||
|
**Estimated Impact**: 50-80% faster imports
|
||||||
|
|
||||||
|
**Problem**: Import endpoints likely save entities one-by-one in loops.
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
```typescript
|
||||||
|
// Instead of:
|
||||||
|
for (const runnerData of importData) {
|
||||||
|
const runner = await createRunner.toEntity();
|
||||||
|
await this.runnerRepository.save(runner); // N queries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use bulk insert:
|
||||||
|
const runners = await Promise.all(
|
||||||
|
importData.map(data => createRunner.toEntity())
|
||||||
|
);
|
||||||
|
await this.runnerRepository.save(runners); // 1 query
|
||||||
|
|
||||||
|
// Or use raw query for massive imports:
|
||||||
|
await getConnection()
|
||||||
|
.createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.into(Runner)
|
||||||
|
.values(runners)
|
||||||
|
.execute();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Response Compression
|
||||||
|
**Priority**: MEDIUM
|
||||||
|
**Complexity**: Low
|
||||||
|
**Estimated Impact**: 60-80% reduction in response size
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
// src/app.ts
|
||||||
|
import compression from 'compression';
|
||||||
|
|
||||||
|
const app = createExpressServer({ ... });
|
||||||
|
app.use(compression({
|
||||||
|
level: 6, // Compression level (1-9)
|
||||||
|
threshold: 1024, // Only compress responses >1KB
|
||||||
|
filter: (req, res) => {
|
||||||
|
if (req.headers['x-no-compression']) return false;
|
||||||
|
return compression.filter(req, res);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- 70-80% smaller JSON responses
|
||||||
|
- Faster transfer times on slow networks
|
||||||
|
- Reduced bandwidth costs
|
||||||
|
|
||||||
|
**Dependencies**: `bun add compression @types/compression`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Lower Priority / High Complexity
|
||||||
|
|
||||||
|
### 10. Implement Read Replicas
|
||||||
|
**Priority**: LOW (requires infrastructure)
|
||||||
|
**Complexity**: High
|
||||||
|
**Estimated Impact**: 30-50% read query improvement
|
||||||
|
|
||||||
|
**When to Consider**:
|
||||||
|
- Database CPU consistently >70%
|
||||||
|
- Read-heavy workload (already true for stats endpoints)
|
||||||
|
- Running PostgreSQL/MySQL in production
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
// ormconfig.js
|
||||||
|
module.exports = {
|
||||||
|
type: 'postgres',
|
||||||
|
replication: {
|
||||||
|
master: {
|
||||||
|
host: process.env.DB_WRITE_HOST,
|
||||||
|
port: 5432,
|
||||||
|
username: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
},
|
||||||
|
slaves: [
|
||||||
|
{
|
||||||
|
host: process.env.DB_READ_REPLICA_1,
|
||||||
|
port: 5432,
|
||||||
|
username: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. Move to Serverless/Edge Functions
|
||||||
|
**Priority**: LOW (architectural change)
|
||||||
|
**Complexity**: Very High
|
||||||
|
**Estimated Impact**: Variable (depends on workload)
|
||||||
|
|
||||||
|
**Considerations**:
|
||||||
|
- Good for: Infrequent workloads, global distribution
|
||||||
|
- Bad for: High-frequency scan intake (cold starts)
|
||||||
|
- May conflict with TypeORM's connection model
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. GraphQL API Layer
|
||||||
|
**Priority**: LOW (major refactor)
|
||||||
|
**Complexity**: Very High
|
||||||
|
**Estimated Impact**: 30-50% for complex queries
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- Clients request only needed fields
|
||||||
|
- Single request for complex nested data
|
||||||
|
- Better mobile app performance
|
||||||
|
|
||||||
|
**Trade-offs**:
|
||||||
|
- Complete rewrite of controller layer
|
||||||
|
- Learning curve for frontend teams
|
||||||
|
- More complex caching strategy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Recommended Implementation Order
|
||||||
|
|
||||||
|
**Phase 1: Quick Wins** (1-2 weeks)
|
||||||
|
1. Add database indexes → Controllers still work, immediate improvement
|
||||||
|
2. Enable response compression → One-line change in `app.ts`
|
||||||
|
3. Implement cursor-based pagination → Better mobile UX
|
||||||
|
|
||||||
|
**Phase 2: Caching Layer** (2-3 weeks)
|
||||||
|
4. Add NATS KV cache for stats endpoints
|
||||||
|
5. Create lightweight response DTOs for list views
|
||||||
|
6. Cache total counts for pagination
|
||||||
|
|
||||||
|
**Phase 3: Query Optimization** (2-3 weeks)
|
||||||
|
7. Refactor controllers to use query builder with selective loading
|
||||||
|
8. Optimize database connection pooling
|
||||||
|
9. Implement bulk operations for imports
|
||||||
|
|
||||||
|
**Phase 4: Infrastructure** (ongoing)
|
||||||
|
10. Monitor query performance and add more indexes as needed
|
||||||
|
11. Consider read replicas when database becomes bottleneck
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Performance Monitoring Recommendations
|
||||||
|
|
||||||
|
### Add Metrics Endpoint
|
||||||
|
```typescript
|
||||||
|
// src/controllers/MetricsController.ts
|
||||||
|
import { performance } from 'perf_hooks';
|
||||||
|
|
||||||
|
const requestMetrics = {
|
||||||
|
totalRequests: 0,
|
||||||
|
avgLatency: 0,
|
||||||
|
p95Latency: 0,
|
||||||
|
dbQueryCount: 0,
|
||||||
|
cacheHitRate: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
@JsonController('/metrics')
|
||||||
|
export class MetricsController {
|
||||||
|
@Get()
|
||||||
|
@Authorized('ADMIN') // Restrict to admins
|
||||||
|
async getMetrics() {
|
||||||
|
return requestMetrics;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable Query Logging
|
||||||
|
```typescript
|
||||||
|
// ormconfig.js
|
||||||
|
logging: ['query', 'error'],
|
||||||
|
maxQueryExecutionTime: 1000, // Warn on queries >1s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add Request Timing Middleware
|
||||||
|
```typescript
|
||||||
|
// src/middlewares/TimingMiddleware.ts
|
||||||
|
export function timingMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const start = performance.now();
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = performance.now() - start;
|
||||||
|
if (duration > 1000) {
|
||||||
|
consola.warn(`Slow request: ${req.method} ${req.path} took ${duration}ms`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Performance Testing Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run baseline benchmark
|
||||||
|
bun run benchmark > baseline.txt
|
||||||
|
|
||||||
|
# After implementing changes, compare
|
||||||
|
bun run benchmark > optimized.txt
|
||||||
|
diff baseline.txt optimized.txt
|
||||||
|
|
||||||
|
# Load testing with artillery (if added)
|
||||||
|
artillery quick --count 100 --num 10 http://localhost:4010/api/runners
|
||||||
|
|
||||||
|
# Database query profiling (PostgreSQL)
|
||||||
|
EXPLAIN ANALYZE SELECT * FROM scan WHERE runner_id = 1;
|
||||||
|
|
||||||
|
# Check database indexes
|
||||||
|
SELECT * FROM pg_indexes WHERE tablename = 'scan';
|
||||||
|
|
||||||
|
# Monitor NATS cache hit rate
|
||||||
|
# (Add custom logging in NATS KV functions)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 Key Principles
|
||||||
|
|
||||||
|
1. **Measure first**: Always benchmark before and after changes
|
||||||
|
2. **Start with indexes**: Biggest impact, lowest risk
|
||||||
|
3. **Cache strategically**: Stats endpoints benefit most
|
||||||
|
4. **Lazy load by default**: Only eager load when absolutely needed
|
||||||
|
5. **Monitor in production**: Use APM tools (New Relic, DataDog, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Additional Resources
|
||||||
|
|
||||||
|
- [TypeORM Performance Tips](https://typeorm.io/performance)
|
||||||
|
- [PostgreSQL Index Best Practices](https://www.postgresql.org/docs/current/indexes.html)
|
||||||
|
- [Bun Performance Benchmarks](https://bun.sh/docs/runtime/performance)
|
||||||
|
- [NATS JetStream KV Guide](https://docs.nats.io/nats-concepts/jetstream/key-value-store)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2026-02-20
|
||||||
|
**Status**: Ready for review and prioritization
|
||||||
137
README.md
137
README.md
@@ -2,72 +2,119 @@
|
|||||||
|
|
||||||
Backend Server
|
Backend Server
|
||||||
|
|
||||||
## Quickstart 🐳
|
## Prerequisites
|
||||||
> Use this to run the backend with a postgresql db in docker
|
|
||||||
|
|
||||||
1. Clone the repo or copy the docker-compose
|
This project uses **Bun** as the runtime and package manager. Install Bun first:
|
||||||
2. Run in toe folder that contains the docker-compose file: `docker-compose up -d`
|
|
||||||
|
```bash
|
||||||
|
# macOS/Linux
|
||||||
|
curl -fsSL https://bun.sh/install | bash
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or visit [bun.sh](https://bun.sh) for other installation methods.
|
||||||
|
|
||||||
|
## Quickstart 🐳
|
||||||
|
> Use this to run the backend with a PostgreSQL db in Docker
|
||||||
|
|
||||||
|
1. Clone the repo or copy the docker-compose
|
||||||
|
2. Run in the folder that contains the docker-compose file: `docker-compose up -d`
|
||||||
3. Visit http://127.0.0.1:4010/api/docs to check if the server is running
|
3. Visit http://127.0.0.1:4010/api/docs to check if the server is running
|
||||||
4. You can now use the default admin user (`demo:demo`)
|
4. You can now use the default admin user (`demo:demo`)
|
||||||
|
|
||||||
## Dev Setup 🛠
|
## Dev Setup 🛠
|
||||||
> Local dev setup utilizing sqlite3 as the database.
|
> Local dev setup utilizing SQLite3 as the database and NATS for caching.
|
||||||
|
|
||||||
1. Rename the .env.example file to .env (you can adjust app port and other settings, if needed)
|
1. Rename the `.env.example` file to `.env` (you can adjust app port and other settings if needed)
|
||||||
2. Install Dependencies
|
2. Start NATS (required for KV cache):
|
||||||
```bash
|
```bash
|
||||||
yarn
|
docker-compose up -d nats
|
||||||
```
|
```
|
||||||
3. Start the server
|
3. Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
yarn dev
|
bun install
|
||||||
```
|
```
|
||||||
|
4. Start the server:
|
||||||
|
```bash
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Bun cannot run TypeScript source files directly due to circular TypeORM dependencies. The `dev` script automatically builds and runs the compiled output. For hot-reload during development, you may need to rebuild manually after code changes.
|
||||||
|
|
||||||
### Run Tests
|
### Run Tests
|
||||||
```bash
|
```bash
|
||||||
# Run tests once (server has to run)
|
# Run tests once (server has to be running)
|
||||||
yarn test
|
bun test
|
||||||
|
|
||||||
# Run test in watch mode (reruns on change)
|
# Run test in watch mode (reruns on change)
|
||||||
yarn test:watch
|
bun run test:watch
|
||||||
|
|
||||||
# Run test in ci mode (automaticly starts the dev server)
|
# Run test in CI mode (automatically starts the dev server)
|
||||||
yarn test:ci
|
bun run test:ci
|
||||||
```
|
```
|
||||||
|
|
||||||
### Use your own mail templates
|
### Run Benchmarks
|
||||||
> You use your own mail templates by replacing the default ones we provided (either in-code or by mounting them into the /app/static/mail_templates folder).
|
```bash
|
||||||
|
# Start the server first
|
||||||
|
bun run dev
|
||||||
|
|
||||||
The mail templates always come in a .html and a .txt variant to provide compatability with legacy mail clients.
|
# In another terminal:
|
||||||
Currently the following templates exist:
|
bun run benchmark
|
||||||
* pw-reset.(html/txt)
|
```
|
||||||
|
|
||||||
### Generate Docs
|
### Generate Docs
|
||||||
```bash
|
```bash
|
||||||
yarn docs
|
bun run docs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other Commands
|
||||||
|
```bash
|
||||||
|
# Build for production
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Start production server
|
||||||
|
bun start
|
||||||
|
|
||||||
|
# Seed database with test data
|
||||||
|
bun run seed
|
||||||
|
|
||||||
|
# Export OpenAPI spec
|
||||||
|
bun run openapi:export
|
||||||
|
|
||||||
|
# Generate license report
|
||||||
|
bun run licenses:export
|
||||||
|
|
||||||
|
# Generate changelog
|
||||||
|
bun run changelog:export
|
||||||
```
|
```
|
||||||
|
|
||||||
## ENV Vars
|
## ENV Vars
|
||||||
> You can provide them via .env file or docker env vars.
|
> You can provide them via .env file or docker env vars.
|
||||||
> You can use the `test:ci:generate_env` package script to generate a example env (uses bs data as test server and ignores the errors).
|
> You can use the `test:ci:generate_env` package script to generate an example env (uses placeholder data for test server and ignores the errors).
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
| ---------------------- | ------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------- |
|
| ------------------------- | ------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||||
| APP_PORT | Number | 4010 | The port the backend server listens on. Is optional. |
|
| APP_PORT | Number | 4010 | The port the backend server listens on. Is optional. |
|
||||||
| DB_TYPE | String | N/A | The type of the db u want to use. It has to be supported by typeorm. Possible: `sqlite`, `mysql`, `postgresql` |
|
| DB_TYPE | String | N/A | The type of the db you want to use. Supported by TypeORM. Possible: `sqlite`, `mysql`, `postgresql` |
|
||||||
| DB_HOST | String | N/A | The db's host's ip-address/fqdn or file path for sqlite |
|
| DB_HOST | String | N/A | The db's host IP address/FQDN or file path for sqlite |
|
||||||
| DB_PORT | String | N/A | The db's port |
|
| DB_PORT | String | N/A | The db's port |
|
||||||
| DB_USER | String | N/A | The user for accessing the db |
|
| DB_USER | String | N/A | The user for accessing the db |
|
||||||
| DB_PASSWORD | String | N/A | The user's password for accessing the db |
|
| DB_PASSWORD | String | N/A | The user's password for accessing the db |
|
||||||
| DB_NAME | String | N/A | The db's name |
|
| DB_NAME | String | N/A | The db's name |
|
||||||
| NODE_ENV | String | dev | The apps env - influences debug info. Also when the env is set to "test", mailing errors get ignored. |
|
| NODE_ENV | String | dev | The app's env - influences debug info. When set to "test", mailing errors get ignored. |
|
||||||
| POSTALCODE_COUNTRYCODE | String/CountryCode | N/A | The countrycode used to validate address's postal codes |
|
| POSTALCODE_COUNTRYCODE | String/CountryCode | N/A | The country code used to validate address postal codes |
|
||||||
| PHONE_COUNTRYCODE | String/CountryCode | null (international) | The countrycode used to validate phone numers |
|
| PHONE_COUNTRYCODE | String/CountryCode | null (international) | The country code used to validate phone numbers |
|
||||||
| SEED_TEST_DATA | Boolean | False | If you want the app to seed some example data set this to true |
|
| SEED_TEST_DATA | Boolean | false | If you want the app to seed example data, set this to true |
|
||||||
| MAILER_URL | String(Url) | N/A | The mailer's base url (no trailing slash) |
|
| STATION_TOKEN_SECRET | String | N/A | Secret key for HMAC-SHA256 station token generation (min 32 chars). **Required.** |
|
||||||
| MAILER_KEY | String | N/A | The mailer's api key. |
|
| NATS_URL | String(URL) | nats://localhost:4222 | NATS server connection URL for KV cache |
|
||||||
| IMPRINT_URL | String(Url) | /imprint | The link to a imprint page for the system (Defaults to the frontend's imprint) |
|
| NATS_PREWARM | Boolean | false | Preload all runner state into NATS cache at startup (eliminates DB reads on first scan) |
|
||||||
| PRIVACY_URL | String(Url) | /privacy | The link to a privacy page for the system (Defaults to the frontend's privacy page) |
|
| MAILER_URL | String(URL) | N/A | The mailer's base URL (no trailing slash) |
|
||||||
|
| MAILER_KEY | String | N/A | The mailer's API key |
|
||||||
|
| SELFSERVICE_URL | String(URL) | N/A | The link to selfservice (no trailing slash) |
|
||||||
|
| IMPRINT_URL | String(URL) | /imprint | The link to an imprint page for the system (defaults to the frontend's imprint) |
|
||||||
|
| PRIVACY_URL | String(URL) | /privacy | The link to a privacy page for the system (defaults to the frontend's privacy page) |
|
||||||
|
|
||||||
|
|
||||||
## Recommended Editor
|
## Recommended Editor
|
||||||
@@ -90,5 +137,5 @@ yarn docs
|
|||||||
* The dev tag of the docker image get's build from this
|
* The dev tag of the docker image get's build from this
|
||||||
* Only push minor changes to this branch!
|
* Only push minor changes to this branch!
|
||||||
* To merge a feature branch into this please create a pull request
|
* To merge a feature branch into this please create a pull request
|
||||||
* feature/xyz: Feature branches - nameing scheme: `feature/issueid-title`
|
* feature/xyz: Feature branches - naming scheme: `feature/issueid-title`
|
||||||
* bugfix/xyz: Branches for bugfixes - nameing scheme:`bugfix/issueid-title`
|
* bugfix/xyz: Branches for bugfixes - naming scheme:`bugfix/issueid-title`
|
||||||
6
bunfig.toml
Normal file
6
bunfig.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Bun configuration
|
||||||
|
# See: https://bun.sh/docs/runtime/bunfig
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
# Enable Node.js compatibility mode
|
||||||
|
bun = true
|
||||||
@@ -1,22 +1,30 @@
|
|||||||
version: "3"
|
|
||||||
services:
|
services:
|
||||||
backend_server:
|
nats:
|
||||||
build: .
|
image: mirror.gcr.io/library/nats:alpine
|
||||||
|
command: ["--jetstream", "--store_dir", "/data"]
|
||||||
ports:
|
ports:
|
||||||
- 4010:4010
|
- "4222:4222"
|
||||||
environment:
|
- "8222:8222"
|
||||||
APP_PORT: 4010
|
volumes:
|
||||||
DB_TYPE: sqlite
|
- nats_data:/data
|
||||||
DB_HOST: bla
|
|
||||||
DB_PORT: bla
|
# backend_server:
|
||||||
DB_USER: bla
|
# build: .
|
||||||
DB_PASSWORD: bla
|
# ports:
|
||||||
DB_NAME: ./db.sqlite
|
# - 4010:4010
|
||||||
NODE_ENV: production
|
# environment:
|
||||||
POSTALCODE_COUNTRYCODE: DE
|
# APP_PORT: 4010
|
||||||
SEED_TEST_DATA: "false"
|
# DB_TYPE: sqlite
|
||||||
MAILER_URL: https://dev.lauf-fuer-kaya.de/mailer
|
# DB_HOST: bla
|
||||||
MAILER_KEY: asdasd
|
# DB_PORT: bla
|
||||||
|
# DB_USER: bla
|
||||||
|
# DB_PASSWORD: bla
|
||||||
|
# DB_NAME: ./db.sqlite
|
||||||
|
# NODE_ENV: production
|
||||||
|
# POSTALCODE_COUNTRYCODE: DE
|
||||||
|
# SEED_TEST_DATA: "true"
|
||||||
|
# MAILER_URL: https://dev.lauf-fuer-kaya.de/mailer
|
||||||
|
# MAILER_KEY: asdasd
|
||||||
# APP_PORT: 4010
|
# APP_PORT: 4010
|
||||||
# DB_TYPE: postgres
|
# DB_TYPE: postgres
|
||||||
# DB_HOST: backend_db
|
# DB_HOST: backend_db
|
||||||
@@ -33,3 +41,6 @@ services:
|
|||||||
# POSTGRES_USER: lfk
|
# POSTGRES_USER: lfk
|
||||||
# ports:
|
# ports:
|
||||||
# - 5432:5432
|
# - 5432:5432
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nats_data:
|
||||||
|
|||||||
432
licenses.md
432
licenses.md
@@ -1,3 +1,32 @@
|
|||||||
|
# @node-rs/argon2
|
||||||
|
**Author**: undefined
|
||||||
|
**Repo**: [object Object]
|
||||||
|
**License**: MIT
|
||||||
|
**Description**: RustCrypto: Argon2 binding for Node.js
|
||||||
|
## License Text
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020-present LongYinan
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
# @odit/class-validator-jsonschema
|
# @odit/class-validator-jsonschema
|
||||||
**Author**: Aleksi Pekkala <aleksipekkala@gmail.com>
|
**Author**: Aleksi Pekkala <aleksipekkala@gmail.com>
|
||||||
**Repo**: git@github.com:epiphone/class-validator-jsonschema.git
|
**Repo**: git@github.com:epiphone/class-validator-jsonschema.git
|
||||||
@@ -27,36 +56,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
# argon2
|
|
||||||
**Author**: Ranieri Althoff <ranisalt+argon2@gmail.com>
|
|
||||||
**Repo**: [object Object]
|
|
||||||
**License**: MIT
|
|
||||||
**Description**: An Argon2 library for Node
|
|
||||||
## License Text
|
|
||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2015 Ranieri Althoff
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# axios
|
# axios
|
||||||
**Author**: Matt Zabriskie
|
**Author**: Matt Zabriskie
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
@@ -380,6 +379,77 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
|||||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
|
# glob
|
||||||
|
**Author**: Isaac Z. Schlueter <i@izs.me> (https://blog.izs.me/)
|
||||||
|
**Repo**: [object Object]
|
||||||
|
**License**: BlueOak-1.0.0
|
||||||
|
**Description**: the most correct and second fastest glob implementation in JavaScript
|
||||||
|
## License Text
|
||||||
|
All packages under `src/` are licensed according to the terms in
|
||||||
|
their respective `LICENSE` or `LICENSE.md` files.
|
||||||
|
|
||||||
|
The remainder of this project is licensed under the Blue Oak
|
||||||
|
Model License, as follows:
|
||||||
|
|
||||||
|
-----
|
||||||
|
|
||||||
|
# Blue Oak Model License
|
||||||
|
|
||||||
|
Version 1.0.0
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This license gives everyone as much permission to work with
|
||||||
|
this software as possible, while protecting contributors
|
||||||
|
from liability.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
In order to receive this license, you must agree to its
|
||||||
|
rules. The rules of this license are both obligations
|
||||||
|
under that agreement and conditions to your license.
|
||||||
|
You must not do anything with this software that triggers
|
||||||
|
a rule that you cannot or will not follow.
|
||||||
|
|
||||||
|
## Copyright
|
||||||
|
|
||||||
|
Each contributor licenses you to do everything with this
|
||||||
|
software that would otherwise infringe that contributor's
|
||||||
|
copyright in it.
|
||||||
|
|
||||||
|
## Notices
|
||||||
|
|
||||||
|
You must ensure that everyone who gets a copy of
|
||||||
|
any part of this software from you, with or without
|
||||||
|
changes, also gets the text of this license or a link to
|
||||||
|
<https://blueoakcouncil.org/license/1.0.0>.
|
||||||
|
|
||||||
|
## Excuse
|
||||||
|
|
||||||
|
If anyone notifies you in writing that you have not
|
||||||
|
complied with [Notices](#notices), you can keep your
|
||||||
|
license by taking all practical steps to comply within 30
|
||||||
|
days after the notice. If you do not do so, your license
|
||||||
|
ends immediately.
|
||||||
|
|
||||||
|
## Patent
|
||||||
|
|
||||||
|
Each contributor licenses you to do everything with this
|
||||||
|
software that would otherwise infringe any patent claims
|
||||||
|
they can license or become able to license.
|
||||||
|
|
||||||
|
## Reliability
|
||||||
|
|
||||||
|
No contributor can revoke this license.
|
||||||
|
|
||||||
|
## No Liability
|
||||||
|
|
||||||
|
***As far as the law allows, this software comes as is,
|
||||||
|
without any warranty or condition, and no contributor
|
||||||
|
will be liable to anyone for any damages related to this
|
||||||
|
software or this license, under any kind of legal claim.***
|
||||||
|
|
||||||
|
|
||||||
# jsonwebtoken
|
# jsonwebtoken
|
||||||
**Author**: auth0
|
**Author**: auth0
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
@@ -465,6 +535,215 @@ Copyright (c) 2012 Felix Geisendörfer (felix@debuggable.com) and contributors
|
|||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
|
# nats
|
||||||
|
**Author**: [object Object]
|
||||||
|
**Repo**: [object Object]
|
||||||
|
**License**: Apache-2.0
|
||||||
|
**Description**: Node.js client for NATS, a lightweight, high-performance cloud native messaging system
|
||||||
|
## License Text
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2013-2018 The NATS Authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
# pg
|
# pg
|
||||||
**Author**: Brian Carlson <brian.m.carlson@gmail.com>
|
**Author**: Brian Carlson <brian.m.carlson@gmail.com>
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
@@ -873,7 +1152,7 @@ OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||||||
**Author**: undefined
|
**Author**: undefined
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
**License**: MIT
|
**License**: MIT
|
||||||
**Description**: TypeScript definitions for Express
|
**Description**: TypeScript definitions for express
|
||||||
## License Text
|
## License Text
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
@@ -902,7 +1181,7 @@ OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||||||
**Author**: undefined
|
**Author**: undefined
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
**License**: MIT
|
**License**: MIT
|
||||||
**Description**: TypeScript definitions for Jest
|
**Description**: TypeScript definitions for jest
|
||||||
## License Text
|
## License Text
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
@@ -960,36 +1239,7 @@ OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||||||
**Author**: undefined
|
**Author**: undefined
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
**License**: MIT
|
**License**: MIT
|
||||||
**Description**: TypeScript definitions for Node.js
|
**Description**: TypeScript definitions for node
|
||||||
## License Text
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) Microsoft Corporation.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE
|
|
||||||
|
|
||||||
|
|
||||||
# @types/uuid
|
|
||||||
**Author**: undefined
|
|
||||||
**Repo**: [object Object]
|
|
||||||
**License**: MIT
|
|
||||||
**Description**: TypeScript definitions for uuid
|
|
||||||
## License Text
|
## License Text
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
@@ -1101,35 +1351,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
# nodemon
|
|
||||||
**Author**: [object Object]
|
|
||||||
**Repo**: [object Object]
|
|
||||||
**License**: MIT
|
|
||||||
**Description**: Simple monitor script for use during development of a node.js app.
|
|
||||||
## License Text
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2010 - present, Remy Sharp, https://remysharp.com <remy@remysharp.com>
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
||||||
|
|
||||||
# release-it
|
# release-it
|
||||||
**Author**: [object Object]
|
**Author**: [object Object]
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
@@ -1219,35 +1440,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
# ts-node
|
|
||||||
**Author**: [object Object]
|
|
||||||
**Repo**: [object Object]
|
|
||||||
**License**: MIT
|
|
||||||
**Description**: TypeScript execution environment and REPL for node.js, with source map support
|
|
||||||
## License Text
|
|
||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
|
|
||||||
|
|
||||||
# typedoc
|
# typedoc
|
||||||
**Author**: undefined
|
**Author**: undefined
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
|
|||||||
10
ormconfig.js
10
ormconfig.js
@@ -1,7 +1,6 @@
|
|||||||
const dotenv = require('dotenv');
|
const dotenv = require('dotenv');
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
//
|
|
||||||
const SOURCE_PATH = process.env.NODE_ENV === 'production' ? 'dist' : 'src';
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
type: process.env.DB_TYPE,
|
type: process.env.DB_TYPE,
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
@@ -9,8 +8,7 @@ module.exports = {
|
|||||||
username: process.env.DB_USER,
|
username: process.env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
// entities: ["src/**/entities/*.ts"],
|
// Run directly from TypeScript source (Bun workflow)
|
||||||
entities: [ `${SOURCE_PATH}/**/entities/*{.ts,.js}` ],
|
entities: ["src/models/entities/**/*.ts"],
|
||||||
seeds: [ `${SOURCE_PATH}/**/seeds/*{.ts,.js}` ]
|
seeds: ["src/seeds/**/*.ts"]
|
||||||
// seeds: ['src/seeds/*.ts'],
|
|
||||||
};
|
};
|
||||||
|
|||||||
58
package.json
58
package.json
@@ -1,11 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@odit/lfk-backend",
|
"name": "@odit/lfk-backend",
|
||||||
"version": "0.15.4",
|
"version": "1.7.2",
|
||||||
"main": "src/app.ts",
|
"main": "src/app.ts",
|
||||||
"repository": "https://git.odit.services/lfk/backend",
|
"repository": "https://git.odit.services/lfk/backend",
|
||||||
"engines": {
|
|
||||||
"pnpm": "8"
|
|
||||||
},
|
|
||||||
"author": {
|
"author": {
|
||||||
"name": "ODIT.Services",
|
"name": "ODIT.Services",
|
||||||
"email": "info@odit.services",
|
"email": "info@odit.services",
|
||||||
@@ -25,13 +22,13 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-NC-SA-4.0",
|
"license": "CC-BY-NC-SA-4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@odit/class-validator-jsonschema": "2.1.1",
|
"@odit/class-validator-jsonschema": "2.1.1",
|
||||||
"argon2": "0.27.1",
|
|
||||||
"axios": "0.21.1",
|
"axios": "0.21.1",
|
||||||
"body-parser": "1.19.0",
|
"body-parser": "1.19.0",
|
||||||
"check-password-strength": "2.0.2",
|
"check-password-strength": "2.0.2",
|
||||||
"class-transformer": "0.3.1",
|
"class-transformer": "0.3.1",
|
||||||
"class-validator": "0.13.1",
|
"class-validator": "0.13.0",
|
||||||
"consola": "2.15.0",
|
"consola": "2.15.0",
|
||||||
"cookie": "0.4.1",
|
"cookie": "0.4.1",
|
||||||
"cookie-parser": "1.4.5",
|
"cookie-parser": "1.4.5",
|
||||||
@@ -39,14 +36,16 @@
|
|||||||
"csvtojson": "2.0.10",
|
"csvtojson": "2.0.10",
|
||||||
"dotenv": "8.2.0",
|
"dotenv": "8.2.0",
|
||||||
"express": "4.17.1",
|
"express": "4.17.1",
|
||||||
|
"glob": "^13.0.6",
|
||||||
"jsonwebtoken": "8.5.1",
|
"jsonwebtoken": "8.5.1",
|
||||||
"libphonenumber-js": "1.9.9",
|
"libphonenumber-js": "1.9.9",
|
||||||
"mysql": "2.18.1",
|
"mysql": "2.18.1",
|
||||||
|
"nats": "^2.29.3",
|
||||||
"pg": "8.5.1",
|
"pg": "8.5.1",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"routing-controllers": "0.9.0-alpha.6",
|
"routing-controllers": "0.9.0-alpha.6",
|
||||||
"routing-controllers-openapi": "2.2.0",
|
"routing-controllers-openapi": "2.2.0",
|
||||||
"sqlite3": "5.0.0",
|
"sqlite3": "5.1.7",
|
||||||
"typeorm": "0.2.30",
|
"typeorm": "0.2.30",
|
||||||
"typeorm-routing-controllers-extensions": "0.2.0",
|
"typeorm-routing-controllers-extensions": "0.2.0",
|
||||||
"typeorm-seeding": "1.6.1",
|
"typeorm-seeding": "1.6.1",
|
||||||
@@ -54,38 +53,37 @@
|
|||||||
"validator": "13.5.2"
|
"validator": "13.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^7.6.0",
|
"@faker-js/faker": "7.6.0",
|
||||||
"@odit/license-exporter": "0.0.9",
|
"@odit/license-exporter": "0.0.9",
|
||||||
"@types/cors": "2.8.9",
|
"@types/cors": "2.8.19",
|
||||||
"@types/csvtojson": "1.1.5",
|
"@types/csvtojson": "1.1.5",
|
||||||
"@types/express": "4.17.11",
|
"@types/express": "5.0.6",
|
||||||
"@types/jest": "26.0.20",
|
"@types/jest": "30.0.0",
|
||||||
"@types/jsonwebtoken": "8.5.0",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "14.14.22",
|
"@types/node": "25.3.0",
|
||||||
"@types/uuid": "8.3.0",
|
"auto-changelog": "2.4.0",
|
||||||
"auto-changelog": "^2.4.0",
|
|
||||||
"cp-cli": "2.0.0",
|
"cp-cli": "2.0.0",
|
||||||
"jest": "26.6.3",
|
"jest": "26.6.3",
|
||||||
"nodemon": "2.0.7",
|
|
||||||
"release-it": "14.2.2",
|
"release-it": "14.2.2",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
"start-server-and-test": "1.11.7",
|
"start-server-and-test": "1.11.7",
|
||||||
"ts-jest": "26.5.0",
|
"ts-jest": "26.5.0",
|
||||||
"ts-node": "9.1.1",
|
|
||||||
"typedoc": "0.20.19",
|
"typedoc": "0.20.19",
|
||||||
"typescript": "4.1.3"
|
"typescript": "5.9.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon src/app.ts",
|
"dev": "bun --watch src/app.ts",
|
||||||
|
"start": "bun src/app.ts",
|
||||||
"build": "rimraf ./dist && tsc && cp-cli ./src/static ./dist/static",
|
"build": "rimraf ./dist && tsc && cp-cli ./src/static ./dist/static",
|
||||||
"docs": "typedoc --out docs src",
|
"docs": "typedoc --out docs src",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watchAll",
|
"test:watch": "jest --watchAll",
|
||||||
"test:ci:generate_env": "ts-node scripts/create_testenv.ts",
|
"test:ci:generate_env": "bun scripts/create_testenv.ts",
|
||||||
"test:ci:run": "start-server-and-test dev http://localhost:4010/api/docs/openapi.json test",
|
"test:ci:run": "start-server-and-test dev http://localhost:4010/api/docs/openapi.json test",
|
||||||
"test:ci": "npm run test:ci:generate_env && npm run test:ci:run",
|
"test:ci": "bun run test:ci:generate_env && bun run test:ci:run",
|
||||||
"seed": "ts-node ./node_modules/typeorm/cli.js schema:sync && ts-node ./node_modules/typeorm-seeding/dist/cli.js seed",
|
"benchmark": "bun scripts/benchmark_scan_intake.ts",
|
||||||
"openapi:export": "ts-node scripts/openapi_export.ts",
|
"seed": "bun ./node_modules/typeorm/cli.js schema:sync && bun ./node_modules/typeorm-seeding/dist/cli.js seed",
|
||||||
|
"openapi:export": "bun scripts/openapi_export.ts",
|
||||||
"licenses:export": "license-exporter --markdown",
|
"licenses:export": "license-exporter --markdown",
|
||||||
"changelog:export": "auto-changelog --commit-limit false -p -u --hide-credit",
|
"changelog:export": "auto-changelog --commit-limit false -p -u --hide-credit",
|
||||||
"release": "release-it --only-version"
|
"release": "release-it --only-version"
|
||||||
@@ -94,24 +92,18 @@
|
|||||||
"git": {
|
"git": {
|
||||||
"commit": true,
|
"commit": true,
|
||||||
"requireCleanWorkingDir": false,
|
"requireCleanWorkingDir": false,
|
||||||
"commitMessage": "🚀Bumped version to v${version}",
|
"commitMessage": "chore(release): ${version}",
|
||||||
"requireBranch": "dev",
|
"requireBranch": "dev",
|
||||||
"push": true,
|
"push": true,
|
||||||
"tag": true,
|
"tag": true,
|
||||||
"tagName": "v${version}",
|
"tagName": "${version}",
|
||||||
"tagAnnotation": "v${version}"
|
"tagAnnotation": "${version}"
|
||||||
},
|
},
|
||||||
"npm": {
|
"npm": {
|
||||||
"publish": false
|
"publish": false
|
||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"after:bump": "npm run changelog:export && npm run licenses:export && git add CHANGELOG.md && git add licenses.md"
|
"after:bump": "bun run changelog:export && bun run licenses:export && git add CHANGELOG.md && git add licenses.md"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"nodemonConfig": {
|
|
||||||
"ignore": [
|
|
||||||
"src/tests/*",
|
|
||||||
"docs/*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9427
pnpm-lock.yaml
generated
Normal file
9427
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- sqlite3
|
||||||
367
scripts/benchmark_scan_intake.ts
Normal file
367
scripts/benchmark_scan_intake.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
/**
|
||||||
|
* Scan Intake Benchmark Script
|
||||||
|
*
|
||||||
|
* Measures TrackScan creation performance before and after each optimisation phase.
|
||||||
|
* Run against a live dev server: bun run dev
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run benchmark
|
||||||
|
* bun scripts/benchmark_scan_intake.ts --base http://localhost:4010
|
||||||
|
*
|
||||||
|
* What it measures:
|
||||||
|
* 1. Single sequential scans — baseline latency per request (p50/p95/p99/max)
|
||||||
|
* 2. Parallel scans (10 stations) — simulates 10 concurrent stations each submitting
|
||||||
|
* one scan at a time at the expected event rate
|
||||||
|
* (~1 scan/3s per station = ~3.3 scans/s total)
|
||||||
|
*
|
||||||
|
* The script self-provisions all required data (org, runners, cards, track, stations)
|
||||||
|
* and cleans up after itself. It authenticates via the station token, matching the
|
||||||
|
* real production auth path exactly.
|
||||||
|
*
|
||||||
|
* Output is printed to stdout in a copy-paste-friendly table format so results can
|
||||||
|
* be compared across phases.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const BASE = (() => {
|
||||||
|
const idx = process.argv.indexOf('--base');
|
||||||
|
return idx !== -1 ? process.argv[idx + 1] : 'http://localhost:4010';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const API = `${BASE}/api`;
|
||||||
|
|
||||||
|
// Number of simulated scan stations
|
||||||
|
const STATION_COUNT = 10;
|
||||||
|
|
||||||
|
// Sequential benchmark: total number of scans to send, one at a time
|
||||||
|
const SEQUENTIAL_SCAN_COUNT = 50;
|
||||||
|
|
||||||
|
// Parallel benchmark: number of rounds. Each round fires STATION_COUNT scans concurrently.
|
||||||
|
// 20 rounds × 10 stations = 200 total scans, matching the expected event throughput pattern.
|
||||||
|
const PARALLEL_ROUNDS = 20;
|
||||||
|
|
||||||
|
// Minimum lap time on the test track (seconds). Set low so most scans are valid.
|
||||||
|
// The benchmark measures submission speed, not business logic.
|
||||||
|
const TRACK_MINIMUM_LAP_TIME = 1;
|
||||||
|
|
||||||
|
// Track distance (metres)
|
||||||
|
const TRACK_DISTANCE = 400;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface StationHandle {
|
||||||
|
id: number;
|
||||||
|
key: string; // cleartext token, used as Bearer token
|
||||||
|
cardCode: number; // EAN-13 barcode of the card assigned to this station's runner
|
||||||
|
axiosInstance: AxiosInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Percentiles {
|
||||||
|
p50: number;
|
||||||
|
p95: number;
|
||||||
|
p99: number;
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
mean: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BenchmarkResult {
|
||||||
|
label: string;
|
||||||
|
totalScans: number;
|
||||||
|
totalTimeMs: number;
|
||||||
|
scansPerSecond: number;
|
||||||
|
latencies: Percentiles;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const adminClient = axios.create({
|
||||||
|
baseURL: API,
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function adminLogin(): Promise<string> {
|
||||||
|
const res = await adminClient.post('/auth/login', { username: 'demo', password: 'demo' });
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error(`Login failed: ${res.status} ${JSON.stringify(res.data)}`);
|
||||||
|
}
|
||||||
|
return res.data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authedClient(token: string): AxiosInstance {
|
||||||
|
return axios.create({
|
||||||
|
baseURL: API,
|
||||||
|
validateStatus: () => true,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data provisioning
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function provision(adminToken: string): Promise<{
|
||||||
|
stations: StationHandle[];
|
||||||
|
trackId: number;
|
||||||
|
orgId: number;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const client = authedClient(adminToken);
|
||||||
|
const createdIds: { type: string; id: number }[] = [];
|
||||||
|
|
||||||
|
const create = async (path: string, body: object): Promise<any> => {
|
||||||
|
const res = await client.post(path, body);
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error(`POST ${path} failed: ${res.status} ${JSON.stringify(res.data)}`);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdout.write('Provisioning test data... ');
|
||||||
|
|
||||||
|
// Organisation
|
||||||
|
const org = await create('/organizations', { name: 'benchmark-org' });
|
||||||
|
createdIds.push({ type: 'organizations', id: org.id });
|
||||||
|
|
||||||
|
// Track with a low minimumLapTime so re-scans within the benchmark are mostly valid
|
||||||
|
const track = await create('/tracks', {
|
||||||
|
name: 'benchmark-track',
|
||||||
|
distance: TRACK_DISTANCE,
|
||||||
|
minimumLapTime: TRACK_MINIMUM_LAP_TIME,
|
||||||
|
});
|
||||||
|
createdIds.push({ type: 'tracks', id: track.id });
|
||||||
|
|
||||||
|
// One runner + card + station per simulated scan station
|
||||||
|
const stations: StationHandle[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < STATION_COUNT; i++) {
|
||||||
|
const runner = await create('/runners', {
|
||||||
|
firstname: `Bench`,
|
||||||
|
lastname: `Runner${i}`,
|
||||||
|
group: org.id,
|
||||||
|
});
|
||||||
|
createdIds.push({ type: 'runners', id: runner.id });
|
||||||
|
|
||||||
|
const card = await create('/cards', { runner: runner.id });
|
||||||
|
createdIds.push({ type: 'cards', id: card.id });
|
||||||
|
|
||||||
|
const station = await create('/stations', {
|
||||||
|
track: track.id,
|
||||||
|
description: `bench-station-${i}`,
|
||||||
|
});
|
||||||
|
createdIds.push({ type: 'stations', id: station.id });
|
||||||
|
|
||||||
|
stations.push({
|
||||||
|
id: station.id,
|
||||||
|
key: station.key,
|
||||||
|
cardCode: card.id, // the test spec uses card.id directly as the barcode value
|
||||||
|
axiosInstance: axios.create({
|
||||||
|
baseURL: API,
|
||||||
|
validateStatus: () => true,
|
||||||
|
headers: { authorization: `Bearer ${station.key}` },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`done. (${STATION_COUNT} stations, ${STATION_COUNT} runners, ${STATION_COUNT} cards)`);
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
process.stdout.write('Cleaning up test data... ');
|
||||||
|
// Delete in reverse-dependency order
|
||||||
|
for (const item of [...createdIds].reverse()) {
|
||||||
|
await client.delete(`/${item.type}/${item.id}?force=true`);
|
||||||
|
}
|
||||||
|
console.log('done.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { stations, trackId: track.id, orgId: org.id, cleanup };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Single scan submission (returns latency in ms)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function submitScan(station: StationHandle): Promise<{ latencyMs: number; ok: boolean }> {
|
||||||
|
const start = performance.now();
|
||||||
|
const res = await station.axiosInstance.post('/scans/trackscans', {
|
||||||
|
card: station.cardCode,
|
||||||
|
station: station.id,
|
||||||
|
});
|
||||||
|
const latencyMs = performance.now() - start;
|
||||||
|
const ok = res.status === 200;
|
||||||
|
return { latencyMs, ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Statistics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function percentiles(latencies: number[]): Percentiles {
|
||||||
|
const sorted = [...latencies].sort((a, b) => a - b);
|
||||||
|
const at = (pct: number) => sorted[Math.floor((pct / 100) * sorted.length)] ?? sorted[sorted.length - 1];
|
||||||
|
const mean = sorted.reduce((s, v) => s + v, 0) / sorted.length;
|
||||||
|
return {
|
||||||
|
p50: Math.round(at(50)),
|
||||||
|
p95: Math.round(at(95)),
|
||||||
|
p99: Math.round(at(99)),
|
||||||
|
max: Math.round(sorted[sorted.length - 1]),
|
||||||
|
min: Math.round(sorted[0]),
|
||||||
|
mean: Math.round(mean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark 1 — Sequential (single station, one scan at a time)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function benchmarkSequential(station: StationHandle): Promise<BenchmarkResult> {
|
||||||
|
const latencies: number[] = [];
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
process.stdout.write(` Running ${SEQUENTIAL_SCAN_COUNT} sequential scans`);
|
||||||
|
const wallStart = performance.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < SEQUENTIAL_SCAN_COUNT; i++) {
|
||||||
|
const { latencyMs, ok } = await submitScan(station);
|
||||||
|
latencies.push(latencyMs);
|
||||||
|
if (!ok) errors++;
|
||||||
|
if ((i + 1) % 10 === 0) process.stdout.write('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTimeMs = performance.now() - wallStart;
|
||||||
|
console.log(' done.');
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: 'Sequential (1 station)',
|
||||||
|
totalScans: SEQUENTIAL_SCAN_COUNT,
|
||||||
|
totalTimeMs,
|
||||||
|
scansPerSecond: (SEQUENTIAL_SCAN_COUNT / totalTimeMs) * 1000,
|
||||||
|
latencies: percentiles(latencies),
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark 2 — Parallel (10 stations, concurrent rounds)
|
||||||
|
//
|
||||||
|
// Models the real event scenario: every ~3 seconds each station submits one scan.
|
||||||
|
// We don't actually sleep between rounds — we fire each round as fast as the
|
||||||
|
// previous one completes, which gives us the worst-case sustained throughput
|
||||||
|
// (all stations submitting at maximum rate simultaneously).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function benchmarkParallel(stations: StationHandle[]): Promise<BenchmarkResult> {
|
||||||
|
const latencies: number[] = [];
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
process.stdout.write(` Running ${PARALLEL_ROUNDS} rounds × ${STATION_COUNT} concurrent stations`);
|
||||||
|
const wallStart = performance.now();
|
||||||
|
|
||||||
|
for (let round = 0; round < PARALLEL_ROUNDS; round++) {
|
||||||
|
const results = await Promise.all(stations.map(s => submitScan(s)));
|
||||||
|
for (const { latencyMs, ok } of results) {
|
||||||
|
latencies.push(latencyMs);
|
||||||
|
if (!ok) errors++;
|
||||||
|
}
|
||||||
|
if ((round + 1) % 4 === 0) process.stdout.write('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTimeMs = performance.now() - wallStart;
|
||||||
|
const totalScans = PARALLEL_ROUNDS * STATION_COUNT;
|
||||||
|
console.log(' done.');
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `Parallel (${STATION_COUNT} stations concurrent)`,
|
||||||
|
totalScans,
|
||||||
|
totalTimeMs,
|
||||||
|
scansPerSecond: (totalScans / totalTimeMs) * 1000,
|
||||||
|
latencies: percentiles(latencies),
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Output formatting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function printResult(result: BenchmarkResult) {
|
||||||
|
const { label, totalScans, totalTimeMs, scansPerSecond, latencies, errors } = result;
|
||||||
|
console.log(`\n ${label}`);
|
||||||
|
console.log(` ${'─'.repeat(52)}`);
|
||||||
|
console.log(` Total scans : ${totalScans}`);
|
||||||
|
console.log(` Total time : ${totalTimeMs.toFixed(0)} ms`);
|
||||||
|
console.log(` Throughput : ${scansPerSecond.toFixed(2)} scans/sec`);
|
||||||
|
console.log(` Latency min : ${latencies.min} ms`);
|
||||||
|
console.log(` Latency mean : ${latencies.mean} ms`);
|
||||||
|
console.log(` Latency p50 : ${latencies.p50} ms`);
|
||||||
|
console.log(` Latency p95 : ${latencies.p95} ms`);
|
||||||
|
console.log(` Latency p99 : ${latencies.p99} ms`);
|
||||||
|
console.log(` Latency max : ${latencies.max} ms`);
|
||||||
|
console.log(` Errors : ${errors}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummary(results: BenchmarkResult[]) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
console.log('\n');
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
console.log(` SCAN INTAKE BENCHMARK RESULTS — ${now}`);
|
||||||
|
console.log(` Server: ${BASE}`);
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
for (const r of results) {
|
||||||
|
printResult(r);
|
||||||
|
}
|
||||||
|
console.log('\n' + '═'.repeat(60));
|
||||||
|
console.log(' Copy the block above to compare across phases.');
|
||||||
|
console.log('═'.repeat(60) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\nScan Intake Benchmark — target: ${BASE}\n`);
|
||||||
|
|
||||||
|
let adminToken: string;
|
||||||
|
try {
|
||||||
|
adminToken = await adminLogin();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Could not authenticate. Is the server running at ${BASE}?\n`, err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stations, cleanup } = await provision(adminToken);
|
||||||
|
|
||||||
|
const results: BenchmarkResult[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('\nBenchmark 1 — Sequential');
|
||||||
|
results.push(await benchmarkSequential(stations[0]));
|
||||||
|
|
||||||
|
// Brief pause between benchmarks so the sequential scans don't skew
|
||||||
|
// the parallel benchmark's first-scan latency (minimumLapTime window)
|
||||||
|
await new Promise(r => setTimeout(r, (TRACK_MINIMUM_LAP_TIME + 1) * 1000));
|
||||||
|
|
||||||
|
console.log('\nBenchmark 2 — Parallel');
|
||||||
|
results.push(await benchmarkParallel(stations));
|
||||||
|
} finally {
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
printSummary(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Benchmark failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
46
src/app.ts
46
src/app.ts
@@ -7,7 +7,28 @@ import authchecker from "./middlewares/authchecker";
|
|||||||
import { ErrorHandler } from './middlewares/ErrorHandler';
|
import { ErrorHandler } from './middlewares/ErrorHandler';
|
||||||
import UserChecker from './middlewares/UserChecker';
|
import UserChecker from './middlewares/UserChecker';
|
||||||
|
|
||||||
const CONTROLLERS_FILE_EXTENSION = process.env.NODE_ENV === 'production' ? 'js' : 'ts';
|
// Import all controllers directly to avoid Bun + routing-controllers glob/require issues
|
||||||
|
import { AuthController } from './controllers/AuthController';
|
||||||
|
import { DonationController } from './controllers/DonationController';
|
||||||
|
import { DonorController } from './controllers/DonorController';
|
||||||
|
import { GroupContactController } from './controllers/GroupContactController';
|
||||||
|
import { ImportController } from './controllers/ImportController';
|
||||||
|
import { MeController } from './controllers/MeController';
|
||||||
|
import { PermissionController } from './controllers/PermissionController';
|
||||||
|
import { RunnerCardController } from './controllers/RunnerCardController';
|
||||||
|
import { RunnerController } from './controllers/RunnerController';
|
||||||
|
import { RunnerOrganizationController } from './controllers/RunnerOrganizationController';
|
||||||
|
import { RunnerSelfServiceController } from './controllers/RunnerSelfServiceController';
|
||||||
|
import { RunnerTeamController } from './controllers/RunnerTeamController';
|
||||||
|
import { ScanController } from './controllers/ScanController';
|
||||||
|
import { ScanStationController } from './controllers/ScanStationController';
|
||||||
|
import { StatsClientController } from './controllers/StatsClientController';
|
||||||
|
import { StatsController } from './controllers/StatsController';
|
||||||
|
import { StatusController } from './controllers/StatusController';
|
||||||
|
import { TrackController } from './controllers/TrackController';
|
||||||
|
import { UserController } from './controllers/UserController';
|
||||||
|
import { UserGroupController } from './controllers/UserGroupController';
|
||||||
|
|
||||||
const app = createExpressServer({
|
const app = createExpressServer({
|
||||||
authorizationChecker: authchecker,
|
authorizationChecker: authchecker,
|
||||||
currentUserChecker: UserChecker,
|
currentUserChecker: UserChecker,
|
||||||
@@ -15,7 +36,28 @@ const app = createExpressServer({
|
|||||||
development: config.development,
|
development: config.development,
|
||||||
cors: true,
|
cors: true,
|
||||||
routePrefix: "/api",
|
routePrefix: "/api",
|
||||||
controllers: [`${__dirname}/controllers/*.${CONTROLLERS_FILE_EXTENSION}`],
|
controllers: [
|
||||||
|
AuthController,
|
||||||
|
DonationController,
|
||||||
|
DonorController,
|
||||||
|
GroupContactController,
|
||||||
|
ImportController,
|
||||||
|
MeController,
|
||||||
|
PermissionController,
|
||||||
|
RunnerCardController,
|
||||||
|
RunnerController,
|
||||||
|
RunnerOrganizationController,
|
||||||
|
RunnerSelfServiceController,
|
||||||
|
RunnerTeamController,
|
||||||
|
ScanController,
|
||||||
|
ScanStationController,
|
||||||
|
StatsClientController,
|
||||||
|
StatsController,
|
||||||
|
StatusController,
|
||||||
|
TrackController,
|
||||||
|
UserController,
|
||||||
|
UserGroupController,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
@@ -1,33 +1,44 @@
|
|||||||
import { config as configDotenv } from 'dotenv';
|
import consola from 'consola';
|
||||||
import { CountryCode } from 'libphonenumber-js';
|
import { config as configDotenv } from 'dotenv';
|
||||||
import ValidatorJS from 'validator';
|
import { CountryCode } from 'libphonenumber-js';
|
||||||
|
import ValidatorJS from 'validator';
|
||||||
configDotenv();
|
|
||||||
export const config = {
|
configDotenv();
|
||||||
internal_port: parseInt(process.env.APP_PORT) || 4010,
|
export const config = {
|
||||||
development: process.env.NODE_ENV === "production",
|
internal_port: parseInt(process.env.APP_PORT) || 4010,
|
||||||
testing: process.env.NODE_ENV === "test",
|
development: process.env.NODE_ENV === "production",
|
||||||
jwt_secret: process.env.JWT_SECRET || "secretjwtsecret",
|
testing: process.env.NODE_ENV === "test",
|
||||||
phone_validation_countrycode: getPhoneCodeLocale(),
|
jwt_secret: process.env.JWT_SECRET || "secretjwtsecret",
|
||||||
postalcode_validation_countrycode: getPostalCodeLocale(),
|
station_token_secret: process.env.STATION_TOKEN_SECRET || "",
|
||||||
version: process.env.VERSION || require('../package.json').version,
|
nats_url: process.env.NATS_URL || "nats://localhost:4222",
|
||||||
seedTestData: getDataSeeding(),
|
nats_prewarm: process.env.NATS_PREWARM === "true",
|
||||||
app_url: process.env.APP_URL || "http://localhost:8080",
|
phone_validation_countrycode: getPhoneCodeLocale(),
|
||||||
privacy_url: process.env.PRIVACY_URL || "/privacy",
|
postalcode_validation_countrycode: getPostalCodeLocale(),
|
||||||
imprint_url: process.env.IMPRINT_URL || "/imprint",
|
version: process.env.VERSION || require('../package.json').version,
|
||||||
mailer_url: process.env.MAILER_URL || "",
|
seedTestData: getDataSeeding(),
|
||||||
mailer_key: process.env.MAILER_KEY || ""
|
app_url: process.env.APP_URL || "http://localhost:8080",
|
||||||
}
|
privacy_url: process.env.PRIVACY_URL || "/privacy",
|
||||||
|
imprint_url: process.env.IMPRINT_URL || "/imprint",
|
||||||
|
mailer_url: process.env.MAILER_URL || "",
|
||||||
|
mailer_key: process.env.MAILER_KEY || ""
|
||||||
|
}
|
||||||
let errors = 0
|
let errors = 0
|
||||||
if (typeof config.internal_port !== "number") {
|
if (typeof config.internal_port !== "number") {
|
||||||
|
consola.error("Error: APP_PORT is not a number")
|
||||||
errors++
|
errors++
|
||||||
}
|
}
|
||||||
if (typeof config.development !== "boolean") {
|
if (typeof config.development !== "boolean") {
|
||||||
|
consola.error("Error: NODE_ENV is not a boolean")
|
||||||
errors++
|
errors++
|
||||||
}
|
}
|
||||||
if (config.mailer_url == "" || config.mailer_key == "") {
|
if (config.mailer_url == "" || config.mailer_key == "") {
|
||||||
errors++;
|
consola.error("Error: invalid mailer config")
|
||||||
}
|
errors++;
|
||||||
|
}
|
||||||
|
if (config.station_token_secret.length < 32) {
|
||||||
|
consola.error("Error: STATION_TOKEN_SECRET must be set and at least 32 characters long")
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
function getPhoneCodeLocale(): CountryCode {
|
function getPhoneCodeLocale(): CountryCode {
|
||||||
return (process.env.PHONE_COUNTRYCODE as CountryCode);
|
return (process.env.PHONE_COUNTRYCODE as CountryCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { getConnectionManager, Repository } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { DonationIdsNotMatchingError, DonationNotFoundError } from '../errors/DonationErrors';
|
import { DonationIdsNotMatchingError, DonationNotFoundError } from '../errors/DonationErrors';
|
||||||
import { DonorNotFoundError } from '../errors/DonorErrors';
|
import { DonorNotFoundError } from '../errors/DonorErrors';
|
||||||
import { RunnerNotFoundError } from '../errors/RunnerErrors';
|
import { RunnerNotFoundError } from '../errors/RunnerErrors';
|
||||||
|
import { CreateAnonymousDonation } from '../models/actions/create/CreateAnonymousDonation';
|
||||||
import { CreateDistanceDonation } from '../models/actions/create/CreateDistanceDonation';
|
import { CreateDistanceDonation } from '../models/actions/create/CreateDistanceDonation';
|
||||||
import { CreateFixedDonation } from '../models/actions/create/CreateFixedDonation';
|
import { CreateFixedDonation } from '../models/actions/create/CreateFixedDonation';
|
||||||
import { UpdateDistanceDonation } from '../models/actions/update/UpdateDistanceDonation';
|
import { UpdateDistanceDonation } from '../models/actions/update/UpdateDistanceDonation';
|
||||||
@@ -11,6 +12,7 @@ import { UpdateFixedDonation } from '../models/actions/update/UpdateFixedDonatio
|
|||||||
import { DistanceDonation } from '../models/entities/DistanceDonation';
|
import { DistanceDonation } from '../models/entities/DistanceDonation';
|
||||||
import { Donation } from '../models/entities/Donation';
|
import { Donation } from '../models/entities/Donation';
|
||||||
import { FixedDonation } from '../models/entities/FixedDonation';
|
import { FixedDonation } from '../models/entities/FixedDonation';
|
||||||
|
import { ResponseAnonymousDonation } from '../models/responses/ResponseAnonymousDonation';
|
||||||
import { ResponseDistanceDonation } from '../models/responses/ResponseDistanceDonation';
|
import { ResponseDistanceDonation } from '../models/responses/ResponseDistanceDonation';
|
||||||
import { ResponseDonation } from '../models/responses/ResponseDonation';
|
import { ResponseDonation } from '../models/responses/ResponseDonation';
|
||||||
import { ResponseEmpty } from '../models/responses/ResponseEmpty';
|
import { ResponseEmpty } from '../models/responses/ResponseEmpty';
|
||||||
@@ -35,10 +37,18 @@ export class DonationController {
|
|||||||
@Authorized("DONATION:GET")
|
@Authorized("DONATION:GET")
|
||||||
@ResponseSchema(ResponseDonation, { isArray: true })
|
@ResponseSchema(ResponseDonation, { isArray: true })
|
||||||
@ResponseSchema(ResponseDistanceDonation, { isArray: true })
|
@ResponseSchema(ResponseDistanceDonation, { isArray: true })
|
||||||
|
@ResponseSchema(ResponseAnonymousDonation, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all donations (fixed or distance based) from all donors. <br> This includes the donations\'s runner\'s distance ran(if distance donation).' })
|
@OpenAPI({ description: 'Lists all donations (fixed or distance based) from all donors. <br> This includes the donations\'s runner\'s distance ran(if distance donation).' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responseDonations: ResponseDonation[] = new Array<ResponseDonation>();
|
let responseDonations: ResponseDonation[] = new Array<ResponseDonation>();
|
||||||
const donations = await this.donationRepository.find({ relations: ['runner', 'donor', 'runner.scans', 'runner.scans.track'] });
|
let donations: Array<Donation>;
|
||||||
|
|
||||||
|
if (page != undefined) {
|
||||||
|
donations = await this.donationRepository.find({ relations: ['runner', 'donor', 'runner.scans', 'runner.scans.track'], skip: page * page_size, take: page_size });
|
||||||
|
} else {
|
||||||
|
donations = await this.donationRepository.find({ relations: ['runner', 'donor', 'runner.scans', 'runner.scans.track'] });
|
||||||
|
}
|
||||||
|
|
||||||
donations.forEach(donation => {
|
donations.forEach(donation => {
|
||||||
responseDonations.push(donation.toResponse());
|
responseDonations.push(donation.toResponse());
|
||||||
});
|
});
|
||||||
@@ -49,6 +59,7 @@ export class DonationController {
|
|||||||
@Authorized("DONATION:GET")
|
@Authorized("DONATION:GET")
|
||||||
@ResponseSchema(ResponseDonation)
|
@ResponseSchema(ResponseDonation)
|
||||||
@ResponseSchema(ResponseDistanceDonation)
|
@ResponseSchema(ResponseDistanceDonation)
|
||||||
|
@ResponseSchema(ResponseAnonymousDonation)
|
||||||
@ResponseSchema(DonationNotFoundError, { statusCode: 404 })
|
@ResponseSchema(DonationNotFoundError, { statusCode: 404 })
|
||||||
@OnUndefined(DonationNotFoundError)
|
@OnUndefined(DonationNotFoundError)
|
||||||
@OpenAPI({ description: 'Lists all information about the donation whose id got provided. This includes the donation\'s runner\'s distance ran (if distance donation).' })
|
@OpenAPI({ description: 'Lists all information about the donation whose id got provided. This includes the donation\'s runner\'s distance ran (if distance donation).' })
|
||||||
@@ -69,6 +80,17 @@ export class DonationController {
|
|||||||
return (await this.donationRepository.findOne({ id: donation.id }, { relations: ['donor'] })).toResponse();
|
return (await this.donationRepository.findOne({ id: donation.id }, { relations: ['donor'] })).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('/anonymous')
|
||||||
|
@Authorized("DONATION:CREATE")
|
||||||
|
@ResponseSchema(ResponseDonation)
|
||||||
|
@ResponseSchema(DonorNotFoundError, { statusCode: 404 })
|
||||||
|
@OpenAPI({ description: 'Create a anonymous donation' })
|
||||||
|
async postAnonymous(@Body({ validate: true }) createDonation: CreateAnonymousDonation) {
|
||||||
|
let donation = await createDonation.toEntity();
|
||||||
|
donation = await this.fixedDonationRepository.save(donation);
|
||||||
|
return (await this.donationRepository.findOne({ id: donation.id })).toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('/distance')
|
@Post('/distance')
|
||||||
@Authorized("DONATION:CREATE")
|
@Authorized("DONATION:CREATE")
|
||||||
@ResponseSchema(ResponseDistanceDonation)
|
@ResponseSchema(ResponseDistanceDonation)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { getConnectionManager, Repository } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { DonorHasDonationsError, DonorIdsNotMatchingError, DonorNotFoundError } from '../errors/DonorErrors';
|
import { DonorHasDonationsError, DonorIdsNotMatchingError, DonorNotFoundError } from '../errors/DonorErrors';
|
||||||
import { CreateDonor } from '../models/actions/create/CreateDonor';
|
import { CreateDonor } from '../models/actions/create/CreateDonor';
|
||||||
import { UpdateDonor } from '../models/actions/update/UpdateDonor';
|
import { UpdateDonor } from '../models/actions/update/UpdateDonor';
|
||||||
@@ -25,9 +25,16 @@ export class DonorController {
|
|||||||
@Authorized("DONOR:GET")
|
@Authorized("DONOR:GET")
|
||||||
@ResponseSchema(ResponseDonor, { isArray: true })
|
@ResponseSchema(ResponseDonor, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all donor. <br> This includes the donor\'s current donation amount.' })
|
@OpenAPI({ description: 'Lists all donor. <br> This includes the donor\'s current donation amount.' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responseDonors: ResponseDonor[] = new Array<ResponseDonor>();
|
let responseDonors: ResponseDonor[] = new Array<ResponseDonor>();
|
||||||
const donors = await this.donorRepository.find({ relations: ['donations', 'donations.runner', 'donations.runner.scans', 'donations.runner.scans.track'] });
|
let donors: Array<Donor>;
|
||||||
|
|
||||||
|
if (page != undefined) {
|
||||||
|
donors = await this.donorRepository.find({ relations: ['donations', 'donations.runner', 'donations.runner.scans', 'donations.runner.scans.track'], skip: page * page_size, take: page_size });
|
||||||
|
} else {
|
||||||
|
donors = await this.donorRepository.find({ relations: ['donations', 'donations.runner', 'donations.runner.scans', 'donations.runner.scans.track'] });
|
||||||
|
}
|
||||||
|
|
||||||
donors.forEach(donor => {
|
donors.forEach(donor => {
|
||||||
responseDonors.push(new ResponseDonor(donor));
|
responseDonors.push(new ResponseDonor(donor));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { getConnection, getConnectionManager, Repository } from 'typeorm';
|
import { Repository, getConnection, getConnectionManager } from 'typeorm';
|
||||||
import { GroupContactIdsNotMatchingError, GroupContactNotFoundError } from '../errors/GroupContactErrors';
|
import { GroupContactIdsNotMatchingError, GroupContactNotFoundError } from '../errors/GroupContactErrors';
|
||||||
import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors';
|
import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors';
|
||||||
import { CreateGroupContact } from '../models/actions/create/CreateGroupContact';
|
import { CreateGroupContact } from '../models/actions/create/CreateGroupContact';
|
||||||
@@ -26,9 +26,16 @@ export class GroupContactController {
|
|||||||
@Authorized("CONTACT:GET")
|
@Authorized("CONTACT:GET")
|
||||||
@ResponseSchema(ResponseGroupContact, { isArray: true })
|
@ResponseSchema(ResponseGroupContact, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all contacts. <br> This includes the contact\'s associated groups.' })
|
@OpenAPI({ description: 'Lists all contacts. <br> This includes the contact\'s associated groups.' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responseContacts: ResponseGroupContact[] = new Array<ResponseGroupContact>();
|
let responseContacts: ResponseGroupContact[] = new Array<ResponseGroupContact>();
|
||||||
const contacts = await this.contactRepository.find({ relations: ['groups', 'groups.parentGroup'] });
|
let contacts: Array<GroupContact>;
|
||||||
|
|
||||||
|
if (page != undefined) {
|
||||||
|
contacts = await this.contactRepository.find({ relations: ['groups', 'groups.parentGroup'], skip: page * page_size, take: page_size });
|
||||||
|
} else {
|
||||||
|
contacts = await this.contactRepository.find({ relations: ['groups', 'groups.parentGroup'] });
|
||||||
|
}
|
||||||
|
|
||||||
contacts.forEach(contact => {
|
contacts.forEach(contact => {
|
||||||
responseContacts.push(contact.toResponse());
|
responseContacts.push(contact.toResponse());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { getConnectionManager, Repository } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { PermissionIdsNotMatchingError, PermissionNeedsPrincipalError, PermissionNotFoundError } from '../errors/PermissionErrors';
|
import { PermissionIdsNotMatchingError, PermissionNeedsPrincipalError, PermissionNotFoundError } from '../errors/PermissionErrors';
|
||||||
import { PrincipalNotFoundError } from '../errors/PrincipalErrors';
|
import { PrincipalNotFoundError } from '../errors/PrincipalErrors';
|
||||||
import { CreatePermission } from '../models/actions/create/CreatePermission';
|
import { CreatePermission } from '../models/actions/create/CreatePermission';
|
||||||
@@ -27,9 +27,16 @@ export class PermissionController {
|
|||||||
@Authorized("PERMISSION:GET")
|
@Authorized("PERMISSION:GET")
|
||||||
@ResponseSchema(ResponsePermission, { isArray: true })
|
@ResponseSchema(ResponsePermission, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all permissions for all users and groups.' })
|
@OpenAPI({ description: 'Lists all permissions for all users and groups.' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responsePermissions: ResponsePermission[] = new Array<ResponsePermission>();
|
let responsePermissions: ResponsePermission[] = new Array<ResponsePermission>();
|
||||||
const permissions = await this.permissionRepository.find({ relations: ['principal'] });
|
let permissions: Array<Permission>;
|
||||||
|
|
||||||
|
if (page != undefined) {
|
||||||
|
permissions = await this.permissionRepository.find({ relations: ['principal'], skip: page * page_size, take: page_size });
|
||||||
|
} else {
|
||||||
|
permissions = await this.permissionRepository.find({ relations: ['principal'] });
|
||||||
|
}
|
||||||
|
|
||||||
permissions.forEach(permission => {
|
permissions.forEach(permission => {
|
||||||
responsePermissions.push(new ResponsePermission(permission));
|
responsePermissions.push(new ResponsePermission(permission));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { Repository, getConnectionManager } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { RunnerCardHasScansError, RunnerCardIdsNotMatchingError, RunnerCardNotFoundError } from '../errors/RunnerCardErrors';
|
import { RunnerCardHasScansError, RunnerCardIdsNotMatchingError, RunnerCardNotFoundError } from '../errors/RunnerCardErrors';
|
||||||
import { RunnerNotFoundError } from '../errors/RunnerErrors';
|
import { RunnerNotFoundError } from '../errors/RunnerErrors';
|
||||||
|
import { deleteCardEntry } from '../nats/CardKV';
|
||||||
import { CreateRunnerCard } from '../models/actions/create/CreateRunnerCard';
|
import { CreateRunnerCard } from '../models/actions/create/CreateRunnerCard';
|
||||||
import { UpdateRunnerCard } from '../models/actions/update/UpdateRunnerCard';
|
import { UpdateRunnerCard } from '../models/actions/update/UpdateRunnerCard';
|
||||||
|
import { UpdateRunnerCardByCode } from '../models/actions/update/UpdateRunnerCardByCode';
|
||||||
import { RunnerCard } from '../models/entities/RunnerCard';
|
import { RunnerCard } from '../models/entities/RunnerCard';
|
||||||
import { ResponseEmpty } from '../models/responses/ResponseEmpty';
|
import { ResponseEmpty } from '../models/responses/ResponseEmpty';
|
||||||
import { ResponseRunnerCard } from '../models/responses/ResponseRunnerCard';
|
import { ResponseRunnerCard } from '../models/responses/ResponseRunnerCard';
|
||||||
@@ -26,9 +28,16 @@ export class RunnerCardController {
|
|||||||
@Authorized("CARD:GET")
|
@Authorized("CARD:GET")
|
||||||
@ResponseSchema(ResponseRunnerCard, { isArray: true })
|
@ResponseSchema(ResponseRunnerCard, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all card.' })
|
@OpenAPI({ description: 'Lists all card.' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responseCards: ResponseRunnerCard[] = new Array<ResponseRunnerCard>();
|
let responseCards: ResponseRunnerCard[] = new Array<ResponseRunnerCard>();
|
||||||
const cards = await this.cardRepository.find({ relations: ['runner', 'runner.group', 'runner.group.parentGroup'] });
|
let cards: Array<RunnerCard>;
|
||||||
|
|
||||||
|
if (page != undefined) {
|
||||||
|
cards = await this.cardRepository.find({ relations: ['runner', 'runner.group', 'runner.group.parentGroup'], skip: page * page_size, take: page_size });
|
||||||
|
} else {
|
||||||
|
cards = await this.cardRepository.find({ relations: ['runner', 'runner.group', 'runner.group.parentGroup'] });
|
||||||
|
}
|
||||||
|
|
||||||
cards.forEach(card => {
|
cards.forEach(card => {
|
||||||
responseCards.push(new ResponseRunnerCard(card));
|
responseCards.push(new ResponseRunnerCard(card));
|
||||||
});
|
});
|
||||||
@@ -101,8 +110,31 @@ export class RunnerCardController {
|
|||||||
throw new RunnerCardIdsNotMatchingError();
|
throw new RunnerCardIdsNotMatchingError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.cardRepository.save(await card.update(oldCard));
|
||||||
|
await deleteCardEntry(id);
|
||||||
|
return (await this.cardRepository.findOne({ id: id }, { relations: ['runner', 'runner.group', 'runner.group.parentGroup'] })).toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('/:code')
|
||||||
|
@Authorized("CARD:UPDATE")
|
||||||
|
@ResponseSchema(ResponseRunnerCard)
|
||||||
|
@ResponseSchema(RunnerCardNotFoundError, { statusCode: 404 })
|
||||||
|
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
|
||||||
|
@ResponseSchema(RunnerCardIdsNotMatchingError, { statusCode: 406 })
|
||||||
|
@OpenAPI({ description: "Update the card whose code you provided." })
|
||||||
|
async putByCode(@Param('code') code: string, @Body({ validate: true }) card: UpdateRunnerCardByCode) {
|
||||||
|
let oldCard = await this.cardRepository.findOne({ code: code });
|
||||||
|
|
||||||
|
if (!oldCard) {
|
||||||
|
throw new RunnerCardNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldCard.code != card.code) {
|
||||||
|
throw new RunnerCardIdsNotMatchingError();
|
||||||
|
}
|
||||||
|
|
||||||
await this.cardRepository.save(await card.update(oldCard));
|
await this.cardRepository.save(await card.update(oldCard));
|
||||||
return (await this.cardRepository.findOne({ id: id }, { relations: ['runner', 'runner.group', 'runner.group.parentGroup'] })).toResponse();
|
return (await this.cardRepository.findOne({ code: code }, { relations: ['runner', 'runner.group', 'runner.group.parentGroup'] })).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('/:id')
|
@Delete('/:id')
|
||||||
@@ -121,11 +153,12 @@ export class RunnerCardController {
|
|||||||
throw new RunnerCardHasScansError();
|
throw new RunnerCardHasScansError();
|
||||||
}
|
}
|
||||||
const scanController = new ScanController;
|
const scanController = new ScanController;
|
||||||
for (let scan of cardScans) {
|
for (let scan of cardScans) {
|
||||||
await scanController.remove(scan.id, force);
|
await scanController.remove(scan.id, force);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.cardRepository.delete(card);
|
await deleteCardEntry(id);
|
||||||
return card.toResponse();
|
await this.cardRepository.delete(card);
|
||||||
|
return card.toResponse();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { Repository, getConnectionManager } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { RunnerGroupNeededError, RunnerHasDistanceDonationsError, RunnerIdsNotMatchingError, RunnerNotFoundError } from '../errors/RunnerErrors';
|
import { RunnerGroupNeededError, RunnerHasDistanceDonationsError, RunnerIdsNotMatchingError, RunnerNotFoundError } from '../errors/RunnerErrors';
|
||||||
import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors';
|
import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors';
|
||||||
import { CreateRunner } from '../models/actions/create/CreateRunner';
|
import { deleteRunnerEntry } from '../nats/RunnerKV';
|
||||||
import { UpdateRunner } from '../models/actions/update/UpdateRunner';
|
import { CreateRunner } from '../models/actions/create/CreateRunner';
|
||||||
import { Runner } from '../models/entities/Runner';
|
import { UpdateRunner } from '../models/actions/update/UpdateRunner';
|
||||||
import { ResponseEmpty } from '../models/responses/ResponseEmpty';
|
import { Runner } from '../models/entities/Runner';
|
||||||
import { ResponseRunner } from '../models/responses/ResponseRunner';
|
import { ResponseEmpty } from '../models/responses/ResponseEmpty';
|
||||||
import { ResponseScan } from '../models/responses/ResponseScan';
|
import { ResponseRunner } from '../models/responses/ResponseRunner';
|
||||||
import { ResponseTrackScan } from '../models/responses/ResponseTrackScan';
|
import { ResponseScan } from '../models/responses/ResponseScan';
|
||||||
import { DonationController } from './DonationController';
|
import { ResponseTrackScan } from '../models/responses/ResponseTrackScan';
|
||||||
import { RunnerCardController } from './RunnerCardController';
|
import { DonationController } from './DonationController';
|
||||||
import { ScanController } from './ScanController';
|
import { RunnerCardController } from './RunnerCardController';
|
||||||
|
import { ScanController } from './ScanController';
|
||||||
|
|
||||||
@JsonController('/runners')
|
@JsonController('/runners')
|
||||||
@OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
@OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||||
@@ -30,11 +31,25 @@ export class RunnerController {
|
|||||||
@Authorized("RUNNER:GET")
|
@Authorized("RUNNER:GET")
|
||||||
@ResponseSchema(ResponseRunner, { isArray: true })
|
@ResponseSchema(ResponseRunner, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all runners from all teams/orgs. <br> This includes the runner\'s group and distance ran.' })
|
@OpenAPI({ description: 'Lists all runners from all teams/orgs. <br> This includes the runner\'s group and distance ran.' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100, @QueryParam("created_via", { required: false }) created_via: string = "all", @QueryParam("selfservice_links", { required: false }) selfservice_links: boolean = false) {
|
||||||
let responseRunners: ResponseRunner[] = new Array<ResponseRunner>();
|
let responseRunners: ResponseRunner[] = new Array<ResponseRunner>();
|
||||||
const runners = await this.runnerRepository.find({ relations: ['scans', 'group', 'group.parentGroup', 'scans.track'] });
|
let runners: Array<Runner>;
|
||||||
|
|
||||||
|
console.log("call to RunnerController.getAll() with page: " + page + " and page_size: " + page_size + " and created_via: " + created_via + " and selfservice_links: " + selfservice_links);
|
||||||
|
if (page != undefined) {
|
||||||
|
runners = await this.runnerRepository.find({ relations: ['scans', 'group', 'group.parentGroup', 'scans.track'], skip: page * page_size, take: page_size });
|
||||||
|
} else {
|
||||||
|
runners = await this.runnerRepository.find({ relations: ['scans', 'group', 'group.parentGroup', 'scans.track'] });
|
||||||
|
}
|
||||||
|
|
||||||
runners.forEach(runner => {
|
runners.forEach(runner => {
|
||||||
responseRunners.push(new ResponseRunner(runner));
|
if (created_via === "all") {
|
||||||
|
responseRunners.push(new ResponseRunner(runner, selfservice_links));
|
||||||
|
} else {
|
||||||
|
if (runner.created_via === created_via) {
|
||||||
|
responseRunners.push(new ResponseRunner(runner, selfservice_links));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return responseRunners;
|
return responseRunners;
|
||||||
}
|
}
|
||||||
@@ -46,9 +61,9 @@ export class RunnerController {
|
|||||||
@OnUndefined(RunnerNotFoundError)
|
@OnUndefined(RunnerNotFoundError)
|
||||||
@OpenAPI({ description: 'Lists all information about the runner whose id got provided.' })
|
@OpenAPI({ description: 'Lists all information about the runner whose id got provided.' })
|
||||||
async getOne(@Param('id') id: number) {
|
async getOne(@Param('id') id: number) {
|
||||||
let runner = await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] })
|
let runner = await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards', 'distanceDonations'] })
|
||||||
if (!runner) { throw new RunnerNotFoundError(); }
|
if (!runner) { throw new RunnerNotFoundError(); }
|
||||||
return new ResponseRunner(runner);
|
return new ResponseRunner(runner, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/:id/scans')
|
@Get('/:id/scans')
|
||||||
@@ -91,7 +106,7 @@ export class RunnerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
runner = await this.runnerRepository.save(runner)
|
runner = await this.runnerRepository.save(runner)
|
||||||
return new ResponseRunner(await this.runnerRepository.findOne(runner, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] }));
|
return new ResponseRunner(await this.runnerRepository.findOne(runner, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] }), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('/:id')
|
@Put('/:id')
|
||||||
@@ -111,8 +126,9 @@ export class RunnerController {
|
|||||||
throw new RunnerIdsNotMatchingError();
|
throw new RunnerIdsNotMatchingError();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.runnerRepository.save(await runner.update(oldRunner));
|
await this.runnerRepository.save(await runner.update(oldRunner));
|
||||||
return new ResponseRunner(await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] }));
|
await deleteRunnerEntry(id);
|
||||||
|
return new ResponseRunner(await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] }), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('/:id')
|
@Delete('/:id')
|
||||||
@@ -125,7 +141,7 @@ export class RunnerController {
|
|||||||
async remove(@Param("id") id: number, @QueryParam("force") force: boolean) {
|
async remove(@Param("id") id: number, @QueryParam("force") force: boolean) {
|
||||||
let runner = await this.runnerRepository.findOne({ id: id });
|
let runner = await this.runnerRepository.findOne({ id: id });
|
||||||
if (!runner) { return null; }
|
if (!runner) { return null; }
|
||||||
const responseRunner = await this.runnerRepository.findOne(runner, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] });
|
const responseRunner = await this.runnerRepository.findOne(runner);
|
||||||
|
|
||||||
if (!runner) {
|
if (!runner) {
|
||||||
throw new RunnerNotFoundError();
|
throw new RunnerNotFoundError();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Authorized, BadRequestError, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
import { Authorized, BadRequestError, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { getConnectionManager, Repository } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { RunnerOrganizationHasRunnersError, RunnerOrganizationHasTeamsError, RunnerOrganizationIdsNotMatchingError, RunnerOrganizationNotFoundError } from '../errors/RunnerOrganizationErrors';
|
import { RunnerOrganizationHasRunnersError, RunnerOrganizationHasTeamsError, RunnerOrganizationIdsNotMatchingError, RunnerOrganizationNotFoundError } from '../errors/RunnerOrganizationErrors';
|
||||||
import { CreateRunnerOrganization } from '../models/actions/create/CreateRunnerOrganization';
|
import { CreateRunnerOrganization } from '../models/actions/create/CreateRunnerOrganization';
|
||||||
import { UpdateRunnerOrganization } from '../models/actions/update/UpdateRunnerOrganization';
|
import { UpdateRunnerOrganization } from '../models/actions/update/UpdateRunnerOrganization';
|
||||||
@@ -29,13 +29,20 @@ export class RunnerOrganizationController {
|
|||||||
@Authorized("ORGANIZATION:GET")
|
@Authorized("ORGANIZATION:GET")
|
||||||
@ResponseSchema(ResponseRunnerOrganization, { isArray: true })
|
@ResponseSchema(ResponseRunnerOrganization, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all organizations. <br> This includes their address, contact and teams (if existing/associated).' })
|
@OpenAPI({ description: 'Lists all organizations. <br> This includes their address, contact and teams (if existing/associated).' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responseTeams: ResponseRunnerOrganization[] = new Array<ResponseRunnerOrganization>();
|
let responseOrgs: ResponseRunnerOrganization[] = new Array<ResponseRunnerOrganization>();
|
||||||
const runners = await this.runnerOrganizationRepository.find({ relations: ['contact', 'teams'] });
|
let orgs: Array<RunnerOrganization>;
|
||||||
runners.forEach(runner => {
|
|
||||||
responseTeams.push(new ResponseRunnerOrganization(runner));
|
if (page != undefined) {
|
||||||
|
orgs = await this.runnerOrganizationRepository.find({ relations: ['contact', 'teams'], skip: page * page_size, take: page_size });
|
||||||
|
} else {
|
||||||
|
orgs = await this.runnerOrganizationRepository.find({ relations: ['contact', 'teams'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
orgs.forEach(org => {
|
||||||
|
responseOrgs.push(new ResponseRunnerOrganization(org));
|
||||||
});
|
});
|
||||||
return responseTeams;
|
return responseOrgs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/:id')
|
@Get('/:id')
|
||||||
@@ -45,7 +52,7 @@ export class RunnerOrganizationController {
|
|||||||
@OnUndefined(RunnerOrganizationNotFoundError)
|
@OnUndefined(RunnerOrganizationNotFoundError)
|
||||||
@OpenAPI({ description: 'Lists all information about the organization whose id got provided.' })
|
@OpenAPI({ description: 'Lists all information about the organization whose id got provided.' })
|
||||||
async getOne(@Param('id') id: number) {
|
async getOne(@Param('id') id: number) {
|
||||||
let runnerOrg = await this.runnerOrganizationRepository.findOne({ id: id }, { relations: ['contact', 'teams'] });
|
let runnerOrg = await this.runnerOrganizationRepository.findOne({ id: id }, { relations: ['contact', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.scans.track', 'runners', 'runners.scans', 'runners.scans.track'] });
|
||||||
if (!runnerOrg) { throw new RunnerOrganizationNotFoundError(); }
|
if (!runnerOrg) { throw new RunnerOrganizationNotFoundError(); }
|
||||||
return new ResponseRunnerOrganization(runnerOrg);
|
return new ResponseRunnerOrganization(runnerOrg);
|
||||||
}
|
}
|
||||||
@@ -55,13 +62,13 @@ export class RunnerOrganizationController {
|
|||||||
@ResponseSchema(ResponseRunner, { isArray: true })
|
@ResponseSchema(ResponseRunner, { isArray: true })
|
||||||
@ResponseSchema(RunnerOrganizationNotFoundError, { statusCode: 404 })
|
@ResponseSchema(RunnerOrganizationNotFoundError, { statusCode: 404 })
|
||||||
@OpenAPI({ description: 'Lists all runners from this org and it\'s teams (if you don\'t provide the ?onlyDirect=true param). <br> This includes the runner\'s group and distance ran.' })
|
@OpenAPI({ description: 'Lists all runners from this org and it\'s teams (if you don\'t provide the ?onlyDirect=true param). <br> This includes the runner\'s group and distance ran.' })
|
||||||
async getRunners(@Param('id') id: number, @QueryParam('onlyDirect') onlyDirect: boolean) {
|
async getRunners(@Param('id') id: number, @QueryParam('onlyDirect') onlyDirect: boolean, @QueryParam("selfservice_links", { required: false }) selfservice_links: boolean = false) {
|
||||||
let responseRunners: ResponseRunner[] = new Array<ResponseRunner>();
|
let responseRunners: ResponseRunner[] = new Array<ResponseRunner>();
|
||||||
let runners: Runner[];
|
let runners: Runner[];
|
||||||
if (!onlyDirect) { runners = (await this.runnerOrganizationRepository.findOne({ id: id }, { relations: ['runners', 'runners.group', 'runners.group.parentGroup', 'runners.scans', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.group', 'teams.runners.group.parentGroup', 'teams.runners.scans', 'teams.runners.scans.track'] })).allRunners; }
|
if (!onlyDirect) { runners = (await this.runnerOrganizationRepository.findOne({ id: id }, { relations: ['runners', 'runners.group', 'runners.group.parentGroup', 'runners.scans', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.group', 'teams.runners.group.parentGroup', 'teams.runners.scans', 'teams.runners.scans.track'] })).allRunners; }
|
||||||
else { runners = (await this.runnerOrganizationRepository.findOne({ id: id }, { relations: ['runners', 'runners.group', 'runners.group.parentGroup', 'runners.scans', 'runners.scans.track'] })).runners; }
|
else { runners = (await this.runnerOrganizationRepository.findOne({ id: id }, { relations: ['runners', 'runners.group', 'runners.group.parentGroup', 'runners.scans', 'runners.scans.track'] })).runners; }
|
||||||
runners.forEach(runner => {
|
runners.forEach(runner => {
|
||||||
responseRunners.push(new ResponseRunner(runner));
|
responseRunners.push(new ResponseRunner(runner, selfservice_links));
|
||||||
});
|
});
|
||||||
return responseRunners;
|
return responseRunners;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Request } from "express";
|
import type { Request } from "express";
|
||||||
import * as jwt from "jsonwebtoken";
|
import * as jwt from "jsonwebtoken";
|
||||||
import { BadRequestError, Body, Delete, Get, JsonController, OnUndefined, Param, Post, QueryParam, Req, UseBefore } from 'routing-controllers';
|
import { BadRequestError, Body, Delete, Get, JsonController, OnUndefined, Param, Post, QueryParam, Req, UseBefore } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
@@ -127,11 +127,11 @@ export class RunnerSelfServiceController {
|
|||||||
const runner = await this.runnerRepository.findOne({ email: mail });
|
const runner = await this.runnerRepository.findOne({ email: mail });
|
||||||
if (!runner) { throw new RunnerNotFoundError(); }
|
if (!runner) { throw new RunnerNotFoundError(); }
|
||||||
|
|
||||||
if (runner.resetRequestedTimestamp > (Math.floor(Date.now() / 1000) - 60 * 15)) { throw new RunnerSelfserviceTimeoutError(); }
|
if (runner.resetRequestedTimestamp > (Math.floor(Date.now() / 1000) - 30)) { throw new RunnerSelfserviceTimeoutError(); }
|
||||||
const token = JwtCreator.createSelfService(runner);
|
const token = JwtCreator.createSelfService(runner);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Mailer.sendSelfserviceForgottenMail(runner.email, token, locale)
|
await Mailer.sendSelfserviceForgottenMail(runner.email, runner.id, runner.firstname, runner.middlename, runner.lastname, token, locale)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new MailSendingError();
|
throw new MailSendingError();
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ export class RunnerSelfServiceController {
|
|||||||
response.token = JwtCreator.createSelfService(runner);
|
response.token = JwtCreator.createSelfService(runner);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Mailer.sendSelfserviceWelcomeMail(runner.email, response.token, locale)
|
await Mailer.sendSelfserviceWelcomeMail(runner.email, runner.id, runner.firstname, runner.middlename, runner.lastname, response.token, locale)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new MailSendingError();
|
throw new MailSendingError();
|
||||||
}
|
}
|
||||||
@@ -182,7 +182,7 @@ export class RunnerSelfServiceController {
|
|||||||
response.token = JwtCreator.createSelfService(runner);
|
response.token = JwtCreator.createSelfService(runner);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Mailer.sendSelfserviceWelcomeMail(runner.email, response.token, locale)
|
await Mailer.sendSelfserviceWelcomeMail(runner.email, runner.id, runner.firstname, runner.middlename, runner.lastname, response.token, locale)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new MailSendingError();
|
throw new MailSendingError();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { getConnectionManager, Repository } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { RunnerTeamHasRunnersError, RunnerTeamIdsNotMatchingError, RunnerTeamNotFoundError } from '../errors/RunnerTeamErrors';
|
import { RunnerTeamHasRunnersError, RunnerTeamIdsNotMatchingError, RunnerTeamNotFoundError } from '../errors/RunnerTeamErrors';
|
||||||
import { CreateRunnerTeam } from '../models/actions/create/CreateRunnerTeam';
|
import { CreateRunnerTeam } from '../models/actions/create/CreateRunnerTeam';
|
||||||
import { UpdateRunnerTeam } from '../models/actions/update/UpdateRunnerTeam';
|
import { UpdateRunnerTeam } from '../models/actions/update/UpdateRunnerTeam';
|
||||||
@@ -27,11 +27,18 @@ export class RunnerTeamController {
|
|||||||
@Authorized("TEAM:GET")
|
@Authorized("TEAM:GET")
|
||||||
@ResponseSchema(ResponseRunnerTeam, { isArray: true })
|
@ResponseSchema(ResponseRunnerTeam, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all teams. <br> This includes their parent organization and contact (if existing/associated).' })
|
@OpenAPI({ description: 'Lists all teams. <br> This includes their parent organization and contact (if existing/associated).' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responseTeams: ResponseRunnerTeam[] = new Array<ResponseRunnerTeam>();
|
let responseTeams: ResponseRunnerTeam[] = new Array<ResponseRunnerTeam>();
|
||||||
const runners = await this.runnerTeamRepository.find({ relations: ['parentGroup', 'contact'] });
|
let teams: Array<RunnerTeam>;
|
||||||
runners.forEach(runner => {
|
|
||||||
responseTeams.push(new ResponseRunnerTeam(runner));
|
if (page != undefined) {
|
||||||
|
teams = await this.runnerTeamRepository.find({ relations: ['parentGroup', 'contact'], skip: page * page_size, take: page_size });
|
||||||
|
} else {
|
||||||
|
teams = await this.runnerTeamRepository.find({ relations: ['parentGroup', 'contact'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
teams.forEach(team => {
|
||||||
|
responseTeams.push(new ResponseRunnerTeam(team));
|
||||||
});
|
});
|
||||||
return responseTeams;
|
return responseTeams;
|
||||||
}
|
}
|
||||||
@@ -43,7 +50,7 @@ export class RunnerTeamController {
|
|||||||
@OnUndefined(RunnerTeamNotFoundError)
|
@OnUndefined(RunnerTeamNotFoundError)
|
||||||
@OpenAPI({ description: 'Lists all information about the team whose id got provided.' })
|
@OpenAPI({ description: 'Lists all information about the team whose id got provided.' })
|
||||||
async getOne(@Param('id') id: number) {
|
async getOne(@Param('id') id: number) {
|
||||||
let runnerTeam = await this.runnerTeamRepository.findOne({ id: id }, { relations: ['parentGroup', 'contact'] });
|
let runnerTeam = await this.runnerTeamRepository.findOne({ id: id }, { relations: ['parentGroup', 'contact', 'runners', 'runners.scans', 'runners.scans.track'] });
|
||||||
if (!runnerTeam) { throw new RunnerTeamNotFoundError(); }
|
if (!runnerTeam) { throw new RunnerTeamNotFoundError(); }
|
||||||
return new ResponseRunnerTeam(runnerTeam);
|
return new ResponseRunnerTeam(runnerTeam);
|
||||||
}
|
}
|
||||||
@@ -53,11 +60,11 @@ export class RunnerTeamController {
|
|||||||
@ResponseSchema(ResponseRunner, { isArray: true })
|
@ResponseSchema(ResponseRunner, { isArray: true })
|
||||||
@ResponseSchema(RunnerTeamNotFoundError, { statusCode: 404 })
|
@ResponseSchema(RunnerTeamNotFoundError, { statusCode: 404 })
|
||||||
@OpenAPI({ description: 'Lists all runners from this team. <br> This includes the runner\'s group and distance ran.' })
|
@OpenAPI({ description: 'Lists all runners from this team. <br> This includes the runner\'s group and distance ran.' })
|
||||||
async getRunners(@Param('id') id: number) {
|
async getRunners(@Param('id') id: number, @QueryParam("selfservice_links", { required: false }) selfservice_links: boolean = false) {
|
||||||
let responseRunners: ResponseRunner[] = new Array<ResponseRunner>();
|
let responseRunners: ResponseRunner[] = new Array<ResponseRunner>();
|
||||||
const runners = (await this.runnerTeamRepository.findOne({ id: id }, { relations: ['runners', 'runners.group', 'runners.group.parentGroup', 'runners.scans', 'runners.scans.track'] })).runners;
|
const runners = (await this.runnerTeamRepository.findOne({ id: id }, { relations: ['runners', 'runners.group', 'runners.group.parentGroup', 'runners.scans', 'runners.scans.track'] })).runners;
|
||||||
runners.forEach(runner => {
|
runners.forEach(runner => {
|
||||||
responseRunners.push(new ResponseRunner(runner));
|
responseRunners.push(new ResponseRunner(runner, selfservice_links));
|
||||||
});
|
});
|
||||||
return responseRunners;
|
return responseRunners;
|
||||||
}
|
}
|
||||||
@@ -112,7 +119,7 @@ export class RunnerTeamController {
|
|||||||
async remove(@Param("id") id: number, @QueryParam("force") force: boolean) {
|
async remove(@Param("id") id: number, @QueryParam("force") force: boolean) {
|
||||||
let team = await this.runnerTeamRepository.findOne({ id: id });
|
let team = await this.runnerTeamRepository.findOne({ id: id });
|
||||||
if (!team) { return null; }
|
if (!team) { return null; }
|
||||||
let runnerTeam = await this.runnerTeamRepository.findOne(team, { relations: ['parentGroup', 'contact', 'runners'] });
|
let runnerTeam = await this.runnerTeamRepository.findOne(team, { relations: ['runners'] });
|
||||||
|
|
||||||
if (!force) {
|
if (!force) {
|
||||||
if (runnerTeam.runners.length != 0) {
|
if (runnerTeam.runners.length != 0) {
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { Request } from "express";
|
import type { Request } from "express";
|
||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam, Req, UseBefore } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, HttpError, JsonController, OnUndefined, Param, Post, Put, QueryParam, Req, UseBefore } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { Repository, getConnectionManager } from 'typeorm';
|
import { Repository, getConnection, getConnectionManager } from 'typeorm';
|
||||||
import { RunnerNotFoundError } from '../errors/RunnerErrors';
|
import { RunnerNotFoundError } from '../errors/RunnerErrors';
|
||||||
import { ScanIdsNotMatchingError, ScanNotFoundError } from '../errors/ScanErrors';
|
import { ScanIdsNotMatchingError, ScanNotFoundError } from '../errors/ScanErrors';
|
||||||
import { ScanStationNotFoundError } from '../errors/ScanStationErrors';
|
import { ScanStationNotFoundError } from '../errors/ScanStationErrors';
|
||||||
import ScanAuth from '../middlewares/ScanAuth';
|
import ScanAuth from '../middlewares/ScanAuth';
|
||||||
|
import { deleteCardEntry, getCardEntry, setCardEntry } from '../nats/CardKV';
|
||||||
|
import { deleteRunnerEntry, getRunnerEntry, RunnerKVEntry, setRunnerEntry, warmRunner } from '../nats/RunnerKV';
|
||||||
|
import { getStationEntryById } from '../nats/StationKV';
|
||||||
import { CreateScan } from '../models/actions/create/CreateScan';
|
import { CreateScan } from '../models/actions/create/CreateScan';
|
||||||
import { CreateTrackScan } from '../models/actions/create/CreateTrackScan';
|
import { CreateTrackScan } from '../models/actions/create/CreateTrackScan';
|
||||||
import { UpdateScan } from '../models/actions/update/UpdateScan';
|
import { UpdateScan } from '../models/actions/update/UpdateScan';
|
||||||
import { UpdateTrackScan } from '../models/actions/update/UpdateTrackScan';
|
import { UpdateTrackScan } from '../models/actions/update/UpdateTrackScan';
|
||||||
|
import { RunnerCard } from '../models/entities/RunnerCard';
|
||||||
import { Scan } from '../models/entities/Scan';
|
import { Scan } from '../models/entities/Scan';
|
||||||
import { TrackScan } from '../models/entities/TrackScan';
|
import { TrackScan } from '../models/entities/TrackScan';
|
||||||
import { ResponseEmpty } from '../models/responses/ResponseEmpty';
|
import { ResponseEmpty } from '../models/responses/ResponseEmpty';
|
||||||
import { ResponseScan } from '../models/responses/ResponseScan';
|
import { ResponseScan } from '../models/responses/ResponseScan';
|
||||||
|
import { ResponseScanIntake, ResponseScanIntakeRunner } from '../models/responses/ResponseScanIntake';
|
||||||
import { ResponseTrackScan } from '../models/responses/ResponseTrackScan';
|
import { ResponseTrackScan } from '../models/responses/ResponseTrackScan';
|
||||||
@JsonController('/scans')
|
@JsonController('/scans')
|
||||||
@OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
@OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||||
@@ -34,9 +39,16 @@ export class ScanController {
|
|||||||
@ResponseSchema(ResponseScan, { isArray: true })
|
@ResponseSchema(ResponseScan, { isArray: true })
|
||||||
@ResponseSchema(ResponseTrackScan, { isArray: true })
|
@ResponseSchema(ResponseTrackScan, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all scans (normal or track) from all runners. <br> This includes the scan\'s runner\'s distance ran.' })
|
@OpenAPI({ description: 'Lists all scans (normal or track) from all runners. <br> This includes the scan\'s runner\'s distance ran.' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responseScans: ResponseScan[] = new Array<ResponseScan>();
|
let responseScans: ResponseScan[] = new Array<ResponseScan>();
|
||||||
const scans = await this.scanRepository.find({ relations: ['runner', 'track'] });
|
let scans: Array<Scan>;
|
||||||
|
|
||||||
|
if (page != undefined) {
|
||||||
|
scans = await this.scanRepository.find({ relations: ['runner', 'track'], skip: page * page_size, take: page_size });
|
||||||
|
} else {
|
||||||
|
scans = await this.scanRepository.find({ relations: ['runner', 'track'] });
|
||||||
|
}
|
||||||
|
|
||||||
scans.forEach(scan => {
|
scans.forEach(scan => {
|
||||||
responseScans.push(scan.toResponse());
|
responseScans.push(scan.toResponse());
|
||||||
});
|
});
|
||||||
@@ -70,18 +82,112 @@ export class ScanController {
|
|||||||
@Post("/trackscans")
|
@Post("/trackscans")
|
||||||
@UseBefore(ScanAuth)
|
@UseBefore(ScanAuth)
|
||||||
@ResponseSchema(ResponseTrackScan)
|
@ResponseSchema(ResponseTrackScan)
|
||||||
|
@ResponseSchema(ResponseScanIntake)
|
||||||
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
|
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
|
||||||
@OpenAPI({ description: 'Create a new track scan (for "normal" scans use /scans instead). <br> Please remember that to provide the scan\'s card\'s station\'s id.', security: [{ "StationApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
@OpenAPI({ description: 'Create a new track scan (for "normal" scans use /scans instead). <br> Please remember that to provide the scan\'s card\'s station\'s id.', security: [{ "StationApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||||
async postTrackScans(@Body({ validate: true }) createScan: CreateTrackScan, @Req() req: Request) {
|
async postTrackScans(@Body({ validate: true }) createScan: CreateTrackScan, @Req() req: Request) {
|
||||||
const station_id = req.headers["station_id"];
|
// Station token path — KV-backed intake flow
|
||||||
if (station_id) {
|
if (req.isStationAuth) {
|
||||||
createScan.station = parseInt(station_id.toString());
|
return this.stationIntake(createScan.card, req.stationId);
|
||||||
}
|
}
|
||||||
|
// JWT path — existing full flow, unchanged
|
||||||
|
createScan.station = createScan.station;
|
||||||
let scan = await createScan.toEntity();
|
let scan = await createScan.toEntity();
|
||||||
scan = await this.trackScanRepository.save(scan);
|
scan = await this.trackScanRepository.save(scan);
|
||||||
return (await this.scanRepository.findOne({ id: scan.id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse();
|
return (await this.scanRepository.findOne({ id: scan.id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KV-backed hot path for scan station submissions.
|
||||||
|
* Zero DB reads on a fully warm cache. Fixes the race condition via CAS on the runner KV entry.
|
||||||
|
*/
|
||||||
|
private async stationIntake(rawCard: number, stationId: number): Promise<ResponseScanIntake> {
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const cardId = rawCard % 200000000000;
|
||||||
|
|
||||||
|
// --- Station (already verified by ScanAuth, just need track data) ---
|
||||||
|
const stationEntry = await getStationEntryById(stationId);
|
||||||
|
// stationEntry is always populated here — ScanAuth wrote it on the cold path
|
||||||
|
const trackDistance = stationEntry.trackDistance;
|
||||||
|
const minimumLapTime = stationEntry.minimumLapTime;
|
||||||
|
|
||||||
|
// --- Card ---
|
||||||
|
let cardEntry = await getCardEntry(cardId);
|
||||||
|
if (!cardEntry) {
|
||||||
|
// Cold path: load from DB and cache
|
||||||
|
const card = await getConnection().getRepository(RunnerCard).findOne({ id: cardId }, { relations: ['runner'] });
|
||||||
|
if (!card) throw new ScanNotFoundError();
|
||||||
|
if (!card.runner) throw new RunnerNotFoundError();
|
||||||
|
cardEntry = {
|
||||||
|
runnerId: card.runner.id,
|
||||||
|
runnerDisplayName: `${card.runner.firstname} ${card.runner.lastname}`,
|
||||||
|
enabled: card.enabled,
|
||||||
|
};
|
||||||
|
await setCardEntry(cardId, cardEntry);
|
||||||
|
}
|
||||||
|
if (!cardEntry.enabled) throw new HttpError(400, 'Card is disabled.');
|
||||||
|
const runnerId = cardEntry.runnerId;
|
||||||
|
|
||||||
|
// --- Runner state + CAS update (fixes race condition) ---
|
||||||
|
const now = Math.round(Date.now() / 1000);
|
||||||
|
let retries = 0;
|
||||||
|
let response: ResponseScanIntake;
|
||||||
|
|
||||||
|
while (retries < MAX_RETRIES) {
|
||||||
|
// Get current runner state (warm or cold)
|
||||||
|
let result = await getRunnerEntry(runnerId);
|
||||||
|
if (!result) {
|
||||||
|
const warmed = await warmRunner(runnerId);
|
||||||
|
result = { entry: warmed, revision: undefined };
|
||||||
|
}
|
||||||
|
const { entry, revision } = result;
|
||||||
|
|
||||||
|
// Compute
|
||||||
|
const lapTime = entry.latestTimestamp === 0 ? 0 : now - entry.latestTimestamp;
|
||||||
|
const valid = minimumLapTime === 0 || lapTime > minimumLapTime;
|
||||||
|
const newDistance = entry.totalDistance + (valid ? trackDistance : 0);
|
||||||
|
const newTimestamp = valid ? now : entry.latestTimestamp;
|
||||||
|
|
||||||
|
const updated: RunnerKVEntry = {
|
||||||
|
displayName: entry.displayName,
|
||||||
|
totalDistance: newDistance,
|
||||||
|
latestTimestamp: newTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// CAS write — if revision is undefined (warmed this request), plain put
|
||||||
|
const success = await setRunnerEntry(runnerId, updated, revision);
|
||||||
|
if (!success) {
|
||||||
|
retries++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB insert — synchronous, keeps DB as source of truth
|
||||||
|
const newScan = new TrackScan();
|
||||||
|
newScan.runner = { id: runnerId } as any;
|
||||||
|
newScan.card = { id: cardId } as any;
|
||||||
|
newScan.station = { id: stationId } as any;
|
||||||
|
newScan.track = { id: stationEntry.trackId } as any;
|
||||||
|
newScan.timestamp = now;
|
||||||
|
newScan.lapTime = lapTime;
|
||||||
|
newScan.valid = valid;
|
||||||
|
await this.trackScanRepository.save(newScan);
|
||||||
|
|
||||||
|
const runnerInfo = new ResponseScanIntakeRunner();
|
||||||
|
runnerInfo.displayName = entry.displayName;
|
||||||
|
runnerInfo.totalDistance = newDistance;
|
||||||
|
|
||||||
|
response = new ResponseScanIntake();
|
||||||
|
response.accepted = true;
|
||||||
|
response.valid = valid;
|
||||||
|
response.lapTime = lapTime;
|
||||||
|
response.runner = runnerInfo;
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpError(409, 'Scan rejected: too many concurrent scans for this runner. Please retry.');
|
||||||
|
}
|
||||||
|
|
||||||
@Put('/:id')
|
@Put('/:id')
|
||||||
@Authorized("SCAN:UPDATE")
|
@Authorized("SCAN:UPDATE")
|
||||||
@ResponseSchema(ResponseScan)
|
@ResponseSchema(ResponseScan)
|
||||||
@@ -90,7 +196,7 @@ export class ScanController {
|
|||||||
@ResponseSchema(ScanIdsNotMatchingError, { statusCode: 406 })
|
@ResponseSchema(ScanIdsNotMatchingError, { statusCode: 406 })
|
||||||
@OpenAPI({ description: "Update the scan (not track scan use /scans/trackscans/:id instead) whose id you provided. <br> Please remember that ids can't be changed and distances must be positive." })
|
@OpenAPI({ description: "Update the scan (not track scan use /scans/trackscans/:id instead) whose id you provided. <br> Please remember that ids can't be changed and distances must be positive." })
|
||||||
async put(@Param('id') id: number, @Body({ validate: true }) scan: UpdateScan) {
|
async put(@Param('id') id: number, @Body({ validate: true }) scan: UpdateScan) {
|
||||||
let oldScan = await this.scanRepository.findOne({ id: id });
|
let oldScan = await this.scanRepository.findOne({ id: id }, { relations: ['runner'] });
|
||||||
|
|
||||||
if (!oldScan) {
|
if (!oldScan) {
|
||||||
throw new ScanNotFoundError();
|
throw new ScanNotFoundError();
|
||||||
@@ -100,7 +206,9 @@ export class ScanController {
|
|||||||
throw new ScanIdsNotMatchingError();
|
throw new ScanIdsNotMatchingError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runnerId = oldScan.runner?.id;
|
||||||
await this.scanRepository.save(await scan.update(oldScan));
|
await this.scanRepository.save(await scan.update(oldScan));
|
||||||
|
if (runnerId) await deleteRunnerEntry(runnerId);
|
||||||
return (await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse();
|
return (await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +221,7 @@ export class ScanController {
|
|||||||
@ResponseSchema(ScanIdsNotMatchingError, { statusCode: 406 })
|
@ResponseSchema(ScanIdsNotMatchingError, { statusCode: 406 })
|
||||||
@OpenAPI({ description: 'Update the track scan (not "normal" scan use /scans/trackscans/:id instead) whose id you provided. <br> Please remember that only the validity, runner and track can be changed.' })
|
@OpenAPI({ description: 'Update the track scan (not "normal" scan use /scans/trackscans/:id instead) whose id you provided. <br> Please remember that only the validity, runner and track can be changed.' })
|
||||||
async putTrackScan(@Param('id') id: number, @Body({ validate: true }) scan: UpdateTrackScan) {
|
async putTrackScan(@Param('id') id: number, @Body({ validate: true }) scan: UpdateTrackScan) {
|
||||||
let oldScan = await this.trackScanRepository.findOne({ id: id });
|
let oldScan = await this.trackScanRepository.findOne({ id: id }, { relations: ['runner'] });
|
||||||
|
|
||||||
if (!oldScan) {
|
if (!oldScan) {
|
||||||
throw new ScanNotFoundError();
|
throw new ScanNotFoundError();
|
||||||
@@ -123,7 +231,9 @@ export class ScanController {
|
|||||||
throw new ScanIdsNotMatchingError();
|
throw new ScanIdsNotMatchingError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runnerId = oldScan.runner?.id;
|
||||||
await this.trackScanRepository.save(await scan.update(oldScan));
|
await this.trackScanRepository.save(await scan.update(oldScan));
|
||||||
|
if (runnerId) await deleteRunnerEntry(runnerId);
|
||||||
return (await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse();
|
return (await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { getConnectionManager, Repository } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { ScanStationHasScansError, ScanStationIdsNotMatchingError, ScanStationNotFoundError } from '../errors/ScanStationErrors';
|
import { ScanStationHasScansError, ScanStationIdsNotMatchingError, ScanStationNotFoundError } from '../errors/ScanStationErrors';
|
||||||
import { TrackNotFoundError } from '../errors/TrackErrors';
|
import { TrackNotFoundError } from '../errors/TrackErrors';
|
||||||
|
import { deleteStationEntry } from '../nats/StationKV';
|
||||||
import { CreateScanStation } from '../models/actions/create/CreateScanStation';
|
import { CreateScanStation } from '../models/actions/create/CreateScanStation';
|
||||||
import { UpdateScanStation } from '../models/actions/update/UpdateScanStation';
|
import { UpdateScanStation } from '../models/actions/update/UpdateScanStation';
|
||||||
import { ScanStation } from '../models/entities/ScanStation';
|
import { ScanStation } from '../models/entities/ScanStation';
|
||||||
@@ -26,9 +27,16 @@ export class ScanStationController {
|
|||||||
@Authorized("STATION:GET")
|
@Authorized("STATION:GET")
|
||||||
@ResponseSchema(ResponseScanStation, { isArray: true })
|
@ResponseSchema(ResponseScanStation, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all stations. <br> This includes their associated tracks.' })
|
@OpenAPI({ description: 'Lists all stations. <br> This includes their associated tracks.' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responseStations: ResponseScanStation[] = new Array<ResponseScanStation>();
|
let responseStations: ResponseScanStation[] = new Array<ResponseScanStation>();
|
||||||
const stations = await this.stationRepository.find({ relations: ['track'] });
|
let stations: Array<ScanStation>;
|
||||||
|
|
||||||
|
if (page != undefined) {
|
||||||
|
stations = await this.stationRepository.find({ relations: ['track'], skip: page * page_size, take: page_size });
|
||||||
|
} else {
|
||||||
|
stations = await this.stationRepository.find({ relations: ['track'] });
|
||||||
|
}
|
||||||
|
|
||||||
stations.forEach(station => {
|
stations.forEach(station => {
|
||||||
responseStations.push(station.toResponse());
|
responseStations.push(station.toResponse());
|
||||||
});
|
});
|
||||||
@@ -78,6 +86,7 @@ export class ScanStationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.stationRepository.save(await station.update(oldStation));
|
await this.stationRepository.save(await station.update(oldStation));
|
||||||
|
await deleteStationEntry(oldStation.prefix);
|
||||||
return (await this.stationRepository.findOne({ id: id }, { relations: ['track'] })).toResponse();
|
return (await this.stationRepository.findOne({ id: id }, { relations: ['track'] })).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +111,7 @@ export class ScanStationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responseStation = await this.stationRepository.findOne({ id: station.id }, { relations: ["track"] });
|
const responseStation = await this.stationRepository.findOne({ id: station.id }, { relations: ["track"] });
|
||||||
|
await deleteStationEntry(station.prefix);
|
||||||
await this.stationRepository.delete(station);
|
await this.stationRepository.delete(station);
|
||||||
return responseStation.toResponse();
|
return responseStation.toResponse();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, QueryParam } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { getConnectionManager, Repository } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { StatsClientNotFoundError } from '../errors/StatsClientErrors';
|
import { StatsClientNotFoundError } from '../errors/StatsClientErrors';
|
||||||
import { TrackNotFoundError } from "../errors/TrackErrors";
|
import { TrackNotFoundError } from "../errors/TrackErrors";
|
||||||
import { CreateStatsClient } from '../models/actions/create/CreateStatsClient';
|
import { CreateStatsClient } from '../models/actions/create/CreateStatsClient';
|
||||||
@@ -24,9 +24,16 @@ export class StatsClientController {
|
|||||||
@Authorized("STATSCLIENT:GET")
|
@Authorized("STATSCLIENT:GET")
|
||||||
@ResponseSchema(ResponseStatsClient, { isArray: true })
|
@ResponseSchema(ResponseStatsClient, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all stats clients. Please remember that the key can only be viewed on creation.' })
|
@OpenAPI({ description: 'Lists all stats clients. Please remember that the key can only be viewed on creation.' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responseClients: ResponseStatsClient[] = new Array<ResponseStatsClient>();
|
let responseClients: ResponseStatsClient[] = new Array<ResponseStatsClient>();
|
||||||
const clients = await this.clientRepository.find();
|
let clients: Array<StatsClient>;
|
||||||
|
|
||||||
|
if (page != undefined) {
|
||||||
|
clients = await this.clientRepository.find({ skip: page * page_size, take: page_size });
|
||||||
|
} else {
|
||||||
|
clients = await this.clientRepository.find();
|
||||||
|
}
|
||||||
|
|
||||||
clients.forEach(clients => {
|
clients.forEach(clients => {
|
||||||
responseClients.push(new ResponseStatsClient(clients));
|
responseClients.push(new ResponseStatsClient(clients));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
|||||||
import { getConnection } from 'typeorm';
|
import { getConnection } from 'typeorm';
|
||||||
import StatsAuth from '../middlewares/StatsAuth';
|
import StatsAuth from '../middlewares/StatsAuth';
|
||||||
import { Donation } from '../models/entities/Donation';
|
import { Donation } from '../models/entities/Donation';
|
||||||
|
import { Donor } from '../models/entities/Donor';
|
||||||
import { Runner } from '../models/entities/Runner';
|
import { Runner } from '../models/entities/Runner';
|
||||||
import { RunnerOrganization } from '../models/entities/RunnerOrganization';
|
import { RunnerOrganization } from '../models/entities/RunnerOrganization';
|
||||||
import { RunnerTeam } from '../models/entities/RunnerTeam';
|
import { RunnerTeam } from '../models/entities/RunnerTeam';
|
||||||
@@ -22,11 +23,14 @@ export class StatsController {
|
|||||||
@OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" })
|
@OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" })
|
||||||
async get() {
|
async get() {
|
||||||
const connection = getConnection();
|
const connection = getConnection();
|
||||||
|
const runnersViaSelfservice = await connection.getRepository(Runner).count({ where: { created_via: "selfservice" } });
|
||||||
|
const runnersViaKiosk = await connection.getRepository(Runner).count({ where: { created_via: "kiosk" } });
|
||||||
const runners = await connection.getRepository(Runner).count();
|
const runners = await connection.getRepository(Runner).count();
|
||||||
const teams = await connection.getRepository(RunnerTeam).count();
|
const teams = await connection.getRepository(RunnerTeam).count();
|
||||||
const orgs = await connection.getRepository(RunnerOrganization).count();
|
const orgs = await connection.getRepository(RunnerOrganization).count();
|
||||||
const users = await connection.getRepository(User).count();
|
const users = await connection.getRepository(User).count();
|
||||||
const scans = await connection.getRepository(Scan).count({ where: { valid: true } });
|
const scans = await connection.getRepository(Scan).count({ where: { valid: true } });
|
||||||
|
|
||||||
const distance_query = await connection.getRepository(Scan).createQueryBuilder('scan')
|
const distance_query = await connection.getRepository(Scan).createQueryBuilder('scan')
|
||||||
.leftJoinAndSelect("scan.track", "track").where("scan.valid = TRUE")
|
.leftJoinAndSelect("scan.track", "track").where("scan.valid = TRUE")
|
||||||
.select("SUM(track.distance)", "sum_track").addSelect("SUM(_distance)", "sum_distance")
|
.select("SUM(track.distance)", "sum_track").addSelect("SUM(_distance)", "sum_distance")
|
||||||
@@ -35,8 +39,11 @@ export class StatsController {
|
|||||||
if (distance_query.sum_distance) {
|
if (distance_query.sum_distance) {
|
||||||
distace += parseInt(distance_query.sum_distance)
|
distace += parseInt(distance_query.sum_distance)
|
||||||
}
|
}
|
||||||
|
|
||||||
let donations = await connection.getRepository(Donation).find({ relations: ['runner', 'runner.scans', 'runner.scans.track'] });
|
let donations = await connection.getRepository(Donation).find({ relations: ['runner', 'runner.scans', 'runner.scans.track'] });
|
||||||
return new ResponseStats(runners, teams, orgs, users, scans, donations, distace)
|
const donors = await connection.getRepository(Donor).count();
|
||||||
|
|
||||||
|
return new ResponseStats(runnersViaSelfservice, runners, teams, orgs, users, scans, donations, distace, donors, runnersViaKiosk)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("/runners/distance")
|
@Get("/runners/distance")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { getConnectionManager, Repository } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { TrackHasScanStationsError, TrackIdsNotMatchingError, TrackLapTimeCantBeNegativeError, TrackNotFoundError } from "../errors/TrackErrors";
|
import { TrackHasScanStationsError, TrackIdsNotMatchingError, TrackLapTimeCantBeNegativeError, TrackNotFoundError } from "../errors/TrackErrors";
|
||||||
import { CreateTrack } from '../models/actions/create/CreateTrack';
|
import { CreateTrack } from '../models/actions/create/CreateTrack';
|
||||||
import { UpdateTrack } from '../models/actions/update/UpdateTrack';
|
import { UpdateTrack } from '../models/actions/update/UpdateTrack';
|
||||||
@@ -25,9 +25,17 @@ export class TrackController {
|
|||||||
@Authorized("TRACK:GET")
|
@Authorized("TRACK:GET")
|
||||||
@ResponseSchema(ResponseTrack, { isArray: true })
|
@ResponseSchema(ResponseTrack, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all tracks.' })
|
@OpenAPI({ description: 'Lists all tracks.' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responseTracks: ResponseTrack[] = new Array<ResponseTrack>();
|
let responseTracks: ResponseTrack[] = new Array<ResponseTrack>();
|
||||||
const tracks = await this.trackRepository.find();
|
let tracks: Array<Track>;
|
||||||
|
|
||||||
|
if (page != undefined) {
|
||||||
|
tracks = await this.trackRepository.find({ skip: page * page_size, take: page_size });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
tracks = await this.trackRepository.find();
|
||||||
|
}
|
||||||
|
|
||||||
tracks.forEach(track => {
|
tracks.forEach(track => {
|
||||||
responseTracks.push(new ResponseTrack(track));
|
responseTracks.push(new ResponseTrack(track));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { getConnectionManager, Repository } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { PasswordMustContainLowercaseLetterError, PasswordMustContainNumberError, PasswordMustContainUppercaseLetterError, PasswordTooShortError, UserDeletionNotConfirmedError, UserIdsNotMatchingError, UsernameContainsIllegalCharacterError, UserNotFoundError } from '../errors/UserErrors';
|
import { PasswordMustContainLowercaseLetterError, PasswordMustContainNumberError, PasswordMustContainUppercaseLetterError, PasswordTooShortError, UserDeletionNotConfirmedError, UserIdsNotMatchingError, UserNotFoundError, UsernameContainsIllegalCharacterError } from '../errors/UserErrors';
|
||||||
import { UserGroupNotFoundError } from '../errors/UserGroupErrors';
|
import { UserGroupNotFoundError } from '../errors/UserGroupErrors';
|
||||||
import { CreateUser } from '../models/actions/create/CreateUser';
|
import { CreateUser } from '../models/actions/create/CreateUser';
|
||||||
import { UpdateUser } from '../models/actions/update/UpdateUser';
|
import { UpdateUser } from '../models/actions/update/UpdateUser';
|
||||||
@@ -28,9 +28,17 @@ export class UserController {
|
|||||||
@Authorized("USER:GET")
|
@Authorized("USER:GET")
|
||||||
@ResponseSchema(ResponseUser, { isArray: true })
|
@ResponseSchema(ResponseUser, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all users. <br> This includes their groups and permissions granted to them.' })
|
@OpenAPI({ description: 'Lists all users. <br> This includes their groups and permissions granted to them.' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responseUsers: ResponseUser[] = new Array<ResponseUser>();
|
let responseUsers: ResponseUser[] = new Array<ResponseUser>();
|
||||||
const users = await this.userRepository.find({ relations: ['permissions', 'groups', 'groups.permissions'] });
|
let users: Array<User>;
|
||||||
|
|
||||||
|
if (page != undefined) {
|
||||||
|
users = await this.userRepository.find({ relations: ['permissions', 'groups', 'groups.permissions'], skip: page * page_size, take: page_size });
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
users = await this.userRepository.find({ relations: ['permissions', 'groups', 'groups.permissions'] });
|
||||||
|
}
|
||||||
|
|
||||||
users.forEach(user => {
|
users.forEach(user => {
|
||||||
responseUsers.push(new ResponseUser(user));
|
responseUsers.push(new ResponseUser(user));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { getConnectionManager, Repository } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { UserGroupIdsNotMatchingError, UserGroupNotFoundError } from '../errors/UserGroupErrors';
|
import { UserGroupIdsNotMatchingError, UserGroupNotFoundError } from '../errors/UserGroupErrors';
|
||||||
import { CreateUserGroup } from '../models/actions/create/CreateUserGroup';
|
import { CreateUserGroup } from '../models/actions/create/CreateUserGroup';
|
||||||
import { UpdateUserGroup } from '../models/actions/update/UpdateUserGroup';
|
import { UpdateUserGroup } from '../models/actions/update/UpdateUserGroup';
|
||||||
@@ -27,9 +27,16 @@ export class UserGroupController {
|
|||||||
@Authorized("USERGROUP:GET")
|
@Authorized("USERGROUP:GET")
|
||||||
@ResponseSchema(ResponseUserGroup, { isArray: true })
|
@ResponseSchema(ResponseUserGroup, { isArray: true })
|
||||||
@OpenAPI({ description: 'Lists all groups. <br> The information provided might change while the project continues to evolve.' })
|
@OpenAPI({ description: 'Lists all groups. <br> The information provided might change while the project continues to evolve.' })
|
||||||
async getAll() {
|
async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
|
||||||
let responseGroups: ResponseUserGroup[] = new Array<ResponseUserGroup>();
|
let responseGroups: ResponseUserGroup[] = new Array<ResponseUserGroup>();
|
||||||
const groups = await this.userGroupsRepository.find({ relations: ['permissions'] });
|
let groups: Array<UserGroup>;
|
||||||
|
|
||||||
|
if (page != undefined) {
|
||||||
|
groups = await this.userGroupsRepository.find({ relations: ['permissions'], skip: page * page_size, take: page_size });
|
||||||
|
} else {
|
||||||
|
groups = await this.userGroupsRepository.find({ relations: ['permissions'] });
|
||||||
|
}
|
||||||
|
|
||||||
groups.forEach(group => {
|
groups.forEach(group => {
|
||||||
responseGroups.push(group.toResponse());
|
responseGroups.push(group.toResponse());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,14 +47,14 @@ export class RunnerEmailNeededError extends NotAcceptableError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error to throw when a runner already requested a new selfservice link in the last 24hrs.
|
* Error to throw when a runner already requested a new selfservice link in the last 30s.
|
||||||
*/
|
*/
|
||||||
export class RunnerSelfserviceTimeoutError extends NotAcceptableError {
|
export class RunnerSelfserviceTimeoutError extends NotAcceptableError {
|
||||||
@IsString()
|
@IsString()
|
||||||
name = "RunnerSelfserviceTimeoutError"
|
name = "RunnerSelfserviceTimeoutError"
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
message = "You can only reqest a new token every 24hrs."
|
message = "You can only reqest a new token every 30s."
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createConnection } from "typeorm";
|
import { createConnection } from "typeorm";
|
||||||
import { runSeeder } from 'typeorm-seeding';
|
import { runSeeder } from 'typeorm-seeding';
|
||||||
|
import consola from 'consola';
|
||||||
import { config } from '../config';
|
import { config } from '../config';
|
||||||
import { ConfigFlag } from '../models/entities/ConfigFlags';
|
import { ConfigFlag } from '../models/entities/ConfigFlags';
|
||||||
import SeedPublicOrg from '../seeds/SeedPublicOrg';
|
import SeedPublicOrg from '../seeds/SeedPublicOrg';
|
||||||
@@ -11,6 +12,11 @@ import SeedUsers from '../seeds/SeedUsers';
|
|||||||
*/
|
*/
|
||||||
export default async () => {
|
export default async () => {
|
||||||
const connection = await createConnection();
|
const connection = await createConnection();
|
||||||
|
|
||||||
|
// Log discovered entities for debugging
|
||||||
|
consola.info(`TypeORM discovered ${connection.entityMetadatas.length} entities:`);
|
||||||
|
consola.info(connection.entityMetadatas.map(m => m.name).sort().join(', '));
|
||||||
|
|
||||||
await connection.synchronize();
|
await connection.synchronize();
|
||||||
|
|
||||||
//The data seeding part
|
//The data seeding part
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { Application } from "express";
|
import { Application } from "express";
|
||||||
|
import consola from "consola";
|
||||||
|
import { config } from "../config";
|
||||||
|
import NatsClient from "../nats/NatsClient";
|
||||||
|
import { warmAll } from "../nats/RunnerKV";
|
||||||
import databaseLoader from "./database";
|
import databaseLoader from "./database";
|
||||||
import expressLoader from "./express";
|
import expressLoader from "./express";
|
||||||
import openapiLoader from "./openapi";
|
import openapiLoader from "./openapi";
|
||||||
@@ -9,6 +13,16 @@ import openapiLoader from "./openapi";
|
|||||||
*/
|
*/
|
||||||
export default async (app: Application) => {
|
export default async (app: Application) => {
|
||||||
await databaseLoader();
|
await databaseLoader();
|
||||||
|
await NatsClient.connect();
|
||||||
|
|
||||||
|
if (config.nats_prewarm) {
|
||||||
|
consola.info("Prewarming NATS runner cache...");
|
||||||
|
const startTime = Date.now();
|
||||||
|
await warmAll();
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
consola.success(`NATS runner cache prewarmed in ${duration}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
await openapiLoader(app);
|
await openapiLoader(app);
|
||||||
await expressLoader(app);
|
await expressLoader(app);
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -18,9 +18,19 @@ export class Mailer {
|
|||||||
*/
|
*/
|
||||||
public static async sendResetMail(to_address: string, token: string, locale: string = "en") {
|
public static async sendResetMail(to_address: string, token: string, locale: string = "en") {
|
||||||
try {
|
try {
|
||||||
await axios.post(`${Mailer.base}/reset?locale=${locale}&key=${Mailer.key}`, {
|
await axios.request({
|
||||||
address: to_address,
|
method: 'POST',
|
||||||
resetKey: token
|
url: `${Mailer.base}/api/v1/email`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${Mailer.key}`,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
to: to_address,
|
||||||
|
templateName: 'password-reset',
|
||||||
|
language: locale,
|
||||||
|
data: { token: token }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (Mailer.testing) { return true; }
|
if (Mailer.testing) { return true; }
|
||||||
@@ -32,12 +42,26 @@ export class Mailer {
|
|||||||
* Function for sending a runner selfservice welcome mail.
|
* Function for sending a runner selfservice welcome mail.
|
||||||
* @param to_address The address the mail will be sent to. Should always get pulled from a runner object.
|
* @param to_address The address the mail will be sent to. Should always get pulled from a runner object.
|
||||||
* @param token The requested selfservice token - will be combined with the app_url to generate a selfservice profile link.
|
* @param token The requested selfservice token - will be combined with the app_url to generate a selfservice profile link.
|
||||||
*/
|
*/
|
||||||
public static async sendSelfserviceWelcomeMail(to_address: string, token: string, locale: string = "en") {
|
public static async sendSelfserviceWelcomeMail(to_address: string, runner_id: number, firstname: string, middlename: string, lastname: string, token: string, locale: string = "en") {
|
||||||
try {
|
try {
|
||||||
await axios.post(`${Mailer.base}/registration?locale=${locale}&key=${Mailer.key}`, {
|
await axios.request({
|
||||||
address: to_address,
|
method: 'POST',
|
||||||
selfserviceToken: token
|
url: `${Mailer.base}/api/v1/email`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${Mailer.key}`,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
to: to_address,
|
||||||
|
templateName: 'welcome',
|
||||||
|
language: locale,
|
||||||
|
data: {
|
||||||
|
name: `${firstname} ${middlename} ${lastname}`,
|
||||||
|
barcode_content: `${runner_id}`,
|
||||||
|
link: `${process.env.SELFSERVICE_URL}/profile/${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (Mailer.testing) { return true; }
|
if (Mailer.testing) { return true; }
|
||||||
@@ -49,15 +73,45 @@ export class Mailer {
|
|||||||
* Function for sending a runner selfservice link forgotten mail.
|
* Function for sending a runner selfservice link forgotten mail.
|
||||||
* @param to_address The address the mail will be sent to. Should always get pulled from a runner object.
|
* @param to_address The address the mail will be sent to. Should always get pulled from a runner object.
|
||||||
* @param token The requested selfservice token - will be combined with the app_url to generate a selfservice profile link.
|
* @param token The requested selfservice token - will be combined with the app_url to generate a selfservice profile link.
|
||||||
*/
|
*/
|
||||||
public static async sendSelfserviceForgottenMail(to_address: string, token: string, locale: string = "en") {
|
public static async sendSelfserviceForgottenMail(to_address: string, runner_id: number, firstname: string, middlename: string, lastname: string, token: string, locale: string = "en") {
|
||||||
try {
|
try {
|
||||||
await axios.post(`${Mailer.base}/registration_forgot?locale=${locale}&key=${Mailer.key}`, {
|
console.log("Mail request", {
|
||||||
address: to_address,
|
to: to_address,
|
||||||
selfserviceToken: token
|
templateName: 'welcome',
|
||||||
|
language: locale,
|
||||||
|
data: {
|
||||||
|
to: to_address,
|
||||||
|
templateName: 'welcome',
|
||||||
|
language: locale,
|
||||||
|
data: {
|
||||||
|
name: `${firstname} ${middlename} ${lastname}`,
|
||||||
|
barcode_content: `${runner_id}`,
|
||||||
|
link: `${process.env.SELFSERVICE_URL}/profile/${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await axios.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${Mailer.base}/api/v1/email`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${Mailer.key}`,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
to: to_address,
|
||||||
|
templateName: 'welcome',
|
||||||
|
language: locale,
|
||||||
|
data: {
|
||||||
|
name: `${firstname} ${middlename} ${lastname}`,
|
||||||
|
barcode_content: `${runner_id}`,
|
||||||
|
link: `${process.env.SELFSERVICE_URL}/profile/${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (Mailer.testing) { return true; }
|
if (Mailer.testing) { return true; }
|
||||||
|
console.error("Error while sending selfservice forgotten mail:", error.message);
|
||||||
throw new MailSendingError();
|
throw new MailSendingError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,129 @@
|
|||||||
import * as argon2 from "argon2";
|
import crypto from 'crypto';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { getConnectionManager } from 'typeorm';
|
import { getConnectionManager } from 'typeorm';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { deleteStationEntry, getStationEntry, setStationEntry, StationKVEntry } from '../nats/StationKV';
|
||||||
import { ScanStation } from '../models/entities/ScanStation';
|
import { ScanStation } from '../models/entities/ScanStation';
|
||||||
import authchecker from './authchecker';
|
import authchecker from './authchecker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the HMAC-SHA256 of the provided token using the station token secret.
|
||||||
|
*/
|
||||||
|
function computeHmac(token: string): string {
|
||||||
|
return crypto.createHmac('sha256', config.station_token_secret).update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time comparison of two hex HMAC strings.
|
||||||
|
* Returns true if they match.
|
||||||
|
*/
|
||||||
|
function verifyHmac(provided_token: string, storedHash: string): boolean {
|
||||||
|
const expectedHash = computeHmac(provided_token);
|
||||||
|
const expectedBuf = Buffer.from(expectedHash);
|
||||||
|
const storedBuf = Buffer.from(storedHash);
|
||||||
|
return expectedBuf.length === storedBuf.length && crypto.timingSafeEqual(expectedBuf, storedBuf);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This middleware handles the authentication of scan station api tokens.
|
* This middleware handles the authentication of scan station api tokens.
|
||||||
* The tokens have to be provided via Bearer authorization header.
|
* The tokens have to be provided via Bearer authorization header.
|
||||||
|
*
|
||||||
|
* Auth flow:
|
||||||
|
* 1. Extract prefix from token (PREFIX.KEY format)
|
||||||
|
* 2. Try NATS KV cache lookup by prefix — warm path: HMAC verify, no DB
|
||||||
|
* 3. On cache miss: DB lookup → HMAC verify → write to KV cache
|
||||||
|
* 4. On no station match at all: fall back to JWT auth (SCAN:CREATE permission)
|
||||||
|
*
|
||||||
|
* On success sets req.isStationAuth = true and req.stationId on the request object.
|
||||||
|
* These are internal server-side properties — not HTTP headers, not spoofable by clients.
|
||||||
|
*
|
||||||
* You have to manually use this middleware via @UseBefore(ScanAuth) instead of using @Authorized().
|
* You have to manually use this middleware via @UseBefore(ScanAuth) instead of using @Authorized().
|
||||||
* @param req Express request object.
|
* @param req Express request object.
|
||||||
* @param res Express response object.
|
* @param res Express response object.
|
||||||
* @param next Next function to call on success.
|
* @param next Next function to call on success.
|
||||||
*/
|
*/
|
||||||
const ScanAuth = async (req: Request, res: Response, next: () => void) => {
|
const ScanAuth = async (req: Request, res: Response, next: () => void) => {
|
||||||
let provided_token: string = req.headers["authorization"];
|
let provided_token: string = req.headers['authorization'];
|
||||||
if (provided_token == "" || provided_token === undefined || provided_token === null) {
|
if (!provided_token) {
|
||||||
res.status(401).send({ http_code: 401, short: "no_token", message: "No api token provided." });
|
res.status(401).send({ http_code: 401, short: 'no_token', message: 'No api token provided.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
provided_token = provided_token.replace('Bearer ', '');
|
||||||
provided_token = provided_token.replace("Bearer ", "");
|
|
||||||
} catch (error) {
|
const prefix = provided_token.split('.')[0];
|
||||||
res.status(401).send({ http_code: 401, short: "no_token", message: "No valid jwt or api token provided." });
|
if (!prefix) {
|
||||||
|
res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let prefix = "";
|
// --- KV cache lookup (warm path) ---
|
||||||
try {
|
const cached = await getStationEntry(prefix);
|
||||||
prefix = provided_token.split(".")[0];
|
if (cached) {
|
||||||
}
|
if (!cached.enabled) {
|
||||||
finally {
|
res.status(401).send({ http_code: 401, short: 'station_disabled', message: 'Station is disabled.' });
|
||||||
if (prefix == "" || prefix == undefined || prefix == null) {
|
|
||||||
res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!verifyHmac(provided_token, cached.tokenHash)) {
|
||||||
|
res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.isStationAuth = true;
|
||||||
|
req.stationId = cached.id;
|
||||||
|
next();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const station = await getConnectionManager().get().getRepository(ScanStation).findOne({ prefix: prefix });
|
// --- DB lookup (cold path) ---
|
||||||
|
const station = await getConnectionManager().get().getRepository(ScanStation).findOne({ prefix }, { relations: ['track'] });
|
||||||
|
|
||||||
if (!station) {
|
if (!station) {
|
||||||
|
// No station with this prefix — fall back to JWT auth
|
||||||
let user_authorized = false;
|
let user_authorized = false;
|
||||||
try {
|
try {
|
||||||
let action = { request: req, response: res, context: null, next: next }
|
const action = { request: req, response: res, context: null, next };
|
||||||
user_authorized = await authchecker(action, ["SCAN:CREATE"]);
|
user_authorized = await authchecker(action, ['SCAN:CREATE']);
|
||||||
}
|
} finally {
|
||||||
finally {
|
if (!user_authorized) {
|
||||||
if (user_authorized == false) {
|
res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
|
||||||
res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
else {
|
next();
|
||||||
next();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
if (station.enabled == false) {
|
// Station found — verify token before caching
|
||||||
res.status(401).send({ http_code: 401, short: "station_disabled", message: "Station is disabled." });
|
const tokenHash = computeHmac(provided_token);
|
||||||
}
|
const storedBuf = Buffer.from(station.key);
|
||||||
if (!(await argon2.verify(station.key, provided_token))) {
|
const computedBuf = Buffer.from(tokenHash);
|
||||||
res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." });
|
const valid = computedBuf.length === storedBuf.length && crypto.timingSafeEqual(computedBuf, storedBuf);
|
||||||
return;
|
|
||||||
}
|
if (!valid) {
|
||||||
req.headers["station_id"] = station.id.toString();
|
res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
|
||||||
next();
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
export default ScanAuth;
|
if (!station.enabled) {
|
||||||
|
res.status(401).send({ http_code: 401, short: 'station_disabled', message: 'Station is disabled.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to KV cache for subsequent requests
|
||||||
|
const entry: StationKVEntry = {
|
||||||
|
id: station.id,
|
||||||
|
enabled: station.enabled,
|
||||||
|
tokenHash,
|
||||||
|
trackId: station.track.id,
|
||||||
|
trackDistance: station.track.distance,
|
||||||
|
minimumLapTime: station.track.minimumLapTime ?? 0,
|
||||||
|
};
|
||||||
|
await setStationEntry(prefix, entry);
|
||||||
|
|
||||||
|
req.isStationAuth = true;
|
||||||
|
req.stationId = station.id;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScanAuth;
|
||||||
|
export { deleteStationEntry };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as argon2 from "argon2";
|
import { verify } from '@node-rs/argon2';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { getConnectionManager } from 'typeorm';
|
import { getConnectionManager } from 'typeorm';
|
||||||
import { StatsClient } from '../models/entities/StatsClient';
|
import { StatsClient } from '../models/entities/StatsClient';
|
||||||
@@ -55,7 +55,7 @@ const StatsAuth = async (req: Request, res: Response, next: () => void) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
if (!(await argon2.verify(client.key, provided_token))) {
|
if (!(await verify(client.key, provided_token))) {
|
||||||
res.status(401).send("Api token invalid.");
|
res.status(401).send("Api token invalid.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as argon2 from "argon2";
|
import { hash } from '@node-rs/argon2';
|
||||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
import * as jsonwebtoken from 'jsonwebtoken';
|
import * as jsonwebtoken from 'jsonwebtoken';
|
||||||
import { getConnectionManager } from 'typeorm';
|
import { getConnectionManager } from 'typeorm';
|
||||||
@@ -49,7 +49,7 @@ export class ResetPassword {
|
|||||||
if (found_user.refreshTokenCount !== decoded["refreshTokenCount"]) { throw new RefreshTokenCountInvalidError(); }
|
if (found_user.refreshTokenCount !== decoded["refreshTokenCount"]) { throw new RefreshTokenCountInvalidError(); }
|
||||||
|
|
||||||
found_user.refreshTokenCount = found_user.refreshTokenCount + 1;
|
found_user.refreshTokenCount = found_user.refreshTokenCount + 1;
|
||||||
found_user.password = await argon2.hash(this.password + found_user.uuid);
|
found_user.password = await hash(this.password + found_user.uuid);
|
||||||
await getConnectionManager().get().getRepository(User).save(found_user);
|
await getConnectionManager().get().getRepository(User).save(found_user);
|
||||||
|
|
||||||
return "password reset successfull";
|
return "password reset successfull";
|
||||||
|
|||||||
29
src/models/actions/create/CreateAnonymousDonation.ts
Normal file
29
src/models/actions/create/CreateAnonymousDonation.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { IsInt, IsPositive } from 'class-validator';
|
||||||
|
import { FixedDonation } from '../../entities/FixedDonation';
|
||||||
|
import { CreateDonation } from './CreateDonation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to create a new FixedDonation entity from a json body (post request).
|
||||||
|
*/
|
||||||
|
export class CreateAnonymousDonation extends CreateDonation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The donation's amount.
|
||||||
|
* The unit is your currency's smallest unit (default: euro cent).
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new FixedDonation entity from this.
|
||||||
|
*/
|
||||||
|
public async toEntity(): Promise<FixedDonation> {
|
||||||
|
let newDonation = new FixedDonation;
|
||||||
|
|
||||||
|
newDonation.amount = this.amount;
|
||||||
|
newDonation.paidAmount = this.amount;
|
||||||
|
|
||||||
|
return newDonation;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as argon2 from "argon2";
|
import { verify } from '@node-rs/argon2';
|
||||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
import { getConnectionManager } from 'typeorm';
|
import { getConnectionManager } from 'typeorm';
|
||||||
import { InvalidCredentialsError, PasswordNeededError, UserDisabledError, UserNotFoundError } from '../../../errors/AuthError';
|
import { InvalidCredentialsError, PasswordNeededError, UserDisabledError, UserNotFoundError } from '../../../errors/AuthError';
|
||||||
@@ -56,16 +56,16 @@ export class CreateAuth {
|
|||||||
throw new UserNotFoundError();
|
throw new UserNotFoundError();
|
||||||
}
|
}
|
||||||
if (found_user.enabled == false) { throw new UserDisabledError(); }
|
if (found_user.enabled == false) { throw new UserDisabledError(); }
|
||||||
if (!(await argon2.verify(found_user.password, this.password + found_user.uuid))) {
|
if (!(await verify(found_user.password, this.password + found_user.uuid))) {
|
||||||
throw new InvalidCredentialsError();
|
throw new InvalidCredentialsError();
|
||||||
}
|
}
|
||||||
|
|
||||||
//Create the access token
|
//Create the access token
|
||||||
const timestamp_accesstoken_expiry = Math.floor(Date.now() / 1000) + 5 * 60
|
const timestamp_accesstoken_expiry = Math.floor(Date.now() / 1000) + 24 * 60 * 60
|
||||||
newAuth.access_token = JwtCreator.createAccess(found_user, timestamp_accesstoken_expiry);
|
newAuth.access_token = JwtCreator.createAccess(found_user, timestamp_accesstoken_expiry);
|
||||||
newAuth.access_token_expires_at = timestamp_accesstoken_expiry
|
newAuth.access_token_expires_at = timestamp_accesstoken_expiry
|
||||||
//Create the refresh token
|
//Create the refresh token
|
||||||
const timestamp_refresh_expiry = Math.floor(Date.now() / 1000) + 10 * 36000
|
const timestamp_refresh_expiry = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
|
||||||
newAuth.refresh_token = JwtCreator.createRefresh(found_user, timestamp_refresh_expiry);
|
newAuth.refresh_token = JwtCreator.createRefresh(found_user, timestamp_refresh_expiry);
|
||||||
newAuth.refresh_token_expires_at = timestamp_refresh_expiry
|
newAuth.refresh_token_expires_at = timestamp_refresh_expiry
|
||||||
return newAuth;
|
return newAuth;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsPositive } from 'class-validator';
|
import { IsInt, IsOptional, IsPositive } from 'class-validator';
|
||||||
import { getConnection } from 'typeorm';
|
import { getConnection } from 'typeorm';
|
||||||
import { RunnerNotFoundError } from '../../../errors/RunnerErrors';
|
import { RunnerNotFoundError } from '../../../errors/RunnerErrors';
|
||||||
import { DistanceDonation } from '../../entities/DistanceDonation';
|
import { DistanceDonation } from '../../entities/DistanceDonation';
|
||||||
@@ -10,6 +10,21 @@ import { CreateDonation } from './CreateDonation';
|
|||||||
*/
|
*/
|
||||||
export class CreateDistanceDonation extends CreateDonation {
|
export class CreateDistanceDonation extends CreateDonation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The donation's associated donor's id.
|
||||||
|
* This is important to link donations to donors.
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
donor: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The donation's paid amount in the smalles unit of your currency (default: euro cent).
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
|
paidAmount?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The donation's associated runner's id.
|
* The donation's associated runner's id.
|
||||||
* This is important to link the runner's distance ran to the donation.
|
* This is important to link the runner's distance ran to the donation.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { IsInt, IsOptional, IsPositive } from 'class-validator';
|
import { IsInt, IsOptional } from 'class-validator';
|
||||||
import { getConnection } from 'typeorm';
|
import { getConnection } from 'typeorm';
|
||||||
import { DonorNotFoundError } from '../../../errors/DonorErrors';
|
|
||||||
import { Donation } from '../../entities/Donation';
|
import { Donation } from '../../entities/Donation';
|
||||||
import { Donor } from '../../entities/Donor';
|
import { Donor } from '../../entities/Donor';
|
||||||
|
|
||||||
@@ -8,17 +7,10 @@ import { Donor } from '../../entities/Donor';
|
|||||||
* This class is used to create a new Donation entity from a json body (post request).
|
* This class is used to create a new Donation entity from a json body (post request).
|
||||||
*/
|
*/
|
||||||
export abstract class CreateDonation {
|
export abstract class CreateDonation {
|
||||||
/**
|
|
||||||
* The donation's associated donor's id.
|
|
||||||
* This is important to link donations to donors.
|
|
||||||
*/
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsPositive()
|
@IsOptional()
|
||||||
donor: number;
|
donor: number;
|
||||||
|
|
||||||
/**
|
|
||||||
* The donation's paid amount in the smalles unit of your currency (default: euro cent).
|
|
||||||
*/
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
paidAmount?: number;
|
paidAmount?: number;
|
||||||
@@ -33,9 +25,6 @@ export abstract class CreateDonation {
|
|||||||
*/
|
*/
|
||||||
public async getDonor(): Promise<Donor> {
|
public async getDonor(): Promise<Donor> {
|
||||||
const donor = await getConnection().getRepository(Donor).findOne({ id: this.donor });
|
const donor = await getConnection().getRepository(Donor).findOne({ id: this.donor });
|
||||||
if (!donor) {
|
|
||||||
throw new DonorNotFoundError();
|
|
||||||
}
|
|
||||||
return donor;
|
return donor;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,21 @@ import { CreateDonation } from './CreateDonation';
|
|||||||
* This class is used to create a new FixedDonation entity from a json body (post request).
|
* This class is used to create a new FixedDonation entity from a json body (post request).
|
||||||
*/
|
*/
|
||||||
export class CreateFixedDonation extends CreateDonation {
|
export class CreateFixedDonation extends CreateDonation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The donation's associated donor's id.
|
||||||
|
* This is important to link donations to donors.
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
donor: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The donation's paid amount in the smalles unit of your currency (default: euro cent).
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
paidAmount?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The donation's amount.
|
* The donation's amount.
|
||||||
* The unit is your currency's smallest unit (default: euro cent).
|
* The unit is your currency's smallest unit (default: euro cent).
|
||||||
|
|||||||
@@ -50,4 +50,11 @@ export abstract class CreateParticipant {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsObject()
|
@IsObject()
|
||||||
address?: Address;
|
address?: Address;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* how the participant got into the system
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
created_via?: string;
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,9 @@ export class CreateRunner extends CreateParticipant {
|
|||||||
newRunner.email = this.email;
|
newRunner.email = this.email;
|
||||||
newRunner.group = await this.getGroup();
|
newRunner.group = await this.getGroup();
|
||||||
newRunner.address = this.address;
|
newRunner.address = this.address;
|
||||||
|
if (this.created_via) {
|
||||||
|
newRunner.created_via = this.created_via;
|
||||||
|
}
|
||||||
Address.validate(newRunner.address);
|
Address.validate(newRunner.address);
|
||||||
|
|
||||||
return newRunner;
|
return newRunner;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as argon2 from "argon2";
|
|
||||||
import { IsBoolean, IsInt, IsOptional, IsPositive, IsString } from 'class-validator';
|
import { IsBoolean, IsInt, IsOptional, IsPositive, IsString } from 'class-validator';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { getConnection } from 'typeorm';
|
import { getConnection } from 'typeorm';
|
||||||
import * as uuid from 'uuid';
|
import * as uuid from 'uuid';
|
||||||
|
import { config } from '../../../config';
|
||||||
import { TrackNotFoundError } from '../../../errors/TrackErrors';
|
import { TrackNotFoundError } from '../../../errors/TrackErrors';
|
||||||
import { ScanStation } from '../../entities/ScanStation';
|
import { ScanStation } from '../../entities/ScanStation';
|
||||||
import { Track } from '../../entities/Track';
|
import { Track } from '../../entities/Track';
|
||||||
@@ -44,8 +44,8 @@ export class CreateScanStation {
|
|||||||
|
|
||||||
let newUUID = uuid.v4().toUpperCase();
|
let newUUID = uuid.v4().toUpperCase();
|
||||||
newStation.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase();
|
newStation.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase();
|
||||||
newStation.key = await argon2.hash(newStation.prefix + "." + newUUID);
|
|
||||||
newStation.cleartextkey = newStation.prefix + "." + newUUID;
|
newStation.cleartextkey = newStation.prefix + "." + newUUID;
|
||||||
|
newStation.key = crypto.createHmac("sha256", config.station_token_secret).update(newStation.cleartextkey).digest('hex');
|
||||||
|
|
||||||
return newStation;
|
return newStation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class CreateSelfServiceCitizenRunner extends CreateParticipant {
|
|||||||
public async toEntity(): Promise<Runner> {
|
public async toEntity(): Promise<Runner> {
|
||||||
let newRunner: Runner = new Runner();
|
let newRunner: Runner = new Runner();
|
||||||
|
|
||||||
|
newRunner.created_via = "selfservice";
|
||||||
newRunner.firstname = this.firstname;
|
newRunner.firstname = this.firstname;
|
||||||
newRunner.middlename = this.middlename;
|
newRunner.middlename = this.middlename;
|
||||||
newRunner.lastname = this.lastname;
|
newRunner.lastname = this.lastname;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class CreateSelfServiceRunner extends CreateParticipant {
|
|||||||
public async toEntity(group: RunnerGroup): Promise<Runner> {
|
public async toEntity(group: RunnerGroup): Promise<Runner> {
|
||||||
let newRunner: Runner = new Runner();
|
let newRunner: Runner = new Runner();
|
||||||
|
|
||||||
|
newRunner.created_via = "selfservice";
|
||||||
newRunner.firstname = this.firstname;
|
newRunner.firstname = this.firstname;
|
||||||
newRunner.middlename = this.middlename;
|
newRunner.middlename = this.middlename;
|
||||||
newRunner.lastname = this.lastname;
|
newRunner.lastname = this.lastname;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as argon2 from "argon2";
|
import { hash } from '@node-rs/argon2';
|
||||||
import { IsOptional, IsString } from 'class-validator';
|
import { IsOptional, IsString } from 'class-validator';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import * as uuid from 'uuid';
|
import * as uuid from 'uuid';
|
||||||
@@ -25,7 +25,7 @@ export class CreateStatsClient {
|
|||||||
|
|
||||||
let newUUID = uuid.v4().toUpperCase();
|
let newUUID = uuid.v4().toUpperCase();
|
||||||
newClient.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase();
|
newClient.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase();
|
||||||
newClient.key = await argon2.hash(newClient.prefix + "." + newUUID);
|
newClient.key = await hash(newClient.prefix + "." + newUUID);
|
||||||
newClient.cleartextkey = newClient.prefix + "." + newUUID;
|
newClient.cleartextkey = newClient.prefix + "." + newUUID;
|
||||||
|
|
||||||
return newClient;
|
return newClient;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as argon2 from "argon2";
|
import { hash } from "@node-rs/argon2";
|
||||||
import { passwordStrength } from "check-password-strength";
|
import { passwordStrength } from "check-password-strength";
|
||||||
import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator';
|
import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator';
|
||||||
import { getConnectionManager } from 'typeorm';
|
import { getConnectionManager } from 'typeorm';
|
||||||
@@ -110,7 +110,7 @@ export class CreateUser {
|
|||||||
newUser.lastname = this.lastname
|
newUser.lastname = this.lastname
|
||||||
newUser.uuid = uuid.v4()
|
newUser.uuid = uuid.v4()
|
||||||
newUser.phone = this.phone
|
newUser.phone = this.phone
|
||||||
newUser.password = await argon2.hash(this.password + newUser.uuid);
|
newUser.password = await hash(this.password + newUser.uuid);
|
||||||
newUser.groups = await this.getGroups();
|
newUser.groups = await this.getGroups();
|
||||||
newUser.enabled = this.enabled;
|
newUser.enabled = this.enabled;
|
||||||
|
|
||||||
|
|||||||
50
src/models/actions/update/UpdateRunnerCardByCode.ts
Normal file
50
src/models/actions/update/UpdateRunnerCardByCode.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { IsBoolean, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { getConnection } from 'typeorm';
|
||||||
|
import { RunnerNotFoundError } from '../../../errors/RunnerErrors';
|
||||||
|
import { Runner } from '../../entities/Runner';
|
||||||
|
import { RunnerCard } from '../../entities/RunnerCard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class is used to update a RunnerCard entity (via put request).
|
||||||
|
*/
|
||||||
|
export class UpdateRunnerCardByCode {
|
||||||
|
/**
|
||||||
|
* The card's code.
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The runner's id.
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
|
runner?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the updated card enabled (for fraud reasons)?
|
||||||
|
* Default: true
|
||||||
|
*/
|
||||||
|
@IsBoolean()
|
||||||
|
enabled: boolean = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new RunnerCard entity from this.
|
||||||
|
*/
|
||||||
|
public async update(card: RunnerCard): Promise<RunnerCard> {
|
||||||
|
card.enabled = this.enabled;
|
||||||
|
card.runner = await this.getRunner();
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getRunner(): Promise<Runner> {
|
||||||
|
if (!this.runner) { return null; }
|
||||||
|
const runner = await getConnection().getRepository(Runner).findOne({ id: this.runner });
|
||||||
|
if (!runner) {
|
||||||
|
throw new RunnerNotFoundError();
|
||||||
|
}
|
||||||
|
return runner;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as argon2 from "argon2";
|
import { hash } from '@node-rs/argon2';
|
||||||
import { passwordStrength } from "check-password-strength";
|
import { passwordStrength } from "check-password-strength";
|
||||||
import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator';
|
import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator';
|
||||||
import { getConnectionManager } from 'typeorm';
|
import { getConnectionManager } from 'typeorm';
|
||||||
@@ -111,7 +111,7 @@ export class UpdateUser {
|
|||||||
if (!password_strength.contains.includes("lowercase")) { throw new PasswordMustContainLowercaseLetterError(); }
|
if (!password_strength.contains.includes("lowercase")) { throw new PasswordMustContainLowercaseLetterError(); }
|
||||||
if (!password_strength.contains.includes("number")) { throw new PasswordMustContainNumberError(); }
|
if (!password_strength.contains.includes("number")) { throw new PasswordMustContainNumberError(); }
|
||||||
if (!(password_strength.length > 9)) { throw new PasswordTooShortError(); }
|
if (!(password_strength.length > 9)) { throw new PasswordTooShortError(); }
|
||||||
user.password = await argon2.hash(this.password + user.uuid);
|
user.password = await hash(this.password + user.uuid);
|
||||||
user.refreshTokenCount = user.refreshTokenCount + 1;
|
user.refreshTokenCount = user.refreshTokenCount + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, PrimaryColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, PrimaryColumn } from "typeorm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the ConfigFlag entity.
|
* Defines the ConfigFlag entity.
|
||||||
@@ -24,4 +26,25 @@ export class ConfigFlag {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
value: string;
|
value: string;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,23 @@
|
|||||||
import { IsInt, IsNotEmpty, IsPositive } from "class-validator";
|
import { IsInt, IsNotEmpty, IsPositive } from "class-validator";
|
||||||
import { ChildEntity, Column, ManyToOne } from "typeorm";
|
import { ChildEntity, Column, Index, ManyToOne } from "typeorm";
|
||||||
import { ResponseDistanceDonation } from '../responses/ResponseDistanceDonation';
|
import { ResponseDistanceDonation } from '../responses/ResponseDistanceDonation';
|
||||||
import { Donation } from "./Donation";
|
import { Donation } from "./Donation";
|
||||||
import { Runner } from "./Runner";
|
import type { Runner } from "./Runner";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the DistanceDonation entity.
|
* Defines the DistanceDonation entity.
|
||||||
* For distanceDonations a donor pledges to donate a certain amount for each kilometer ran by a runner.
|
* For distanceDonations a donor pledges to donate a certain amount for each kilometer ran by a runner.
|
||||||
*/
|
*/
|
||||||
@ChildEntity()
|
@ChildEntity()
|
||||||
|
@Index(['runner'])
|
||||||
export class DistanceDonation extends Donation {
|
export class DistanceDonation extends Donation {
|
||||||
/**
|
/**
|
||||||
* The donation's associated runner.
|
* The donation's associated runner.
|
||||||
* Used as the source of the donation's distance.
|
* Used as the source of the donation's distance.
|
||||||
*/
|
*/
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ManyToOne(() => Runner, runner => runner.distanceDonations)
|
@ManyToOne(() => require("./Runner").Runner, (runner: Runner) => runner.distanceDonations)
|
||||||
runner: Runner;
|
runner!: Runner;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The donation's amount donated per distance.
|
* The donation's amount donated per distance.
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||||
import { ResponseDonation } from '../responses/ResponseDonation';
|
import { ResponseDonation } from '../responses/ResponseDonation';
|
||||||
import { Donor } from './Donor';
|
import type { Donor } from './Donor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the Donation entity.
|
* Defines the Donation entity.
|
||||||
* A donation just associates a donor with a donation amount.
|
* A donation just associates a donor with a donation amount.
|
||||||
* The specifics of the amoun's determination has to be implemented in child classes.
|
* The specifics of the amoun's determination has to be implemented in child classes.
|
||||||
*/
|
*/
|
||||||
@Entity()
|
@Entity()
|
||||||
@TableInheritance({ column: { name: "type", type: "varchar" } })
|
@TableInheritance({ column: { name: "type", type: "varchar" } })
|
||||||
|
@Index(['donor'])
|
||||||
export abstract class Donation {
|
export abstract class Donation {
|
||||||
/**
|
/**
|
||||||
* Autogenerated unique id (primary key).
|
* Autogenerated unique id (primary key).
|
||||||
@@ -24,9 +25,8 @@ export abstract class Donation {
|
|||||||
/**
|
/**
|
||||||
* The donations's donor.
|
* The donations's donor.
|
||||||
*/
|
*/
|
||||||
@IsNotEmpty()
|
@ManyToOne(() => require("./Donor").Donor, (donor: Donor) => donor.donations)
|
||||||
@ManyToOne(() => Donor, donor => donor.donations)
|
donor!: Donor;
|
||||||
donor: Donor;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The donation's amount in cents (or whatever your currency's smallest unit is.).
|
* The donation's amount in cents (or whatever your currency's smallest unit is.).
|
||||||
@@ -42,6 +42,27 @@ export abstract class Donation {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
paidAmount: number;
|
paidAmount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { IsBoolean, IsInt } from "class-validator";
|
import { IsBoolean, IsInt } from "class-validator";
|
||||||
import { ChildEntity, Column, OneToMany } from "typeorm";
|
import { ChildEntity, Column, OneToMany } from "typeorm";
|
||||||
import { ResponseDonor } from '../responses/ResponseDonor';
|
import { ResponseDonor } from '../responses/ResponseDonor';
|
||||||
import { Donation } from './Donation';
|
import type { Donation } from './Donation';
|
||||||
import { Participant } from "./Participant";
|
import { Participant } from "./Participant";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,8 +21,8 @@ export class Donor extends Participant {
|
|||||||
* Used to link the participant as the donor of a donation.
|
* Used to link the participant as the donor of a donation.
|
||||||
* Attention: Only runner's can be associated as a distanceDonations distance source.
|
* Attention: Only runner's can be associated as a distanceDonations distance source.
|
||||||
*/
|
*/
|
||||||
@OneToMany(() => Donation, donation => donation.donor, { nullable: true })
|
@OneToMany(() => require("./Donation").Donation, (donation: Donation) => donation.donor, { nullable: true })
|
||||||
donations: Donation[];
|
donations!: Donation[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the total donations of a donor based on his linked donations.
|
* Returns the total donations of a donor based on his linked donations.
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
IsEmail,
|
IsEmail,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsPhoneNumber,
|
IsPhoneNumber,
|
||||||
|
|
||||||
IsString
|
IsPositive,
|
||||||
} from "class-validator";
|
|
||||||
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
IsString
|
||||||
import { config } from '../../config';
|
} from "class-validator";
|
||||||
import { ResponseGroupContact } from '../responses/ResponseGroupContact';
|
import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { Address } from "./Address";
|
import { config } from '../../config';
|
||||||
import { RunnerGroup } from "./RunnerGroup";
|
import { ResponseGroupContact } from '../responses/ResponseGroupContact';
|
||||||
|
import { Address } from "./Address";
|
||||||
|
import type { RunnerGroup } from "./RunnerGroup";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the GroupContact entity.
|
* Defines the GroupContact entity.
|
||||||
@@ -75,11 +77,32 @@ export class GroupContact {
|
|||||||
@IsEmail()
|
@IsEmail()
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to link contacts to groups.
|
* Used to link contacts to groups.
|
||||||
*/
|
*/
|
||||||
@OneToMany(() => RunnerGroup, group => group.contact, { nullable: true })
|
@OneToMany(() => require("./RunnerGroup").RunnerGroup, (group: RunnerGroup) => group.contact, { nullable: true })
|
||||||
groups: RunnerGroup[];
|
groups!: RunnerGroup[];
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
|
|||||||
@@ -5,20 +5,23 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsPhoneNumber,
|
IsPhoneNumber,
|
||||||
|
|
||||||
|
IsPositive,
|
||||||
|
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, Index, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||||
import { config } from '../../config';
|
import { config } from '../../config';
|
||||||
import { ResponseParticipant } from '../responses/ResponseParticipant';
|
import { ResponseParticipant } from '../responses/ResponseParticipant';
|
||||||
import { Address } from "./Address";
|
import { Address } from "./Address";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the Participant entity.
|
* Defines the Participant entity.
|
||||||
* Participans can donate and therefor be associated with donation entities.
|
* Participans can donate and therefor be associated with donation entities.
|
||||||
*/
|
*/
|
||||||
@Entity()
|
@Entity()
|
||||||
@TableInheritance({ column: { name: "type", type: "varchar" } })
|
@TableInheritance({ column: { name: "type", type: "varchar" } })
|
||||||
export abstract class Participant {
|
@Index(['email'])
|
||||||
|
export abstract class Participant {
|
||||||
/**
|
/**
|
||||||
* Autogenerated unique id (primary key).
|
* Autogenerated unique id (primary key).
|
||||||
*/
|
*/
|
||||||
@@ -75,6 +78,35 @@ export abstract class Participant {
|
|||||||
@IsEmail()
|
@IsEmail()
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* how the participant got into the system
|
||||||
|
*/
|
||||||
|
@Column({ nullable: true, default: "backend" })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
created_via?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty
|
IsNotEmpty,
|
||||||
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { PermissionAction } from '../enums/PermissionAction';
|
import { PermissionAction } from '../enums/PermissionAction';
|
||||||
import { PermissionTarget } from '../enums/PermissionTargets';
|
import { PermissionTarget } from '../enums/PermissionTargets';
|
||||||
import { ResponsePermission } from '../responses/ResponsePermission';
|
import { ResponsePermission } from '../responses/ResponsePermission';
|
||||||
import { Principal } from './Principal';
|
import type { Principal } from './Principal';
|
||||||
/**
|
/**
|
||||||
* Defines the Permission entity.
|
* Defines the Permission entity.
|
||||||
* Permissions can be granted to principals.
|
* Permissions can be granted to principals.
|
||||||
@@ -25,8 +26,8 @@ export class Permission {
|
|||||||
/**
|
/**
|
||||||
* The permission's principal.
|
* The permission's principal.
|
||||||
*/
|
*/
|
||||||
@ManyToOne(() => Principal, principal => principal.permissions)
|
@ManyToOne(() => require("./Principal").Principal, (principal: Principal) => principal.permissions)
|
||||||
principal: Principal;
|
principal!: Principal;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The permission's target.
|
* The permission's target.
|
||||||
@@ -45,6 +46,27 @@ export class Permission {
|
|||||||
@IsEnum(PermissionAction)
|
@IsEnum(PermissionAction)
|
||||||
action: PermissionAction;
|
action: PermissionAction;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn this into a string for exporting and jwts.
|
* Turn this into a string for exporting and jwts.
|
||||||
* Mainly used to shrink the size of jwts (otherwise the would contain entire objects).
|
* Mainly used to shrink the size of jwts (otherwise the would contain entire objects).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { IsInt } from 'class-validator';
|
import { IsInt, IsPositive } from 'class-validator';
|
||||||
import { Entity, OneToMany, PrimaryGeneratedColumn, TableInheritance } from 'typeorm';
|
import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn, TableInheritance } from 'typeorm';
|
||||||
import { ResponsePrincipal } from '../responses/ResponsePrincipal';
|
import { ResponsePrincipal } from '../responses/ResponsePrincipal';
|
||||||
import { Permission } from './Permission';
|
import type { Permission } from './Permission';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the principal entity.
|
* Defines the principal entity.
|
||||||
@@ -20,8 +20,29 @@ export abstract class Principal {
|
|||||||
/**
|
/**
|
||||||
* The participant's permissions.
|
* The participant's permissions.
|
||||||
*/
|
*/
|
||||||
@OneToMany(() => Permission, permission => permission.principal, { nullable: true })
|
@OneToMany(() => require("./Permission").Permission, (permission: Permission) => permission.principal, { nullable: true })
|
||||||
permissions: Permission[];
|
permissions!: Permission[];
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { IsInt, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
import { IsInt, IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||||
import { ChildEntity, Column, ManyToOne, OneToMany } from "typeorm";
|
import { ChildEntity, Column, Index, ManyToOne, OneToMany } from "typeorm";
|
||||||
import { ResponseRunner } from '../responses/ResponseRunner';
|
import { ResponseRunner } from '../responses/ResponseRunner';
|
||||||
import { DistanceDonation } from "./DistanceDonation";
|
import type { DistanceDonation } from "./DistanceDonation";
|
||||||
import { Participant } from "./Participant";
|
import { Participant } from "./Participant";
|
||||||
import { RunnerCard } from "./RunnerCard";
|
import type { RunnerCard } from "./RunnerCard";
|
||||||
import { RunnerGroup } from "./RunnerGroup";
|
import { RunnerGroup } from "./RunnerGroup";
|
||||||
import { Scan } from "./Scan";
|
import type { Scan } from "./Scan";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the runner entity.
|
* Defines the runner entity.
|
||||||
* Runners differ from participants in being able to actually accumulate a ran distance through scans.
|
* Runners differ from participants in being able to actually accumulate a ran distance through scans.
|
||||||
* Runner's get organized in groups.
|
* Runner's get organized in groups.
|
||||||
*/
|
*/
|
||||||
@ChildEntity()
|
@ChildEntity()
|
||||||
|
@Index(['group'])
|
||||||
export class Runner extends Participant {
|
export class Runner extends Participant {
|
||||||
/**
|
/**
|
||||||
* The runner's associated group.
|
* The runner's associated group.
|
||||||
@@ -26,22 +27,22 @@ export class Runner extends Participant {
|
|||||||
* The runner's associated distanceDonations.
|
* The runner's associated distanceDonations.
|
||||||
* Used to link runners to distanceDonations in order to calculate the donation's amount based on the distance the runner ran.
|
* Used to link runners to distanceDonations in order to calculate the donation's amount based on the distance the runner ran.
|
||||||
*/
|
*/
|
||||||
@OneToMany(() => DistanceDonation, distanceDonation => distanceDonation.runner, { nullable: true })
|
@OneToMany(() => require("./DistanceDonation").DistanceDonation, (distanceDonation: DistanceDonation) => distanceDonation.runner, { nullable: true })
|
||||||
distanceDonations: DistanceDonation[];
|
distanceDonations!: DistanceDonation[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The runner's associated cards.
|
* The runner's associated cards.
|
||||||
* Used to link runners to cards - yes a runner be associated with multiple cards this came in handy in the past.
|
* Used to link runners to cards - yes a runner be associated with multiple cards this came in handy in the past.
|
||||||
*/
|
*/
|
||||||
@OneToMany(() => RunnerCard, card => card.runner, { nullable: true })
|
@OneToMany(() => require("./RunnerCard").RunnerCard, (card: RunnerCard) => card.runner, { nullable: true })
|
||||||
cards: RunnerCard[];
|
cards!: RunnerCard[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The runner's associated scans.
|
* The runner's associated scans.
|
||||||
* Used to link runners to scans (valid and fraudulant).
|
* Used to link runners to scans (valid and fraudulant).
|
||||||
*/
|
*/
|
||||||
@OneToMany(() => Scan, scan => scan.runner, { nullable: true })
|
@OneToMany(() => require("./Scan").Scan, (scan: Scan) => scan.runner, { nullable: true })
|
||||||
scans: Scan[];
|
scans!: Scan[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The last time the runner requested a selfservice link.
|
* The last time the runner requested a selfservice link.
|
||||||
@@ -57,7 +58,10 @@ export class Runner extends Participant {
|
|||||||
* This is implemented here to avoid duplicate code in other files.
|
* This is implemented here to avoid duplicate code in other files.
|
||||||
*/
|
*/
|
||||||
public get validScans(): Scan[] {
|
public get validScans(): Scan[] {
|
||||||
return this.scans.filter(scan => scan.valid == true);
|
if (this.scans) {
|
||||||
|
return this.scans.filter(scan => scan.valid == true);
|
||||||
|
}
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +85,6 @@ export class Runner extends Participant {
|
|||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
public toResponse(): ResponseRunner {
|
public toResponse(): ResponseRunner {
|
||||||
return new ResponseRunner(this);
|
return new ResponseRunner(this, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,20 +3,23 @@ import {
|
|||||||
|
|
||||||
IsInt,
|
IsInt,
|
||||||
|
|
||||||
IsOptional
|
IsOptional,
|
||||||
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { RunnerCardIdOutOfRangeError } from '../../errors/RunnerCardErrors';
|
import { RunnerCardIdOutOfRangeError } from '../../errors/RunnerCardErrors';
|
||||||
import { ResponseRunnerCard } from '../responses/ResponseRunnerCard';
|
import { ResponseRunnerCard } from '../responses/ResponseRunnerCard';
|
||||||
import { Runner } from "./Runner";
|
import type { Runner } from "./Runner";
|
||||||
import { TrackScan } from "./TrackScan";
|
import type { TrackScan } from "./TrackScan";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the RunnerCard entity.
|
* Defines the RunnerCard entity.
|
||||||
* A runnerCard is a physical representation for a runner.
|
* A runnerCard is a physical representation for a runner.
|
||||||
* It can be associated with a runner to create scans via the scan station's.
|
* It can be associated with a runner to create scans via the scan station's.
|
||||||
*/
|
*/
|
||||||
@Entity()
|
@Entity()
|
||||||
|
@Index(['runner'])
|
||||||
|
@Index(['enabled'])
|
||||||
export class RunnerCard {
|
export class RunnerCard {
|
||||||
/**
|
/**
|
||||||
* Autogenerated unique id (primary key).
|
* Autogenerated unique id (primary key).
|
||||||
@@ -30,8 +33,8 @@ export class RunnerCard {
|
|||||||
* To increase reusability a card can be reassigned.
|
* To increase reusability a card can be reassigned.
|
||||||
*/
|
*/
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@ManyToOne(() => Runner, runner => runner.cards, { nullable: true })
|
@ManyToOne(() => require("./Runner").Runner, (runner: Runner) => runner.cards, { nullable: true })
|
||||||
runner: Runner;
|
runner!: Runner;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is the card enabled (for fraud reasons)?
|
* Is the card enabled (for fraud reasons)?
|
||||||
@@ -45,8 +48,29 @@ export class RunnerCard {
|
|||||||
* The card's associated scans.
|
* The card's associated scans.
|
||||||
* Used to link cards to track scans.
|
* Used to link cards to track scans.
|
||||||
*/
|
*/
|
||||||
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true })
|
@OneToMany(() => require("./TrackScan").TrackScan, (scan: TrackScan) => scan.card, { nullable: true })
|
||||||
scans: TrackScan[];
|
scans!: TrackScan[];
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a ean-13 compliant string for barcode generation.
|
* Generates a ean-13 compliant string for barcode generation.
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||||
import { ResponseRunnerGroup } from '../responses/ResponseRunnerGroup';
|
import { ResponseRunnerGroup } from '../responses/ResponseRunnerGroup';
|
||||||
import { GroupContact } from "./GroupContact";
|
import { GroupContact } from "./GroupContact";
|
||||||
import { Runner } from "./Runner";
|
import type { Runner } from "./Runner";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the RunnerGroup entity.
|
* Defines the RunnerGroup entity.
|
||||||
@@ -43,8 +44,29 @@ export abstract class RunnerGroup {
|
|||||||
* The group's associated runners.
|
* The group's associated runners.
|
||||||
* Used to link runners to a runner group.
|
* Used to link runners to a runner group.
|
||||||
*/
|
*/
|
||||||
@OneToMany(() => Runner, runner => runner.group, { nullable: true })
|
@OneToMany(() => require("./Runner").Runner, (runner: Runner) => runner.group, { nullable: true })
|
||||||
runners: Runner[];
|
runners!: Runner[];
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the total distance ran by this group's runners based on all their valid scans.
|
* Returns the total distance ran by this group's runners based on all their valid scans.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ResponseRunnerOrganization } from '../responses/ResponseRunnerOrganizat
|
|||||||
import { Address } from './Address';
|
import { Address } from './Address';
|
||||||
import { Runner } from './Runner';
|
import { Runner } from './Runner';
|
||||||
import { RunnerGroup } from "./RunnerGroup";
|
import { RunnerGroup } from "./RunnerGroup";
|
||||||
import { RunnerTeam } from "./RunnerTeam";
|
import type { RunnerTeam } from "./RunnerTeam";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the RunnerOrganization entity.
|
* Defines the RunnerOrganization entity.
|
||||||
@@ -24,8 +24,8 @@ export class RunnerOrganization extends RunnerGroup {
|
|||||||
* The organization's teams.
|
* The organization's teams.
|
||||||
* Used to link teams to a organization.
|
* Used to link teams to a organization.
|
||||||
*/
|
*/
|
||||||
@OneToMany(() => RunnerTeam, team => team.parentGroup, { nullable: true })
|
@OneToMany(() => require("./RunnerTeam").RunnerTeam, (team: RunnerTeam) => team.parentGroup, { nullable: true })
|
||||||
teams: RunnerTeam[];
|
teams!: RunnerTeam[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The organization's api key for self-service registration.
|
* The organization's api key for self-service registration.
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { IsNotEmpty } from "class-validator";
|
import { IsNotEmpty } from "class-validator";
|
||||||
import { ChildEntity, ManyToOne } from "typeorm";
|
import { ChildEntity, Index, ManyToOne } from "typeorm";
|
||||||
import { ResponseRunnerTeam } from '../responses/ResponseRunnerTeam';
|
import { ResponseRunnerTeam } from '../responses/ResponseRunnerTeam';
|
||||||
import { RunnerGroup } from "./RunnerGroup";
|
import { RunnerGroup } from "./RunnerGroup";
|
||||||
import { RunnerOrganization } from "./RunnerOrganization";
|
import type { RunnerOrganization } from "./RunnerOrganization";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the RunnerTeam entity.
|
* Defines the RunnerTeam entity.
|
||||||
* This usually is a school class or department in a company.
|
* This usually is a school class or department in a company.
|
||||||
*/
|
*/
|
||||||
@ChildEntity()
|
@ChildEntity()
|
||||||
|
@Index(['parentGroup'])
|
||||||
export class RunnerTeam extends RunnerGroup {
|
export class RunnerTeam extends RunnerGroup {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +17,7 @@ export class RunnerTeam extends RunnerGroup {
|
|||||||
* Every team has to be part of a runnerOrganization - this get's checked on creation and update.
|
* Every team has to be part of a runnerOrganization - this get's checked on creation and update.
|
||||||
*/
|
*/
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ManyToOne(() => RunnerOrganization, org => org.teams, { nullable: true })
|
@ManyToOne(() => require("./RunnerOrganization").RunnerOrganization, (org: RunnerOrganization) => org.teams, { nullable: true })
|
||||||
parentGroup?: RunnerOrganization;
|
parentGroup?: RunnerOrganization;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import {
|
|||||||
|
|
||||||
IsPositive
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||||
import { ResponseScan } from '../responses/ResponseScan';
|
import { ResponseScan } from '../responses/ResponseScan';
|
||||||
import { Runner } from "./Runner";
|
import type { Runner } from "./Runner";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the Scan entity.
|
* Defines the Scan entity.
|
||||||
* A scan basicly adds a certain distance to a runner's total ran distance.
|
* A scan basicly adds a certain distance to a runner's total ran distance.
|
||||||
*/
|
*/
|
||||||
@Entity()
|
@Entity()
|
||||||
@TableInheritance({ column: { name: "type", type: "varchar" } })
|
@TableInheritance({ column: { name: "type", type: "varchar" } })
|
||||||
|
@Index(['runner'])
|
||||||
|
@Index(['runner', 'created_at'])
|
||||||
|
@Index(['valid'])
|
||||||
export class Scan {
|
export class Scan {
|
||||||
/**
|
/**
|
||||||
* Autogenerated unique id (primary key).
|
* Autogenerated unique id (primary key).
|
||||||
@@ -28,8 +31,8 @@ export class Scan {
|
|||||||
* This is important to link ran distances to runners.
|
* This is important to link ran distances to runners.
|
||||||
*/
|
*/
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ManyToOne(() => Runner, runner => runner.scans, { nullable: false })
|
@ManyToOne(() => require("./Runner").Runner, (runner: Runner) => runner.scans, { nullable: false })
|
||||||
runner: Runner;
|
runner!: Runner;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is the scan valid (for fraud reasons).
|
* Is the scan valid (for fraud reasons).
|
||||||
@@ -40,6 +43,27 @@ export class Scan {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
valid: boolean = true;
|
valid: boolean = true;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scan's distance in meters.
|
* The scan's distance in meters.
|
||||||
* This is the "real" value used by "normal" scans..
|
* This is the "real" value used by "normal" scans..
|
||||||
|
|||||||
@@ -3,18 +3,22 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { ResponseScanStation } from '../responses/ResponseScanStation';
|
import { ResponseScanStation } from '../responses/ResponseScanStation';
|
||||||
import { Track } from "./Track";
|
import type { Track } from "./Track";
|
||||||
import { TrackScan } from "./TrackScan";
|
import type { TrackScan } from "./TrackScan";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the ScanStation entity.
|
* Defines the ScanStation entity.
|
||||||
* ScanStations get used to create TrackScans for runners based on a scan of their runnerCard.
|
* ScanStations get used to create TrackScans for runners based on a scan of their runnerCard.
|
||||||
*/
|
*/
|
||||||
@Entity()
|
@Entity()
|
||||||
|
@Index(['track'])
|
||||||
|
@Index(['prefix'])
|
||||||
|
@Index(['enabled'])
|
||||||
export class ScanStation {
|
export class ScanStation {
|
||||||
/**
|
/**
|
||||||
* Autogenerated unique id (primary key).
|
* Autogenerated unique id (primary key).
|
||||||
@@ -37,8 +41,8 @@ export class ScanStation {
|
|||||||
* All scans created by this station will also be associated with this track.
|
* All scans created by this station will also be associated with this track.
|
||||||
*/
|
*/
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ManyToOne(() => Track, track => track.stations, { nullable: false })
|
@ManyToOne(() => require("./Track").Track, (track: Track) => track.stations, { nullable: false })
|
||||||
track: Track;
|
track!: Track;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The client's api key prefix.
|
* The client's api key prefix.
|
||||||
@@ -68,8 +72,8 @@ export class ScanStation {
|
|||||||
/**
|
/**
|
||||||
* Used to link track scans to a scan station.
|
* Used to link track scans to a scan station.
|
||||||
*/
|
*/
|
||||||
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true })
|
@OneToMany(() => require("./TrackScan").TrackScan, (scan: TrackScan) => scan.station, { nullable: true })
|
||||||
scans: TrackScan[];
|
scans!: TrackScan[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Is this station enabled?
|
* Is this station enabled?
|
||||||
@@ -78,6 +82,27 @@ export class ScanStation {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enabled?: boolean = true;
|
enabled?: boolean = true;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IsInt, IsOptional, IsString } from "class-validator";
|
import { IsInt, IsOptional, IsPositive, IsString } from "class-validator";
|
||||||
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { ResponseStatsClient } from '../responses/ResponseStatsClient';
|
import { ResponseStatsClient } from '../responses/ResponseStatsClient';
|
||||||
/**
|
/**
|
||||||
* Defines the StatsClient entity.
|
* Defines the StatsClient entity.
|
||||||
@@ -47,6 +47,27 @@ export class StatsClient {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
cleartextkey?: string;
|
cleartextkey?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import {
|
|||||||
IsPositive,
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { ResponseTrack } from '../responses/ResponseTrack';
|
import { ResponseTrack } from '../responses/ResponseTrack';
|
||||||
import { ScanStation } from "./ScanStation";
|
import type { ScanStation } from "./ScanStation";
|
||||||
import { TrackScan } from "./TrackScan";
|
import type { TrackScan } from "./TrackScan";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the Track entity.
|
* Defines the Track entity.
|
||||||
@@ -53,15 +53,36 @@ export class Track {
|
|||||||
* Used to link scan stations to a certain track.
|
* Used to link scan stations to a certain track.
|
||||||
* This makes the configuration of the scan stations easier.
|
* This makes the configuration of the scan stations easier.
|
||||||
*/
|
*/
|
||||||
@OneToMany(() => ScanStation, station => station.track, { nullable: true })
|
@OneToMany(() => require("./ScanStation").ScanStation, (station: ScanStation) => station.track, { nullable: true })
|
||||||
stations: ScanStation[];
|
stations!: ScanStation[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Used to link track scans to a track.
|
* Used to link track scans to a track.
|
||||||
* The scan will derive it's distance from the track's distance.
|
* The scan will derive it's distance from the track's distance.
|
||||||
*/
|
*/
|
||||||
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true })
|
@OneToMany(() => require("./TrackScan").TrackScan, (scan: TrackScan) => scan.track, { nullable: true })
|
||||||
scans: TrackScan[];
|
scans!: TrackScan[];
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
|
|||||||
@@ -6,42 +6,47 @@ import {
|
|||||||
|
|
||||||
IsPositive
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { ChildEntity, Column, ManyToOne } from "typeorm";
|
import { ChildEntity, Column, Index, ManyToOne } from "typeorm";
|
||||||
import { ResponseTrackScan } from '../responses/ResponseTrackScan';
|
import { ResponseTrackScan } from '../responses/ResponseTrackScan';
|
||||||
import { RunnerCard } from "./RunnerCard";
|
import type { RunnerCard } from "./RunnerCard";
|
||||||
import { Scan } from "./Scan";
|
import { Scan } from "./Scan";
|
||||||
import { ScanStation } from "./ScanStation";
|
import type { ScanStation } from "./ScanStation";
|
||||||
import { Track } from "./Track";
|
import type { Track } from "./Track";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the TrackScan entity.
|
* Defines the TrackScan entity.
|
||||||
* A track scan usaually get's generated by a scan station.
|
* A track scan usaually get's generated by a scan station.
|
||||||
*/
|
*/
|
||||||
@ChildEntity()
|
@ChildEntity()
|
||||||
|
@Index(['track'])
|
||||||
|
@Index(['card'])
|
||||||
|
@Index(['station'])
|
||||||
|
@Index(['timestamp'])
|
||||||
|
@Index(['station', 'timestamp'])
|
||||||
export class TrackScan extends Scan {
|
export class TrackScan extends Scan {
|
||||||
/**
|
/**
|
||||||
* The scan's associated track.
|
* The scan's associated track.
|
||||||
* This is used to determine the scan's distance.
|
* This is used to determine the scan's distance.
|
||||||
*/
|
*/
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ManyToOne(() => Track, track => track.scans, { nullable: true })
|
@ManyToOne(() => require("./Track").Track, (track: Track) => track.scans, { nullable: true })
|
||||||
track: Track;
|
track!: Track;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The runnerCard associated with the scan.
|
* The runnerCard associated with the scan.
|
||||||
* This get's saved for documentation and management purposes.
|
* This get's saved for documentation and management purposes.
|
||||||
*/
|
*/
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ManyToOne(() => RunnerCard, card => card.scans, { nullable: true })
|
@ManyToOne(() => require("./RunnerCard").RunnerCard, (card: RunnerCard) => card.scans, { nullable: true })
|
||||||
card: RunnerCard;
|
card!: RunnerCard;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scanning station that created the scan.
|
* The scanning station that created the scan.
|
||||||
* Mainly used for logging and traceing back scans (or errors)
|
* Mainly used for logging and traceing back scans (or errors)
|
||||||
*/
|
*/
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@ManyToOne(() => ScanStation, station => station.scans, { nullable: true })
|
@ManyToOne(() => require("./ScanStation").ScanStation, (station: ScanStation) => station.scans, { nullable: true })
|
||||||
station: ScanStation;
|
station!: ScanStation;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scan's distance in meters.
|
* The scan's distance in meters.
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl, IsUUID } from "class-validator";
|
import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl, IsUUID } from "class-validator";
|
||||||
import { ChildEntity, Column, JoinTable, ManyToMany, OneToMany } from "typeorm";
|
import { ChildEntity, Column, Index, JoinTable, ManyToMany, OneToMany } from "typeorm";
|
||||||
import { config } from '../../config';
|
import { config } from '../../config';
|
||||||
import { ResponsePrincipal } from '../responses/ResponsePrincipal';
|
import { ResponsePrincipal } from '../responses/ResponsePrincipal';
|
||||||
import { ResponseUser } from '../responses/ResponseUser';
|
import { ResponseUser } from '../responses/ResponseUser';
|
||||||
import { Permission } from './Permission';
|
import { Permission } from './Permission';
|
||||||
import { Principal } from './Principal';
|
import { Principal } from './Principal';
|
||||||
import { UserAction } from './UserAction';
|
import type { UserAction } from './UserAction';
|
||||||
import { UserGroup } from './UserGroup';
|
import { UserGroup } from './UserGroup';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the User entity.
|
* Defines the User entity.
|
||||||
* Users are the ones that can use the "admin" webui and do stuff in the backend.
|
* Users are the ones that can use the "admin" webui and do stuff in the backend.
|
||||||
*/
|
*/
|
||||||
@ChildEntity()
|
@ChildEntity()
|
||||||
export class User extends Principal {
|
@Index(['enabled'])
|
||||||
|
export class User extends Principal {
|
||||||
/**
|
/**
|
||||||
* The user's uuid.
|
* The user's uuid.
|
||||||
* Mainly gets used as a per-user salt for the password hash.
|
* Mainly gets used as a per-user salt for the password hash.
|
||||||
@@ -124,11 +125,11 @@ export class User extends Principal {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The actions performed by this user.
|
* The actions performed by this user.
|
||||||
* For documentation purposes only, will be implemented later.
|
* For documentation purposes only, will be implemented later.
|
||||||
*/
|
*/
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@OneToMany(() => UserAction, action => action.user, { nullable: true })
|
@OneToMany(() => require("./UserAction").UserAction, (action: UserAction) => action.user, { nullable: true })
|
||||||
actions: UserAction[]
|
actions!: UserAction[]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves all permissions granted to this user through groups.
|
* Resolves all permissions granted to this user through groups.
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { PermissionAction } from '../enums/PermissionAction';
|
import { PermissionAction } from '../enums/PermissionAction';
|
||||||
import { User } from './User';
|
import type { User } from './User';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the UserAction entity.
|
* Defines the UserAction entity.
|
||||||
@@ -25,8 +26,8 @@ export class UserAction {
|
|||||||
/**
|
/**
|
||||||
* The user that performed the action.
|
* The user that performed the action.
|
||||||
*/
|
*/
|
||||||
@ManyToOne(() => User, user => user.actions)
|
@ManyToOne(() => require("./User").User, (user: User) => user.actions)
|
||||||
user: User
|
user!: User
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The actions's target (e.g. Track#2)
|
* The actions's target (e.g. Track#2)
|
||||||
@@ -53,6 +54,27 @@ export class UserAction {
|
|||||||
@IsString()
|
@IsString()
|
||||||
changed: string;
|
changed: string;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
35
src/models/entities/index.ts
Normal file
35
src/models/entities/index.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Entity barrel file for Bun compatibility.
|
||||||
|
* Imports all entities in the correct order to resolve circular dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Base/parent entities first
|
||||||
|
export * from './Participant';
|
||||||
|
export * from './Donation';
|
||||||
|
export * from './Scan';
|
||||||
|
|
||||||
|
// Child entities that depend on the above
|
||||||
|
export * from './Runner';
|
||||||
|
export * from './DistanceDonation';
|
||||||
|
export * from './FixedDonation';
|
||||||
|
export * from './TrackScan';
|
||||||
|
|
||||||
|
// Entities with cross-references
|
||||||
|
export * from './RunnerCard';
|
||||||
|
export * from './RunnerGroup';
|
||||||
|
export * from './RunnerOrganization';
|
||||||
|
export * from './RunnerTeam';
|
||||||
|
export * from './ScanStation';
|
||||||
|
export * from './Track';
|
||||||
|
|
||||||
|
// Independent entities
|
||||||
|
export * from './Address';
|
||||||
|
export * from './ConfigFlags';
|
||||||
|
export * from './Donor';
|
||||||
|
export * from './GroupContact';
|
||||||
|
export * from './Permission';
|
||||||
|
export * from './Principal';
|
||||||
|
export * from './StatsClient';
|
||||||
|
export * from './User';
|
||||||
|
export * from './UserAction';
|
||||||
|
export * from './UserGroup';
|
||||||
68
src/models/responses/ResponseAnonymousDonation.ts
Normal file
68
src/models/responses/ResponseAnonymousDonation.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { IsInt, IsPositive } from "class-validator";
|
||||||
|
import { Donation } from '../entities/Donation';
|
||||||
|
import { DonationStatus } from '../enums/DonationStatus';
|
||||||
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
|
import { IResponse } from './IResponse';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the donation response.
|
||||||
|
*/
|
||||||
|
export class ResponseAnonymousDonation implements IResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The responseType.
|
||||||
|
* This contains the type of class/entity this response contains.
|
||||||
|
*/
|
||||||
|
responseType: ResponseObjectType = ResponseObjectType.DONATION;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The donation's payment status.
|
||||||
|
* Provides you with a quick indicator of it's payment status.
|
||||||
|
*/
|
||||||
|
status: DonationStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The donation's id.
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The donation's amount in the smalles unit of your currency (default: euro cent).
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
amount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The donation's paid amount in the smalles unit of your currency (default: euro cent).
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
paidAmount: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a ResponseDonation object from a scan.
|
||||||
|
* @param donation The donation the response shall be build for.
|
||||||
|
*/
|
||||||
|
public constructor(donation: Donation) {
|
||||||
|
this.id = donation.id;
|
||||||
|
this.amount = donation.amount;
|
||||||
|
this.paidAmount = donation.paidAmount || 0;
|
||||||
|
if (this.paidAmount < this.amount) {
|
||||||
|
this.status = DonationStatus.OPEN;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this.status = DonationStatus.PAID;
|
||||||
|
}
|
||||||
|
this.created_at = donation.created_at;
|
||||||
|
this.updated_at = donation.updated_at;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Donation } from '../entities/Donation';
|
|||||||
import { DonationStatus } from '../enums/DonationStatus';
|
import { DonationStatus } from '../enums/DonationStatus';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
import { IResponse } from './IResponse';
|
import { IResponse } from './IResponse';
|
||||||
import { ResponseDonor } from './ResponseDonor';
|
import type { ResponseDonor } from './ResponseDonor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the donation response.
|
* Defines the donation response.
|
||||||
@@ -33,7 +33,7 @@ export class ResponseDonation implements IResponse {
|
|||||||
* The donation's donor.
|
* The donation's donor.
|
||||||
*/
|
*/
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
donor: ResponseDonor;
|
donor?: ResponseDonor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The donation's amount in the smalles unit of your currency (default: euro cent).
|
* The donation's amount in the smalles unit of your currency (default: euro cent).
|
||||||
@@ -47,13 +47,23 @@ export class ResponseDonation implements IResponse {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
paidAmount: number;
|
paidAmount: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseDonation object from a scan.
|
* Creates a ResponseDonation object from a scan.
|
||||||
* @param donation The donation the response shall be build for.
|
* @param donation The donation the response shall be build for.
|
||||||
*/
|
*/
|
||||||
public constructor(donation: Donation) {
|
public constructor(donation: Donation) {
|
||||||
this.id = donation.id;
|
this.id = donation.id;
|
||||||
this.donor = donation.donor.toResponse();
|
if (donation.donor) {
|
||||||
|
this.donor = donation.donor.toResponse();
|
||||||
|
}
|
||||||
this.amount = donation.amount;
|
this.amount = donation.amount;
|
||||||
this.paidAmount = donation.paidAmount || 0;
|
this.paidAmount = donation.paidAmount || 0;
|
||||||
if (this.paidAmount < this.amount) {
|
if (this.paidAmount < this.amount) {
|
||||||
@@ -62,5 +72,7 @@ export class ResponseDonation implements IResponse {
|
|||||||
else {
|
else {
|
||||||
this.status = DonationStatus.PAID;
|
this.status = DonationStatus.PAID;
|
||||||
}
|
}
|
||||||
|
this.created_at = donation.created_at;
|
||||||
|
this.updated_at = donation.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
import { Donor } from '../entities/Donor';
|
import { Donor } from '../entities/Donor';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
import { IResponse } from './IResponse';
|
import { IResponse } from './IResponse';
|
||||||
|
import type { ResponseDonation } from './ResponseDonation';
|
||||||
import { ResponseParticipant } from './ResponseParticipant';
|
import { ResponseParticipant } from './ResponseParticipant';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +35,8 @@ export class ResponseDonor extends ResponseParticipant implements IResponse {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
paidDonationAmount: number;
|
paidDonationAmount: number;
|
||||||
|
|
||||||
|
donations?: Array<ResponseDonation>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseRunner object from a runner.
|
* Creates a ResponseRunner object from a runner.
|
||||||
* @param runner The user the response shall be build for.
|
* @param runner The user the response shall be build for.
|
||||||
@@ -43,5 +46,12 @@ export class ResponseDonor extends ResponseParticipant implements IResponse {
|
|||||||
this.receiptNeeded = donor.receiptNeeded;
|
this.receiptNeeded = donor.receiptNeeded;
|
||||||
this.donationAmount = donor.donationAmount;
|
this.donationAmount = donor.donationAmount;
|
||||||
this.paidDonationAmount = donor.paidDonationAmount;
|
this.paidDonationAmount = donor.paidDonationAmount;
|
||||||
|
const ResponseDonation = require('./ResponseDonation').ResponseDonation;
|
||||||
|
this.donations = new Array<ResponseDonation>();
|
||||||
|
if (donor.donations?.length > 0) {
|
||||||
|
for (const donation of donor.donations) {
|
||||||
|
this.donations.push(donation.toResponse())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsObject, IsString } from "class-validator";
|
import { IsInt, IsObject, IsPositive, IsString } from "class-validator";
|
||||||
import { Address } from '../entities/Address';
|
import { Address } from '../entities/Address';
|
||||||
import { GroupContact } from '../entities/GroupContact';
|
import { GroupContact } from '../entities/GroupContact';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
@@ -64,6 +64,14 @@ export class ResponseGroupContact implements IResponse {
|
|||||||
@IsObject()
|
@IsObject()
|
||||||
address?: Address;
|
address?: Address;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseGroupContact object from a contact.
|
* Creates a ResponseGroupContact object from a contact.
|
||||||
* @param contact The contact the response shall be build for.
|
* @param contact The contact the response shall be build for.
|
||||||
@@ -82,5 +90,7 @@ export class ResponseGroupContact implements IResponse {
|
|||||||
this.groups.push(group.toResponse());
|
this.groups.push(group.toResponse());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.created_at = contact.created_at;
|
||||||
|
this.updated_at = contact.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsObject, IsOptional, IsString } from "class-validator";
|
import { IsInt, IsObject, IsOptional, IsPositive, IsString } from "class-validator";
|
||||||
import { Address } from '../entities/Address';
|
import { Address } from '../entities/Address';
|
||||||
import { Participant } from '../entities/Participant';
|
import { Participant } from '../entities/Participant';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
@@ -50,6 +50,12 @@ export abstract class ResponseParticipant implements IResponse {
|
|||||||
@IsString()
|
@IsString()
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* how the participant got into the system
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
created_via?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The participant's address.
|
* The participant's address.
|
||||||
*/
|
*/
|
||||||
@@ -57,6 +63,14 @@ export abstract class ResponseParticipant implements IResponse {
|
|||||||
@IsObject()
|
@IsObject()
|
||||||
address?: Address;
|
address?: Address;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseParticipant object from a participant.
|
* Creates a ResponseParticipant object from a participant.
|
||||||
* @param participant The participant the response shall be build for.
|
* @param participant The participant the response shall be build for.
|
||||||
@@ -64,10 +78,13 @@ export abstract class ResponseParticipant implements IResponse {
|
|||||||
public constructor(participant: Participant) {
|
public constructor(participant: Participant) {
|
||||||
this.id = participant.id;
|
this.id = participant.id;
|
||||||
this.firstname = participant.firstname;
|
this.firstname = participant.firstname;
|
||||||
|
this.created_via = participant.created_via;
|
||||||
this.middlename = participant.middlename;
|
this.middlename = participant.middlename;
|
||||||
this.lastname = participant.lastname;
|
this.lastname = participant.lastname;
|
||||||
this.phone = participant.phone;
|
this.phone = participant.phone;
|
||||||
this.email = participant.email;
|
this.email = participant.email;
|
||||||
this.address = participant.address;
|
this.address = participant.address;
|
||||||
|
this.created_at = participant.created_at;
|
||||||
|
this.updated_at = participant.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import {
|
|||||||
IsEnum,
|
IsEnum,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsObject
|
IsObject,
|
||||||
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Permission } from '../entities/Permission';
|
import { Permission } from '../entities/Permission';
|
||||||
import { PermissionAction } from '../enums/PermissionAction';
|
import { PermissionAction } from '../enums/PermissionAction';
|
||||||
@@ -48,6 +49,14 @@ export class ResponsePermission implements IResponse {
|
|||||||
@IsEnum(PermissionAction)
|
@IsEnum(PermissionAction)
|
||||||
action: PermissionAction;
|
action: PermissionAction;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponsePermission object from a permission.
|
* Creates a ResponsePermission object from a permission.
|
||||||
* @param permission The permission the response shall be build for.
|
* @param permission The permission the response shall be build for.
|
||||||
@@ -57,5 +66,7 @@ export class ResponsePermission implements IResponse {
|
|||||||
this.principal = permission.principal.toResponse();
|
this.principal = permission.principal.toResponse();
|
||||||
this.target = permission.target;
|
this.target = permission.target;
|
||||||
this.action = permission.action;
|
this.action = permission.action;
|
||||||
|
this.created_at = permission.created_at;
|
||||||
|
this.updated_at = permission.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
IsInt
|
IsInt,
|
||||||
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Principal } from '../entities/Principal';
|
import { Principal } from '../entities/Principal';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
@@ -22,11 +23,21 @@ export abstract class ResponsePrincipal implements IResponse {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
id: number;
|
id: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponsePrincipal object from a principal.
|
* Creates a ResponsePrincipal object from a principal.
|
||||||
* @param principal The principal the response shall be build for.
|
* @param principal The principal the response shall be build for.
|
||||||
*/
|
*/
|
||||||
public constructor(principal: Principal) {
|
public constructor(principal: Principal) {
|
||||||
this.id = principal.id;
|
this.id = principal.id;
|
||||||
|
this.created_at = principal.created_at;
|
||||||
|
this.updated_at = principal.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
IsInt,
|
IsInt,
|
||||||
IsObject
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
|
import { JwtCreator } from '../../jwtcreator';
|
||||||
import { Runner } from '../entities/Runner';
|
import { Runner } from '../entities/Runner';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
import { IResponse } from './IResponse';
|
import { IResponse } from './IResponse';
|
||||||
@@ -24,20 +27,43 @@ export class ResponseRunner extends ResponseParticipant implements IResponse {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
distance: number;
|
distance: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The runner's current donation amount based on distance.
|
||||||
|
* Only available for queries for single runners.
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
donationAmount: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The runner's group.
|
* The runner's group.
|
||||||
*/
|
*/
|
||||||
@IsObject()
|
@IsObject()
|
||||||
group: ResponseRunnerGroup;
|
group: ResponseRunnerGroup;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A selfservice link for our new runner.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
selfserviceLink: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseRunner object from a runner.
|
* Creates a ResponseRunner object from a runner.
|
||||||
* @param runner The user the response shall be build for.
|
* @param runner The user the response shall be build for.
|
||||||
*/
|
*/
|
||||||
public constructor(runner: Runner) {
|
public constructor(runner: Runner, generateSelfServiceLink: boolean = false) {
|
||||||
super(runner);
|
super(runner);
|
||||||
if (!runner.scans) { this.distance = 0 }
|
if (!runner.scans) { this.distance = 0 }
|
||||||
else { this.distance = runner.validScans.reduce((sum, current) => sum + current.distance, 0); }
|
else { this.distance = runner.validScans.reduce((sum, current) => sum + current.distance, 0); }
|
||||||
if (runner.group) { this.group = runner.group.toResponse(); }
|
if (runner.group) { this.group = runner.group.toResponse(); }
|
||||||
|
|
||||||
|
if (runner.distanceDonations) {
|
||||||
|
this.donationAmount = runner.distanceDonations.reduce((sum, current) => sum + (current.amountPerDistance * runner.distance / 1000), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generateSelfServiceLink) {
|
||||||
|
const token = JwtCreator.createSelfService(runner);
|
||||||
|
this.selfserviceLink = `${process.env.SELFSERVICE_URL}/profile/${token}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsBoolean, IsEAN, IsInt, IsNotEmpty, IsObject, IsString } from "class-validator";
|
import { IsBoolean, IsEAN, IsInt, IsNotEmpty, IsObject, IsPositive, IsString } from "class-validator";
|
||||||
import { RunnerCard } from '../entities/RunnerCard';
|
import { RunnerCard } from '../entities/RunnerCard';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
import { IResponse } from './IResponse';
|
import { IResponse } from './IResponse';
|
||||||
@@ -42,6 +42,14 @@ export class ResponseRunnerCard implements IResponse {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enabled: boolean = true;
|
enabled: boolean = true;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseRunnerCard object from a runner card.
|
* Creates a ResponseRunnerCard object from a runner card.
|
||||||
* @param card The card the response shall be build for.
|
* @param card The card the response shall be build for.
|
||||||
@@ -57,5 +65,7 @@ export class ResponseRunnerCard implements IResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.enabled = card.enabled;
|
this.enabled = card.enabled;
|
||||||
|
this.created_at = card.created_at;
|
||||||
|
this.updated_at = card.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsNotEmpty, IsObject, IsOptional, IsString } from "class-validator";
|
import { IsInt, IsNotEmpty, IsNumber, IsObject, IsOptional, IsPositive, IsString } from "class-validator";
|
||||||
import { RunnerGroup } from '../entities/RunnerGroup';
|
import { RunnerGroup } from '../entities/RunnerGroup';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
import { IResponse } from './IResponse';
|
import { IResponse } from './IResponse';
|
||||||
@@ -36,6 +36,18 @@ export abstract class ResponseRunnerGroup implements IResponse {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
contact?: ResponseGroupContact;
|
contact?: ResponseGroupContact;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
total_distance: number
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseRunnerGroup object from a runnerGroup.
|
* Creates a ResponseRunnerGroup object from a runnerGroup.
|
||||||
* @param group The runnerGroup the response shall be build for.
|
* @param group The runnerGroup the response shall be build for.
|
||||||
@@ -44,5 +56,8 @@ export abstract class ResponseRunnerGroup implements IResponse {
|
|||||||
this.id = group.id;
|
this.id = group.id;
|
||||||
this.name = group.name;
|
this.name = group.name;
|
||||||
if (group.contact) { this.contact = group.contact.toResponse(); };
|
if (group.contact) { this.contact = group.contact.toResponse(); };
|
||||||
|
if (group.runners) { this.total_distance = group.runners.reduce((p, c) => p + c.distance, 0) }
|
||||||
|
this.created_at = group.created_at;
|
||||||
|
this.updated_at = group.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { RunnerOrganization } from '../entities/RunnerOrganization';
|
|||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
import { IResponse } from './IResponse';
|
import { IResponse } from './IResponse';
|
||||||
import { ResponseRunnerGroup } from './ResponseRunnerGroup';
|
import { ResponseRunnerGroup } from './ResponseRunnerGroup';
|
||||||
import { ResponseRunnerTeam } from './ResponseRunnerTeam';
|
import type { ResponseRunnerTeam } from './ResponseRunnerTeam';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the runnerOrganization response.
|
* Defines the runnerOrganization response.
|
||||||
@@ -37,7 +37,7 @@ export class ResponseRunnerOrganization extends ResponseRunnerGroup implements I
|
|||||||
* The runnerOrganization associated teams.
|
* The runnerOrganization associated teams.
|
||||||
*/
|
*/
|
||||||
@IsArray()
|
@IsArray()
|
||||||
teams: ResponseRunnerTeam[];
|
teams?: ResponseRunnerTeam[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The organization's registration key.
|
* The organization's registration key.
|
||||||
@@ -62,11 +62,15 @@ export class ResponseRunnerOrganization extends ResponseRunnerGroup implements I
|
|||||||
public constructor(org: RunnerOrganization) {
|
public constructor(org: RunnerOrganization) {
|
||||||
super(org);
|
super(org);
|
||||||
this.address = org.address;
|
this.address = org.address;
|
||||||
|
const ResponseRunnerTeam = require('./ResponseRunnerTeam').ResponseRunnerTeam;
|
||||||
this.teams = new Array<ResponseRunnerTeam>();
|
this.teams = new Array<ResponseRunnerTeam>();
|
||||||
if (org.teams) {
|
if (org.teams) {
|
||||||
for (let team of org.teams) {
|
for (let team of org.teams) {
|
||||||
this.teams.push(team.toResponse());
|
this.teams.push(team.toResponse());
|
||||||
}
|
}
|
||||||
|
for (const team of this.teams) {
|
||||||
|
this.total_distance += team.total_distance;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!org.key) { this.registrationEnabled = false; }
|
if (!org.key) { this.registrationEnabled = false; }
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { RunnerTeam } from '../entities/RunnerTeam';
|
|||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
import { IResponse } from './IResponse';
|
import { IResponse } from './IResponse';
|
||||||
import { ResponseRunnerGroup } from './ResponseRunnerGroup';
|
import { ResponseRunnerGroup } from './ResponseRunnerGroup';
|
||||||
import { ResponseRunnerOrganization } from './ResponseRunnerOrganization';
|
import type { ResponseRunnerOrganization } from './ResponseRunnerOrganization';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the runnerTeam response.
|
* Defines the runnerTeam response.
|
||||||
@@ -20,7 +20,7 @@ export class ResponseRunnerTeam extends ResponseRunnerGroup implements IResponse
|
|||||||
*/
|
*/
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
parentGroup: ResponseRunnerOrganization;
|
parentGroup?: ResponseRunnerOrganization;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseRunnerTeam object from a runnerTeam.
|
* Creates a ResponseRunnerTeam object from a runnerTeam.
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export class ResponseScan implements IResponse {
|
|||||||
@IsPositive()
|
@IsPositive()
|
||||||
distance: number;
|
distance: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseScan object from a scan.
|
* Creates a ResponseScan object from a scan.
|
||||||
* @param scan The scan the response shall be build for.
|
* @param scan The scan the response shall be build for.
|
||||||
@@ -50,5 +58,7 @@ export class ResponseScan implements IResponse {
|
|||||||
if (scan.runner) { this.runner = scan.runner.toResponse(); }
|
if (scan.runner) { this.runner = scan.runner.toResponse(); }
|
||||||
this.distance = scan.distance;
|
this.distance = scan.distance;
|
||||||
this.valid = scan.valid;
|
this.valid = scan.valid;
|
||||||
|
this.created_at = scan.created_at;
|
||||||
|
this.updated_at = scan.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/models/responses/ResponseScanIntake.ts
Normal file
30
src/models/responses/ResponseScanIntake.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { IsBoolean, IsInt, IsNotEmpty, IsObject, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight response returned to scan stations after a TrackScan submission.
|
||||||
|
* Contains only what the scan display needs — validity, lap time, and runner info.
|
||||||
|
* Full ResponseTrackScan is still returned to JWT-authenticated admin/UI callers.
|
||||||
|
*/
|
||||||
|
export class ResponseScanIntakeRunner {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
totalDistance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResponseScanIntake {
|
||||||
|
@IsBoolean()
|
||||||
|
accepted: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
valid: boolean;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
lapTime: number;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsNotEmpty()
|
||||||
|
runner: ResponseScanIntakeRunner;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
|
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
IsObject,
|
IsObject,
|
||||||
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { ScanStation } from '../entities/ScanStation';
|
import { ScanStation } from '../entities/ScanStation';
|
||||||
@@ -63,6 +63,14 @@ export class ResponseScanStation implements IResponse {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enabled?: boolean = true;
|
enabled?: boolean = true;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseStatsClient object from a statsClient.
|
* Creates a ResponseStatsClient object from a statsClient.
|
||||||
* @param client The statsClient the response shall be build for.
|
* @param client The statsClient the response shall be build for.
|
||||||
@@ -74,5 +82,7 @@ export class ResponseScanStation implements IResponse {
|
|||||||
this.key = "Only visible on creation.";
|
this.key = "Only visible on creation.";
|
||||||
if (station.track) { this.track = station.track.toResponse(); }
|
if (station.track) { this.track = station.track.toResponse(); }
|
||||||
this.enabled = station.enabled;
|
this.enabled = station.enabled;
|
||||||
|
this.created_at = station.created_at;
|
||||||
|
this.updated_at = station.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,18 @@ export class ResponseStats implements IResponse {
|
|||||||
*/
|
*/
|
||||||
responseType: ResponseObjectType = ResponseObjectType.STATS;
|
responseType: ResponseObjectType = ResponseObjectType.STATS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of runners registered via selfservice.
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
runnersViaSelfservice: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of runners registered via kiosk.
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
runnersViaKiosk: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The amount of runners registered in the system.
|
* The amount of runners registered in the system.
|
||||||
*/
|
*/
|
||||||
@@ -58,22 +70,42 @@ export class ResponseStats implements IResponse {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
total_donation: number;
|
total_donation: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total donation count (cent).
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
total_donations: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total donor count.
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
total_donors: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The average distance ran per runner.
|
* The average distance ran per runner.
|
||||||
*/
|
*/
|
||||||
@IsInt()
|
@IsInt()
|
||||||
average_distance: number;
|
average_distance: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The average donation per distance (cent).
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
average_donation: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new stats response containing some basic statistics for a dashboard or public display.
|
* Creates a new stats response containing some basic statistics for a dashboard or public display.
|
||||||
* @param runners Array containing all runners - the following relations have to be resolved: scans, scans.track
|
* @param runnersViaSelfservice number of runners registered via selfservice
|
||||||
* @param teams Array containing all teams - no relations have to be resolved.
|
* @param runners number of runners
|
||||||
* @param orgs Array containing all orgs - no relations have to be resolved.
|
* @param teams number of teams - no relations have to be resolved.
|
||||||
* @param users Array containing all users - no relations have to be resolved.
|
* @param orgs number of orgs - no relations have to be resolved.
|
||||||
* @param scans Array containing all scans - no relations have to be resolved.
|
* @param users number of users - no relations have to be resolved.
|
||||||
|
* @param scans number of scans - no relations have to be resolved.
|
||||||
* @param donations Array containing all donations - the following relations have to be resolved: runner, runner.scans, runner.scans.track
|
* @param donations Array containing all donations - the following relations have to be resolved: runner, runner.scans, runner.scans.track
|
||||||
*/
|
*/
|
||||||
public constructor(runners: number, teams: number, orgs: number, users: number, scans: number, donations: Donation[], distance: number) {
|
public constructor(runnersViaSelfservice: number, runners: number, teams: number, orgs: number, users: number, scans: number, donations: Donation[], distance: number, donors: number, runnersViaKiosk: number) {
|
||||||
|
this.runnersViaSelfservice = runnersViaSelfservice;
|
||||||
this.total_runners = runners;
|
this.total_runners = runners;
|
||||||
this.total_teams = teams;
|
this.total_teams = teams;
|
||||||
this.total_orgs = orgs;
|
this.total_orgs = orgs;
|
||||||
@@ -81,6 +113,10 @@ export class ResponseStats implements IResponse {
|
|||||||
this.total_scans = scans;
|
this.total_scans = scans;
|
||||||
this.total_distance = distance;
|
this.total_distance = distance;
|
||||||
this.total_donation = donations.reduce((sum, current) => sum + current.amount, 0);
|
this.total_donation = donations.reduce((sum, current) => sum + current.amount, 0);
|
||||||
|
this.total_donations = donations.length;
|
||||||
|
this.average_donation = this.total_donation / this.total_donations
|
||||||
|
this.total_donors = donors;
|
||||||
this.average_distance = this.total_distance / this.total_runners;
|
this.average_distance = this.total_distance / this.total_runners;
|
||||||
|
this.runnersViaKiosk = runnersViaKiosk;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
|
||||||
IsInt,
|
IsInt,
|
||||||
|
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { StatsClient } from '../entities/StatsClient';
|
import { StatsClient } from '../entities/StatsClient';
|
||||||
@@ -49,6 +49,14 @@ export class ResponseStatsClient implements IResponse {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
prefix: string;
|
prefix: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseStatsClient object from a statsClient.
|
* Creates a ResponseStatsClient object from a statsClient.
|
||||||
* @param client The statsClient the response shall be build for.
|
* @param client The statsClient the response shall be build for.
|
||||||
@@ -58,5 +66,7 @@ export class ResponseStatsClient implements IResponse {
|
|||||||
this.description = client.description;
|
this.description = client.description;
|
||||||
this.prefix = client.prefix;
|
this.prefix = client.prefix;
|
||||||
this.key = "Only visible on creation.";
|
this.key = "Only visible on creation.";
|
||||||
|
this.created_at = client.created_at;
|
||||||
|
this.updated_at = client.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsOptional, IsString } from "class-validator";
|
import { IsInt, IsOptional, IsPositive, IsString } from "class-validator";
|
||||||
import { TrackLapTimeCantBeNegativeError } from '../../errors/TrackErrors';
|
import { TrackLapTimeCantBeNegativeError } from '../../errors/TrackErrors';
|
||||||
import { Track } from '../entities/Track';
|
import { Track } from '../entities/Track';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
@@ -40,6 +40,14 @@ export class ResponseTrack implements IResponse {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
minimumLapTime?: number;
|
minimumLapTime?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseTrack object from a track.
|
* Creates a ResponseTrack object from a track.
|
||||||
* @param track The track the response shall be build for.
|
* @param track The track the response shall be build for.
|
||||||
@@ -52,5 +60,7 @@ export class ResponseTrack implements IResponse {
|
|||||||
if (this.minimumLapTime < 0) {
|
if (this.minimumLapTime < 0) {
|
||||||
throw new TrackLapTimeCantBeNegativeError();
|
throw new TrackLapTimeCantBeNegativeError();
|
||||||
}
|
}
|
||||||
|
this.created_at = track.created_at;
|
||||||
|
this.updated_at = track.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/nats/CardKV.ts
Normal file
67
src/nats/CardKV.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { KvEntry } from 'nats';
|
||||||
|
import NatsClient from './NatsClient';
|
||||||
|
|
||||||
|
const BUCKET = 'card_state';
|
||||||
|
/** 1 hour TTL in milliseconds — sliding window, reset on each access. */
|
||||||
|
const TTL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached card data stored in NATS KV.
|
||||||
|
* Keyed by the stripped card id (rawBarcode % 200000000000).
|
||||||
|
* TTL of 1 hour of inactivity — re-put on each access to slide the window.
|
||||||
|
*/
|
||||||
|
export interface CardKVEntry {
|
||||||
|
runnerId: number;
|
||||||
|
runnerDisplayName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBucket() {
|
||||||
|
return NatsClient.getKV(BUCKET, { ttl: TTL_MS });
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryKey(cardId: number): string {
|
||||||
|
return `card.${cardId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached CardKVEntry for the given stripped card id, or null on a miss.
|
||||||
|
* On a cache hit the entry is re-put with a fresh TTL to slide the inactivity window.
|
||||||
|
*/
|
||||||
|
export async function getCardEntry(cardId: number): Promise<CardKVEntry | null> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
let entry: KvEntry | null = null;
|
||||||
|
try {
|
||||||
|
entry = await bucket.get(entryKey(cardId));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!entry || entry.operation === 'DEL' || entry.operation === 'PURGE') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = JSON.parse(entry.string()) as CardKVEntry;
|
||||||
|
// Re-put to slide the TTL window
|
||||||
|
await bucket.put(entryKey(cardId), JSON.stringify(value));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a CardKVEntry for the given stripped card id with a 1-hour TTL.
|
||||||
|
*/
|
||||||
|
export async function setCardEntry(cardId: number, entry: CardKVEntry): Promise<void> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
await bucket.put(entryKey(cardId), JSON.stringify(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the cached entry for the given stripped card id.
|
||||||
|
* Call on card update (runner reassignment, enable/disable change) or delete.
|
||||||
|
*/
|
||||||
|
export async function deleteCardEntry(cardId: number): Promise<void> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
try {
|
||||||
|
await bucket.delete(entryKey(cardId));
|
||||||
|
} catch {
|
||||||
|
// Entry may not exist in KV yet — that's fine
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/nats/NatsClient.ts
Normal file
60
src/nats/NatsClient.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import consola from 'consola';
|
||||||
|
import { connect, JetStreamClient, KV, KvOptions, NatsConnection } from 'nats';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton NATS client.
|
||||||
|
* Call connect() once during app startup (after the DB loader).
|
||||||
|
* All other modules obtain the connection via getKV().
|
||||||
|
*/
|
||||||
|
class NatsClient {
|
||||||
|
private connection: NatsConnection | null = null;
|
||||||
|
private js: JetStreamClient | null = null;
|
||||||
|
private kvBuckets: Map<string, KV> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establishes the NATS connection and JetStream context.
|
||||||
|
* Must be called once before any KV operations.
|
||||||
|
*/
|
||||||
|
public async connect(): Promise<void> {
|
||||||
|
this.connection = await connect({ servers: config.nats_url });
|
||||||
|
this.js = this.connection.jetstream();
|
||||||
|
consola.success(`NATS connected to ${config.nats_url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a KV bucket by name, creating it if it doesn't exist yet.
|
||||||
|
* Results are cached — repeated calls with the same name return the same instance.
|
||||||
|
*/
|
||||||
|
public async getKV(bucketName: string, options?: Partial<KvOptions>): Promise<KV> {
|
||||||
|
if (this.kvBuckets.has(bucketName)) {
|
||||||
|
return this.kvBuckets.get(bucketName);
|
||||||
|
}
|
||||||
|
if (!this.js) {
|
||||||
|
throw new Error('NATS not connected. Call NatsClient.connect() first.');
|
||||||
|
}
|
||||||
|
const kv = await this.js.views.kv(bucketName, options);
|
||||||
|
this.kvBuckets.set(bucketName, kv);
|
||||||
|
return kv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gracefully closes the NATS connection.
|
||||||
|
* Call during app shutdown if needed.
|
||||||
|
*/
|
||||||
|
public async disconnect(): Promise<void> {
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.drain();
|
||||||
|
this.connection = null;
|
||||||
|
this.js = null;
|
||||||
|
this.kvBuckets.clear();
|
||||||
|
consola.info('NATS disconnected.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isConnected(): boolean {
|
||||||
|
return this.connection !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new NatsClient();
|
||||||
190
src/nats/RunnerKV.ts
Normal file
190
src/nats/RunnerKV.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { KvEntry } from 'nats';
|
||||||
|
import { getConnection } from 'typeorm';
|
||||||
|
import { Runner } from '../models/entities/Runner';
|
||||||
|
import { TrackScan } from '../models/entities/TrackScan';
|
||||||
|
import NatsClient from './NatsClient';
|
||||||
|
|
||||||
|
const BUCKET = 'runner_state';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached runner state stored in NATS KV.
|
||||||
|
* Keyed by runner id. No TTL — entries are permanent until explicitly deleted.
|
||||||
|
*/
|
||||||
|
export interface RunnerKVEntry {
|
||||||
|
/** "Firstname Lastname" — middlename omitted. */
|
||||||
|
displayName: string;
|
||||||
|
/** Sum of all valid scan distances in metres. */
|
||||||
|
totalDistance: number;
|
||||||
|
/** Unix seconds timestamp of the last valid scan. 0 if none. */
|
||||||
|
latestTimestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returned from getRunnerEntry — includes the KV revision for CAS updates. */
|
||||||
|
export interface RunnerKVResult {
|
||||||
|
entry: RunnerKVEntry;
|
||||||
|
revision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBucket() {
|
||||||
|
return NatsClient.getKV(BUCKET);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryKey(runnerId: number): string {
|
||||||
|
return `runner.${runnerId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached RunnerKVEntry + revision for the given runner id, or null on a miss.
|
||||||
|
* The revision is required for CAS (compare-and-swap) updates.
|
||||||
|
*/
|
||||||
|
export async function getRunnerEntry(runnerId: number): Promise<RunnerKVResult | null> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
let entry: KvEntry | null = null;
|
||||||
|
try {
|
||||||
|
entry = await bucket.get(entryKey(runnerId));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!entry || entry.operation === 'DEL' || entry.operation === 'PURGE') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
entry: JSON.parse(entry.string()) as RunnerKVEntry,
|
||||||
|
revision: entry.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a RunnerKVEntry for the given runner id.
|
||||||
|
* If revision is provided, performs a CAS update — returns false if the revision
|
||||||
|
* has changed (concurrent write), true on success.
|
||||||
|
* Without a revision, performs an unconditional put.
|
||||||
|
*/
|
||||||
|
export async function setRunnerEntry(runnerId: number, entry: RunnerKVEntry, revision?: number): Promise<boolean> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
try {
|
||||||
|
if (revision !== undefined) {
|
||||||
|
await bucket.update(entryKey(runnerId), JSON.stringify(entry), revision);
|
||||||
|
} else {
|
||||||
|
await bucket.put(entryKey(runnerId), JSON.stringify(entry));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// CAS conflict — revision has changed
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the cached entry for the given runner id.
|
||||||
|
* Call on runner name update or when a scan's valid flag is changed via PUT /scans/:id.
|
||||||
|
*/
|
||||||
|
export async function deleteRunnerEntry(runnerId: number): Promise<void> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
try {
|
||||||
|
await bucket.delete(entryKey(runnerId));
|
||||||
|
} catch {
|
||||||
|
// Entry may not exist in KV yet — that's fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB fallback: loads a runner's display name, total valid distance, and latest valid
|
||||||
|
* scan timestamp from the database, writes the result to KV, and returns it.
|
||||||
|
*
|
||||||
|
* Called on any KV cache miss during the scan intake flow.
|
||||||
|
* Also handles the first-scan-ever case — latestTimestamp=0, totalDistance=0.
|
||||||
|
*/
|
||||||
|
export async function warmRunner(runnerId: number): Promise<RunnerKVEntry> {
|
||||||
|
const connection = getConnection();
|
||||||
|
|
||||||
|
const runner = await connection.getRepository(Runner).findOne({ id: runnerId });
|
||||||
|
const displayName = runner ? `${runner.firstname} ${runner.lastname}` : 'Unknown Runner';
|
||||||
|
|
||||||
|
const distanceResult = await connection
|
||||||
|
.getRepository(TrackScan)
|
||||||
|
.createQueryBuilder('scan')
|
||||||
|
.select('COALESCE(SUM(track.distance), 0)', 'total')
|
||||||
|
.innerJoin('scan.track', 'track')
|
||||||
|
.where('scan.runner = :runnerId', { runnerId })
|
||||||
|
.andWhere('scan.valid = :valid', { valid: true })
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
const latestScan = await connection
|
||||||
|
.getRepository(TrackScan)
|
||||||
|
.findOne({
|
||||||
|
where: { runner: { id: runnerId }, valid: true },
|
||||||
|
order: { timestamp: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry: RunnerKVEntry = {
|
||||||
|
displayName,
|
||||||
|
totalDistance: parseInt(distanceResult?.total ?? '0', 10),
|
||||||
|
latestTimestamp: latestScan?.timestamp ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await setRunnerEntry(runnerId, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk cache prewarming: loads all runners from the database and populates the KV cache.
|
||||||
|
* Uses 3 efficient queries and parallel KV writes to minimize startup time.
|
||||||
|
*
|
||||||
|
* Call from loader during startup (if NATS_PREWARM=true) to eliminate DB reads on the hot
|
||||||
|
* path from the very first scan.
|
||||||
|
*/
|
||||||
|
export async function warmAll(): Promise<void> {
|
||||||
|
const connection = getConnection();
|
||||||
|
|
||||||
|
// Query 1: All runners
|
||||||
|
const runners = await connection
|
||||||
|
.getRepository(Runner)
|
||||||
|
.createQueryBuilder('runner')
|
||||||
|
.select(['runner.id', 'runner.firstname', 'runner.lastname'])
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Query 2: Total valid distance per runner
|
||||||
|
const distanceResults = await connection
|
||||||
|
.getRepository(TrackScan)
|
||||||
|
.createQueryBuilder('scan')
|
||||||
|
.select('scan.runner', 'runnerId')
|
||||||
|
.addSelect('COALESCE(SUM(track.distance), 0)', 'total')
|
||||||
|
.innerJoin('scan.track', 'track')
|
||||||
|
.where('scan.valid = :valid', { valid: true })
|
||||||
|
.groupBy('scan.runner')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
// Query 3: Latest valid scan timestamp per runner
|
||||||
|
const latestResults = await connection
|
||||||
|
.getRepository(TrackScan)
|
||||||
|
.createQueryBuilder('scan')
|
||||||
|
.select('scan.runner', 'runnerId')
|
||||||
|
.addSelect('MAX(scan.timestamp)', 'latestTimestamp')
|
||||||
|
.where('scan.valid = :valid', { valid: true })
|
||||||
|
.groupBy('scan.runner')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
// Build lookup maps
|
||||||
|
const distanceMap = new Map<number, number>();
|
||||||
|
distanceResults.forEach((row: any) => {
|
||||||
|
distanceMap.set(parseInt(row.runnerId, 10), parseInt(row.total, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestMap = new Map<number, number>();
|
||||||
|
latestResults.forEach((row: any) => {
|
||||||
|
latestMap.set(parseInt(row.runnerId, 10), parseInt(row.latestTimestamp, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write all entries in parallel
|
||||||
|
const writePromises = runners.map((runner) => {
|
||||||
|
const entry: RunnerKVEntry = {
|
||||||
|
displayName: `${runner.firstname} ${runner.lastname}`,
|
||||||
|
totalDistance: distanceMap.get(runner.id) ?? 0,
|
||||||
|
latestTimestamp: latestMap.get(runner.id) ?? 0,
|
||||||
|
};
|
||||||
|
return setRunnerEntry(runner.id, entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(writePromises);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user