Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
7aaac65af4
|
|||
|
13e0c81957
|
|||
|
329a29aca7
|
|||
|
abdadb8e64
|
|||
|
abce517d86
|
|||
|
a1e697acb2
|
|||
|
c9b8614f53
|
|||
|
cbf1da31c9
|
|||
|
fd18e56251
|
|||
|
3bb8b202b0
|
|||
|
d1c4744231
|
|||
|
fe90414dd9
|
|||
|
21ceb9fa26
|
|||
|
5081819281
|
|||
|
240bd9cba1
|
@@ -10,12 +10,9 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 19
|
||||
- run: npm i -g pnpm@10.7 && pnpm i
|
||||
- run: pnpm licenses:export
|
||||
- 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:
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -126,8 +126,12 @@ dist
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Old package manager lockfiles (Bun migration - keep bun.lock)
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
|
||||
build
|
||||
|
||||
*.sqlite
|
||||
|
||||
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.
|
||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -2,10 +2,50 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
||||
|
||||
#### [1.8.1](https://git.odit.services/lfk/backend/compare/1.8.0...1.8.1)
|
||||
|
||||
- perf(stats): Cache stats results for 60 seconds [`13e0c81`](https://git.odit.services/lfk/backend/commit/13e0c81957768c1b380914a0b93d3617c60e08a0)
|
||||
|
||||
#### [1.8.0](https://git.odit.services/lfk/backend/compare/1.7.2...1.8.0)
|
||||
|
||||
> 20 February 2026
|
||||
|
||||
- chore(release): 1.8.0 [`329a29a`](https://git.odit.services/lfk/backend/commit/329a29aca70b8c779c592149dc1cfe197ab62463)
|
||||
- refactor: Switch from official argon2 to Bun's implementation [`a1e697a`](https://git.odit.services/lfk/backend/commit/a1e697acb264a753534c5ff8f5f43357cbc287da)
|
||||
- refactor: Replace uuid and dotenv with bun primitives [`abce517`](https://git.odit.services/lfk/backend/commit/abce517d86daa00d76d691081907cb832494cb91)
|
||||
- refactor(deps): Remove unused glob dependency from package.json and bun.lock [`abdadb8`](https://git.odit.services/lfk/backend/commit/abdadb8e6419c5ec9f8cc0a9e5ebf68671d84a94)
|
||||
|
||||
#### [1.7.2](https://git.odit.services/lfk/backend/compare/1.7.1...1.7.2)
|
||||
|
||||
> 20 February 2026
|
||||
|
||||
- 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)
|
||||
- chore(release): 1.7.2 [`c9b8614`](https://git.odit.services/lfk/backend/commit/c9b8614f53619ec76ccf76875c138c986699c746)
|
||||
|
||||
#### [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)
|
||||
|
||||
24
Dockerfile
24
Dockerfile
@@ -1,27 +1,23 @@
|
||||
# Typescript Build
|
||||
FROM registry.odit.services/hub/library/node:23.10.0-alpine3.21 AS build
|
||||
ARG NPM_REGISTRY_URL=https://registry.npmjs.org
|
||||
FROM registry.odit.services/hub/oven/bun:1.3.9-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
COPY pnpm-workspace.yaml ./
|
||||
COPY pnpm-lock.yaml ./
|
||||
RUN npm config set registry $NPM_REGISTRY_URL && npm i -g pnpm@10.7
|
||||
RUN mkdir /pnpm && pnpm config set store-dir /pnpm && pnpm i
|
||||
COPY package.json bun.lockb* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
COPY tsconfig.json ormconfig.js ./
|
||||
COPY tsconfig.json ormconfig.js bunfig.toml ./
|
||||
COPY src ./src
|
||||
RUN pnpm run build \
|
||||
RUN bun run build \
|
||||
&& rm -rf /app/node_modules \
|
||||
&& pnpm i --production --prefer-offline
|
||||
&& bun install --production --frozen-lockfile
|
||||
|
||||
# final image
|
||||
FROM registry.odit.services/hub/library/node:23.10.0-alpine3.21 AS final
|
||||
FROM registry.odit.services/hub/oven/bun:1.3.9-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/package.json /app/package.json
|
||||
COPY --from=build /app/pnpm-lock.yaml /app/pnpm-lock.yaml
|
||||
COPY --from=build /app/pnpm-workspace.yaml /app/pnpm-workspace.yaml
|
||||
COPY --from=build /app/bun.lockb* /app/
|
||||
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/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
|
||||
123
README.md
123
README.md
@@ -2,66 +2,119 @@
|
||||
|
||||
Backend Server
|
||||
|
||||
## Quickstart 🐳
|
||||
> Use this to run the backend with a postgresql db in docker
|
||||
## Prerequisites
|
||||
|
||||
1. Clone the repo or copy the docker-compose
|
||||
2. Run in toe folder that contains the docker-compose file: `docker-compose up -d`
|
||||
This project uses **Bun** as the runtime and package manager. Install Bun first:
|
||||
|
||||
```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
|
||||
4. You can now use the default admin user (`demo:demo`)
|
||||
|
||||
## 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)
|
||||
2. Install Dependencies
|
||||
1. Rename the `.env.example` file to `.env` (you can adjust app port and other settings if needed)
|
||||
2. Start NATS (required for KV cache):
|
||||
```bash
|
||||
pnpm i
|
||||
docker-compose up -d nats
|
||||
```
|
||||
3. Start the server
|
||||
3. Install dependencies:
|
||||
```bash
|
||||
pnpm 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
|
||||
```bash
|
||||
# Run tests once (server has to run)
|
||||
pnpm test
|
||||
# Run tests once (server has to be running)
|
||||
bun test
|
||||
|
||||
# Run test in watch mode (reruns on change)
|
||||
pnpm test:watch
|
||||
bun run test:watch
|
||||
|
||||
# Run test in ci mode (automaticly starts the dev server)
|
||||
pnpm test:ci
|
||||
# Run test in CI mode (automatically starts the dev server)
|
||||
bun run test:ci
|
||||
```
|
||||
|
||||
### Run Benchmarks
|
||||
```bash
|
||||
# Start the server first
|
||||
bun run dev
|
||||
|
||||
# In another terminal:
|
||||
bun run benchmark
|
||||
```
|
||||
|
||||
### Generate Docs
|
||||
```bash
|
||||
pnpm 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
|
||||
> 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 |
|
||||
| ---------------------- | ------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| 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_HOST | String | N/A | The db's host's ip-address/fqdn or file path for sqlite |
|
||||
| DB_PORT | String | N/A | The db's port |
|
||||
| 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_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. |
|
||||
| POSTALCODE_COUNTRYCODE | String/CountryCode | N/A | The countrycode used to validate address's postal codes |
|
||||
| PHONE_COUNTRYCODE | String/CountryCode | null (international) | The countrycode used to validate phone numers |
|
||||
| SEED_TEST_DATA | Boolean | False | If you want the app to seed some example data set this to true |
|
||||
| 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 a 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) |
|
||||
| Name | Type | Default | Description |
|
||||
| ------------------------- | ------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| APP_PORT | Number | 4010 | The port the backend server listens on. Is optional. |
|
||||
| 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 IP address/FQDN or file path for sqlite |
|
||||
| DB_PORT | String | N/A | The db's port |
|
||||
| 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_NAME | String | N/A | The db's name |
|
||||
| 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 country code used to validate address postal codes |
|
||||
| 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 example data, set this to true |
|
||||
| STATION_TOKEN_SECRET | String | N/A | Secret key for HMAC-SHA256 station token generation (min 32 chars). **Required.** |
|
||||
| NATS_URL | String(URL) | nats://localhost:4222 | NATS server connection URL for KV cache |
|
||||
| NATS_PREWARM | Boolean | false | Preload all runner state into NATS cache at startup (eliminates DB reads on first scan) |
|
||||
| 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
|
||||
|
||||
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
|
||||
203
licenses.md
203
licenses.md
@@ -1,32 +1,3 @@
|
||||
# @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
|
||||
**Author**: Aleksi Pekkala <aleksipekkala@gmail.com>
|
||||
**Repo**: git@github.com:epiphone/class-validator-jsonschema.git
|
||||
@@ -316,37 +287,6 @@ The above copyright notice and this permission notice shall be included in all c
|
||||
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.
|
||||
|
||||
|
||||
# dotenv
|
||||
**Author**: undefined
|
||||
**Repo**: [object Object]
|
||||
**License**: BSD-2-Clause
|
||||
**Description**: Loads environment variables from .env file
|
||||
## License Text
|
||||
Copyright (c) 2015, Scott Motte
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
# express
|
||||
**Author**: TJ Holowaychuk <tj@vision-media.ca>
|
||||
**Repo**: expressjs/express
|
||||
@@ -878,23 +818,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
# uuid
|
||||
**Author**: undefined
|
||||
**Repo**: [object Object]
|
||||
**License**: MIT
|
||||
**Description**: RFC4122 (v1, v4, and v5) UUIDs
|
||||
## License Text
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2010-2020 Robert Kieffer and other contributors
|
||||
|
||||
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.
|
||||
|
||||
|
||||
# validator
|
||||
**Author**: Chris O'Hara <cohara87@gmail.com>
|
||||
**Repo**: [object Object]
|
||||
@@ -1280,35 +1203,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
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
|
||||
**Author**: [object Object]
|
||||
**Repo**: [object Object]
|
||||
@@ -1340,25 +1234,65 @@ SOFTWARE.
|
||||
|
||||
# rimraf
|
||||
**Author**: Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me/)
|
||||
**Repo**: git://github.com/isaacs/rimraf.git
|
||||
**License**: ISC
|
||||
**Repo**: git@github.com:isaacs/rimraf.git
|
||||
**License**: BlueOak-1.0.0
|
||||
**Description**: A deep deletion module for node (like `rm -rf`)
|
||||
## License Text
|
||||
The ISC License
|
||||
# Blue Oak Model License
|
||||
|
||||
Copyright (c) Isaac Z. Schlueter and Contributors
|
||||
Version 1.0.0
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
## Purpose
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
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.***
|
||||
|
||||
|
||||
# start-server-and-test
|
||||
@@ -1398,35 +1332,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
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
|
||||
**Author**: undefined
|
||||
**Repo**: [object Object]
|
||||
|
||||
11
ormconfig.js
11
ormconfig.js
@@ -1,7 +1,3 @@
|
||||
const dotenv = require('dotenv');
|
||||
dotenv.config();
|
||||
//
|
||||
const SOURCE_PATH = process.env.NODE_ENV === 'production' ? 'dist' : 'src';
|
||||
module.exports = {
|
||||
type: process.env.DB_TYPE,
|
||||
host: process.env.DB_HOST,
|
||||
@@ -9,8 +5,7 @@ module.exports = {
|
||||
username: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
// entities: ["src/**/entities/*.ts"],
|
||||
entities: [ `${SOURCE_PATH}/**/entities/*{.ts,.js}` ],
|
||||
seeds: [ `${SOURCE_PATH}/**/seeds/*{.ts,.js}` ]
|
||||
// seeds: ['src/seeds/*.ts'],
|
||||
// Run directly from TypeScript source (Bun workflow)
|
||||
entities: ["src/models/entities/**/*.ts"],
|
||||
seeds: ["src/seeds/**/*.ts"]
|
||||
};
|
||||
|
||||
30
package.json
30
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@odit/lfk-backend",
|
||||
"version": "1.6.0",
|
||||
"version": "1.8.1",
|
||||
"main": "src/app.ts",
|
||||
"repository": "https://git.odit.services/lfk/backend",
|
||||
"author": {
|
||||
@@ -22,7 +22,6 @@
|
||||
],
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"dependencies": {
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@odit/class-validator-jsonschema": "2.1.1",
|
||||
"axios": "0.21.1",
|
||||
"body-parser": "1.19.0",
|
||||
@@ -34,7 +33,6 @@
|
||||
"cookie-parser": "1.4.5",
|
||||
"cors": "2.8.5",
|
||||
"csvtojson": "2.0.10",
|
||||
"dotenv": "8.2.0",
|
||||
"express": "4.17.1",
|
||||
"jsonwebtoken": "8.5.1",
|
||||
"libphonenumber-js": "1.9.9",
|
||||
@@ -48,7 +46,6 @@
|
||||
"typeorm": "0.2.30",
|
||||
"typeorm-routing-controllers-extensions": "0.2.0",
|
||||
"typeorm-seeding": "1.6.1",
|
||||
"uuid": "8.3.2",
|
||||
"validator": "13.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -63,27 +60,26 @@
|
||||
"auto-changelog": "2.4.0",
|
||||
"cp-cli": "2.0.0",
|
||||
"jest": "26.6.3",
|
||||
"nodemon": "2.0.7",
|
||||
"release-it": "14.2.2",
|
||||
"rimraf": "3.0.2",
|
||||
"rimraf": "^6.1.3",
|
||||
"start-server-and-test": "1.11.7",
|
||||
"ts-jest": "26.5.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typedoc": "0.20.19",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"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",
|
||||
"docs": "typedoc --out docs src",
|
||||
"test": "jest",
|
||||
"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": "npm run test:ci:generate_env && npm run test:ci:run",
|
||||
"benchmark": "ts-node scripts/benchmark_scan_intake.ts",
|
||||
"seed": "ts-node ./node_modules/typeorm/cli.js schema:sync && ts-node ./node_modules/typeorm-seeding/dist/cli.js seed",
|
||||
"openapi:export": "ts-node scripts/openapi_export.ts",
|
||||
"test:ci": "bun run test:ci:generate_env && bun run test:ci:run",
|
||||
"benchmark": "bun scripts/benchmark_scan_intake.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",
|
||||
"changelog:export": "auto-changelog --commit-limit false -p -u --hide-credit",
|
||||
"release": "release-it --only-version"
|
||||
@@ -103,13 +99,7 @@
|
||||
"publish": false
|
||||
},
|
||||
"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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* Scan Intake Benchmark Script
|
||||
*
|
||||
* Measures TrackScan creation performance before and after each optimisation phase.
|
||||
* Run against a live dev server: npm run dev
|
||||
* Run against a live dev server: bun run dev
|
||||
*
|
||||
* Usage:
|
||||
* npx ts-node scripts/benchmark_scan_intake.ts
|
||||
* npx ts-node scripts/benchmark_scan_intake.ts --base http://localhost:4010
|
||||
* 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)
|
||||
|
||||
46
src/app.ts
46
src/app.ts
@@ -7,7 +7,28 @@ import authchecker from "./middlewares/authchecker";
|
||||
import { ErrorHandler } from './middlewares/ErrorHandler';
|
||||
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({
|
||||
authorizationChecker: authchecker,
|
||||
currentUserChecker: UserChecker,
|
||||
@@ -15,7 +36,28 @@ const app = createExpressServer({
|
||||
development: config.development,
|
||||
cors: true,
|
||||
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() {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import consola from 'consola';
|
||||
import { config as configDotenv } from 'dotenv';
|
||||
import { CountryCode } from 'libphonenumber-js';
|
||||
import ValidatorJS from 'validator';
|
||||
|
||||
configDotenv();
|
||||
import consola from 'consola';
|
||||
import { CountryCode } from 'libphonenumber-js';
|
||||
import ValidatorJS from 'validator';
|
||||
|
||||
export const config = {
|
||||
internal_port: parseInt(process.env.APP_PORT) || 4010,
|
||||
development: process.env.NODE_ENV === "production",
|
||||
@@ -22,15 +20,15 @@ export const config = {
|
||||
mailer_url: process.env.MAILER_URL || "",
|
||||
mailer_key: process.env.MAILER_KEY || ""
|
||||
}
|
||||
let errors = 0
|
||||
if (typeof config.internal_port !== "number") {
|
||||
consola.error("Error: APP_PORT is not a number")
|
||||
errors++
|
||||
}
|
||||
if (typeof config.development !== "boolean") {
|
||||
consola.error("Error: NODE_ENV is not a boolean")
|
||||
errors++
|
||||
}
|
||||
let errors = 0
|
||||
if (typeof config.internal_port !== "number") {
|
||||
consola.error("Error: APP_PORT is not a number")
|
||||
errors++
|
||||
}
|
||||
if (typeof config.development !== "boolean") {
|
||||
consola.error("Error: NODE_ENV is not a boolean")
|
||||
errors++
|
||||
}
|
||||
if (config.mailer_url == "" || config.mailer_key == "") {
|
||||
consola.error("Error: invalid mailer config")
|
||||
errors++;
|
||||
@@ -39,23 +37,23 @@ 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 {
|
||||
return (process.env.PHONE_COUNTRYCODE as CountryCode);
|
||||
}
|
||||
function getPostalCodeLocale(): any {
|
||||
try {
|
||||
const stringArray: String[] = ValidatorJS.isPostalCodeLocales;
|
||||
let index = stringArray.indexOf(process.env.POSTALCODE_COUNTRYCODE);
|
||||
return ValidatorJS.isPostalCodeLocales[index];
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function getDataSeeding(): Boolean {
|
||||
try {
|
||||
return JSON.parse(process.env.SEED_TEST_DATA);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function getPhoneCodeLocale(): CountryCode {
|
||||
return (process.env.PHONE_COUNTRYCODE as CountryCode);
|
||||
}
|
||||
function getPostalCodeLocale(): any {
|
||||
try {
|
||||
const stringArray: String[] = ValidatorJS.isPostalCodeLocales;
|
||||
let index = stringArray.indexOf(process.env.POSTALCODE_COUNTRYCODE);
|
||||
return ValidatorJS.isPostalCodeLocales[index];
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function getDataSeeding(): Boolean {
|
||||
try {
|
||||
return JSON.parse(process.env.SEED_TEST_DATA);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export let e = errors
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Request } from "express";
|
||||
import type { Request } from "express";
|
||||
import * as jwt from "jsonwebtoken";
|
||||
import { BadRequestError, Body, Delete, Get, JsonController, OnUndefined, Param, Post, QueryParam, Req, UseBefore } from 'routing-controllers';
|
||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Request } from "express";
|
||||
import type { Request } from "express";
|
||||
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 { Repository, getConnection, getConnectionManager } from 'typeorm';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ResponseStats } from '../models/responses/ResponseStats';
|
||||
import { ResponseStatsOrgnisation } from '../models/responses/ResponseStatsOrganization';
|
||||
import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner';
|
||||
import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam';
|
||||
import { getStatsCache, setStatsCache } from '../nats/StatsKV';
|
||||
|
||||
@JsonController('/stats')
|
||||
export class StatsController {
|
||||
@@ -22,6 +23,13 @@ export class StatsController {
|
||||
@ResponseSchema(ResponseStats)
|
||||
@OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" })
|
||||
async get() {
|
||||
// Try cache first
|
||||
const cached = await getStatsCache<ResponseStats>('overview');
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss - compute fresh stats
|
||||
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" } });
|
||||
@@ -43,7 +51,12 @@ export class StatsController {
|
||||
let donations = await connection.getRepository(Donation).find({ relations: ['runner', 'runner.scans', 'runner.scans.track'] });
|
||||
const donors = await connection.getRepository(Donor).count();
|
||||
|
||||
return new ResponseStats(runnersViaSelfservice, runners, teams, orgs, users, scans, donations, distace, donors, runnersViaKiosk)
|
||||
const result = new ResponseStats(runnersViaSelfservice, runners, teams, orgs, users, scans, donations, distace, donors, runnersViaKiosk);
|
||||
|
||||
// Store in cache for 60 seconds
|
||||
await setStatsCache('overview', result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get("/runners/distance")
|
||||
@@ -51,6 +64,13 @@ export class StatsController {
|
||||
@ResponseSchema(ResponseStatsRunner, { isArray: true })
|
||||
@OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||
async getTopRunnersByDistance() {
|
||||
// Try cache first
|
||||
const cached = await getStatsCache<ResponseStatsRunner[]>('runners.distance');
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss - compute fresh stats
|
||||
let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] });
|
||||
if (!runners || runners.length == 0) {
|
||||
return [];
|
||||
@@ -60,6 +80,10 @@ export class StatsController {
|
||||
topRunners.forEach(runner => {
|
||||
responseRunners.push(new ResponseStatsRunner(runner));
|
||||
});
|
||||
|
||||
// Store in cache for 60 seconds
|
||||
await setStatsCache('runners.distance', responseRunners);
|
||||
|
||||
return responseRunners;
|
||||
}
|
||||
|
||||
@@ -68,6 +92,13 @@ export class StatsController {
|
||||
@ResponseSchema(ResponseStatsRunner, { isArray: true })
|
||||
@OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||
async getTopRunnersByDonations() {
|
||||
// Try cache first
|
||||
const cached = await getStatsCache<ResponseStatsRunner[]>('runners.donations');
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss - compute fresh stats
|
||||
let runners = await getConnection().getRepository(Runner).find({ relations: ['group', 'distanceDonations', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] });
|
||||
if (!runners || runners.length == 0) {
|
||||
return [];
|
||||
@@ -77,6 +108,10 @@ export class StatsController {
|
||||
topRunners.forEach(runner => {
|
||||
responseRunners.push(new ResponseStatsRunner(runner));
|
||||
});
|
||||
|
||||
// Store in cache for 60 seconds
|
||||
await setStatsCache('runners.donations', responseRunners);
|
||||
|
||||
return responseRunners;
|
||||
}
|
||||
|
||||
@@ -85,6 +120,14 @@ export class StatsController {
|
||||
@ResponseSchema(ResponseStatsRunner, { isArray: true })
|
||||
@OpenAPI({ description: "Returns the top ten runners by fastest laptime on your selected track (track by id).", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||
async getTopRunnersByLaptime(@QueryParam("track") track: number) {
|
||||
// Try cache first (cache key includes track id, using dots for NATS KV compatibility)
|
||||
const cacheKey = `runners.laptime.${track}`;
|
||||
const cached = await getStatsCache<ResponseStatsRunner[]>(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss - compute fresh stats
|
||||
let scans = await getConnection().getRepository(TrackScan).find({ relations: ['track', 'runner', 'runner.group', 'runner.scans', 'runner.scans.track', 'runner.distanceDonations'] });
|
||||
if (!scans || scans.length == 0) {
|
||||
return [];
|
||||
@@ -105,6 +148,10 @@ export class StatsController {
|
||||
topScans.forEach(scan => {
|
||||
responseRunners.push(new ResponseStatsRunner(scan.runner, scan.lapTime));
|
||||
});
|
||||
|
||||
// Store in cache for 60 seconds
|
||||
await setStatsCache(cacheKey, responseRunners);
|
||||
|
||||
return responseRunners;
|
||||
}
|
||||
|
||||
@@ -121,6 +168,13 @@ export class StatsController {
|
||||
@ResponseSchema(ResponseStatsTeam, { isArray: true })
|
||||
@OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||
async getTopTeamsByDistance() {
|
||||
// Try cache first
|
||||
const cached = await getStatsCache<ResponseStatsTeam[]>('teams.distance');
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss - compute fresh stats
|
||||
let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.scans.track'] });
|
||||
if (!teams || teams.length == 0) {
|
||||
return [];
|
||||
@@ -130,6 +184,10 @@ export class StatsController {
|
||||
topTeams.forEach(team => {
|
||||
responseTeams.push(new ResponseStatsTeam(team));
|
||||
});
|
||||
|
||||
// Store in cache for 60 seconds
|
||||
await setStatsCache('teams.distance', responseTeams);
|
||||
|
||||
return responseTeams;
|
||||
}
|
||||
|
||||
@@ -138,6 +196,13 @@ export class StatsController {
|
||||
@ResponseSchema(ResponseStatsTeam, { isArray: true })
|
||||
@OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||
async getTopTeamsByDonations() {
|
||||
// Try cache first
|
||||
const cached = await getStatsCache<ResponseStatsTeam[]>('teams.donations');
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss - compute fresh stats
|
||||
let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] });
|
||||
if (!teams || teams.length == 0) {
|
||||
return [];
|
||||
@@ -147,6 +212,10 @@ export class StatsController {
|
||||
topTeams.forEach(team => {
|
||||
responseTeams.push(new ResponseStatsTeam(team));
|
||||
});
|
||||
|
||||
// Store in cache for 60 seconds
|
||||
await setStatsCache('teams.donations', responseTeams);
|
||||
|
||||
return responseTeams;
|
||||
}
|
||||
|
||||
@@ -155,6 +224,13 @@ export class StatsController {
|
||||
@ResponseSchema(ResponseStatsOrgnisation, { isArray: true })
|
||||
@OpenAPI({ description: "Returns the top ten organizations by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||
async getTopOrgsByDistance() {
|
||||
// Try cache first
|
||||
const cached = await getStatsCache<ResponseStatsOrgnisation[]>('organizations.distance');
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss - compute fresh stats
|
||||
let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] });
|
||||
if (!orgs || orgs.length == 0) {
|
||||
return [];
|
||||
@@ -164,6 +240,10 @@ export class StatsController {
|
||||
topOrgs.forEach(org => {
|
||||
responseOrgs.push(new ResponseStatsOrgnisation(org));
|
||||
});
|
||||
|
||||
// Store in cache for 60 seconds
|
||||
await setStatsCache('organizations.distance', responseOrgs);
|
||||
|
||||
return responseOrgs;
|
||||
}
|
||||
|
||||
@@ -172,6 +252,13 @@ export class StatsController {
|
||||
@ResponseSchema(ResponseStatsOrgnisation, { isArray: true })
|
||||
@OpenAPI({ description: "Returns the top ten organizations by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||
async getTopOrgsByDonations() {
|
||||
// Try cache first
|
||||
const cached = await getStatsCache<ResponseStatsOrgnisation[]>('organizations.donations');
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss - compute fresh stats
|
||||
let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.distanceDonations', 'runners.distanceDonations.runner', 'runners.distanceDonations.runner.scans', 'runners.distanceDonations.runner.scans.track', 'teams', 'teams.runners', 'teams.runners.distanceDonations', 'teams.runners.distanceDonations.runner', 'teams.runners.distanceDonations.runner.scans', 'teams.runners.distanceDonations.runner.scans.track'] });
|
||||
if (!orgs || orgs.length == 0) {
|
||||
return [];
|
||||
@@ -181,6 +268,10 @@ export class StatsController {
|
||||
topOrgs.forEach(org => {
|
||||
responseOrgs.push(new ResponseStatsOrgnisation(org));
|
||||
});
|
||||
|
||||
// Store in cache for 60 seconds
|
||||
await setStatsCache('organizations.donations', responseOrgs);
|
||||
|
||||
return responseOrgs;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createConnection } from "typeorm";
|
||||
import { runSeeder } from 'typeorm-seeding';
|
||||
import consola from 'consola';
|
||||
import { config } from '../config';
|
||||
import { ConfigFlag } from '../models/entities/ConfigFlags';
|
||||
import SeedPublicOrg from '../seeds/SeedPublicOrg';
|
||||
@@ -11,6 +12,11 @@ import SeedUsers from '../seeds/SeedUsers';
|
||||
*/
|
||||
export default async () => {
|
||||
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();
|
||||
|
||||
//The data seeding part
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { verify } from '@node-rs/argon2';
|
||||
import * as Bun from 'bun';
|
||||
import { Request, Response } from 'express';
|
||||
import { getConnectionManager } from 'typeorm';
|
||||
import { StatsClient } from '../models/entities/StatsClient';
|
||||
@@ -55,7 +55,7 @@ const StatsAuth = async (req: Request, res: Response, next: () => void) => {
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!(await verify(client.key, provided_token))) {
|
||||
if (!(await Bun.password.verify(provided_token, client.key))) {
|
||||
res.status(401).send("Api token invalid.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hash } from '@node-rs/argon2';
|
||||
import * as Bun from 'bun';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import * as jsonwebtoken from 'jsonwebtoken';
|
||||
import { getConnectionManager } from 'typeorm';
|
||||
@@ -49,7 +49,7 @@ export class ResetPassword {
|
||||
if (found_user.refreshTokenCount !== decoded["refreshTokenCount"]) { throw new RefreshTokenCountInvalidError(); }
|
||||
|
||||
found_user.refreshTokenCount = found_user.refreshTokenCount + 1;
|
||||
found_user.password = await hash(this.password + found_user.uuid);
|
||||
found_user.password = await Bun.password.hash(this.password + found_user.uuid);
|
||||
await getConnectionManager().get().getRepository(User).save(found_user);
|
||||
|
||||
return "password reset successfull";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { verify } from '@node-rs/argon2';
|
||||
import * as Bun from 'bun';
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { getConnectionManager } from 'typeorm';
|
||||
import { InvalidCredentialsError, PasswordNeededError, UserDisabledError, UserNotFoundError } from '../../../errors/AuthError';
|
||||
@@ -56,7 +56,7 @@ export class CreateAuth {
|
||||
throw new UserNotFoundError();
|
||||
}
|
||||
if (found_user.enabled == false) { throw new UserDisabledError(); }
|
||||
if (!(await verify(found_user.password, this.password + found_user.uuid))) {
|
||||
if (!(await Bun.password.verify(this.password + found_user.uuid, found_user.password))) {
|
||||
throw new InvalidCredentialsError();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IsBoolean, IsObject, IsOptional } from 'class-validator';
|
||||
import * as uuid from 'uuid';
|
||||
import { Address } from '../../entities/Address';
|
||||
import { RunnerOrganization } from '../../entities/RunnerOrganization';
|
||||
import { CreateRunnerGroup } from './CreateRunnerGroup';
|
||||
@@ -35,7 +34,7 @@ export class CreateRunnerOrganization extends CreateRunnerGroup {
|
||||
Address.validate(newRunnerOrganization.address);
|
||||
|
||||
if (this.registrationEnabled) {
|
||||
newRunnerOrganization.key = uuid.v4().toUpperCase();
|
||||
newRunnerOrganization.key = crypto.randomUUID()
|
||||
}
|
||||
|
||||
return newRunnerOrganization;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { IsBoolean, IsInt, IsOptional, IsPositive, IsString } from 'class-validator';
|
||||
import crypto from 'crypto';
|
||||
import { getConnection } from 'typeorm';
|
||||
import * as uuid from 'uuid';
|
||||
import { config } from '../../../config';
|
||||
import { TrackNotFoundError } from '../../../errors/TrackErrors';
|
||||
import { ScanStation } from '../../entities/ScanStation';
|
||||
@@ -42,7 +41,7 @@ export class CreateScanStation {
|
||||
newStation.enabled = this.enabled;
|
||||
newStation.track = await this.getTrack();
|
||||
|
||||
let newUUID = uuid.v4().toUpperCase();
|
||||
let newUUID = crypto.randomUUID().toUpperCase();
|
||||
newStation.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase();
|
||||
newStation.cleartextkey = newStation.prefix + "." + newUUID;
|
||||
newStation.key = crypto.createHmac("sha256", config.station_token_secret).update(newStation.cleartextkey).digest('hex');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { hash } from '@node-rs/argon2';
|
||||
import * as Bun from 'bun';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import crypto from 'crypto';
|
||||
import * as uuid from 'uuid';
|
||||
import { StatsClient } from '../../entities/StatsClient';
|
||||
|
||||
/**
|
||||
@@ -23,9 +22,9 @@ export class CreateStatsClient {
|
||||
|
||||
newClient.description = this.description;
|
||||
|
||||
let newUUID = uuid.v4().toUpperCase();
|
||||
let newUUID = crypto.randomUUID().toUpperCase();
|
||||
newClient.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase();
|
||||
newClient.key = await hash(newClient.prefix + "." + newUUID);
|
||||
newClient.key = await Bun.password.hash(newClient.prefix + "." + newUUID);
|
||||
newClient.cleartextkey = newClient.prefix + "." + newUUID;
|
||||
|
||||
return newClient;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import * as Bun from 'bun';
|
||||
import { passwordStrength } from "check-password-strength";
|
||||
import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator';
|
||||
import { getConnectionManager } from 'typeorm';
|
||||
import * as uuid from 'uuid';
|
||||
import { config } from '../../../config';
|
||||
import { PasswordMustContainLowercaseLetterError, PasswordMustContainNumberError, PasswordMustContainUppercaseLetterError, PasswordTooShortError, UserEmailNeededError, UsernameContainsIllegalCharacterError } from '../../../errors/UserErrors';
|
||||
import { UserGroupNotFoundError } from '../../../errors/UserGroupErrors';
|
||||
@@ -108,9 +107,9 @@ export class CreateUser {
|
||||
newUser.firstname = this.firstname
|
||||
newUser.middlename = this.middlename
|
||||
newUser.lastname = this.lastname
|
||||
newUser.uuid = uuid.v4()
|
||||
newUser.uuid = crypto.randomUUID()
|
||||
newUser.phone = this.phone
|
||||
newUser.password = await hash(this.password + newUser.uuid);
|
||||
newUser.password = Bun.password.hash(this.password + newUser.uuid);
|
||||
newUser.groups = await this.getGroups();
|
||||
newUser.enabled = this.enabled;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { IsBoolean, IsInt, IsObject, IsOptional } from 'class-validator';
|
||||
import * as uuid from 'uuid';
|
||||
import { Address } from '../../entities/Address';
|
||||
import { RunnerOrganization } from '../../entities/RunnerOrganization';
|
||||
import { CreateRunnerGroup } from '../create/CreateRunnerGroup';
|
||||
@@ -42,7 +41,7 @@ export class UpdateRunnerOrganization extends CreateRunnerGroup {
|
||||
Address.validate(organization.address);
|
||||
|
||||
if (this.registrationEnabled && !organization.key) {
|
||||
organization.key = uuid.v4().toUpperCase();
|
||||
organization.key = crypto.randomUUID().toUpperCase();
|
||||
}
|
||||
else {
|
||||
organization.key = null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hash } from '@node-rs/argon2';
|
||||
import * as Bun from 'bun';
|
||||
import { passwordStrength } from "check-password-strength";
|
||||
import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator';
|
||||
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("number")) { throw new PasswordMustContainNumberError(); }
|
||||
if (!(password_strength.length > 9)) { throw new PasswordTooShortError(); }
|
||||
user.password = await hash(this.password + user.uuid);
|
||||
user.password = await Bun.password.hash(this.password + user.uuid);
|
||||
user.refreshTokenCount = user.refreshTokenCount + 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
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 { Donation } from "./Donation";
|
||||
import { Runner } from "./Runner";
|
||||
import type { Runner } from "./Runner";
|
||||
|
||||
/**
|
||||
* Defines the DistanceDonation entity.
|
||||
* For distanceDonations a donor pledges to donate a certain amount for each kilometer ran by a runner.
|
||||
*/
|
||||
*/
|
||||
@ChildEntity()
|
||||
@Index(['runner'])
|
||||
export class DistanceDonation extends Donation {
|
||||
/**
|
||||
* The donation's associated runner.
|
||||
* Used as the source of the donation's distance.
|
||||
*/
|
||||
@IsNotEmpty()
|
||||
@ManyToOne(() => Runner, runner => runner.distanceDonations)
|
||||
runner: Runner;
|
||||
@ManyToOne(() => require("./Runner").Runner, (runner: Runner) => runner.distanceDonations)
|
||||
runner!: Runner;
|
||||
|
||||
/**
|
||||
* The donation's amount donated per distance.
|
||||
|
||||
@@ -2,17 +2,18 @@ import {
|
||||
IsInt,
|
||||
IsPositive
|
||||
} from "class-validator";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||
import { ResponseDonation } from '../responses/ResponseDonation';
|
||||
import { Donor } from './Donor';
|
||||
import type { Donor } from './Donor';
|
||||
|
||||
/**
|
||||
* Defines the Donation entity.
|
||||
* A donation just associates a donor with a donation amount.
|
||||
* The specifics of the amoun's determination has to be implemented in child classes.
|
||||
*/
|
||||
*/
|
||||
@Entity()
|
||||
@TableInheritance({ column: { name: "type", type: "varchar" } })
|
||||
@Index(['donor'])
|
||||
export abstract class Donation {
|
||||
/**
|
||||
* Autogenerated unique id (primary key).
|
||||
@@ -24,8 +25,8 @@ export abstract class Donation {
|
||||
/**
|
||||
* The donations's donor.
|
||||
*/
|
||||
@ManyToOne(() => Donor, donor => donor.donations)
|
||||
donor: Donor;
|
||||
@ManyToOne(() => require("./Donor").Donor, (donor: Donor) => donor.donations)
|
||||
donor!: Donor;
|
||||
|
||||
/**
|
||||
* The donation's amount in cents (or whatever your currency's smallest unit is.).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IsBoolean, IsInt } from "class-validator";
|
||||
import { ChildEntity, Column, OneToMany } from "typeorm";
|
||||
import { ResponseDonor } from '../responses/ResponseDonor';
|
||||
import { Donation } from './Donation';
|
||||
import type { Donation } from './Donation';
|
||||
import { Participant } from "./Participant";
|
||||
|
||||
/**
|
||||
@@ -21,8 +21,8 @@ export class Donor extends Participant {
|
||||
* Used to link the participant as the donor of a donation.
|
||||
* Attention: Only runner's can be associated as a distanceDonations distance source.
|
||||
*/
|
||||
@OneToMany(() => Donation, donation => donation.donor, { nullable: true })
|
||||
donations: Donation[];
|
||||
@OneToMany(() => require("./Donation").Donation, (donation: Donation) => donation.donor, { nullable: true })
|
||||
donations!: Donation[];
|
||||
|
||||
/**
|
||||
* Returns the total donations of a donor based on his linked donations.
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import {
|
||||
IsEmail,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsPhoneNumber,
|
||||
|
||||
IsPositive,
|
||||
|
||||
IsString
|
||||
} from "class-validator";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { config } from '../../config';
|
||||
import { ResponseGroupContact } from '../responses/ResponseGroupContact';
|
||||
import { Address } from "./Address";
|
||||
import { RunnerGroup } from "./RunnerGroup";
|
||||
import {
|
||||
IsEmail,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsPhoneNumber,
|
||||
|
||||
IsPositive,
|
||||
|
||||
IsString
|
||||
} from "class-validator";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { config } from '../../config';
|
||||
import { ResponseGroupContact } from '../responses/ResponseGroupContact';
|
||||
import { Address } from "./Address";
|
||||
import type { RunnerGroup } from "./RunnerGroup";
|
||||
|
||||
/**
|
||||
* Defines the GroupContact entity.
|
||||
@@ -77,11 +77,11 @@ export class GroupContact {
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
/**
|
||||
* Used to link contacts to groups.
|
||||
*/
|
||||
@OneToMany(() => RunnerGroup, group => group.contact, { nullable: true })
|
||||
groups: RunnerGroup[];
|
||||
/**
|
||||
* Used to link contacts to groups.
|
||||
*/
|
||||
@OneToMany(() => require("./RunnerGroup").RunnerGroup, (group: RunnerGroup) => group.contact, { nullable: true })
|
||||
groups!: RunnerGroup[];
|
||||
|
||||
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||
@IsInt()
|
||||
|
||||
@@ -9,18 +9,19 @@ import {
|
||||
|
||||
IsString
|
||||
} from "class-validator";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, Index, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||
import { config } from '../../config';
|
||||
import { ResponseParticipant } from '../responses/ResponseParticipant';
|
||||
import { Address } from "./Address";
|
||||
|
||||
/**
|
||||
* Defines the Participant entity.
|
||||
* Participans can donate and therefor be associated with donation entities.
|
||||
*/
|
||||
@Entity()
|
||||
@TableInheritance({ column: { name: "type", type: "varchar" } })
|
||||
export abstract class Participant {
|
||||
/**
|
||||
* Defines the Participant entity.
|
||||
* Participans can donate and therefor be associated with donation entities.
|
||||
*/
|
||||
@Entity()
|
||||
@TableInheritance({ column: { name: "type", type: "varchar" } })
|
||||
@Index(['email'])
|
||||
export abstract class Participant {
|
||||
/**
|
||||
* Autogenerated unique id (primary key).
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, PrimaryGenerated
|
||||
import { PermissionAction } from '../enums/PermissionAction';
|
||||
import { PermissionTarget } from '../enums/PermissionTargets';
|
||||
import { ResponsePermission } from '../responses/ResponsePermission';
|
||||
import { Principal } from './Principal';
|
||||
import type { Principal } from './Principal';
|
||||
/**
|
||||
* Defines the Permission entity.
|
||||
* Permissions can be granted to principals.
|
||||
@@ -26,8 +26,8 @@ export class Permission {
|
||||
/**
|
||||
* The permission's principal.
|
||||
*/
|
||||
@ManyToOne(() => Principal, principal => principal.permissions)
|
||||
principal: Principal;
|
||||
@ManyToOne(() => require("./Principal").Principal, (principal: Principal) => principal.permissions)
|
||||
principal!: Principal;
|
||||
|
||||
/**
|
||||
* The permission's target.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IsInt, IsPositive } from 'class-validator';
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn, TableInheritance } from 'typeorm';
|
||||
import { ResponsePrincipal } from '../responses/ResponsePrincipal';
|
||||
import { Permission } from './Permission';
|
||||
import type { Permission } from './Permission';
|
||||
|
||||
/**
|
||||
* Defines the principal entity.
|
||||
@@ -20,8 +20,8 @@ export abstract class Principal {
|
||||
/**
|
||||
* The participant's permissions.
|
||||
*/
|
||||
@OneToMany(() => Permission, permission => permission.principal, { nullable: true })
|
||||
permissions: Permission[];
|
||||
@OneToMany(() => require("./Permission").Permission, (permission: Permission) => permission.principal, { nullable: true })
|
||||
permissions!: Permission[];
|
||||
|
||||
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||
@IsInt()
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
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 { DistanceDonation } from "./DistanceDonation";
|
||||
import type { DistanceDonation } from "./DistanceDonation";
|
||||
import { Participant } from "./Participant";
|
||||
import { RunnerCard } from "./RunnerCard";
|
||||
import type { RunnerCard } from "./RunnerCard";
|
||||
import { RunnerGroup } from "./RunnerGroup";
|
||||
import { Scan } from "./Scan";
|
||||
import type { Scan } from "./Scan";
|
||||
|
||||
/**
|
||||
* Defines the runner entity.
|
||||
* Runners differ from participants in being able to actually accumulate a ran distance through scans.
|
||||
* Runner's get organized in groups.
|
||||
*/
|
||||
*/
|
||||
@ChildEntity()
|
||||
@Index(['group'])
|
||||
export class Runner extends Participant {
|
||||
/**
|
||||
* The runner's associated group.
|
||||
@@ -26,22 +27,22 @@ export class Runner extends Participant {
|
||||
* 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.
|
||||
*/
|
||||
@OneToMany(() => DistanceDonation, distanceDonation => distanceDonation.runner, { nullable: true })
|
||||
distanceDonations: DistanceDonation[];
|
||||
@OneToMany(() => require("./DistanceDonation").DistanceDonation, (distanceDonation: DistanceDonation) => distanceDonation.runner, { nullable: true })
|
||||
distanceDonations!: DistanceDonation[];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@OneToMany(() => RunnerCard, card => card.runner, { nullable: true })
|
||||
cards: RunnerCard[];
|
||||
@OneToMany(() => require("./RunnerCard").RunnerCard, (card: RunnerCard) => card.runner, { nullable: true })
|
||||
cards!: RunnerCard[];
|
||||
|
||||
/**
|
||||
* The runner's associated scans.
|
||||
* Used to link runners to scans (valid and fraudulant).
|
||||
*/
|
||||
@OneToMany(() => Scan, scan => scan.runner, { nullable: true })
|
||||
scans: Scan[];
|
||||
@OneToMany(() => require("./Scan").Scan, (scan: Scan) => scan.runner, { nullable: true })
|
||||
scans!: Scan[];
|
||||
|
||||
/**
|
||||
* The last time the runner requested a selfservice link.
|
||||
|
||||
@@ -6,18 +6,20 @@ import {
|
||||
IsOptional,
|
||||
IsPositive
|
||||
} from "class-validator";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { RunnerCardIdOutOfRangeError } from '../../errors/RunnerCardErrors';
|
||||
import { ResponseRunnerCard } from '../responses/ResponseRunnerCard';
|
||||
import { Runner } from "./Runner";
|
||||
import { TrackScan } from "./TrackScan";
|
||||
import type { Runner } from "./Runner";
|
||||
import type { TrackScan } from "./TrackScan";
|
||||
|
||||
/**
|
||||
* Defines the RunnerCard entity.
|
||||
* A runnerCard is a physical representation for a runner.
|
||||
* It can be associated with a runner to create scans via the scan station's.
|
||||
*/
|
||||
*/
|
||||
@Entity()
|
||||
@Index(['runner'])
|
||||
@Index(['enabled'])
|
||||
export class RunnerCard {
|
||||
/**
|
||||
* Autogenerated unique id (primary key).
|
||||
@@ -31,8 +33,8 @@ export class RunnerCard {
|
||||
* To increase reusability a card can be reassigned.
|
||||
*/
|
||||
@IsOptional()
|
||||
@ManyToOne(() => Runner, runner => runner.cards, { nullable: true })
|
||||
runner: Runner;
|
||||
@ManyToOne(() => require("./Runner").Runner, (runner: Runner) => runner.cards, { nullable: true })
|
||||
runner!: Runner;
|
||||
|
||||
/**
|
||||
* Is the card enabled (for fraud reasons)?
|
||||
@@ -46,8 +48,8 @@ export class RunnerCard {
|
||||
* The card's associated scans.
|
||||
* Used to link cards to track scans.
|
||||
*/
|
||||
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true })
|
||||
scans: TrackScan[];
|
||||
@OneToMany(() => require("./TrackScan").TrackScan, (scan: TrackScan) => scan.card, { nullable: true })
|
||||
scans!: TrackScan[];
|
||||
|
||||
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||
@IsInt()
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||
import { ResponseRunnerGroup } from '../responses/ResponseRunnerGroup';
|
||||
import { GroupContact } from "./GroupContact";
|
||||
import { Runner } from "./Runner";
|
||||
import type { Runner } from "./Runner";
|
||||
|
||||
/**
|
||||
* Defines the RunnerGroup entity.
|
||||
@@ -44,8 +44,8 @@ export abstract class RunnerGroup {
|
||||
* The group's associated runners.
|
||||
* Used to link runners to a runner group.
|
||||
*/
|
||||
@OneToMany(() => Runner, runner => runner.group, { nullable: true })
|
||||
runners: Runner[];
|
||||
@OneToMany(() => require("./Runner").Runner, (runner: Runner) => runner.group, { nullable: true })
|
||||
runners!: Runner[];
|
||||
|
||||
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||
@IsInt()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ResponseRunnerOrganization } from '../responses/ResponseRunnerOrganizat
|
||||
import { Address } from './Address';
|
||||
import { Runner } from './Runner';
|
||||
import { RunnerGroup } from "./RunnerGroup";
|
||||
import { RunnerTeam } from "./RunnerTeam";
|
||||
import type { RunnerTeam } from "./RunnerTeam";
|
||||
|
||||
/**
|
||||
* Defines the RunnerOrganization entity.
|
||||
@@ -24,8 +24,8 @@ export class RunnerOrganization extends RunnerGroup {
|
||||
* The organization's teams.
|
||||
* Used to link teams to a organization.
|
||||
*/
|
||||
@OneToMany(() => RunnerTeam, team => team.parentGroup, { nullable: true })
|
||||
teams: RunnerTeam[];
|
||||
@OneToMany(() => require("./RunnerTeam").RunnerTeam, (team: RunnerTeam) => team.parentGroup, { nullable: true })
|
||||
teams!: RunnerTeam[];
|
||||
|
||||
/**
|
||||
* The organization's api key for self-service registration.
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { IsNotEmpty } from "class-validator";
|
||||
import { ChildEntity, ManyToOne } from "typeorm";
|
||||
import { ChildEntity, Index, ManyToOne } from "typeorm";
|
||||
import { ResponseRunnerTeam } from '../responses/ResponseRunnerTeam';
|
||||
import { RunnerGroup } from "./RunnerGroup";
|
||||
import { RunnerOrganization } from "./RunnerOrganization";
|
||||
import type { RunnerOrganization } from "./RunnerOrganization";
|
||||
|
||||
/**
|
||||
* Defines the RunnerTeam entity.
|
||||
* This usually is a school class or department in a company.
|
||||
*/
|
||||
*/
|
||||
@ChildEntity()
|
||||
@Index(['parentGroup'])
|
||||
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.
|
||||
*/
|
||||
@IsNotEmpty()
|
||||
@ManyToOne(() => RunnerOrganization, org => org.teams, { nullable: true })
|
||||
@ManyToOne(() => require("./RunnerOrganization").RunnerOrganization, (org: RunnerOrganization) => org.teams, { nullable: true })
|
||||
parentGroup?: RunnerOrganization;
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,16 +5,19 @@ import {
|
||||
|
||||
IsPositive
|
||||
} from "class-validator";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||
import { ResponseScan } from '../responses/ResponseScan';
|
||||
import { Runner } from "./Runner";
|
||||
import type { Runner } from "./Runner";
|
||||
|
||||
/**
|
||||
* Defines the Scan entity.
|
||||
* A scan basicly adds a certain distance to a runner's total ran distance.
|
||||
*/
|
||||
*/
|
||||
@Entity()
|
||||
@TableInheritance({ column: { name: "type", type: "varchar" } })
|
||||
@Index(['runner'])
|
||||
@Index(['runner', 'created_at'])
|
||||
@Index(['valid'])
|
||||
export class Scan {
|
||||
/**
|
||||
* Autogenerated unique id (primary key).
|
||||
@@ -28,8 +31,8 @@ export class Scan {
|
||||
* This is important to link ran distances to runners.
|
||||
*/
|
||||
@IsNotEmpty()
|
||||
@ManyToOne(() => Runner, runner => runner.scans, { nullable: false })
|
||||
runner: Runner;
|
||||
@ManyToOne(() => require("./Runner").Runner, (runner: Runner) => runner.scans, { nullable: false })
|
||||
runner!: Runner;
|
||||
|
||||
/**
|
||||
* Is the scan valid (for fraud reasons).
|
||||
|
||||
@@ -6,16 +6,19 @@ import {
|
||||
IsPositive,
|
||||
IsString
|
||||
} from "class-validator";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { ResponseScanStation } from '../responses/ResponseScanStation';
|
||||
import { Track } from "./Track";
|
||||
import { TrackScan } from "./TrackScan";
|
||||
import type { Track } from "./Track";
|
||||
import type { TrackScan } from "./TrackScan";
|
||||
|
||||
/**
|
||||
* Defines the ScanStation entity.
|
||||
* ScanStations get used to create TrackScans for runners based on a scan of their runnerCard.
|
||||
*/
|
||||
*/
|
||||
@Entity()
|
||||
@Index(['track'])
|
||||
@Index(['prefix'])
|
||||
@Index(['enabled'])
|
||||
export class ScanStation {
|
||||
/**
|
||||
* Autogenerated unique id (primary key).
|
||||
@@ -38,8 +41,8 @@ export class ScanStation {
|
||||
* All scans created by this station will also be associated with this track.
|
||||
*/
|
||||
@IsNotEmpty()
|
||||
@ManyToOne(() => Track, track => track.stations, { nullable: false })
|
||||
track: Track;
|
||||
@ManyToOne(() => require("./Track").Track, (track: Track) => track.stations, { nullable: false })
|
||||
track!: Track;
|
||||
|
||||
/**
|
||||
* The client's api key prefix.
|
||||
@@ -69,8 +72,8 @@ export class ScanStation {
|
||||
/**
|
||||
* Used to link track scans to a scan station.
|
||||
*/
|
||||
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true })
|
||||
scans: TrackScan[];
|
||||
@OneToMany(() => require("./TrackScan").TrackScan, (scan: TrackScan) => scan.station, { nullable: true })
|
||||
scans!: TrackScan[];
|
||||
|
||||
/**
|
||||
* Is this station enabled?
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
} from "class-validator";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { ResponseTrack } from '../responses/ResponseTrack';
|
||||
import { ScanStation } from "./ScanStation";
|
||||
import { TrackScan } from "./TrackScan";
|
||||
import type { ScanStation } from "./ScanStation";
|
||||
import type { TrackScan } from "./TrackScan";
|
||||
|
||||
/**
|
||||
* Defines the Track entity.
|
||||
@@ -53,15 +53,15 @@ export class Track {
|
||||
* Used to link scan stations to a certain track.
|
||||
* This makes the configuration of the scan stations easier.
|
||||
*/
|
||||
@OneToMany(() => ScanStation, station => station.track, { nullable: true })
|
||||
stations: ScanStation[];
|
||||
@OneToMany(() => require("./ScanStation").ScanStation, (station: ScanStation) => station.track, { nullable: true })
|
||||
stations!: ScanStation[];
|
||||
|
||||
/**
|
||||
* Used to link track scans to a track.
|
||||
* The scan will derive it's distance from the track's distance.
|
||||
*/
|
||||
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true })
|
||||
scans: TrackScan[];
|
||||
@OneToMany(() => require("./TrackScan").TrackScan, (scan: TrackScan) => scan.track, { nullable: true })
|
||||
scans!: TrackScan[];
|
||||
|
||||
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||
@IsInt()
|
||||
|
||||
@@ -6,42 +6,47 @@ import {
|
||||
|
||||
IsPositive
|
||||
} from "class-validator";
|
||||
import { ChildEntity, Column, ManyToOne } from "typeorm";
|
||||
import { ChildEntity, Column, Index, ManyToOne } from "typeorm";
|
||||
import { ResponseTrackScan } from '../responses/ResponseTrackScan';
|
||||
import { RunnerCard } from "./RunnerCard";
|
||||
import type { RunnerCard } from "./RunnerCard";
|
||||
import { Scan } from "./Scan";
|
||||
import { ScanStation } from "./ScanStation";
|
||||
import { Track } from "./Track";
|
||||
import type { ScanStation } from "./ScanStation";
|
||||
import type { Track } from "./Track";
|
||||
|
||||
/**
|
||||
* Defines the TrackScan entity.
|
||||
* A track scan usaually get's generated by a scan station.
|
||||
*/
|
||||
*/
|
||||
@ChildEntity()
|
||||
@Index(['track'])
|
||||
@Index(['card'])
|
||||
@Index(['station'])
|
||||
@Index(['timestamp'])
|
||||
@Index(['station', 'timestamp'])
|
||||
export class TrackScan extends Scan {
|
||||
/**
|
||||
* The scan's associated track.
|
||||
* This is used to determine the scan's distance.
|
||||
*/
|
||||
@IsNotEmpty()
|
||||
@ManyToOne(() => Track, track => track.scans, { nullable: true })
|
||||
track: Track;
|
||||
@ManyToOne(() => require("./Track").Track, (track: Track) => track.scans, { nullable: true })
|
||||
track!: Track;
|
||||
|
||||
/**
|
||||
* The runnerCard associated with the scan.
|
||||
* This get's saved for documentation and management purposes.
|
||||
*/
|
||||
@IsNotEmpty()
|
||||
@ManyToOne(() => RunnerCard, card => card.scans, { nullable: true })
|
||||
card: RunnerCard;
|
||||
@ManyToOne(() => require("./RunnerCard").RunnerCard, (card: RunnerCard) => card.scans, { nullable: true })
|
||||
card!: RunnerCard;
|
||||
|
||||
/**
|
||||
* The scanning station that created the scan.
|
||||
* Mainly used for logging and traceing back scans (or errors)
|
||||
*/
|
||||
@IsNotEmpty()
|
||||
@ManyToOne(() => ScanStation, station => station.scans, { nullable: true })
|
||||
station: ScanStation;
|
||||
@ManyToOne(() => require("./ScanStation").ScanStation, (station: ScanStation) => station.scans, { nullable: true })
|
||||
station!: ScanStation;
|
||||
|
||||
/**
|
||||
* The scan's distance in meters.
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl, IsUUID } from "class-validator";
|
||||
import { ChildEntity, Column, JoinTable, ManyToMany, OneToMany } from "typeorm";
|
||||
import { config } from '../../config';
|
||||
import { ResponsePrincipal } from '../responses/ResponsePrincipal';
|
||||
import { ResponseUser } from '../responses/ResponseUser';
|
||||
import { Permission } from './Permission';
|
||||
import { Principal } from './Principal';
|
||||
import { UserAction } from './UserAction';
|
||||
import { UserGroup } from './UserGroup';
|
||||
import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl, IsUUID } from "class-validator";
|
||||
import { ChildEntity, Column, Index, JoinTable, ManyToMany, OneToMany } from "typeorm";
|
||||
import { config } from '../../config';
|
||||
import { ResponsePrincipal } from '../responses/ResponsePrincipal';
|
||||
import { ResponseUser } from '../responses/ResponseUser';
|
||||
import { Permission } from './Permission';
|
||||
import { Principal } from './Principal';
|
||||
import type { UserAction } from './UserAction';
|
||||
import { UserGroup } from './UserGroup';
|
||||
|
||||
/**
|
||||
* Defines the User entity.
|
||||
* Users are the ones that can use the "admin" webui and do stuff in the backend.
|
||||
*/
|
||||
@ChildEntity()
|
||||
export class User extends Principal {
|
||||
/**
|
||||
* Defines the User entity.
|
||||
* Users are the ones that can use the "admin" webui and do stuff in the backend.
|
||||
*/
|
||||
@ChildEntity()
|
||||
@Index(['enabled'])
|
||||
export class User extends Principal {
|
||||
/**
|
||||
* The user's uuid.
|
||||
* 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.
|
||||
* For documentation purposes only, will be implemented later.
|
||||
*/
|
||||
@IsOptional()
|
||||
@OneToMany(() => UserAction, action => action.user, { nullable: true })
|
||||
actions: UserAction[]
|
||||
* For documentation purposes only, will be implemented later.
|
||||
*/
|
||||
@IsOptional()
|
||||
@OneToMany(() => require("./UserAction").UserAction, (action: UserAction) => action.user, { nullable: true })
|
||||
actions!: UserAction[]
|
||||
|
||||
/**
|
||||
* Resolves all permissions granted to this user through groups.
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "class-validator";
|
||||
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
||||
import { PermissionAction } from '../enums/PermissionAction';
|
||||
import { User } from './User';
|
||||
import type { User } from './User';
|
||||
|
||||
/**
|
||||
* Defines the UserAction entity.
|
||||
@@ -26,8 +26,8 @@ export class UserAction {
|
||||
/**
|
||||
* The user that performed the action.
|
||||
*/
|
||||
@ManyToOne(() => User, user => user.actions)
|
||||
user: User
|
||||
@ManyToOne(() => require("./User").User, (user: User) => user.actions)
|
||||
user!: User
|
||||
|
||||
/**
|
||||
* The actions's target (e.g. Track#2)
|
||||
|
||||
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';
|
||||
@@ -3,7 +3,7 @@ import { Donation } from '../entities/Donation';
|
||||
import { DonationStatus } from '../enums/DonationStatus';
|
||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||
import { IResponse } from './IResponse';
|
||||
import { ResponseDonor } from './ResponseDonor';
|
||||
import type { ResponseDonor } from './ResponseDonor';
|
||||
|
||||
/**
|
||||
* Defines the donation response.
|
||||
@@ -33,7 +33,7 @@ export class ResponseDonation implements IResponse {
|
||||
* The donation's donor.
|
||||
*/
|
||||
@IsNotEmpty()
|
||||
donor: ResponseDonor;
|
||||
donor?: ResponseDonor;
|
||||
|
||||
/**
|
||||
* The donation's amount in the smalles unit of your currency (default: euro cent).
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
import { Donor } from '../entities/Donor';
|
||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||
import { IResponse } from './IResponse';
|
||||
import { ResponseDonation } from './ResponseDonation';
|
||||
import type { ResponseDonation } from './ResponseDonation';
|
||||
import { ResponseParticipant } from './ResponseParticipant';
|
||||
|
||||
/**
|
||||
@@ -35,7 +35,7 @@ export class ResponseDonor extends ResponseParticipant implements IResponse {
|
||||
@IsInt()
|
||||
paidDonationAmount: number;
|
||||
|
||||
donations: Array<ResponseDonation>;
|
||||
donations?: Array<ResponseDonation>;
|
||||
|
||||
/**
|
||||
* Creates a ResponseRunner object from a runner.
|
||||
@@ -46,6 +46,7 @@ export class ResponseDonor extends ResponseParticipant implements IResponse {
|
||||
this.receiptNeeded = donor.receiptNeeded;
|
||||
this.donationAmount = donor.donationAmount;
|
||||
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) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { RunnerOrganization } from '../entities/RunnerOrganization';
|
||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||
import { IResponse } from './IResponse';
|
||||
import { ResponseRunnerGroup } from './ResponseRunnerGroup';
|
||||
import { ResponseRunnerTeam } from './ResponseRunnerTeam';
|
||||
import type { ResponseRunnerTeam } from './ResponseRunnerTeam';
|
||||
|
||||
/**
|
||||
* Defines the runnerOrganization response.
|
||||
@@ -37,7 +37,7 @@ export class ResponseRunnerOrganization extends ResponseRunnerGroup implements I
|
||||
* The runnerOrganization associated teams.
|
||||
*/
|
||||
@IsArray()
|
||||
teams: ResponseRunnerTeam[];
|
||||
teams?: ResponseRunnerTeam[];
|
||||
|
||||
/**
|
||||
* The organization's registration key.
|
||||
@@ -62,6 +62,7 @@ export class ResponseRunnerOrganization extends ResponseRunnerGroup implements I
|
||||
public constructor(org: RunnerOrganization) {
|
||||
super(org);
|
||||
this.address = org.address;
|
||||
const ResponseRunnerTeam = require('./ResponseRunnerTeam').ResponseRunnerTeam;
|
||||
this.teams = new Array<ResponseRunnerTeam>();
|
||||
if (org.teams) {
|
||||
for (let team of org.teams) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RunnerTeam } from '../entities/RunnerTeam';
|
||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||
import { IResponse } from './IResponse';
|
||||
import { ResponseRunnerGroup } from './ResponseRunnerGroup';
|
||||
import { ResponseRunnerOrganization } from './ResponseRunnerOrganization';
|
||||
import type { ResponseRunnerOrganization } from './ResponseRunnerOrganization';
|
||||
|
||||
/**
|
||||
* Defines the runnerTeam response.
|
||||
@@ -20,7 +20,7 @@ export class ResponseRunnerTeam extends ResponseRunnerGroup implements IResponse
|
||||
*/
|
||||
@IsObject()
|
||||
@IsNotEmpty()
|
||||
parentGroup: ResponseRunnerOrganization;
|
||||
parentGroup?: ResponseRunnerOrganization;
|
||||
|
||||
/**
|
||||
* Creates a ResponseRunnerTeam object from a runnerTeam.
|
||||
|
||||
86
src/nats/StatsKV.ts
Normal file
86
src/nats/StatsKV.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { KvEntry } from 'nats';
|
||||
import NatsClient from './NatsClient';
|
||||
|
||||
const BUCKET = 'stats_cache';
|
||||
const TTL_SECONDS = 60; // 60 second TTL
|
||||
|
||||
/**
|
||||
* Stats cache stored in NATS KV with 60 second TTL.
|
||||
* Used to cache expensive aggregation queries from the stats endpoints.
|
||||
*/
|
||||
|
||||
async function getBucket() {
|
||||
return NatsClient.getKV(BUCKET, { ttl: TTL_SECONDS * 1000 }); // TTL in milliseconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache key patterns (using dots instead of colons for NATS KV compatibility):
|
||||
* - "stats.overview" - main stats endpoint (GET /stats)
|
||||
* - "stats.runners.distance" - top runners by distance
|
||||
* - "stats.runners.donations" - top runners by donations
|
||||
* - "stats.runners.laptime.{trackId}" - top runners by laptime for specific track
|
||||
* - "stats.teams.distance" - top teams by distance
|
||||
* - "stats.teams.donations" - top teams by donations
|
||||
* - "stats.organizations.distance" - top organizations by distance
|
||||
* - "stats.organizations.donations" - top organizations by donations
|
||||
*/
|
||||
|
||||
function cacheKey(path: string): string {
|
||||
// Replace colons with dots for NATS KV compatibility
|
||||
return `stats.${path.replace(/:/g, '.')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached value for the given stats cache key, or null on a miss.
|
||||
*/
|
||||
export async function getStatsCache<T>(path: string): Promise<T | null> {
|
||||
const bucket = await getBucket();
|
||||
let entry: KvEntry | null = null;
|
||||
try {
|
||||
entry = await bucket.get(cacheKey(path));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!entry || entry.operation === 'DEL' || entry.operation === 'PURGE') {
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(entry.string()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a value in the stats cache with 60 second TTL.
|
||||
* The TTL is applied at the bucket level, so all entries expire automatically.
|
||||
*/
|
||||
export async function setStatsCache<T>(path: string, value: T): Promise<void> {
|
||||
const bucket = await getBucket();
|
||||
await bucket.put(cacheKey(path), JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the cached entry for the given stats path.
|
||||
* Useful for cache invalidation when data changes.
|
||||
*/
|
||||
export async function deleteStatsCache(path: string): Promise<void> {
|
||||
const bucket = await getBucket();
|
||||
try {
|
||||
await bucket.delete(cacheKey(path));
|
||||
} catch {
|
||||
// Entry doesn't exist or already deleted - ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all cached stats entries.
|
||||
* Call this when runners, scans, or donations are modified to ensure fresh data.
|
||||
*/
|
||||
export async function invalidateAllStats(): Promise<void> {
|
||||
const bucket = await getBucket();
|
||||
try {
|
||||
// Purge the entire bucket to clear all cached stats
|
||||
await bucket.destroy();
|
||||
// Recreate the bucket for future use
|
||||
await NatsClient.getKV(BUCKET, { ttl: TTL_SECONDS * 1000 });
|
||||
} catch {
|
||||
// Bucket operations can fail if bucket doesn't exist - ignore
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { hash } from '@node-rs/argon2';
|
||||
import * as Bun from 'bun';
|
||||
import { Connection } from 'typeorm';
|
||||
import { Factory, Seeder } from 'typeorm-seeding';
|
||||
import * as uuid from 'uuid';
|
||||
import { CreatePermission } from '../models/actions/create/CreatePermission';
|
||||
import { CreateUserGroup } from '../models/actions/create/CreateUserGroup';
|
||||
import { Permission } from '../models/entities/Permission';
|
||||
@@ -32,8 +31,8 @@ export default class SeedUsers implements Seeder {
|
||||
initialUser.firstname = "demo";
|
||||
initialUser.lastname = "demo";
|
||||
initialUser.username = "demo";
|
||||
initialUser.uuid = uuid.v4();
|
||||
initialUser.password = await hash("demo" + initialUser.uuid);
|
||||
initialUser.uuid = crypto.randomUUID();
|
||||
initialUser.password = await Bun.password.hash("demo" + initialUser.uuid);
|
||||
initialUser.email = "demo@dev.lauf-fuer-kaya.de"
|
||||
initialUser.groups = [group];
|
||||
return await connection.getRepository(User).save(initialUser);
|
||||
|
||||
Reference in New Issue
Block a user