Compare commits
30 Commits
CreateAnon
...
1.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
5081819281
|
|||
|
240bd9cba1
|
|||
|
53fb0389cd
|
|||
|
d230350027
|
|||
|
024e647295
|
|||
|
d3e0206a3c
|
|||
|
b0c6759813
|
|||
|
526738e487
|
|||
|
778f159405
|
|||
|
2da8247978
|
|||
|
bbf6ea6c0f
|
|||
|
3584b3facf
|
|||
|
e27e819609
|
|||
|
0f532b139c
|
|||
|
eebcc2e328
|
|||
|
284954d064
|
|||
|
401ca923a6
|
|||
|
bf1f6411e0
|
|||
|
f225cc4954
|
|||
|
728f8a14e9
|
|||
|
a4480589a0
|
|||
|
0ad9eeb52f
|
|||
|
4494afc64b
|
|||
|
f4747c51de
|
|||
|
07a0195f12
|
|||
|
7ac98229d1
|
|||
|
dd5b538783
|
|||
|
8e6d67428c
|
|||
|
7ffb7523aa
|
|||
|
bacfc437f9
|
@@ -9,3 +9,6 @@ NODE_ENV=production
|
|||||||
POSTALCODE_COUNTRYCODE=DE
|
POSTALCODE_COUNTRYCODE=DE
|
||||||
SEED_TEST_DATA=false
|
SEED_TEST_DATA=false
|
||||||
SELFSERVICE_URL=bla
|
SELFSERVICE_URL=bla
|
||||||
|
STATION_TOKEN_SECRET=<replace-with-random-secret-min-32-chars>
|
||||||
|
NATS_URL=nats://localhost:4222
|
||||||
|
NATS_PREWARM=false
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -126,8 +126,12 @@ dist
|
|||||||
.yarn/build-state.yml
|
.yarn/build-state.yml
|
||||||
.yarn/install-state.gz
|
.yarn/install-state.gz
|
||||||
.pnp.*
|
.pnp.*
|
||||||
|
|
||||||
|
# Old package manager lockfiles (Bun migration - keep bun.lock)
|
||||||
yarn.lock
|
yarn.lock
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
build
|
build
|
||||||
|
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|||||||
85
CHANGELOG.md
85
CHANGELOG.md
@@ -2,9 +2,94 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
||||||
|
|
||||||
|
#### [1.7.0](https://git.odit.services/lfk/backend/compare/1.6.0...1.7.0)
|
||||||
|
|
||||||
|
- refactor: Bun by default [`240bd9c`](https://git.odit.services/lfk/backend/commit/240bd9cba10636bfc100ea2732508d805639f105)
|
||||||
|
|
||||||
|
#### [1.6.0](https://git.odit.services/lfk/backend/compare/1.5.2...1.6.0)
|
||||||
|
|
||||||
|
> 20 February 2026
|
||||||
|
|
||||||
|
- feat(data): Added nats jetstream dependency [`bbf6ea6`](https://git.odit.services/lfk/backend/commit/bbf6ea6c0fdffa11dacdf4b9afb6160ce54e197d)
|
||||||
|
- chore(deps): Bump typescript and get rid of now legacy imports [`2da8247`](https://git.odit.services/lfk/backend/commit/2da8247978c5142eec194651a7520fa53396d762)
|
||||||
|
- chore(release): 1.6.0 [`53fb038`](https://git.odit.services/lfk/backend/commit/53fb0389cd1da2b71b82102e82fc3d30f0be3820)
|
||||||
|
- feat(nats): Implement caching for card, runner, and station entries with improved key management [`b0c6759`](https://git.odit.services/lfk/backend/commit/b0c67598132deffce697f19c83bd4826420abe76)
|
||||||
|
- feat(auth): Implement caching for scanauth [`526738e`](https://git.odit.services/lfk/backend/commit/526738e48722fffe4493102fad69f65b40fc3b49)
|
||||||
|
- refactor(scan): Implement KV-backed scan station submissions and response model [`d3e0206`](https://git.odit.services/lfk/backend/commit/d3e0206a3ccbff0e69024426bb2bf266cde30eeb)
|
||||||
|
- fix(types): Add custom Express request types for station authentication [`778f159`](https://git.odit.services/lfk/backend/commit/778f15940594d5c2e423ef001eddd2d505ebd5f5)
|
||||||
|
- perf(nats): Implement bulk cache prewarming for runners to optimize startup performance [`024e647`](https://git.odit.services/lfk/backend/commit/024e64729594237773f3819646bdbc806ee985bc)
|
||||||
|
- feat(auth): Switch scanstation auth from argon2 to sha256 to improve performance [`3584b3f`](https://git.odit.services/lfk/backend/commit/3584b3facf7641f18db6eafe7035f17de8c5086c)
|
||||||
|
- perf(nats): Implement bulk cache prewarming for runners to optimize startup performance [`d230350`](https://git.odit.services/lfk/backend/commit/d230350027dea4dcdad9feddd9408a866ed787df)
|
||||||
|
|
||||||
|
#### [1.5.2](https://git.odit.services/lfk/backend/compare/1.5.1...1.5.2)
|
||||||
|
|
||||||
|
> 26 May 2025
|
||||||
|
|
||||||
|
- feat(mailer): Add logging for selfservice forgotten mail requests [`eebcc2e`](https://git.odit.services/lfk/backend/commit/eebcc2e3284230135e3911b4edaecd1a9cfd2100)
|
||||||
|
- chore(release): 1.5.2 [`e27e819`](https://git.odit.services/lfk/backend/commit/e27e8196097da19e24af22368ca8be5a8d9ef6b9)
|
||||||
|
- feat(mailer): Log error message when sending selfservice forgotten mail fails [`0f532b1`](https://git.odit.services/lfk/backend/commit/0f532b139c2bc5cd89ca2dbff0867825a9363250)
|
||||||
|
|
||||||
|
#### [1.5.1](https://git.odit.services/lfk/backend/compare/1.5.0...1.5.1)
|
||||||
|
|
||||||
|
> 26 May 2025
|
||||||
|
|
||||||
|
- chore(release): 1.5.1 [`284954d`](https://git.odit.services/lfk/backend/commit/284954d064f09951c13584e9d50a83be2c4b9f72)
|
||||||
|
- feat(mailer): Log error when sending selfservice forgotten mail fails [`401ca92`](https://git.odit.services/lfk/backend/commit/401ca923a61bc5988e73209c086bc9a5a4fa04f9)
|
||||||
|
|
||||||
|
#### [1.5.0](https://git.odit.services/lfk/backend/compare/1.4.3...1.5.0)
|
||||||
|
|
||||||
|
> 6 May 2025
|
||||||
|
|
||||||
|
- feat(entities): Added created/updated at to all entities [`728f8a1`](https://git.odit.services/lfk/backend/commit/728f8a14e9fb7360fce92640bfa5658af8cadb4f)
|
||||||
|
- feat(responses): Added created_at/updated_at [`f225cc4`](https://git.odit.services/lfk/backend/commit/f225cc49548605de48cf6c6e6f7c86b163236545)
|
||||||
|
- feat(participants): Added created/updated at [`a448058`](https://git.odit.services/lfk/backend/commit/a4480589a0e23a4481332ab5efa0777c62bbab56)
|
||||||
|
- chore(release): 1.5.0 [`bf1f641`](https://git.odit.services/lfk/backend/commit/bf1f6411e0f5113842a537f5bcf632638bdf1048)
|
||||||
|
|
||||||
|
#### [1.4.3](https://git.odit.services/lfk/backend/compare/1.4.2...1.4.3)
|
||||||
|
|
||||||
|
> 1 May 2025
|
||||||
|
|
||||||
|
- feat(runners): Include collected distance donation amount in runner detail [`4494afc`](https://git.odit.services/lfk/backend/commit/4494afc64b433d26b54a293fe156d13c40faad95)
|
||||||
|
- chore(release): 1.4.3 [`0ad9eeb`](https://git.odit.services/lfk/backend/commit/0ad9eeb52f18af3ea7d86fe1bf15edb04f4cfd2d)
|
||||||
|
|
||||||
|
#### [1.4.2](https://git.odit.services/lfk/backend/compare/1.4.1...1.4.2)
|
||||||
|
|
||||||
|
> 1 May 2025
|
||||||
|
|
||||||
|
- fix(donations): Fixed creation bug [`07a0195`](https://git.odit.services/lfk/backend/commit/07a0195f125519f239d255a0cc081ddbde8f1da3)
|
||||||
|
- chore(release): 1.4.2 [`f4747c5`](https://git.odit.services/lfk/backend/commit/f4747c51de71d9b28cca1b00a91de3cfd6f0f56e)
|
||||||
|
|
||||||
|
#### [1.4.1](https://git.odit.services/lfk/backend/compare/1.4.0...1.4.1)
|
||||||
|
|
||||||
|
> 28 April 2025
|
||||||
|
|
||||||
|
- chore(release): 1.4.1 [`7ac9822`](https://git.odit.services/lfk/backend/commit/7ac98229d17e7cb019d5dcc5402870490a97f910)
|
||||||
|
- refactor(auth): Increased token timeouts to 24hrs/7days [`dd5b538`](https://git.odit.services/lfk/backend/commit/dd5b538783f9c806f0c883cd391754fb5c842ec8)
|
||||||
|
|
||||||
|
#### [1.4.0](https://git.odit.services/lfk/backend/compare/1.3.12...1.4.0)
|
||||||
|
|
||||||
|
> 28 April 2025
|
||||||
|
|
||||||
|
- feat(donations): Implement response type to indicate possible missing donor [`f4bf309`](https://git.odit.services/lfk/backend/commit/f4bf309821c140f2bc0ae8b6d96c7458fcc80978)
|
||||||
|
- wip [`9875b4f`](https://git.odit.services/lfk/backend/commit/9875b4f3926e04b502e7af64c17f54fd3c1d8e3e)
|
||||||
|
- refactor(donations): Make anon prepaid [`02b1cb9`](https://git.odit.services/lfk/backend/commit/02b1cb9904cc593faeac025ae302a8684f650f5e)
|
||||||
|
- chore(release): 1.4.0 [`8e6d674`](https://git.odit.services/lfk/backend/commit/8e6d67428c85b6ee504a379ff13a3a951f7b9543)
|
||||||
|
- fix(donations): Move donor over to the types that need it [`7697acf`](https://git.odit.services/lfk/backend/commit/7697acff82b23d0c05dbbd17fee6e70eb1b7061c)
|
||||||
|
|
||||||
|
#### [1.3.12](https://git.odit.services/lfk/backend/compare/1.3.11...1.3.12)
|
||||||
|
|
||||||
|
> 28 April 2025
|
||||||
|
|
||||||
|
- chore(release): 1.3.12 [`bacfc43`](https://git.odit.services/lfk/backend/commit/bacfc437f97cac6a20c32b79ae2d6391466f78a6)
|
||||||
|
- refactor: make Donation.donor optional [`2ab6e98`](https://git.odit.services/lfk/backend/commit/2ab6e985e356f0f3d8637d81630d191cc11b8806)
|
||||||
|
- refactor(config): improve consola error logs [`ce9b765`](https://git.odit.services/lfk/backend/commit/ce9b765b81b014623e79ce64d8d835f1f86cecf3)
|
||||||
|
|
||||||
#### [1.3.11](https://git.odit.services/lfk/backend/compare/1.3.10...1.3.11)
|
#### [1.3.11](https://git.odit.services/lfk/backend/compare/1.3.10...1.3.11)
|
||||||
|
|
||||||
|
> 17 April 2025
|
||||||
|
|
||||||
- feat(RunnerController): add selfservice_links parameter to getRunners method [`a50d72f`](https://git.odit.services/lfk/backend/commit/a50d72f2f5281b8c28ca64a0970161a35a7af95a)
|
- feat(RunnerController): add selfservice_links parameter to getRunners method [`a50d72f`](https://git.odit.services/lfk/backend/commit/a50d72f2f5281b8c28ca64a0970161a35a7af95a)
|
||||||
|
- chore(release): 1.3.11 [`d06f6a4`](https://git.odit.services/lfk/backend/commit/d06f6a44072971d1853411b255f9b49eb423b3a2)
|
||||||
|
|
||||||
#### [1.3.10](https://git.odit.services/lfk/backend/compare/1.3.9...1.3.10)
|
#### [1.3.10](https://git.odit.services/lfk/backend/compare/1.3.9...1.3.10)
|
||||||
|
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@@ -1,27 +1,23 @@
|
|||||||
# Typescript Build
|
# Typescript Build
|
||||||
FROM registry.odit.services/hub/library/node:23.10.0-alpine3.21 AS build
|
FROM registry.odit.services/hub/oven/bun:1.3.9-alpine AS build
|
||||||
ARG NPM_REGISTRY_URL=https://registry.npmjs.org
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package.json bun.lockb* ./
|
||||||
COPY pnpm-workspace.yaml ./
|
RUN bun install --frozen-lockfile
|
||||||
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 tsconfig.json ormconfig.js ./
|
COPY tsconfig.json ormconfig.js bunfig.toml ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
RUN pnpm run build \
|
RUN bun run build \
|
||||||
&& rm -rf /app/node_modules \
|
&& rm -rf /app/node_modules \
|
||||||
&& pnpm i --production --prefer-offline
|
&& bun install --production --frozen-lockfile
|
||||||
|
|
||||||
# final image
|
# final image
|
||||||
FROM registry.odit.services/hub/library/node:23.10.0-alpine3.21 AS final
|
FROM registry.odit.services/hub/oven/bun:1.3.9-alpine AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/package.json /app/package.json
|
COPY --from=build /app/package.json /app/package.json
|
||||||
COPY --from=build /app/pnpm-lock.yaml /app/pnpm-lock.yaml
|
COPY --from=build /app/bun.lockb* /app/
|
||||||
COPY --from=build /app/pnpm-workspace.yaml /app/pnpm-workspace.yaml
|
|
||||||
COPY --from=build /app/ormconfig.js /app/ormconfig.js
|
COPY --from=build /app/ormconfig.js /app/ormconfig.js
|
||||||
|
COPY --from=build /app/bunfig.toml /app/bunfig.toml
|
||||||
COPY --from=build /app/dist /app/dist
|
COPY --from=build /app/dist /app/dist
|
||||||
COPY --from=build /app/node_modules /app/node_modules
|
COPY --from=build /app/node_modules /app/node_modules
|
||||||
ENTRYPOINT ["node", "/app/dist/app.js"]
|
ENTRYPOINT ["bun", "/app/dist/app.js"]
|
||||||
119
README.md
119
README.md
@@ -2,66 +2,119 @@
|
|||||||
|
|
||||||
Backend Server
|
Backend Server
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
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 🐳
|
## Quickstart 🐳
|
||||||
> Use this to run the backend with a postgresql db in docker
|
> Use this to run the backend with a PostgreSQL db in Docker
|
||||||
|
|
||||||
1. Clone the repo or copy the docker-compose
|
1. Clone the repo or copy the docker-compose
|
||||||
2. Run in toe folder that contains the docker-compose file: `docker-compose up -d`
|
2. Run in the folder that contains the docker-compose file: `docker-compose up -d`
|
||||||
3. Visit http://127.0.0.1:4010/api/docs to check if the server is running
|
3. Visit http://127.0.0.1:4010/api/docs to check if the server is running
|
||||||
4. You can now use the default admin user (`demo:demo`)
|
4. You can now use the default admin user (`demo:demo`)
|
||||||
|
|
||||||
## Dev Setup 🛠
|
## Dev Setup 🛠
|
||||||
> Local dev setup utilizing sqlite3 as the database.
|
> Local dev setup utilizing SQLite3 as the database and NATS for caching.
|
||||||
|
|
||||||
1. Rename the .env.example file to .env (you can adjust app port and other settings, if needed)
|
1. Rename the `.env.example` file to `.env` (you can adjust app port and other settings if needed)
|
||||||
2. Install Dependencies
|
2. Start NATS (required for KV cache):
|
||||||
```bash
|
```bash
|
||||||
pnpm i
|
docker-compose up -d nats
|
||||||
```
|
```
|
||||||
3. Start the server
|
3. Install dependencies:
|
||||||
```bash
|
```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
|
### Run Tests
|
||||||
```bash
|
```bash
|
||||||
# Run tests once (server has to run)
|
# Run tests once (server has to be running)
|
||||||
pnpm test
|
bun test
|
||||||
|
|
||||||
# Run test in watch mode (reruns on change)
|
# 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)
|
# Run test in CI mode (automatically starts the dev server)
|
||||||
pnpm test:ci
|
bun run test:ci
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run Benchmarks
|
||||||
|
```bash
|
||||||
|
# Start the server first
|
||||||
|
bun run dev
|
||||||
|
|
||||||
|
# In another terminal:
|
||||||
|
bun run benchmark
|
||||||
```
|
```
|
||||||
|
|
||||||
### Generate Docs
|
### Generate Docs
|
||||||
```bash
|
```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
|
## ENV Vars
|
||||||
> You can provide them via .env file or docker env vars.
|
> You can provide them via .env file or docker env vars.
|
||||||
> You can use the `test:ci:generate_env` package script to generate a example env (uses bs data as test server and ignores the errors).
|
> You can use the `test:ci:generate_env` package script to generate an example env (uses placeholder data for test server and ignores the errors).
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
| ---------------------- | ------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------- |
|
| ------------------------- | ------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||||
| APP_PORT | Number | 4010 | The port the backend server listens on. Is optional. |
|
| APP_PORT | Number | 4010 | The port the backend server listens on. Is optional. |
|
||||||
| DB_TYPE | String | N/A | The type of the db u want to use. It has to be supported by typeorm. Possible: `sqlite`, `mysql`, `postgresql` |
|
| DB_TYPE | String | N/A | The type of the db you want to use. Supported by TypeORM. Possible: `sqlite`, `mysql`, `postgresql` |
|
||||||
| DB_HOST | String | N/A | The db's host's ip-address/fqdn or file path for sqlite |
|
| DB_HOST | String | N/A | The db's host IP address/FQDN or file path for sqlite |
|
||||||
| DB_PORT | String | N/A | The db's port |
|
| DB_PORT | String | N/A | The db's port |
|
||||||
| DB_USER | String | N/A | The user for accessing the db |
|
| DB_USER | String | N/A | The user for accessing the db |
|
||||||
| DB_PASSWORD | String | N/A | The user's password for accessing the db |
|
| DB_PASSWORD | String | N/A | The user's password for accessing the db |
|
||||||
| DB_NAME | String | N/A | The db's name |
|
| DB_NAME | String | N/A | The db's name |
|
||||||
| NODE_ENV | String | dev | The apps env - influences debug info. Also when the env is set to "test", mailing errors get ignored. |
|
| NODE_ENV | String | dev | The app's env - influences debug info. When set to "test", mailing errors get ignored. |
|
||||||
| POSTALCODE_COUNTRYCODE | String/CountryCode | N/A | The countrycode used to validate address's postal codes |
|
| POSTALCODE_COUNTRYCODE | String/CountryCode | N/A | The country code used to validate address postal codes |
|
||||||
| PHONE_COUNTRYCODE | String/CountryCode | null (international) | The countrycode used to validate phone numers |
|
| PHONE_COUNTRYCODE | String/CountryCode | null (international) | The country code used to validate phone numbers |
|
||||||
| SEED_TEST_DATA | Boolean | False | If you want the app to seed some example data set this to true |
|
| SEED_TEST_DATA | Boolean | false | If you want the app to seed example data, set this to true |
|
||||||
| MAILER_URL | String(Url) | N/A | The mailer's base url (no trailing slash) |
|
| STATION_TOKEN_SECRET | String | N/A | Secret key for HMAC-SHA256 station token generation (min 32 chars). **Required.** |
|
||||||
| MAILER_KEY | String | N/A | The mailer's api key. |
|
| NATS_URL | String(URL) | nats://localhost:4222 | NATS server connection URL for KV cache |
|
||||||
| SELFSERVICE_URL | String(Url) | N/A | The link to selfservice (no trailing slash) |
|
| NATS_PREWARM | Boolean | false | Preload all runner state into NATS cache at startup (eliminates DB reads on first scan) |
|
||||||
| IMPRINT_URL | String(Url) | /imprint | The link to a imprint page for the system (Defaults to the frontend's imprint) |
|
| MAILER_URL | String(URL) | N/A | The mailer's base URL (no trailing slash) |
|
||||||
| PRIVACY_URL | String(Url) | /privacy | The link to a privacy page for the system (Defaults to the frontend's privacy page) |
|
| MAILER_KEY | String | N/A | The mailer's API key |
|
||||||
|
| SELFSERVICE_URL | String(URL) | N/A | The link to selfservice (no trailing slash) |
|
||||||
|
| IMPRINT_URL | String(URL) | /imprint | The link to an imprint page for the system (defaults to the frontend's imprint) |
|
||||||
|
| PRIVACY_URL | String(URL) | /privacy | The link to a privacy page for the system (defaults to the frontend's privacy page) |
|
||||||
|
|
||||||
|
|
||||||
## Recommended Editor
|
## Recommended Editor
|
||||||
|
|||||||
6
bunfig.toml
Normal file
6
bunfig.toml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Bun configuration
|
||||||
|
# See: https://bun.sh/docs/runtime/bunfig
|
||||||
|
|
||||||
|
[runtime]
|
||||||
|
# Enable Node.js compatibility mode
|
||||||
|
bun = true
|
||||||
@@ -1,21 +1,30 @@
|
|||||||
services:
|
services:
|
||||||
backend_server:
|
nats:
|
||||||
build: .
|
image: mirror.gcr.io/library/nats:alpine
|
||||||
|
command: ["--jetstream", "--store_dir", "/data"]
|
||||||
ports:
|
ports:
|
||||||
- 4010:4010
|
- "4222:4222"
|
||||||
environment:
|
- "8222:8222"
|
||||||
APP_PORT: 4010
|
volumes:
|
||||||
DB_TYPE: sqlite
|
- nats_data:/data
|
||||||
DB_HOST: bla
|
|
||||||
DB_PORT: bla
|
# backend_server:
|
||||||
DB_USER: bla
|
# build: .
|
||||||
DB_PASSWORD: bla
|
# ports:
|
||||||
DB_NAME: ./db.sqlite
|
# - 4010:4010
|
||||||
NODE_ENV: production
|
# environment:
|
||||||
POSTALCODE_COUNTRYCODE: DE
|
# APP_PORT: 4010
|
||||||
SEED_TEST_DATA: "true"
|
# DB_TYPE: sqlite
|
||||||
MAILER_URL: https://dev.lauf-fuer-kaya.de/mailer
|
# DB_HOST: bla
|
||||||
MAILER_KEY: asdasd
|
# DB_PORT: bla
|
||||||
|
# DB_USER: bla
|
||||||
|
# DB_PASSWORD: bla
|
||||||
|
# DB_NAME: ./db.sqlite
|
||||||
|
# NODE_ENV: production
|
||||||
|
# POSTALCODE_COUNTRYCODE: DE
|
||||||
|
# SEED_TEST_DATA: "true"
|
||||||
|
# MAILER_URL: https://dev.lauf-fuer-kaya.de/mailer
|
||||||
|
# MAILER_KEY: asdasd
|
||||||
# APP_PORT: 4010
|
# APP_PORT: 4010
|
||||||
# DB_TYPE: postgres
|
# DB_TYPE: postgres
|
||||||
# DB_HOST: backend_db
|
# DB_HOST: backend_db
|
||||||
@@ -32,3 +41,6 @@ services:
|
|||||||
# POSTGRES_USER: lfk
|
# POSTGRES_USER: lfk
|
||||||
# ports:
|
# ports:
|
||||||
# - 5432:5432
|
# - 5432:5432
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nats_data:
|
||||||
|
|||||||
302
licenses.md
302
licenses.md
@@ -464,6 +464,215 @@ Copyright (c) 2012 Felix Geisendörfer (felix@debuggable.com) and contributors
|
|||||||
THE SOFTWARE.
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
|
# nats
|
||||||
|
**Author**: [object Object]
|
||||||
|
**Repo**: [object Object]
|
||||||
|
**License**: Apache-2.0
|
||||||
|
**Description**: Node.js client for NATS, a lightweight, high-performance cloud native messaging system
|
||||||
|
## License Text
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright 2013-2018 The NATS Authors
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
# pg
|
# pg
|
||||||
**Author**: Brian Carlson <brian.m.carlson@gmail.com>
|
**Author**: Brian Carlson <brian.m.carlson@gmail.com>
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
@@ -872,7 +1081,7 @@ OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||||||
**Author**: undefined
|
**Author**: undefined
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
**License**: MIT
|
**License**: MIT
|
||||||
**Description**: TypeScript definitions for Express
|
**Description**: TypeScript definitions for express
|
||||||
## License Text
|
## License Text
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
@@ -901,7 +1110,7 @@ OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||||||
**Author**: undefined
|
**Author**: undefined
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
**License**: MIT
|
**License**: MIT
|
||||||
**Description**: TypeScript definitions for Jest
|
**Description**: TypeScript definitions for jest
|
||||||
## License Text
|
## License Text
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
@@ -959,36 +1168,7 @@ OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||||||
**Author**: undefined
|
**Author**: undefined
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
**License**: MIT
|
**License**: MIT
|
||||||
**Description**: TypeScript definitions for Node.js
|
**Description**: TypeScript definitions for node
|
||||||
## License Text
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) Microsoft Corporation.
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE
|
|
||||||
|
|
||||||
|
|
||||||
# @types/uuid
|
|
||||||
**Author**: undefined
|
|
||||||
**Repo**: [object Object]
|
|
||||||
**License**: MIT
|
|
||||||
**Description**: TypeScript definitions for uuid
|
|
||||||
## License Text
|
## License Text
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
@@ -1100,35 +1280,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
# nodemon
|
|
||||||
**Author**: [object Object]
|
|
||||||
**Repo**: [object Object]
|
|
||||||
**License**: MIT
|
|
||||||
**Description**: Simple monitor script for use during development of a node.js app.
|
|
||||||
## License Text
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2010 - present, Remy Sharp, https://remysharp.com <remy@remysharp.com>
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
||||||
|
|
||||||
# release-it
|
# release-it
|
||||||
**Author**: [object Object]
|
**Author**: [object Object]
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
@@ -1218,35 +1369,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
# ts-node
|
|
||||||
**Author**: [object Object]
|
|
||||||
**Repo**: [object Object]
|
|
||||||
**License**: MIT
|
|
||||||
**Description**: TypeScript execution environment and REPL for node.js, with source map support
|
|
||||||
## License Text
|
|
||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in
|
|
||||||
all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
THE SOFTWARE.
|
|
||||||
|
|
||||||
|
|
||||||
# typedoc
|
# typedoc
|
||||||
**Author**: undefined
|
**Author**: undefined
|
||||||
**Repo**: [object Object]
|
**Repo**: [object Object]
|
||||||
|
|||||||
10
ormconfig.js
10
ormconfig.js
@@ -1,7 +1,8 @@
|
|||||||
const dotenv = require('dotenv');
|
const dotenv = require('dotenv');
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
//
|
//
|
||||||
const SOURCE_PATH = process.env.NODE_ENV === 'production' ? 'dist' : 'src';
|
// Bun workflow: always compile first, then run from dist/
|
||||||
|
const SOURCE_PATH = 'dist';
|
||||||
module.exports = {
|
module.exports = {
|
||||||
type: process.env.DB_TYPE,
|
type: process.env.DB_TYPE,
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
@@ -9,8 +10,7 @@ module.exports = {
|
|||||||
username: process.env.DB_USER,
|
username: process.env.DB_USER,
|
||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
// entities: ["src/**/entities/*.ts"],
|
// Always load compiled .js files from dist/ (TypeORM entities have circular deps)
|
||||||
entities: [ `${SOURCE_PATH}/**/entities/*{.ts,.js}` ],
|
entities: [ `${SOURCE_PATH}/**/entities/*.js` ],
|
||||||
seeds: [ `${SOURCE_PATH}/**/seeds/*{.ts,.js}` ]
|
seeds: [ `${SOURCE_PATH}/**/seeds/*.js` ]
|
||||||
// seeds: ['src/seeds/*.ts'],
|
|
||||||
};
|
};
|
||||||
|
|||||||
38
package.json
38
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@odit/lfk-backend",
|
"name": "@odit/lfk-backend",
|
||||||
"version": "1.3.11",
|
"version": "1.7.0",
|
||||||
"main": "src/app.ts",
|
"main": "src/app.ts",
|
||||||
"repository": "https://git.odit.services/lfk/backend",
|
"repository": "https://git.odit.services/lfk/backend",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -39,6 +39,7 @@
|
|||||||
"jsonwebtoken": "8.5.1",
|
"jsonwebtoken": "8.5.1",
|
||||||
"libphonenumber-js": "1.9.9",
|
"libphonenumber-js": "1.9.9",
|
||||||
"mysql": "2.18.1",
|
"mysql": "2.18.1",
|
||||||
|
"nats": "^2.29.3",
|
||||||
"pg": "8.5.1",
|
"pg": "8.5.1",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"routing-controllers": "0.9.0-alpha.6",
|
"routing-controllers": "0.9.0-alpha.6",
|
||||||
@@ -53,36 +54,35 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "7.6.0",
|
"@faker-js/faker": "7.6.0",
|
||||||
"@odit/license-exporter": "0.0.9",
|
"@odit/license-exporter": "0.0.9",
|
||||||
"@types/cors": "2.8.9",
|
"@types/cors": "2.8.19",
|
||||||
"@types/csvtojson": "1.1.5",
|
"@types/csvtojson": "1.1.5",
|
||||||
"@types/express": "4.17.11",
|
"@types/express": "5.0.6",
|
||||||
"@types/jest": "26.0.20",
|
"@types/jest": "30.0.0",
|
||||||
"@types/jsonwebtoken": "8.5.0",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "14.14.22",
|
"@types/node": "25.3.0",
|
||||||
"@types/uuid": "8.3.0",
|
|
||||||
"auto-changelog": "2.4.0",
|
"auto-changelog": "2.4.0",
|
||||||
"cp-cli": "2.0.0",
|
"cp-cli": "2.0.0",
|
||||||
"jest": "26.6.3",
|
"jest": "26.6.3",
|
||||||
"nodemon": "2.0.7",
|
|
||||||
"release-it": "14.2.2",
|
"release-it": "14.2.2",
|
||||||
"rimraf": "3.0.2",
|
"rimraf": "3.0.2",
|
||||||
"start-server-and-test": "1.11.7",
|
"start-server-and-test": "1.11.7",
|
||||||
"ts-jest": "26.5.0",
|
"ts-jest": "26.5.0",
|
||||||
"ts-node": "9.1.1",
|
|
||||||
"typedoc": "0.20.19",
|
"typedoc": "0.20.19",
|
||||||
"typescript": "4.1.3"
|
"typescript": "5.9.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nodemon src/app.ts",
|
"dev": "bun scripts/dev_watch.ts",
|
||||||
|
"start": "bun dist/app.js",
|
||||||
"build": "rimraf ./dist && tsc && cp-cli ./src/static ./dist/static",
|
"build": "rimraf ./dist && tsc && cp-cli ./src/static ./dist/static",
|
||||||
"docs": "typedoc --out docs src",
|
"docs": "typedoc --out docs src",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watchAll",
|
"test:watch": "jest --watchAll",
|
||||||
"test:ci:generate_env": "ts-node scripts/create_testenv.ts",
|
"test:ci:generate_env": "bun scripts/create_testenv.ts",
|
||||||
"test:ci:run": "start-server-and-test dev http://localhost:4010/api/docs/openapi.json test",
|
"test:ci:run": "start-server-and-test dev http://localhost:4010/api/docs/openapi.json test",
|
||||||
"test:ci": "npm run test:ci:generate_env && npm run test:ci:run",
|
"test:ci": "bun run test:ci:generate_env && bun run test:ci:run",
|
||||||
"seed": "ts-node ./node_modules/typeorm/cli.js schema:sync && ts-node ./node_modules/typeorm-seeding/dist/cli.js seed",
|
"benchmark": "bun scripts/benchmark_scan_intake.ts",
|
||||||
"openapi:export": "ts-node scripts/openapi_export.ts",
|
"seed": "bun ./node_modules/typeorm/cli.js schema:sync && bun ./node_modules/typeorm-seeding/dist/cli.js seed",
|
||||||
|
"openapi:export": "bun scripts/openapi_export.ts",
|
||||||
"licenses:export": "license-exporter --markdown",
|
"licenses:export": "license-exporter --markdown",
|
||||||
"changelog:export": "auto-changelog --commit-limit false -p -u --hide-credit",
|
"changelog:export": "auto-changelog --commit-limit false -p -u --hide-credit",
|
||||||
"release": "release-it --only-version"
|
"release": "release-it --only-version"
|
||||||
@@ -102,13 +102,7 @@
|
|||||||
"publish": false
|
"publish": false
|
||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"after:bump": "npm run changelog:export && npm run licenses:export && git add CHANGELOG.md && git add licenses.md"
|
"after:bump": "bun run changelog:export && bun run licenses:export && git add CHANGELOG.md && git add licenses.md"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"nodemonConfig": {
|
|
||||||
"ignore": [
|
|
||||||
"src/tests/*",
|
|
||||||
"docs/*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
521
pnpm-lock.yaml
generated
521
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
367
scripts/benchmark_scan_intake.ts
Normal file
367
scripts/benchmark_scan_intake.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
/**
|
||||||
|
* Scan Intake Benchmark Script
|
||||||
|
*
|
||||||
|
* Measures TrackScan creation performance before and after each optimisation phase.
|
||||||
|
* Run against a live dev server: bun run dev
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run benchmark
|
||||||
|
* bun scripts/benchmark_scan_intake.ts --base http://localhost:4010
|
||||||
|
*
|
||||||
|
* What it measures:
|
||||||
|
* 1. Single sequential scans — baseline latency per request (p50/p95/p99/max)
|
||||||
|
* 2. Parallel scans (10 stations) — simulates 10 concurrent stations each submitting
|
||||||
|
* one scan at a time at the expected event rate
|
||||||
|
* (~1 scan/3s per station = ~3.3 scans/s total)
|
||||||
|
*
|
||||||
|
* The script self-provisions all required data (org, runners, cards, track, stations)
|
||||||
|
* and cleans up after itself. It authenticates via the station token, matching the
|
||||||
|
* real production auth path exactly.
|
||||||
|
*
|
||||||
|
* Output is printed to stdout in a copy-paste-friendly table format so results can
|
||||||
|
* be compared across phases.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const BASE = (() => {
|
||||||
|
const idx = process.argv.indexOf('--base');
|
||||||
|
return idx !== -1 ? process.argv[idx + 1] : 'http://localhost:4010';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const API = `${BASE}/api`;
|
||||||
|
|
||||||
|
// Number of simulated scan stations
|
||||||
|
const STATION_COUNT = 10;
|
||||||
|
|
||||||
|
// Sequential benchmark: total number of scans to send, one at a time
|
||||||
|
const SEQUENTIAL_SCAN_COUNT = 50;
|
||||||
|
|
||||||
|
// Parallel benchmark: number of rounds. Each round fires STATION_COUNT scans concurrently.
|
||||||
|
// 20 rounds × 10 stations = 200 total scans, matching the expected event throughput pattern.
|
||||||
|
const PARALLEL_ROUNDS = 20;
|
||||||
|
|
||||||
|
// Minimum lap time on the test track (seconds). Set low so most scans are valid.
|
||||||
|
// The benchmark measures submission speed, not business logic.
|
||||||
|
const TRACK_MINIMUM_LAP_TIME = 1;
|
||||||
|
|
||||||
|
// Track distance (metres)
|
||||||
|
const TRACK_DISTANCE = 400;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface StationHandle {
|
||||||
|
id: number;
|
||||||
|
key: string; // cleartext token, used as Bearer token
|
||||||
|
cardCode: number; // EAN-13 barcode of the card assigned to this station's runner
|
||||||
|
axiosInstance: AxiosInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Percentiles {
|
||||||
|
p50: number;
|
||||||
|
p95: number;
|
||||||
|
p99: number;
|
||||||
|
max: number;
|
||||||
|
min: number;
|
||||||
|
mean: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BenchmarkResult {
|
||||||
|
label: string;
|
||||||
|
totalScans: number;
|
||||||
|
totalTimeMs: number;
|
||||||
|
scansPerSecond: number;
|
||||||
|
latencies: Percentiles;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HTTP helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const adminClient = axios.create({
|
||||||
|
baseURL: API,
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function adminLogin(): Promise<string> {
|
||||||
|
const res = await adminClient.post('/auth/login', { username: 'demo', password: 'demo' });
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error(`Login failed: ${res.status} ${JSON.stringify(res.data)}`);
|
||||||
|
}
|
||||||
|
return res.data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authedClient(token: string): AxiosInstance {
|
||||||
|
return axios.create({
|
||||||
|
baseURL: API,
|
||||||
|
validateStatus: () => true,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data provisioning
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function provision(adminToken: string): Promise<{
|
||||||
|
stations: StationHandle[];
|
||||||
|
trackId: number;
|
||||||
|
orgId: number;
|
||||||
|
cleanup: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const client = authedClient(adminToken);
|
||||||
|
const createdIds: { type: string; id: number }[] = [];
|
||||||
|
|
||||||
|
const create = async (path: string, body: object): Promise<any> => {
|
||||||
|
const res = await client.post(path, body);
|
||||||
|
if (res.status !== 200) {
|
||||||
|
throw new Error(`POST ${path} failed: ${res.status} ${JSON.stringify(res.data)}`);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdout.write('Provisioning test data... ');
|
||||||
|
|
||||||
|
// Organisation
|
||||||
|
const org = await create('/organizations', { name: 'benchmark-org' });
|
||||||
|
createdIds.push({ type: 'organizations', id: org.id });
|
||||||
|
|
||||||
|
// Track with a low minimumLapTime so re-scans within the benchmark are mostly valid
|
||||||
|
const track = await create('/tracks', {
|
||||||
|
name: 'benchmark-track',
|
||||||
|
distance: TRACK_DISTANCE,
|
||||||
|
minimumLapTime: TRACK_MINIMUM_LAP_TIME,
|
||||||
|
});
|
||||||
|
createdIds.push({ type: 'tracks', id: track.id });
|
||||||
|
|
||||||
|
// One runner + card + station per simulated scan station
|
||||||
|
const stations: StationHandle[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < STATION_COUNT; i++) {
|
||||||
|
const runner = await create('/runners', {
|
||||||
|
firstname: `Bench`,
|
||||||
|
lastname: `Runner${i}`,
|
||||||
|
group: org.id,
|
||||||
|
});
|
||||||
|
createdIds.push({ type: 'runners', id: runner.id });
|
||||||
|
|
||||||
|
const card = await create('/cards', { runner: runner.id });
|
||||||
|
createdIds.push({ type: 'cards', id: card.id });
|
||||||
|
|
||||||
|
const station = await create('/stations', {
|
||||||
|
track: track.id,
|
||||||
|
description: `bench-station-${i}`,
|
||||||
|
});
|
||||||
|
createdIds.push({ type: 'stations', id: station.id });
|
||||||
|
|
||||||
|
stations.push({
|
||||||
|
id: station.id,
|
||||||
|
key: station.key,
|
||||||
|
cardCode: card.id, // the test spec uses card.id directly as the barcode value
|
||||||
|
axiosInstance: axios.create({
|
||||||
|
baseURL: API,
|
||||||
|
validateStatus: () => true,
|
||||||
|
headers: { authorization: `Bearer ${station.key}` },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`done. (${STATION_COUNT} stations, ${STATION_COUNT} runners, ${STATION_COUNT} cards)`);
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
process.stdout.write('Cleaning up test data... ');
|
||||||
|
// Delete in reverse-dependency order
|
||||||
|
for (const item of [...createdIds].reverse()) {
|
||||||
|
await client.delete(`/${item.type}/${item.id}?force=true`);
|
||||||
|
}
|
||||||
|
console.log('done.');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { stations, trackId: track.id, orgId: org.id, cleanup };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Single scan submission (returns latency in ms)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function submitScan(station: StationHandle): Promise<{ latencyMs: number; ok: boolean }> {
|
||||||
|
const start = performance.now();
|
||||||
|
const res = await station.axiosInstance.post('/scans/trackscans', {
|
||||||
|
card: station.cardCode,
|
||||||
|
station: station.id,
|
||||||
|
});
|
||||||
|
const latencyMs = performance.now() - start;
|
||||||
|
const ok = res.status === 200;
|
||||||
|
return { latencyMs, ok };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Statistics
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function percentiles(latencies: number[]): Percentiles {
|
||||||
|
const sorted = [...latencies].sort((a, b) => a - b);
|
||||||
|
const at = (pct: number) => sorted[Math.floor((pct / 100) * sorted.length)] ?? sorted[sorted.length - 1];
|
||||||
|
const mean = sorted.reduce((s, v) => s + v, 0) / sorted.length;
|
||||||
|
return {
|
||||||
|
p50: Math.round(at(50)),
|
||||||
|
p95: Math.round(at(95)),
|
||||||
|
p99: Math.round(at(99)),
|
||||||
|
max: Math.round(sorted[sorted.length - 1]),
|
||||||
|
min: Math.round(sorted[0]),
|
||||||
|
mean: Math.round(mean),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark 1 — Sequential (single station, one scan at a time)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function benchmarkSequential(station: StationHandle): Promise<BenchmarkResult> {
|
||||||
|
const latencies: number[] = [];
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
process.stdout.write(` Running ${SEQUENTIAL_SCAN_COUNT} sequential scans`);
|
||||||
|
const wallStart = performance.now();
|
||||||
|
|
||||||
|
for (let i = 0; i < SEQUENTIAL_SCAN_COUNT; i++) {
|
||||||
|
const { latencyMs, ok } = await submitScan(station);
|
||||||
|
latencies.push(latencyMs);
|
||||||
|
if (!ok) errors++;
|
||||||
|
if ((i + 1) % 10 === 0) process.stdout.write('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTimeMs = performance.now() - wallStart;
|
||||||
|
console.log(' done.');
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: 'Sequential (1 station)',
|
||||||
|
totalScans: SEQUENTIAL_SCAN_COUNT,
|
||||||
|
totalTimeMs,
|
||||||
|
scansPerSecond: (SEQUENTIAL_SCAN_COUNT / totalTimeMs) * 1000,
|
||||||
|
latencies: percentiles(latencies),
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benchmark 2 — Parallel (10 stations, concurrent rounds)
|
||||||
|
//
|
||||||
|
// Models the real event scenario: every ~3 seconds each station submits one scan.
|
||||||
|
// We don't actually sleep between rounds — we fire each round as fast as the
|
||||||
|
// previous one completes, which gives us the worst-case sustained throughput
|
||||||
|
// (all stations submitting at maximum rate simultaneously).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function benchmarkParallel(stations: StationHandle[]): Promise<BenchmarkResult> {
|
||||||
|
const latencies: number[] = [];
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
process.stdout.write(` Running ${PARALLEL_ROUNDS} rounds × ${STATION_COUNT} concurrent stations`);
|
||||||
|
const wallStart = performance.now();
|
||||||
|
|
||||||
|
for (let round = 0; round < PARALLEL_ROUNDS; round++) {
|
||||||
|
const results = await Promise.all(stations.map(s => submitScan(s)));
|
||||||
|
for (const { latencyMs, ok } of results) {
|
||||||
|
latencies.push(latencyMs);
|
||||||
|
if (!ok) errors++;
|
||||||
|
}
|
||||||
|
if ((round + 1) % 4 === 0) process.stdout.write('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalTimeMs = performance.now() - wallStart;
|
||||||
|
const totalScans = PARALLEL_ROUNDS * STATION_COUNT;
|
||||||
|
console.log(' done.');
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `Parallel (${STATION_COUNT} stations concurrent)`,
|
||||||
|
totalScans,
|
||||||
|
totalTimeMs,
|
||||||
|
scansPerSecond: (totalScans / totalTimeMs) * 1000,
|
||||||
|
latencies: percentiles(latencies),
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Output formatting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function printResult(result: BenchmarkResult) {
|
||||||
|
const { label, totalScans, totalTimeMs, scansPerSecond, latencies, errors } = result;
|
||||||
|
console.log(`\n ${label}`);
|
||||||
|
console.log(` ${'─'.repeat(52)}`);
|
||||||
|
console.log(` Total scans : ${totalScans}`);
|
||||||
|
console.log(` Total time : ${totalTimeMs.toFixed(0)} ms`);
|
||||||
|
console.log(` Throughput : ${scansPerSecond.toFixed(2)} scans/sec`);
|
||||||
|
console.log(` Latency min : ${latencies.min} ms`);
|
||||||
|
console.log(` Latency mean : ${latencies.mean} ms`);
|
||||||
|
console.log(` Latency p50 : ${latencies.p50} ms`);
|
||||||
|
console.log(` Latency p95 : ${latencies.p95} ms`);
|
||||||
|
console.log(` Latency p99 : ${latencies.p99} ms`);
|
||||||
|
console.log(` Latency max : ${latencies.max} ms`);
|
||||||
|
console.log(` Errors : ${errors}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummary(results: BenchmarkResult[]) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
console.log('\n');
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
console.log(` SCAN INTAKE BENCHMARK RESULTS — ${now}`);
|
||||||
|
console.log(` Server: ${BASE}`);
|
||||||
|
console.log('═'.repeat(60));
|
||||||
|
for (const r of results) {
|
||||||
|
printResult(r);
|
||||||
|
}
|
||||||
|
console.log('\n' + '═'.repeat(60));
|
||||||
|
console.log(' Copy the block above to compare across phases.');
|
||||||
|
console.log('═'.repeat(60) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Entry point
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`\nScan Intake Benchmark — target: ${BASE}\n`);
|
||||||
|
|
||||||
|
let adminToken: string;
|
||||||
|
try {
|
||||||
|
adminToken = await adminLogin();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Could not authenticate. Is the server running at ${BASE}?\n`, err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stations, cleanup } = await provision(adminToken);
|
||||||
|
|
||||||
|
const results: BenchmarkResult[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('\nBenchmark 1 — Sequential');
|
||||||
|
results.push(await benchmarkSequential(stations[0]));
|
||||||
|
|
||||||
|
// Brief pause between benchmarks so the sequential scans don't skew
|
||||||
|
// the parallel benchmark's first-scan latency (minimumLapTime window)
|
||||||
|
await new Promise(r => setTimeout(r, (TRACK_MINIMUM_LAP_TIME + 1) * 1000));
|
||||||
|
|
||||||
|
console.log('\nBenchmark 2 — Parallel');
|
||||||
|
results.push(await benchmarkParallel(stations));
|
||||||
|
} finally {
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
printSummary(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => {
|
||||||
|
console.error('Benchmark failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
138
scripts/dev_watch.ts
Normal file
138
scripts/dev_watch.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Development watch script for Bun
|
||||||
|
*
|
||||||
|
* Watches src/ for changes, rebuilds on change, and restarts the server.
|
||||||
|
* This is necessary because we must compile TypeScript first due to circular
|
||||||
|
* TypeORM entity dependencies that Bun's TS loader cannot handle directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { watch } from "fs";
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import consola from "consola";
|
||||||
|
|
||||||
|
let serverProcess: ReturnType<typeof spawn> | null = null;
|
||||||
|
let isRebuilding = false;
|
||||||
|
let pendingRestart = false;
|
||||||
|
let debounceTimer: NodeJS.Timeout | null = null;
|
||||||
|
let watcherReady = false;
|
||||||
|
|
||||||
|
function killServer() {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
if (serverProcess) {
|
||||||
|
consola.info("Stopping server...");
|
||||||
|
serverProcess.kill();
|
||||||
|
serverProcess = null;
|
||||||
|
// Wait for port to be fully released (longer on Windows)
|
||||||
|
setTimeout(resolve, 2000);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer() {
|
||||||
|
consola.info("Starting server...");
|
||||||
|
serverProcess = spawn("bun", ["dist/app.js"], {
|
||||||
|
stdio: "inherit",
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.on("error", (err) => {
|
||||||
|
consola.error("Server process error:", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
serverProcess.on("exit", (code) => {
|
||||||
|
if (code !== null && code !== 0 && code !== 143) {
|
||||||
|
consola.error(`Server exited with code ${code}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable watcher after initial server start
|
||||||
|
if (!watcherReady) {
|
||||||
|
setTimeout(() => {
|
||||||
|
watcherReady = true;
|
||||||
|
consola.success("👀 Watching for file changes...");
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuild() {
|
||||||
|
if (isRebuilding) {
|
||||||
|
pendingRestart = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRebuilding = true;
|
||||||
|
pendingRestart = false;
|
||||||
|
|
||||||
|
consola.info("Rebuilding...");
|
||||||
|
await killServer();
|
||||||
|
|
||||||
|
const buildProcess = spawn("bun", ["run", "build"], {
|
||||||
|
stdio: "inherit",
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
buildProcess.on("exit", (code) => {
|
||||||
|
isRebuilding = false;
|
||||||
|
|
||||||
|
if (code === 0) {
|
||||||
|
consola.success("Build complete!");
|
||||||
|
startServer();
|
||||||
|
|
||||||
|
if (pendingRestart) {
|
||||||
|
consola.info("Change detected during build, rebuilding again...");
|
||||||
|
setTimeout(() => rebuild(), 100);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
consola.error(`Build failed with code ${code}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial build and start
|
||||||
|
consola.info("🔄 Development mode - watching for changes...");
|
||||||
|
rebuild();
|
||||||
|
|
||||||
|
// Watch src/ for changes (including subdirectories)
|
||||||
|
const watcher = watch(
|
||||||
|
"./src",
|
||||||
|
{ recursive: true },
|
||||||
|
(eventType, filename) => {
|
||||||
|
if (!watcherReady) return; // Ignore changes during initial build
|
||||||
|
|
||||||
|
if (filename && filename.endsWith(".ts")) {
|
||||||
|
// Ignore test files and declaration files
|
||||||
|
if (filename.endsWith(".spec.ts") || filename.endsWith(".d.ts")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce: wait 500ms for multiple rapid changes
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
consola.info(`File changed: ${filename}`);
|
||||||
|
rebuild();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup on exit
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
consola.info("\nShutting down...");
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
killServer();
|
||||||
|
watcher.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
killServer();
|
||||||
|
watcher.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
@@ -7,7 +7,8 @@ import authchecker from "./middlewares/authchecker";
|
|||||||
import { ErrorHandler } from './middlewares/ErrorHandler';
|
import { ErrorHandler } from './middlewares/ErrorHandler';
|
||||||
import UserChecker from './middlewares/UserChecker';
|
import UserChecker from './middlewares/UserChecker';
|
||||||
|
|
||||||
const CONTROLLERS_FILE_EXTENSION = process.env.NODE_ENV === 'production' ? 'js' : 'ts';
|
// Always use .js when running from dist/ (Bun workflow: always build first)
|
||||||
|
const CONTROLLERS_FILE_EXTENSION = 'js';
|
||||||
const app = createExpressServer({
|
const app = createExpressServer({
|
||||||
authorizationChecker: authchecker,
|
authorizationChecker: authchecker,
|
||||||
currentUserChecker: UserChecker,
|
currentUserChecker: UserChecker,
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export const config = {
|
|||||||
development: process.env.NODE_ENV === "production",
|
development: process.env.NODE_ENV === "production",
|
||||||
testing: process.env.NODE_ENV === "test",
|
testing: process.env.NODE_ENV === "test",
|
||||||
jwt_secret: process.env.JWT_SECRET || "secretjwtsecret",
|
jwt_secret: process.env.JWT_SECRET || "secretjwtsecret",
|
||||||
|
station_token_secret: process.env.STATION_TOKEN_SECRET || "",
|
||||||
|
nats_url: process.env.NATS_URL || "nats://localhost:4222",
|
||||||
|
nats_prewarm: process.env.NATS_PREWARM === "true",
|
||||||
phone_validation_countrycode: getPhoneCodeLocale(),
|
phone_validation_countrycode: getPhoneCodeLocale(),
|
||||||
postalcode_validation_countrycode: getPostalCodeLocale(),
|
postalcode_validation_countrycode: getPostalCodeLocale(),
|
||||||
version: process.env.VERSION || require('../package.json').version,
|
version: process.env.VERSION || require('../package.json').version,
|
||||||
@@ -32,6 +35,10 @@ if (config.mailer_url == "" || config.mailer_key == "") {
|
|||||||
consola.error("Error: invalid mailer config")
|
consola.error("Error: invalid mailer config")
|
||||||
errors++;
|
errors++;
|
||||||
}
|
}
|
||||||
|
if (config.station_token_secret.length < 32) {
|
||||||
|
consola.error("Error: STATION_TOKEN_SECRET must be set and at least 32 characters long")
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
function getPhoneCodeLocale(): CountryCode {
|
function getPhoneCodeLocale(): CountryCode {
|
||||||
return (process.env.PHONE_COUNTRYCODE as CountryCode);
|
return (process.env.PHONE_COUNTRYCODE as CountryCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
|||||||
import { Repository, getConnectionManager } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { RunnerCardHasScansError, RunnerCardIdsNotMatchingError, RunnerCardNotFoundError } from '../errors/RunnerCardErrors';
|
import { RunnerCardHasScansError, RunnerCardIdsNotMatchingError, RunnerCardNotFoundError } from '../errors/RunnerCardErrors';
|
||||||
import { RunnerNotFoundError } from '../errors/RunnerErrors';
|
import { RunnerNotFoundError } from '../errors/RunnerErrors';
|
||||||
|
import { deleteCardEntry } from '../nats/CardKV';
|
||||||
import { CreateRunnerCard } from '../models/actions/create/CreateRunnerCard';
|
import { CreateRunnerCard } from '../models/actions/create/CreateRunnerCard';
|
||||||
import { UpdateRunnerCard } from '../models/actions/update/UpdateRunnerCard';
|
import { UpdateRunnerCard } from '../models/actions/update/UpdateRunnerCard';
|
||||||
import { UpdateRunnerCardByCode } from '../models/actions/update/UpdateRunnerCardByCode';
|
import { UpdateRunnerCardByCode } from '../models/actions/update/UpdateRunnerCardByCode';
|
||||||
@@ -110,6 +111,7 @@ export class RunnerCardController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.cardRepository.save(await card.update(oldCard));
|
await this.cardRepository.save(await card.update(oldCard));
|
||||||
|
await deleteCardEntry(id);
|
||||||
return (await this.cardRepository.findOne({ id: id }, { relations: ['runner', 'runner.group', 'runner.group.parentGroup'] })).toResponse();
|
return (await this.cardRepository.findOne({ id: id }, { relations: ['runner', 'runner.group', 'runner.group.parentGroup'] })).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +157,7 @@ export class RunnerCardController {
|
|||||||
await scanController.remove(scan.id, force);
|
await scanController.remove(scan.id, force);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await deleteCardEntry(id);
|
||||||
await this.cardRepository.delete(card);
|
await this.cardRepository.delete(card);
|
||||||
return card.toResponse();
|
return card.toResponse();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
|||||||
import { Repository, getConnectionManager } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { RunnerGroupNeededError, RunnerHasDistanceDonationsError, RunnerIdsNotMatchingError, RunnerNotFoundError } from '../errors/RunnerErrors';
|
import { RunnerGroupNeededError, RunnerHasDistanceDonationsError, RunnerIdsNotMatchingError, RunnerNotFoundError } from '../errors/RunnerErrors';
|
||||||
import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors';
|
import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors';
|
||||||
|
import { deleteRunnerEntry } from '../nats/RunnerKV';
|
||||||
import { CreateRunner } from '../models/actions/create/CreateRunner';
|
import { CreateRunner } from '../models/actions/create/CreateRunner';
|
||||||
import { UpdateRunner } from '../models/actions/update/UpdateRunner';
|
import { UpdateRunner } from '../models/actions/update/UpdateRunner';
|
||||||
import { Runner } from '../models/entities/Runner';
|
import { Runner } from '../models/entities/Runner';
|
||||||
@@ -60,7 +61,7 @@ export class RunnerController {
|
|||||||
@OnUndefined(RunnerNotFoundError)
|
@OnUndefined(RunnerNotFoundError)
|
||||||
@OpenAPI({ description: 'Lists all information about the runner whose id got provided.' })
|
@OpenAPI({ description: 'Lists all information about the runner whose id got provided.' })
|
||||||
async getOne(@Param('id') id: number) {
|
async getOne(@Param('id') id: number) {
|
||||||
let runner = await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] })
|
let runner = await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards', 'distanceDonations'] })
|
||||||
if (!runner) { throw new RunnerNotFoundError(); }
|
if (!runner) { throw new RunnerNotFoundError(); }
|
||||||
return new ResponseRunner(runner, true);
|
return new ResponseRunner(runner, true);
|
||||||
}
|
}
|
||||||
@@ -126,6 +127,7 @@ export class RunnerController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.runnerRepository.save(await runner.update(oldRunner));
|
await this.runnerRepository.save(await runner.update(oldRunner));
|
||||||
|
await deleteRunnerEntry(id);
|
||||||
return new ResponseRunner(await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] }), true);
|
return new ResponseRunner(await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] }), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam, Req, UseBefore } from 'routing-controllers';
|
import { Authorized, Body, Delete, Get, HttpError, JsonController, OnUndefined, Param, Post, Put, QueryParam, Req, UseBefore } from 'routing-controllers';
|
||||||
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
||||||
import { Repository, getConnectionManager } from 'typeorm';
|
import { Repository, getConnection, getConnectionManager } from 'typeorm';
|
||||||
import { RunnerNotFoundError } from '../errors/RunnerErrors';
|
import { RunnerNotFoundError } from '../errors/RunnerErrors';
|
||||||
import { ScanIdsNotMatchingError, ScanNotFoundError } from '../errors/ScanErrors';
|
import { ScanIdsNotMatchingError, ScanNotFoundError } from '../errors/ScanErrors';
|
||||||
import { ScanStationNotFoundError } from '../errors/ScanStationErrors';
|
import { ScanStationNotFoundError } from '../errors/ScanStationErrors';
|
||||||
import ScanAuth from '../middlewares/ScanAuth';
|
import ScanAuth from '../middlewares/ScanAuth';
|
||||||
|
import { deleteCardEntry, getCardEntry, setCardEntry } from '../nats/CardKV';
|
||||||
|
import { deleteRunnerEntry, getRunnerEntry, RunnerKVEntry, setRunnerEntry, warmRunner } from '../nats/RunnerKV';
|
||||||
|
import { getStationEntryById } from '../nats/StationKV';
|
||||||
import { CreateScan } from '../models/actions/create/CreateScan';
|
import { CreateScan } from '../models/actions/create/CreateScan';
|
||||||
import { CreateTrackScan } from '../models/actions/create/CreateTrackScan';
|
import { CreateTrackScan } from '../models/actions/create/CreateTrackScan';
|
||||||
import { UpdateScan } from '../models/actions/update/UpdateScan';
|
import { UpdateScan } from '../models/actions/update/UpdateScan';
|
||||||
import { UpdateTrackScan } from '../models/actions/update/UpdateTrackScan';
|
import { UpdateTrackScan } from '../models/actions/update/UpdateTrackScan';
|
||||||
|
import { RunnerCard } from '../models/entities/RunnerCard';
|
||||||
import { Scan } from '../models/entities/Scan';
|
import { Scan } from '../models/entities/Scan';
|
||||||
import { TrackScan } from '../models/entities/TrackScan';
|
import { TrackScan } from '../models/entities/TrackScan';
|
||||||
import { ResponseEmpty } from '../models/responses/ResponseEmpty';
|
import { ResponseEmpty } from '../models/responses/ResponseEmpty';
|
||||||
import { ResponseScan } from '../models/responses/ResponseScan';
|
import { ResponseScan } from '../models/responses/ResponseScan';
|
||||||
|
import { ResponseScanIntake, ResponseScanIntakeRunner } from '../models/responses/ResponseScanIntake';
|
||||||
import { ResponseTrackScan } from '../models/responses/ResponseTrackScan';
|
import { ResponseTrackScan } from '../models/responses/ResponseTrackScan';
|
||||||
@JsonController('/scans')
|
@JsonController('/scans')
|
||||||
@OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
@OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||||
@@ -77,18 +82,112 @@ export class ScanController {
|
|||||||
@Post("/trackscans")
|
@Post("/trackscans")
|
||||||
@UseBefore(ScanAuth)
|
@UseBefore(ScanAuth)
|
||||||
@ResponseSchema(ResponseTrackScan)
|
@ResponseSchema(ResponseTrackScan)
|
||||||
|
@ResponseSchema(ResponseScanIntake)
|
||||||
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
|
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
|
||||||
@OpenAPI({ description: 'Create a new track scan (for "normal" scans use /scans instead). <br> Please remember that to provide the scan\'s card\'s station\'s id.', security: [{ "StationApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
@OpenAPI({ description: 'Create a new track scan (for "normal" scans use /scans instead). <br> Please remember that to provide the scan\'s card\'s station\'s id.', security: [{ "StationApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
|
||||||
async postTrackScans(@Body({ validate: true }) createScan: CreateTrackScan, @Req() req: Request) {
|
async postTrackScans(@Body({ validate: true }) createScan: CreateTrackScan, @Req() req: Request) {
|
||||||
const station_id = req.headers["station_id"];
|
// Station token path — KV-backed intake flow
|
||||||
if (station_id) {
|
if (req.isStationAuth) {
|
||||||
createScan.station = parseInt(station_id.toString());
|
return this.stationIntake(createScan.card, req.stationId);
|
||||||
}
|
}
|
||||||
|
// JWT path — existing full flow, unchanged
|
||||||
|
createScan.station = createScan.station;
|
||||||
let scan = await createScan.toEntity();
|
let scan = await createScan.toEntity();
|
||||||
scan = await this.trackScanRepository.save(scan);
|
scan = await this.trackScanRepository.save(scan);
|
||||||
return (await this.scanRepository.findOne({ id: scan.id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse();
|
return (await this.scanRepository.findOne({ id: scan.id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KV-backed hot path for scan station submissions.
|
||||||
|
* Zero DB reads on a fully warm cache. Fixes the race condition via CAS on the runner KV entry.
|
||||||
|
*/
|
||||||
|
private async stationIntake(rawCard: number, stationId: number): Promise<ResponseScanIntake> {
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const cardId = rawCard % 200000000000;
|
||||||
|
|
||||||
|
// --- Station (already verified by ScanAuth, just need track data) ---
|
||||||
|
const stationEntry = await getStationEntryById(stationId);
|
||||||
|
// stationEntry is always populated here — ScanAuth wrote it on the cold path
|
||||||
|
const trackDistance = stationEntry.trackDistance;
|
||||||
|
const minimumLapTime = stationEntry.minimumLapTime;
|
||||||
|
|
||||||
|
// --- Card ---
|
||||||
|
let cardEntry = await getCardEntry(cardId);
|
||||||
|
if (!cardEntry) {
|
||||||
|
// Cold path: load from DB and cache
|
||||||
|
const card = await getConnection().getRepository(RunnerCard).findOne({ id: cardId }, { relations: ['runner'] });
|
||||||
|
if (!card) throw new ScanNotFoundError();
|
||||||
|
if (!card.runner) throw new RunnerNotFoundError();
|
||||||
|
cardEntry = {
|
||||||
|
runnerId: card.runner.id,
|
||||||
|
runnerDisplayName: `${card.runner.firstname} ${card.runner.lastname}`,
|
||||||
|
enabled: card.enabled,
|
||||||
|
};
|
||||||
|
await setCardEntry(cardId, cardEntry);
|
||||||
|
}
|
||||||
|
if (!cardEntry.enabled) throw new HttpError(400, 'Card is disabled.');
|
||||||
|
const runnerId = cardEntry.runnerId;
|
||||||
|
|
||||||
|
// --- Runner state + CAS update (fixes race condition) ---
|
||||||
|
const now = Math.round(Date.now() / 1000);
|
||||||
|
let retries = 0;
|
||||||
|
let response: ResponseScanIntake;
|
||||||
|
|
||||||
|
while (retries < MAX_RETRIES) {
|
||||||
|
// Get current runner state (warm or cold)
|
||||||
|
let result = await getRunnerEntry(runnerId);
|
||||||
|
if (!result) {
|
||||||
|
const warmed = await warmRunner(runnerId);
|
||||||
|
result = { entry: warmed, revision: undefined };
|
||||||
|
}
|
||||||
|
const { entry, revision } = result;
|
||||||
|
|
||||||
|
// Compute
|
||||||
|
const lapTime = entry.latestTimestamp === 0 ? 0 : now - entry.latestTimestamp;
|
||||||
|
const valid = minimumLapTime === 0 || lapTime > minimumLapTime;
|
||||||
|
const newDistance = entry.totalDistance + (valid ? trackDistance : 0);
|
||||||
|
const newTimestamp = valid ? now : entry.latestTimestamp;
|
||||||
|
|
||||||
|
const updated: RunnerKVEntry = {
|
||||||
|
displayName: entry.displayName,
|
||||||
|
totalDistance: newDistance,
|
||||||
|
latestTimestamp: newTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// CAS write — if revision is undefined (warmed this request), plain put
|
||||||
|
const success = await setRunnerEntry(runnerId, updated, revision);
|
||||||
|
if (!success) {
|
||||||
|
retries++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB insert — synchronous, keeps DB as source of truth
|
||||||
|
const newScan = new TrackScan();
|
||||||
|
newScan.runner = { id: runnerId } as any;
|
||||||
|
newScan.card = { id: cardId } as any;
|
||||||
|
newScan.station = { id: stationId } as any;
|
||||||
|
newScan.track = { id: stationEntry.trackId } as any;
|
||||||
|
newScan.timestamp = now;
|
||||||
|
newScan.lapTime = lapTime;
|
||||||
|
newScan.valid = valid;
|
||||||
|
await this.trackScanRepository.save(newScan);
|
||||||
|
|
||||||
|
const runnerInfo = new ResponseScanIntakeRunner();
|
||||||
|
runnerInfo.displayName = entry.displayName;
|
||||||
|
runnerInfo.totalDistance = newDistance;
|
||||||
|
|
||||||
|
response = new ResponseScanIntake();
|
||||||
|
response.accepted = true;
|
||||||
|
response.valid = valid;
|
||||||
|
response.lapTime = lapTime;
|
||||||
|
response.runner = runnerInfo;
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpError(409, 'Scan rejected: too many concurrent scans for this runner. Please retry.');
|
||||||
|
}
|
||||||
|
|
||||||
@Put('/:id')
|
@Put('/:id')
|
||||||
@Authorized("SCAN:UPDATE")
|
@Authorized("SCAN:UPDATE")
|
||||||
@ResponseSchema(ResponseScan)
|
@ResponseSchema(ResponseScan)
|
||||||
@@ -97,7 +196,7 @@ export class ScanController {
|
|||||||
@ResponseSchema(ScanIdsNotMatchingError, { statusCode: 406 })
|
@ResponseSchema(ScanIdsNotMatchingError, { statusCode: 406 })
|
||||||
@OpenAPI({ description: "Update the scan (not track scan use /scans/trackscans/:id instead) whose id you provided. <br> Please remember that ids can't be changed and distances must be positive." })
|
@OpenAPI({ description: "Update the scan (not track scan use /scans/trackscans/:id instead) whose id you provided. <br> Please remember that ids can't be changed and distances must be positive." })
|
||||||
async put(@Param('id') id: number, @Body({ validate: true }) scan: UpdateScan) {
|
async put(@Param('id') id: number, @Body({ validate: true }) scan: UpdateScan) {
|
||||||
let oldScan = await this.scanRepository.findOne({ id: id });
|
let oldScan = await this.scanRepository.findOne({ id: id }, { relations: ['runner'] });
|
||||||
|
|
||||||
if (!oldScan) {
|
if (!oldScan) {
|
||||||
throw new ScanNotFoundError();
|
throw new ScanNotFoundError();
|
||||||
@@ -107,7 +206,9 @@ export class ScanController {
|
|||||||
throw new ScanIdsNotMatchingError();
|
throw new ScanIdsNotMatchingError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runnerId = oldScan.runner?.id;
|
||||||
await this.scanRepository.save(await scan.update(oldScan));
|
await this.scanRepository.save(await scan.update(oldScan));
|
||||||
|
if (runnerId) await deleteRunnerEntry(runnerId);
|
||||||
return (await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse();
|
return (await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +221,7 @@ export class ScanController {
|
|||||||
@ResponseSchema(ScanIdsNotMatchingError, { statusCode: 406 })
|
@ResponseSchema(ScanIdsNotMatchingError, { statusCode: 406 })
|
||||||
@OpenAPI({ description: 'Update the track scan (not "normal" scan use /scans/trackscans/:id instead) whose id you provided. <br> Please remember that only the validity, runner and track can be changed.' })
|
@OpenAPI({ description: 'Update the track scan (not "normal" scan use /scans/trackscans/:id instead) whose id you provided. <br> Please remember that only the validity, runner and track can be changed.' })
|
||||||
async putTrackScan(@Param('id') id: number, @Body({ validate: true }) scan: UpdateTrackScan) {
|
async putTrackScan(@Param('id') id: number, @Body({ validate: true }) scan: UpdateTrackScan) {
|
||||||
let oldScan = await this.trackScanRepository.findOne({ id: id });
|
let oldScan = await this.trackScanRepository.findOne({ id: id }, { relations: ['runner'] });
|
||||||
|
|
||||||
if (!oldScan) {
|
if (!oldScan) {
|
||||||
throw new ScanNotFoundError();
|
throw new ScanNotFoundError();
|
||||||
@@ -130,7 +231,9 @@ export class ScanController {
|
|||||||
throw new ScanIdsNotMatchingError();
|
throw new ScanIdsNotMatchingError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const runnerId = oldScan.runner?.id;
|
||||||
await this.trackScanRepository.save(await scan.update(oldScan));
|
await this.trackScanRepository.save(await scan.update(oldScan));
|
||||||
|
if (runnerId) await deleteRunnerEntry(runnerId);
|
||||||
return (await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse();
|
return (await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] })).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
|
|||||||
import { Repository, getConnectionManager } from 'typeorm';
|
import { Repository, getConnectionManager } from 'typeorm';
|
||||||
import { ScanStationHasScansError, ScanStationIdsNotMatchingError, ScanStationNotFoundError } from '../errors/ScanStationErrors';
|
import { ScanStationHasScansError, ScanStationIdsNotMatchingError, ScanStationNotFoundError } from '../errors/ScanStationErrors';
|
||||||
import { TrackNotFoundError } from '../errors/TrackErrors';
|
import { TrackNotFoundError } from '../errors/TrackErrors';
|
||||||
|
import { deleteStationEntry } from '../nats/StationKV';
|
||||||
import { CreateScanStation } from '../models/actions/create/CreateScanStation';
|
import { CreateScanStation } from '../models/actions/create/CreateScanStation';
|
||||||
import { UpdateScanStation } from '../models/actions/update/UpdateScanStation';
|
import { UpdateScanStation } from '../models/actions/update/UpdateScanStation';
|
||||||
import { ScanStation } from '../models/entities/ScanStation';
|
import { ScanStation } from '../models/entities/ScanStation';
|
||||||
@@ -85,6 +86,7 @@ export class ScanStationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.stationRepository.save(await station.update(oldStation));
|
await this.stationRepository.save(await station.update(oldStation));
|
||||||
|
await deleteStationEntry(oldStation.prefix);
|
||||||
return (await this.stationRepository.findOne({ id: id }, { relations: ['track'] })).toResponse();
|
return (await this.stationRepository.findOne({ id: id }, { relations: ['track'] })).toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +111,7 @@ export class ScanStationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responseStation = await this.stationRepository.findOne({ id: station.id }, { relations: ["track"] });
|
const responseStation = await this.stationRepository.findOne({ id: station.id }, { relations: ["track"] });
|
||||||
|
await deleteStationEntry(station.prefix);
|
||||||
await this.stationRepository.delete(station);
|
await this.stationRepository.delete(station);
|
||||||
return responseStation.toResponse();
|
return responseStation.toResponse();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { Application } from "express";
|
import { Application } from "express";
|
||||||
|
import consola from "consola";
|
||||||
|
import { config } from "../config";
|
||||||
|
import NatsClient from "../nats/NatsClient";
|
||||||
|
import { warmAll } from "../nats/RunnerKV";
|
||||||
import databaseLoader from "./database";
|
import databaseLoader from "./database";
|
||||||
import expressLoader from "./express";
|
import expressLoader from "./express";
|
||||||
import openapiLoader from "./openapi";
|
import openapiLoader from "./openapi";
|
||||||
@@ -9,6 +13,16 @@ import openapiLoader from "./openapi";
|
|||||||
*/
|
*/
|
||||||
export default async (app: Application) => {
|
export default async (app: Application) => {
|
||||||
await databaseLoader();
|
await databaseLoader();
|
||||||
|
await NatsClient.connect();
|
||||||
|
|
||||||
|
if (config.nats_prewarm) {
|
||||||
|
consola.info("Prewarming NATS runner cache...");
|
||||||
|
const startTime = Date.now();
|
||||||
|
await warmAll();
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
consola.success(`NATS runner cache prewarmed in ${duration}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
await openapiLoader(app);
|
await openapiLoader(app);
|
||||||
await expressLoader(app);
|
await expressLoader(app);
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -76,6 +76,21 @@ export class Mailer {
|
|||||||
*/
|
*/
|
||||||
public static async sendSelfserviceForgottenMail(to_address: string, runner_id: number, firstname: string, middlename: string, lastname: string, token: string, locale: string = "en") {
|
public static async sendSelfserviceForgottenMail(to_address: string, runner_id: number, firstname: string, middlename: string, lastname: string, token: string, locale: string = "en") {
|
||||||
try {
|
try {
|
||||||
|
console.log("Mail request", {
|
||||||
|
to: to_address,
|
||||||
|
templateName: 'welcome',
|
||||||
|
language: locale,
|
||||||
|
data: {
|
||||||
|
to: to_address,
|
||||||
|
templateName: 'welcome',
|
||||||
|
language: locale,
|
||||||
|
data: {
|
||||||
|
name: `${firstname} ${middlename} ${lastname}`,
|
||||||
|
barcode_content: `${runner_id}`,
|
||||||
|
link: `${process.env.SELFSERVICE_URL}/profile/${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
await axios.request({
|
await axios.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: `${Mailer.base}/api/v1/email`,
|
url: `${Mailer.base}/api/v1/email`,
|
||||||
@@ -96,6 +111,7 @@ export class Mailer {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (Mailer.testing) { return true; }
|
if (Mailer.testing) { return true; }
|
||||||
|
console.error("Error while sending selfservice forgotten mail:", error.message);
|
||||||
throw new MailSendingError();
|
throw new MailSendingError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,129 @@
|
|||||||
import { verify } from '@node-rs/argon2';
|
import crypto from 'crypto';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { getConnectionManager } from 'typeorm';
|
import { getConnectionManager } from 'typeorm';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { deleteStationEntry, getStationEntry, setStationEntry, StationKVEntry } from '../nats/StationKV';
|
||||||
import { ScanStation } from '../models/entities/ScanStation';
|
import { ScanStation } from '../models/entities/ScanStation';
|
||||||
import authchecker from './authchecker';
|
import authchecker from './authchecker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the HMAC-SHA256 of the provided token using the station token secret.
|
||||||
|
*/
|
||||||
|
function computeHmac(token: string): string {
|
||||||
|
return crypto.createHmac('sha256', config.station_token_secret).update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constant-time comparison of two hex HMAC strings.
|
||||||
|
* Returns true if they match.
|
||||||
|
*/
|
||||||
|
function verifyHmac(provided_token: string, storedHash: string): boolean {
|
||||||
|
const expectedHash = computeHmac(provided_token);
|
||||||
|
const expectedBuf = Buffer.from(expectedHash);
|
||||||
|
const storedBuf = Buffer.from(storedHash);
|
||||||
|
return expectedBuf.length === storedBuf.length && crypto.timingSafeEqual(expectedBuf, storedBuf);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This middleware handles the authentication of scan station api tokens.
|
* This middleware handles the authentication of scan station api tokens.
|
||||||
* The tokens have to be provided via Bearer authorization header.
|
* The tokens have to be provided via Bearer authorization header.
|
||||||
|
*
|
||||||
|
* Auth flow:
|
||||||
|
* 1. Extract prefix from token (PREFIX.KEY format)
|
||||||
|
* 2. Try NATS KV cache lookup by prefix — warm path: HMAC verify, no DB
|
||||||
|
* 3. On cache miss: DB lookup → HMAC verify → write to KV cache
|
||||||
|
* 4. On no station match at all: fall back to JWT auth (SCAN:CREATE permission)
|
||||||
|
*
|
||||||
|
* On success sets req.isStationAuth = true and req.stationId on the request object.
|
||||||
|
* These are internal server-side properties — not HTTP headers, not spoofable by clients.
|
||||||
|
*
|
||||||
* You have to manually use this middleware via @UseBefore(ScanAuth) instead of using @Authorized().
|
* You have to manually use this middleware via @UseBefore(ScanAuth) instead of using @Authorized().
|
||||||
* @param req Express request object.
|
* @param req Express request object.
|
||||||
* @param res Express response object.
|
* @param res Express response object.
|
||||||
* @param next Next function to call on success.
|
* @param next Next function to call on success.
|
||||||
*/
|
*/
|
||||||
const ScanAuth = async (req: Request, res: Response, next: () => void) => {
|
const ScanAuth = async (req: Request, res: Response, next: () => void) => {
|
||||||
let provided_token: string = req.headers["authorization"];
|
let provided_token: string = req.headers['authorization'];
|
||||||
if (provided_token == "" || provided_token === undefined || provided_token === null) {
|
if (!provided_token) {
|
||||||
res.status(401).send({ http_code: 401, short: "no_token", message: "No api token provided." });
|
res.status(401).send({ http_code: 401, short: 'no_token', message: 'No api token provided.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
provided_token = provided_token.replace('Bearer ', '');
|
||||||
provided_token = provided_token.replace("Bearer ", "");
|
|
||||||
} catch (error) {
|
const prefix = provided_token.split('.')[0];
|
||||||
res.status(401).send({ http_code: 401, short: "no_token", message: "No valid jwt or api token provided." });
|
if (!prefix) {
|
||||||
|
res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let prefix = "";
|
// --- KV cache lookup (warm path) ---
|
||||||
try {
|
const cached = await getStationEntry(prefix);
|
||||||
prefix = provided_token.split(".")[0];
|
if (cached) {
|
||||||
}
|
if (!cached.enabled) {
|
||||||
finally {
|
res.status(401).send({ http_code: 401, short: 'station_disabled', message: 'Station is disabled.' });
|
||||||
if (prefix == "" || prefix == undefined || prefix == null) {
|
|
||||||
res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!verifyHmac(provided_token, cached.tokenHash)) {
|
||||||
|
res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.isStationAuth = true;
|
||||||
|
req.stationId = cached.id;
|
||||||
|
next();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const station = await getConnectionManager().get().getRepository(ScanStation).findOne({ prefix: prefix });
|
// --- DB lookup (cold path) ---
|
||||||
|
const station = await getConnectionManager().get().getRepository(ScanStation).findOne({ prefix }, { relations: ['track'] });
|
||||||
|
|
||||||
if (!station) {
|
if (!station) {
|
||||||
|
// No station with this prefix — fall back to JWT auth
|
||||||
let user_authorized = false;
|
let user_authorized = false;
|
||||||
try {
|
try {
|
||||||
let action = { request: req, response: res, context: null, next: next }
|
const action = { request: req, response: res, context: null, next };
|
||||||
user_authorized = await authchecker(action, ["SCAN:CREATE"]);
|
user_authorized = await authchecker(action, ['SCAN:CREATE']);
|
||||||
}
|
} finally {
|
||||||
finally {
|
if (!user_authorized) {
|
||||||
if (user_authorized == false) {
|
res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
|
||||||
res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
else {
|
next();
|
||||||
next();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
if (station.enabled == false) {
|
// Station found — verify token before caching
|
||||||
res.status(401).send({ http_code: 401, short: "station_disabled", message: "Station is disabled." });
|
const tokenHash = computeHmac(provided_token);
|
||||||
}
|
const storedBuf = Buffer.from(station.key);
|
||||||
if (!(await verify(station.key, provided_token))) {
|
const computedBuf = Buffer.from(tokenHash);
|
||||||
res.status(401).send({ http_code: 401, short: "invalid_token", message: "Api token non-existent or invalid syntax." });
|
const valid = computedBuf.length === storedBuf.length && crypto.timingSafeEqual(computedBuf, storedBuf);
|
||||||
return;
|
|
||||||
}
|
if (!valid) {
|
||||||
req.headers["station_id"] = station.id.toString();
|
res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
|
||||||
next();
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
if (!station.enabled) {
|
||||||
|
res.status(401).send({ http_code: 401, short: 'station_disabled', message: 'Station is disabled.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to KV cache for subsequent requests
|
||||||
|
const entry: StationKVEntry = {
|
||||||
|
id: station.id,
|
||||||
|
enabled: station.enabled,
|
||||||
|
tokenHash,
|
||||||
|
trackId: station.track.id,
|
||||||
|
trackDistance: station.track.distance,
|
||||||
|
minimumLapTime: station.track.minimumLapTime ?? 0,
|
||||||
|
};
|
||||||
|
await setStationEntry(prefix, entry);
|
||||||
|
|
||||||
|
req.isStationAuth = true;
|
||||||
|
req.stationId = station.id;
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
export default ScanAuth;
|
export default ScanAuth;
|
||||||
|
export { deleteStationEntry };
|
||||||
|
|||||||
@@ -61,11 +61,11 @@ export class CreateAuth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//Create the access token
|
//Create the access token
|
||||||
const timestamp_accesstoken_expiry = Math.floor(Date.now() / 1000) + 5 * 60
|
const timestamp_accesstoken_expiry = Math.floor(Date.now() / 1000) + 24 * 60 * 60
|
||||||
newAuth.access_token = JwtCreator.createAccess(found_user, timestamp_accesstoken_expiry);
|
newAuth.access_token = JwtCreator.createAccess(found_user, timestamp_accesstoken_expiry);
|
||||||
newAuth.access_token_expires_at = timestamp_accesstoken_expiry
|
newAuth.access_token_expires_at = timestamp_accesstoken_expiry
|
||||||
//Create the refresh token
|
//Create the refresh token
|
||||||
const timestamp_refresh_expiry = Math.floor(Date.now() / 1000) + 10 * 36000
|
const timestamp_refresh_expiry = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
|
||||||
newAuth.refresh_token = JwtCreator.createRefresh(found_user, timestamp_refresh_expiry);
|
newAuth.refresh_token = JwtCreator.createRefresh(found_user, timestamp_refresh_expiry);
|
||||||
newAuth.refresh_token_expires_at = timestamp_refresh_expiry
|
newAuth.refresh_token_expires_at = timestamp_refresh_expiry
|
||||||
return newAuth;
|
return newAuth;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsPositive } from 'class-validator';
|
import { IsInt, IsOptional, IsPositive } from 'class-validator';
|
||||||
import { getConnection } from 'typeorm';
|
import { getConnection } from 'typeorm';
|
||||||
import { RunnerNotFoundError } from '../../../errors/RunnerErrors';
|
import { RunnerNotFoundError } from '../../../errors/RunnerErrors';
|
||||||
import { DistanceDonation } from '../../entities/DistanceDonation';
|
import { DistanceDonation } from '../../entities/DistanceDonation';
|
||||||
@@ -22,6 +22,7 @@ export class CreateDistanceDonation extends CreateDonation {
|
|||||||
* The donation's paid amount in the smalles unit of your currency (default: euro cent).
|
* The donation's paid amount in the smalles unit of your currency (default: euro cent).
|
||||||
*/
|
*/
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
paidAmount?: number;
|
paidAmount?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Exclude } from 'class-transformer';
|
import { IsInt, IsOptional } from 'class-validator';
|
||||||
import { getConnection } from 'typeorm';
|
import { getConnection } from 'typeorm';
|
||||||
import { Donation } from '../../entities/Donation';
|
import { Donation } from '../../entities/Donation';
|
||||||
import { Donor } from '../../entities/Donor';
|
import { Donor } from '../../entities/Donor';
|
||||||
@@ -7,10 +7,12 @@ import { Donor } from '../../entities/Donor';
|
|||||||
* This class is used to create a new Donation entity from a json body (post request).
|
* This class is used to create a new Donation entity from a json body (post request).
|
||||||
*/
|
*/
|
||||||
export abstract class CreateDonation {
|
export abstract class CreateDonation {
|
||||||
@Exclude()
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
donor: number;
|
donor: number;
|
||||||
|
|
||||||
@Exclude()
|
@IsInt()
|
||||||
|
@IsOptional()
|
||||||
paidAmount?: number;
|
paidAmount?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { hash } from '@node-rs/argon2';
|
|
||||||
import { IsBoolean, IsInt, IsOptional, IsPositive, IsString } from 'class-validator';
|
import { IsBoolean, IsInt, IsOptional, IsPositive, IsString } from 'class-validator';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { getConnection } from 'typeorm';
|
import { getConnection } from 'typeorm';
|
||||||
import * as uuid from 'uuid';
|
import * as uuid from 'uuid';
|
||||||
|
import { config } from '../../../config';
|
||||||
import { TrackNotFoundError } from '../../../errors/TrackErrors';
|
import { TrackNotFoundError } from '../../../errors/TrackErrors';
|
||||||
import { ScanStation } from '../../entities/ScanStation';
|
import { ScanStation } from '../../entities/ScanStation';
|
||||||
import { Track } from '../../entities/Track';
|
import { Track } from '../../entities/Track';
|
||||||
@@ -44,8 +44,8 @@ export class CreateScanStation {
|
|||||||
|
|
||||||
let newUUID = uuid.v4().toUpperCase();
|
let newUUID = uuid.v4().toUpperCase();
|
||||||
newStation.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase();
|
newStation.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase();
|
||||||
newStation.key = await hash(newStation.prefix + "." + newUUID);
|
|
||||||
newStation.cleartextkey = newStation.prefix + "." + newUUID;
|
newStation.cleartextkey = newStation.prefix + "." + newUUID;
|
||||||
|
newStation.key = crypto.createHmac("sha256", config.station_token_secret).update(newStation.cleartextkey).digest('hex');
|
||||||
|
|
||||||
return newStation;
|
return newStation;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, PrimaryColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, PrimaryColumn } from "typeorm";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the ConfigFlag entity.
|
* Defines the ConfigFlag entity.
|
||||||
@@ -24,4 +26,25 @@ export class ConfigFlag {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
value: string;
|
value: string;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
IsInt
|
IsInt,
|
||||||
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||||
import { ResponseDonation } from '../responses/ResponseDonation';
|
import { ResponseDonation } from '../responses/ResponseDonation';
|
||||||
import { Donor } from './Donor';
|
import { Donor } from './Donor';
|
||||||
|
|
||||||
@@ -40,6 +41,27 @@ export abstract class Donation {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
paidAmount: number;
|
paidAmount: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsPhoneNumber,
|
IsPhoneNumber,
|
||||||
|
|
||||||
|
IsPositive,
|
||||||
|
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { config } from '../../config';
|
import { config } from '../../config';
|
||||||
import { ResponseGroupContact } from '../responses/ResponseGroupContact';
|
import { ResponseGroupContact } from '../responses/ResponseGroupContact';
|
||||||
import { Address } from "./Address";
|
import { Address } from "./Address";
|
||||||
@@ -81,6 +83,27 @@ export class GroupContact {
|
|||||||
@OneToMany(() => RunnerGroup, group => group.contact, { nullable: true })
|
@OneToMany(() => RunnerGroup, group => group.contact, { nullable: true })
|
||||||
groups: RunnerGroup[];
|
groups: RunnerGroup[];
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsPhoneNumber,
|
IsPhoneNumber,
|
||||||
|
|
||||||
|
IsPositive,
|
||||||
|
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||||
import { config } from '../../config';
|
import { config } from '../../config';
|
||||||
import { ResponseParticipant } from '../responses/ResponseParticipant';
|
import { ResponseParticipant } from '../responses/ResponseParticipant';
|
||||||
import { Address } from "./Address";
|
import { Address } from "./Address";
|
||||||
@@ -83,6 +85,27 @@ export abstract class Participant {
|
|||||||
@IsString()
|
@IsString()
|
||||||
created_via?: string;
|
created_via?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty
|
IsNotEmpty,
|
||||||
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { PermissionAction } from '../enums/PermissionAction';
|
import { PermissionAction } from '../enums/PermissionAction';
|
||||||
import { PermissionTarget } from '../enums/PermissionTargets';
|
import { PermissionTarget } from '../enums/PermissionTargets';
|
||||||
import { ResponsePermission } from '../responses/ResponsePermission';
|
import { ResponsePermission } from '../responses/ResponsePermission';
|
||||||
@@ -45,6 +46,27 @@ export class Permission {
|
|||||||
@IsEnum(PermissionAction)
|
@IsEnum(PermissionAction)
|
||||||
action: PermissionAction;
|
action: PermissionAction;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turn this into a string for exporting and jwts.
|
* Turn this into a string for exporting and jwts.
|
||||||
* Mainly used to shrink the size of jwts (otherwise the would contain entire objects).
|
* Mainly used to shrink the size of jwts (otherwise the would contain entire objects).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IsInt } from 'class-validator';
|
import { IsInt, IsPositive } from 'class-validator';
|
||||||
import { Entity, OneToMany, PrimaryGeneratedColumn, TableInheritance } from 'typeorm';
|
import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn, TableInheritance } from 'typeorm';
|
||||||
import { ResponsePrincipal } from '../responses/ResponsePrincipal';
|
import { ResponsePrincipal } from '../responses/ResponsePrincipal';
|
||||||
import { Permission } from './Permission';
|
import { Permission } from './Permission';
|
||||||
|
|
||||||
@@ -23,6 +23,27 @@ export abstract class Principal {
|
|||||||
@OneToMany(() => Permission, permission => permission.principal, { nullable: true })
|
@OneToMany(() => Permission, permission => permission.principal, { nullable: true })
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import {
|
|||||||
|
|
||||||
IsInt,
|
IsInt,
|
||||||
|
|
||||||
IsOptional
|
IsOptional,
|
||||||
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { RunnerCardIdOutOfRangeError } from '../../errors/RunnerCardErrors';
|
import { RunnerCardIdOutOfRangeError } from '../../errors/RunnerCardErrors';
|
||||||
import { ResponseRunnerCard } from '../responses/ResponseRunnerCard';
|
import { ResponseRunnerCard } from '../responses/ResponseRunnerCard';
|
||||||
import { Runner } from "./Runner";
|
import { Runner } from "./Runner";
|
||||||
@@ -48,6 +49,27 @@ export class RunnerCard {
|
|||||||
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true })
|
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true })
|
||||||
scans: TrackScan[];
|
scans: TrackScan[];
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates a ean-13 compliant string for barcode generation.
|
* Generates a ean-13 compliant string for barcode generation.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||||
import { ResponseRunnerGroup } from '../responses/ResponseRunnerGroup';
|
import { ResponseRunnerGroup } from '../responses/ResponseRunnerGroup';
|
||||||
import { GroupContact } from "./GroupContact";
|
import { GroupContact } from "./GroupContact";
|
||||||
import { Runner } from "./Runner";
|
import { Runner } from "./Runner";
|
||||||
@@ -46,6 +47,27 @@ export abstract class RunnerGroup {
|
|||||||
@OneToMany(() => Runner, runner => runner.group, { nullable: true })
|
@OneToMany(() => Runner, runner => runner.group, { nullable: true })
|
||||||
runners: Runner[];
|
runners: Runner[];
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the total distance ran by this group's runners based on all their valid scans.
|
* Returns the total distance ran by this group's runners based on all their valid scans.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
|
|
||||||
IsPositive
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
|
||||||
import { ResponseScan } from '../responses/ResponseScan';
|
import { ResponseScan } from '../responses/ResponseScan';
|
||||||
import { Runner } from "./Runner";
|
import { Runner } from "./Runner";
|
||||||
|
|
||||||
@@ -40,6 +40,27 @@ export class Scan {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
valid: boolean = true;
|
valid: boolean = true;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scan's distance in meters.
|
* The scan's distance in meters.
|
||||||
* This is the "real" value used by "normal" scans..
|
* This is the "real" value used by "normal" scans..
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { ResponseScanStation } from '../responses/ResponseScanStation';
|
import { ResponseScanStation } from '../responses/ResponseScanStation';
|
||||||
import { Track } from "./Track";
|
import { Track } from "./Track";
|
||||||
import { TrackScan } from "./TrackScan";
|
import { TrackScan } from "./TrackScan";
|
||||||
@@ -78,6 +79,27 @@ export class ScanStation {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enabled?: boolean = true;
|
enabled?: boolean = true;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IsInt, IsOptional, IsString } from "class-validator";
|
import { IsInt, IsOptional, IsPositive, IsString } from "class-validator";
|
||||||
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { ResponseStatsClient } from '../responses/ResponseStatsClient';
|
import { ResponseStatsClient } from '../responses/ResponseStatsClient';
|
||||||
/**
|
/**
|
||||||
* Defines the StatsClient entity.
|
* Defines the StatsClient entity.
|
||||||
@@ -47,6 +47,27 @@ export class StatsClient {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
cleartextkey?: string;
|
cleartextkey?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
IsPositive,
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { ResponseTrack } from '../responses/ResponseTrack';
|
import { ResponseTrack } from '../responses/ResponseTrack';
|
||||||
import { ScanStation } from "./ScanStation";
|
import { ScanStation } from "./ScanStation";
|
||||||
import { TrackScan } from "./TrackScan";
|
import { TrackScan } from "./TrackScan";
|
||||||
@@ -63,6 +63,27 @@ export class Track {
|
|||||||
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true })
|
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true })
|
||||||
scans: TrackScan[];
|
scans: TrackScan[];
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import {
|
|||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
|
||||||
import { PermissionAction } from '../enums/PermissionAction';
|
import { PermissionAction } from '../enums/PermissionAction';
|
||||||
import { User } from './User';
|
import { User } from './User';
|
||||||
|
|
||||||
@@ -53,6 +54,27 @@ export class UserAction {
|
|||||||
@IsString()
|
@IsString()
|
||||||
changed: string;
|
changed: string;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true, readonly: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@Column({ type: 'bigint', nullable: true })
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
|
@BeforeInsert()
|
||||||
|
public setCreatedAt() {
|
||||||
|
this.created_at = Math.floor(Date.now() / 1000);
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeUpdate()
|
||||||
|
public setUpdatedAt() {
|
||||||
|
this.updated_at = Math.floor(Date.now() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns this entity into it's response class.
|
* Turns this entity into it's response class.
|
||||||
*/
|
*/
|
||||||
|
|||||||
35
src/models/entities/index.ts
Normal file
35
src/models/entities/index.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Entity barrel file for Bun compatibility.
|
||||||
|
* Imports all entities in the correct order to resolve circular dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Base/parent entities first
|
||||||
|
export * from './Participant';
|
||||||
|
export * from './Donation';
|
||||||
|
export * from './Scan';
|
||||||
|
|
||||||
|
// Child entities that depend on the above
|
||||||
|
export * from './Runner';
|
||||||
|
export * from './DistanceDonation';
|
||||||
|
export * from './FixedDonation';
|
||||||
|
export * from './TrackScan';
|
||||||
|
|
||||||
|
// Entities with cross-references
|
||||||
|
export * from './RunnerCard';
|
||||||
|
export * from './RunnerGroup';
|
||||||
|
export * from './RunnerOrganization';
|
||||||
|
export * from './RunnerTeam';
|
||||||
|
export * from './ScanStation';
|
||||||
|
export * from './Track';
|
||||||
|
|
||||||
|
// Independent entities
|
||||||
|
export * from './Address';
|
||||||
|
export * from './ConfigFlags';
|
||||||
|
export * from './Donor';
|
||||||
|
export * from './GroupContact';
|
||||||
|
export * from './Permission';
|
||||||
|
export * from './Principal';
|
||||||
|
export * from './StatsClient';
|
||||||
|
export * from './User';
|
||||||
|
export * from './UserAction';
|
||||||
|
export * from './UserGroup';
|
||||||
@@ -40,6 +40,14 @@ export class ResponseAnonymousDonation implements IResponse {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
paidAmount: number;
|
paidAmount: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseDonation object from a scan.
|
* Creates a ResponseDonation object from a scan.
|
||||||
* @param donation The donation the response shall be build for.
|
* @param donation The donation the response shall be build for.
|
||||||
@@ -54,5 +62,7 @@ export class ResponseAnonymousDonation implements IResponse {
|
|||||||
else {
|
else {
|
||||||
this.status = DonationStatus.PAID;
|
this.status = DonationStatus.PAID;
|
||||||
}
|
}
|
||||||
|
this.created_at = donation.created_at;
|
||||||
|
this.updated_at = donation.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ export class ResponseDonation implements IResponse {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
paidAmount: number;
|
paidAmount: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseDonation object from a scan.
|
* Creates a ResponseDonation object from a scan.
|
||||||
* @param donation The donation the response shall be build for.
|
* @param donation The donation the response shall be build for.
|
||||||
@@ -64,5 +72,7 @@ export class ResponseDonation implements IResponse {
|
|||||||
else {
|
else {
|
||||||
this.status = DonationStatus.PAID;
|
this.status = DonationStatus.PAID;
|
||||||
}
|
}
|
||||||
|
this.created_at = donation.created_at;
|
||||||
|
this.updated_at = donation.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsObject, IsString } from "class-validator";
|
import { IsInt, IsObject, IsPositive, IsString } from "class-validator";
|
||||||
import { Address } from '../entities/Address';
|
import { Address } from '../entities/Address';
|
||||||
import { GroupContact } from '../entities/GroupContact';
|
import { GroupContact } from '../entities/GroupContact';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
@@ -64,6 +64,14 @@ export class ResponseGroupContact implements IResponse {
|
|||||||
@IsObject()
|
@IsObject()
|
||||||
address?: Address;
|
address?: Address;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseGroupContact object from a contact.
|
* Creates a ResponseGroupContact object from a contact.
|
||||||
* @param contact The contact the response shall be build for.
|
* @param contact The contact the response shall be build for.
|
||||||
@@ -82,5 +90,7 @@ export class ResponseGroupContact implements IResponse {
|
|||||||
this.groups.push(group.toResponse());
|
this.groups.push(group.toResponse());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this.created_at = contact.created_at;
|
||||||
|
this.updated_at = contact.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsObject, IsOptional, IsString } from "class-validator";
|
import { IsInt, IsObject, IsOptional, IsPositive, IsString } from "class-validator";
|
||||||
import { Address } from '../entities/Address';
|
import { Address } from '../entities/Address';
|
||||||
import { Participant } from '../entities/Participant';
|
import { Participant } from '../entities/Participant';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
@@ -63,6 +63,14 @@ export abstract class ResponseParticipant implements IResponse {
|
|||||||
@IsObject()
|
@IsObject()
|
||||||
address?: Address;
|
address?: Address;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseParticipant object from a participant.
|
* Creates a ResponseParticipant object from a participant.
|
||||||
* @param participant The participant the response shall be build for.
|
* @param participant The participant the response shall be build for.
|
||||||
@@ -76,5 +84,7 @@ export abstract class ResponseParticipant implements IResponse {
|
|||||||
this.phone = participant.phone;
|
this.phone = participant.phone;
|
||||||
this.email = participant.email;
|
this.email = participant.email;
|
||||||
this.address = participant.address;
|
this.address = participant.address;
|
||||||
|
this.created_at = participant.created_at;
|
||||||
|
this.updated_at = participant.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import {
|
|||||||
IsEnum,
|
IsEnum,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsObject
|
IsObject,
|
||||||
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Permission } from '../entities/Permission';
|
import { Permission } from '../entities/Permission';
|
||||||
import { PermissionAction } from '../enums/PermissionAction';
|
import { PermissionAction } from '../enums/PermissionAction';
|
||||||
@@ -48,6 +49,14 @@ export class ResponsePermission implements IResponse {
|
|||||||
@IsEnum(PermissionAction)
|
@IsEnum(PermissionAction)
|
||||||
action: PermissionAction;
|
action: PermissionAction;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponsePermission object from a permission.
|
* Creates a ResponsePermission object from a permission.
|
||||||
* @param permission The permission the response shall be build for.
|
* @param permission The permission the response shall be build for.
|
||||||
@@ -57,5 +66,7 @@ export class ResponsePermission implements IResponse {
|
|||||||
this.principal = permission.principal.toResponse();
|
this.principal = permission.principal.toResponse();
|
||||||
this.target = permission.target;
|
this.target = permission.target;
|
||||||
this.action = permission.action;
|
this.action = permission.action;
|
||||||
|
this.created_at = permission.created_at;
|
||||||
|
this.updated_at = permission.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
IsInt
|
IsInt,
|
||||||
|
IsPositive
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { Principal } from '../entities/Principal';
|
import { Principal } from '../entities/Principal';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
@@ -22,11 +23,21 @@ export abstract class ResponsePrincipal implements IResponse {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
id: number;
|
id: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponsePrincipal object from a principal.
|
* Creates a ResponsePrincipal object from a principal.
|
||||||
* @param principal The principal the response shall be build for.
|
* @param principal The principal the response shall be build for.
|
||||||
*/
|
*/
|
||||||
public constructor(principal: Principal) {
|
public constructor(principal: Principal) {
|
||||||
this.id = principal.id;
|
this.id = principal.id;
|
||||||
|
this.created_at = principal.created_at;
|
||||||
|
this.updated_at = principal.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ export class ResponseRunner extends ResponseParticipant implements IResponse {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
distance: number;
|
distance: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The runner's current donation amount based on distance.
|
||||||
|
* Only available for queries for single runners.
|
||||||
|
*/
|
||||||
|
@IsInt()
|
||||||
|
donationAmount: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The runner's group.
|
* The runner's group.
|
||||||
*/
|
*/
|
||||||
@@ -50,6 +57,10 @@ export class ResponseRunner extends ResponseParticipant implements IResponse {
|
|||||||
else { this.distance = runner.validScans.reduce((sum, current) => sum + current.distance, 0); }
|
else { this.distance = runner.validScans.reduce((sum, current) => sum + current.distance, 0); }
|
||||||
if (runner.group) { this.group = runner.group.toResponse(); }
|
if (runner.group) { this.group = runner.group.toResponse(); }
|
||||||
|
|
||||||
|
if (runner.distanceDonations) {
|
||||||
|
this.donationAmount = runner.distanceDonations.reduce((sum, current) => sum + (current.amountPerDistance * runner.distance / 1000), 0);
|
||||||
|
}
|
||||||
|
|
||||||
if (generateSelfServiceLink) {
|
if (generateSelfServiceLink) {
|
||||||
const token = JwtCreator.createSelfService(runner);
|
const token = JwtCreator.createSelfService(runner);
|
||||||
this.selfserviceLink = `${process.env.SELFSERVICE_URL}/profile/${token}`;
|
this.selfserviceLink = `${process.env.SELFSERVICE_URL}/profile/${token}`;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsBoolean, IsEAN, IsInt, IsNotEmpty, IsObject, IsString } from "class-validator";
|
import { IsBoolean, IsEAN, IsInt, IsNotEmpty, IsObject, IsPositive, IsString } from "class-validator";
|
||||||
import { RunnerCard } from '../entities/RunnerCard';
|
import { RunnerCard } from '../entities/RunnerCard';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
import { IResponse } from './IResponse';
|
import { IResponse } from './IResponse';
|
||||||
@@ -42,6 +42,14 @@ export class ResponseRunnerCard implements IResponse {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enabled: boolean = true;
|
enabled: boolean = true;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseRunnerCard object from a runner card.
|
* Creates a ResponseRunnerCard object from a runner card.
|
||||||
* @param card The card the response shall be build for.
|
* @param card The card the response shall be build for.
|
||||||
@@ -57,5 +65,7 @@ export class ResponseRunnerCard implements IResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.enabled = card.enabled;
|
this.enabled = card.enabled;
|
||||||
|
this.created_at = card.created_at;
|
||||||
|
this.updated_at = card.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString } from "class-validator";
|
import { IsInt, IsNotEmpty, IsNumber, IsObject, IsOptional, IsPositive, IsString } from "class-validator";
|
||||||
import { RunnerGroup } from '../entities/RunnerGroup';
|
import { RunnerGroup } from '../entities/RunnerGroup';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
import { IResponse } from './IResponse';
|
import { IResponse } from './IResponse';
|
||||||
@@ -40,6 +40,14 @@ export abstract class ResponseRunnerGroup implements IResponse {
|
|||||||
@IsNumber()
|
@IsNumber()
|
||||||
total_distance: number
|
total_distance: number
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseRunnerGroup object from a runnerGroup.
|
* Creates a ResponseRunnerGroup object from a runnerGroup.
|
||||||
* @param group The runnerGroup the response shall be build for.
|
* @param group The runnerGroup the response shall be build for.
|
||||||
@@ -49,5 +57,7 @@ export abstract class ResponseRunnerGroup implements IResponse {
|
|||||||
this.name = group.name;
|
this.name = group.name;
|
||||||
if (group.contact) { this.contact = group.contact.toResponse(); };
|
if (group.contact) { this.contact = group.contact.toResponse(); };
|
||||||
if (group.runners) { this.total_distance = group.runners.reduce((p, c) => p + c.distance, 0) }
|
if (group.runners) { this.total_distance = group.runners.reduce((p, c) => p + c.distance, 0) }
|
||||||
|
this.created_at = group.created_at;
|
||||||
|
this.updated_at = group.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export class ResponseScan implements IResponse {
|
|||||||
@IsPositive()
|
@IsPositive()
|
||||||
distance: number;
|
distance: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseScan object from a scan.
|
* Creates a ResponseScan object from a scan.
|
||||||
* @param scan The scan the response shall be build for.
|
* @param scan The scan the response shall be build for.
|
||||||
@@ -50,5 +58,7 @@ export class ResponseScan implements IResponse {
|
|||||||
if (scan.runner) { this.runner = scan.runner.toResponse(); }
|
if (scan.runner) { this.runner = scan.runner.toResponse(); }
|
||||||
this.distance = scan.distance;
|
this.distance = scan.distance;
|
||||||
this.valid = scan.valid;
|
this.valid = scan.valid;
|
||||||
|
this.created_at = scan.created_at;
|
||||||
|
this.updated_at = scan.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/models/responses/ResponseScanIntake.ts
Normal file
30
src/models/responses/ResponseScanIntake.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { IsBoolean, IsInt, IsNotEmpty, IsObject, IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight response returned to scan stations after a TrackScan submission.
|
||||||
|
* Contains only what the scan display needs — validity, lap time, and runner info.
|
||||||
|
* Full ResponseTrackScan is still returned to JWT-authenticated admin/UI callers.
|
||||||
|
*/
|
||||||
|
export class ResponseScanIntakeRunner {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
displayName: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
totalDistance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ResponseScanIntake {
|
||||||
|
@IsBoolean()
|
||||||
|
accepted: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
valid: boolean;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
lapTime: number;
|
||||||
|
|
||||||
|
@IsObject()
|
||||||
|
@IsNotEmpty()
|
||||||
|
runner: ResponseScanIntakeRunner;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
|
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsInt,
|
IsInt,
|
||||||
|
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
IsObject,
|
IsObject,
|
||||||
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { ScanStation } from '../entities/ScanStation';
|
import { ScanStation } from '../entities/ScanStation';
|
||||||
@@ -63,6 +63,14 @@ export class ResponseScanStation implements IResponse {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enabled?: boolean = true;
|
enabled?: boolean = true;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseStatsClient object from a statsClient.
|
* Creates a ResponseStatsClient object from a statsClient.
|
||||||
* @param client The statsClient the response shall be build for.
|
* @param client The statsClient the response shall be build for.
|
||||||
@@ -74,5 +82,7 @@ export class ResponseScanStation implements IResponse {
|
|||||||
this.key = "Only visible on creation.";
|
this.key = "Only visible on creation.";
|
||||||
if (station.track) { this.track = station.track.toResponse(); }
|
if (station.track) { this.track = station.track.toResponse(); }
|
||||||
this.enabled = station.enabled;
|
this.enabled = station.enabled;
|
||||||
|
this.created_at = station.created_at;
|
||||||
|
this.updated_at = station.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
|
|
||||||
IsInt,
|
IsInt,
|
||||||
|
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString
|
IsString
|
||||||
} from "class-validator";
|
} from "class-validator";
|
||||||
import { StatsClient } from '../entities/StatsClient';
|
import { StatsClient } from '../entities/StatsClient';
|
||||||
@@ -49,6 +49,14 @@ export class ResponseStatsClient implements IResponse {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
prefix: string;
|
prefix: string;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseStatsClient object from a statsClient.
|
* Creates a ResponseStatsClient object from a statsClient.
|
||||||
* @param client The statsClient the response shall be build for.
|
* @param client The statsClient the response shall be build for.
|
||||||
@@ -58,5 +66,7 @@ export class ResponseStatsClient implements IResponse {
|
|||||||
this.description = client.description;
|
this.description = client.description;
|
||||||
this.prefix = client.prefix;
|
this.prefix = client.prefix;
|
||||||
this.key = "Only visible on creation.";
|
this.key = "Only visible on creation.";
|
||||||
|
this.created_at = client.created_at;
|
||||||
|
this.updated_at = client.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsOptional, IsString } from "class-validator";
|
import { IsInt, IsOptional, IsPositive, IsString } from "class-validator";
|
||||||
import { TrackLapTimeCantBeNegativeError } from '../../errors/TrackErrors';
|
import { TrackLapTimeCantBeNegativeError } from '../../errors/TrackErrors';
|
||||||
import { Track } from '../entities/Track';
|
import { Track } from '../entities/Track';
|
||||||
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
import { ResponseObjectType } from '../enums/ResponseObjectType';
|
||||||
@@ -40,6 +40,14 @@ export class ResponseTrack implements IResponse {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
minimumLapTime?: number;
|
minimumLapTime?: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
created_at: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
updated_at: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a ResponseTrack object from a track.
|
* Creates a ResponseTrack object from a track.
|
||||||
* @param track The track the response shall be build for.
|
* @param track The track the response shall be build for.
|
||||||
@@ -52,5 +60,7 @@ export class ResponseTrack implements IResponse {
|
|||||||
if (this.minimumLapTime < 0) {
|
if (this.minimumLapTime < 0) {
|
||||||
throw new TrackLapTimeCantBeNegativeError();
|
throw new TrackLapTimeCantBeNegativeError();
|
||||||
}
|
}
|
||||||
|
this.created_at = track.created_at;
|
||||||
|
this.updated_at = track.updated_at;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/nats/CardKV.ts
Normal file
67
src/nats/CardKV.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { KvEntry } from 'nats';
|
||||||
|
import NatsClient from './NatsClient';
|
||||||
|
|
||||||
|
const BUCKET = 'card_state';
|
||||||
|
/** 1 hour TTL in milliseconds — sliding window, reset on each access. */
|
||||||
|
const TTL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached card data stored in NATS KV.
|
||||||
|
* Keyed by the stripped card id (rawBarcode % 200000000000).
|
||||||
|
* TTL of 1 hour of inactivity — re-put on each access to slide the window.
|
||||||
|
*/
|
||||||
|
export interface CardKVEntry {
|
||||||
|
runnerId: number;
|
||||||
|
runnerDisplayName: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBucket() {
|
||||||
|
return NatsClient.getKV(BUCKET, { ttl: TTL_MS });
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryKey(cardId: number): string {
|
||||||
|
return `card.${cardId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached CardKVEntry for the given stripped card id, or null on a miss.
|
||||||
|
* On a cache hit the entry is re-put with a fresh TTL to slide the inactivity window.
|
||||||
|
*/
|
||||||
|
export async function getCardEntry(cardId: number): Promise<CardKVEntry | null> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
let entry: KvEntry | null = null;
|
||||||
|
try {
|
||||||
|
entry = await bucket.get(entryKey(cardId));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!entry || entry.operation === 'DEL' || entry.operation === 'PURGE') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = JSON.parse(entry.string()) as CardKVEntry;
|
||||||
|
// Re-put to slide the TTL window
|
||||||
|
await bucket.put(entryKey(cardId), JSON.stringify(value));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a CardKVEntry for the given stripped card id with a 1-hour TTL.
|
||||||
|
*/
|
||||||
|
export async function setCardEntry(cardId: number, entry: CardKVEntry): Promise<void> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
await bucket.put(entryKey(cardId), JSON.stringify(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the cached entry for the given stripped card id.
|
||||||
|
* Call on card update (runner reassignment, enable/disable change) or delete.
|
||||||
|
*/
|
||||||
|
export async function deleteCardEntry(cardId: number): Promise<void> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
try {
|
||||||
|
await bucket.delete(entryKey(cardId));
|
||||||
|
} catch {
|
||||||
|
// Entry may not exist in KV yet — that's fine
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/nats/NatsClient.ts
Normal file
60
src/nats/NatsClient.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import consola from 'consola';
|
||||||
|
import { connect, JetStreamClient, KV, KvOptions, NatsConnection } from 'nats';
|
||||||
|
import { config } from '../config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton NATS client.
|
||||||
|
* Call connect() once during app startup (after the DB loader).
|
||||||
|
* All other modules obtain the connection via getKV().
|
||||||
|
*/
|
||||||
|
class NatsClient {
|
||||||
|
private connection: NatsConnection | null = null;
|
||||||
|
private js: JetStreamClient | null = null;
|
||||||
|
private kvBuckets: Map<string, KV> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Establishes the NATS connection and JetStream context.
|
||||||
|
* Must be called once before any KV operations.
|
||||||
|
*/
|
||||||
|
public async connect(): Promise<void> {
|
||||||
|
this.connection = await connect({ servers: config.nats_url });
|
||||||
|
this.js = this.connection.jetstream();
|
||||||
|
consola.success(`NATS connected to ${config.nats_url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a KV bucket by name, creating it if it doesn't exist yet.
|
||||||
|
* Results are cached — repeated calls with the same name return the same instance.
|
||||||
|
*/
|
||||||
|
public async getKV(bucketName: string, options?: Partial<KvOptions>): Promise<KV> {
|
||||||
|
if (this.kvBuckets.has(bucketName)) {
|
||||||
|
return this.kvBuckets.get(bucketName);
|
||||||
|
}
|
||||||
|
if (!this.js) {
|
||||||
|
throw new Error('NATS not connected. Call NatsClient.connect() first.');
|
||||||
|
}
|
||||||
|
const kv = await this.js.views.kv(bucketName, options);
|
||||||
|
this.kvBuckets.set(bucketName, kv);
|
||||||
|
return kv;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gracefully closes the NATS connection.
|
||||||
|
* Call during app shutdown if needed.
|
||||||
|
*/
|
||||||
|
public async disconnect(): Promise<void> {
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.drain();
|
||||||
|
this.connection = null;
|
||||||
|
this.js = null;
|
||||||
|
this.kvBuckets.clear();
|
||||||
|
consola.info('NATS disconnected.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public isConnected(): boolean {
|
||||||
|
return this.connection !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new NatsClient();
|
||||||
190
src/nats/RunnerKV.ts
Normal file
190
src/nats/RunnerKV.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { KvEntry } from 'nats';
|
||||||
|
import { getConnection } from 'typeorm';
|
||||||
|
import { Runner } from '../models/entities/Runner';
|
||||||
|
import { TrackScan } from '../models/entities/TrackScan';
|
||||||
|
import NatsClient from './NatsClient';
|
||||||
|
|
||||||
|
const BUCKET = 'runner_state';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached runner state stored in NATS KV.
|
||||||
|
* Keyed by runner id. No TTL — entries are permanent until explicitly deleted.
|
||||||
|
*/
|
||||||
|
export interface RunnerKVEntry {
|
||||||
|
/** "Firstname Lastname" — middlename omitted. */
|
||||||
|
displayName: string;
|
||||||
|
/** Sum of all valid scan distances in metres. */
|
||||||
|
totalDistance: number;
|
||||||
|
/** Unix seconds timestamp of the last valid scan. 0 if none. */
|
||||||
|
latestTimestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returned from getRunnerEntry — includes the KV revision for CAS updates. */
|
||||||
|
export interface RunnerKVResult {
|
||||||
|
entry: RunnerKVEntry;
|
||||||
|
revision: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBucket() {
|
||||||
|
return NatsClient.getKV(BUCKET);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryKey(runnerId: number): string {
|
||||||
|
return `runner.${runnerId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached RunnerKVEntry + revision for the given runner id, or null on a miss.
|
||||||
|
* The revision is required for CAS (compare-and-swap) updates.
|
||||||
|
*/
|
||||||
|
export async function getRunnerEntry(runnerId: number): Promise<RunnerKVResult | null> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
let entry: KvEntry | null = null;
|
||||||
|
try {
|
||||||
|
entry = await bucket.get(entryKey(runnerId));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!entry || entry.operation === 'DEL' || entry.operation === 'PURGE') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
entry: JSON.parse(entry.string()) as RunnerKVEntry,
|
||||||
|
revision: entry.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a RunnerKVEntry for the given runner id.
|
||||||
|
* If revision is provided, performs a CAS update — returns false if the revision
|
||||||
|
* has changed (concurrent write), true on success.
|
||||||
|
* Without a revision, performs an unconditional put.
|
||||||
|
*/
|
||||||
|
export async function setRunnerEntry(runnerId: number, entry: RunnerKVEntry, revision?: number): Promise<boolean> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
try {
|
||||||
|
if (revision !== undefined) {
|
||||||
|
await bucket.update(entryKey(runnerId), JSON.stringify(entry), revision);
|
||||||
|
} else {
|
||||||
|
await bucket.put(entryKey(runnerId), JSON.stringify(entry));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// CAS conflict — revision has changed
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the cached entry for the given runner id.
|
||||||
|
* Call on runner name update or when a scan's valid flag is changed via PUT /scans/:id.
|
||||||
|
*/
|
||||||
|
export async function deleteRunnerEntry(runnerId: number): Promise<void> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
try {
|
||||||
|
await bucket.delete(entryKey(runnerId));
|
||||||
|
} catch {
|
||||||
|
// Entry may not exist in KV yet — that's fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DB fallback: loads a runner's display name, total valid distance, and latest valid
|
||||||
|
* scan timestamp from the database, writes the result to KV, and returns it.
|
||||||
|
*
|
||||||
|
* Called on any KV cache miss during the scan intake flow.
|
||||||
|
* Also handles the first-scan-ever case — latestTimestamp=0, totalDistance=0.
|
||||||
|
*/
|
||||||
|
export async function warmRunner(runnerId: number): Promise<RunnerKVEntry> {
|
||||||
|
const connection = getConnection();
|
||||||
|
|
||||||
|
const runner = await connection.getRepository(Runner).findOne({ id: runnerId });
|
||||||
|
const displayName = runner ? `${runner.firstname} ${runner.lastname}` : 'Unknown Runner';
|
||||||
|
|
||||||
|
const distanceResult = await connection
|
||||||
|
.getRepository(TrackScan)
|
||||||
|
.createQueryBuilder('scan')
|
||||||
|
.select('COALESCE(SUM(track.distance), 0)', 'total')
|
||||||
|
.innerJoin('scan.track', 'track')
|
||||||
|
.where('scan.runner = :runnerId', { runnerId })
|
||||||
|
.andWhere('scan.valid = :valid', { valid: true })
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
const latestScan = await connection
|
||||||
|
.getRepository(TrackScan)
|
||||||
|
.findOne({
|
||||||
|
where: { runner: { id: runnerId }, valid: true },
|
||||||
|
order: { timestamp: 'DESC' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const entry: RunnerKVEntry = {
|
||||||
|
displayName,
|
||||||
|
totalDistance: parseInt(distanceResult?.total ?? '0', 10),
|
||||||
|
latestTimestamp: latestScan?.timestamp ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
await setRunnerEntry(runnerId, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk cache prewarming: loads all runners from the database and populates the KV cache.
|
||||||
|
* Uses 3 efficient queries and parallel KV writes to minimize startup time.
|
||||||
|
*
|
||||||
|
* Call from loader during startup (if NATS_PREWARM=true) to eliminate DB reads on the hot
|
||||||
|
* path from the very first scan.
|
||||||
|
*/
|
||||||
|
export async function warmAll(): Promise<void> {
|
||||||
|
const connection = getConnection();
|
||||||
|
|
||||||
|
// Query 1: All runners
|
||||||
|
const runners = await connection
|
||||||
|
.getRepository(Runner)
|
||||||
|
.createQueryBuilder('runner')
|
||||||
|
.select(['runner.id', 'runner.firstname', 'runner.lastname'])
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Query 2: Total valid distance per runner
|
||||||
|
const distanceResults = await connection
|
||||||
|
.getRepository(TrackScan)
|
||||||
|
.createQueryBuilder('scan')
|
||||||
|
.select('scan.runner', 'runnerId')
|
||||||
|
.addSelect('COALESCE(SUM(track.distance), 0)', 'total')
|
||||||
|
.innerJoin('scan.track', 'track')
|
||||||
|
.where('scan.valid = :valid', { valid: true })
|
||||||
|
.groupBy('scan.runner')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
// Query 3: Latest valid scan timestamp per runner
|
||||||
|
const latestResults = await connection
|
||||||
|
.getRepository(TrackScan)
|
||||||
|
.createQueryBuilder('scan')
|
||||||
|
.select('scan.runner', 'runnerId')
|
||||||
|
.addSelect('MAX(scan.timestamp)', 'latestTimestamp')
|
||||||
|
.where('scan.valid = :valid', { valid: true })
|
||||||
|
.groupBy('scan.runner')
|
||||||
|
.getRawMany();
|
||||||
|
|
||||||
|
// Build lookup maps
|
||||||
|
const distanceMap = new Map<number, number>();
|
||||||
|
distanceResults.forEach((row: any) => {
|
||||||
|
distanceMap.set(parseInt(row.runnerId, 10), parseInt(row.total, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestMap = new Map<number, number>();
|
||||||
|
latestResults.forEach((row: any) => {
|
||||||
|
latestMap.set(parseInt(row.runnerId, 10), parseInt(row.latestTimestamp, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write all entries in parallel
|
||||||
|
const writePromises = runners.map((runner) => {
|
||||||
|
const entry: RunnerKVEntry = {
|
||||||
|
displayName: `${runner.firstname} ${runner.lastname}`,
|
||||||
|
totalDistance: distanceMap.get(runner.id) ?? 0,
|
||||||
|
latestTimestamp: latestMap.get(runner.id) ?? 0,
|
||||||
|
};
|
||||||
|
return setRunnerEntry(runner.id, entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(writePromises);
|
||||||
|
}
|
||||||
88
src/nats/StationKV.ts
Normal file
88
src/nats/StationKV.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { KvEntry } from 'nats';
|
||||||
|
import NatsClient from './NatsClient';
|
||||||
|
|
||||||
|
const BUCKET = 'station_state';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached station data stored in NATS KV.
|
||||||
|
* Keyed by station prefix — the same prefix embedded in the station token.
|
||||||
|
* Carries all fields needed for auth and scan validation so no DB read
|
||||||
|
* is required on the hot path after the first request from a station.
|
||||||
|
*/
|
||||||
|
export interface StationKVEntry {
|
||||||
|
id: number;
|
||||||
|
enabled: boolean;
|
||||||
|
/** HMAC-SHA256 of the full station token, for re-verification on cache hit. */
|
||||||
|
tokenHash: string;
|
||||||
|
trackId: number;
|
||||||
|
/** Track distance in metres. */
|
||||||
|
trackDistance: number;
|
||||||
|
/** Minimum lap time in seconds. 0 means no minimum (DB null mapped to 0). */
|
||||||
|
minimumLapTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBucket() {
|
||||||
|
return NatsClient.getKV(BUCKET);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefixKey(prefix: string): string {
|
||||||
|
return `station.prefix.${prefix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function idKey(id: number): string {
|
||||||
|
return `station.id.${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEntry(key: string): Promise<StationKVEntry | null> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
let raw: KvEntry | null = null;
|
||||||
|
try {
|
||||||
|
raw = await bucket.get(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!raw || raw.operation === 'DEL' || raw.operation === 'PURGE') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return JSON.parse(raw.string()) as StationKVEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached StationKVEntry for the given token prefix, or null on a cache miss.
|
||||||
|
*/
|
||||||
|
export async function getStationEntry(prefix: string): Promise<StationKVEntry | null> {
|
||||||
|
return getEntry(prefixKey(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the cached StationKVEntry for the given station DB id, or null on a cache miss.
|
||||||
|
* Used by the intake flow where only stationId is available after ScanAuth.
|
||||||
|
*/
|
||||||
|
export async function getStationEntryById(id: number): Promise<StationKVEntry | null> {
|
||||||
|
return getEntry(idKey(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a StationKVEntry under both the prefix key and the id key.
|
||||||
|
* No TTL — entries are permanent until explicitly deleted.
|
||||||
|
*/
|
||||||
|
export async function setStationEntry(prefix: string, entry: StationKVEntry): Promise<void> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
const serialised = JSON.stringify(entry);
|
||||||
|
await bucket.put(prefixKey(prefix), serialised);
|
||||||
|
await bucket.put(idKey(entry.id), serialised);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the cached entries for the given prefix (and its id mirror).
|
||||||
|
* Call this on station update or delete so the next request re-fetches from DB.
|
||||||
|
*/
|
||||||
|
export async function deleteStationEntry(prefix: string): Promise<void> {
|
||||||
|
const bucket = await getBucket();
|
||||||
|
// Fetch the entry first so we can also delete the id-keyed mirror
|
||||||
|
const entry = await getEntry(prefixKey(prefix));
|
||||||
|
try { await bucket.delete(prefixKey(prefix)); } catch { /* not cached yet */ }
|
||||||
|
if (entry) {
|
||||||
|
try { await bucket.delete(idKey(entry.id)); } catch { /* not cached yet */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/types/express.d.ts
vendored
Normal file
8
src/types/express.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
declare namespace Express {
|
||||||
|
interface Request {
|
||||||
|
/** Set by ScanAuth when the request was authenticated via a station token. Not a header — not spoofable by clients. */
|
||||||
|
isStationAuth?: boolean;
|
||||||
|
/** The authenticated station's DB id. Only present when isStationAuth === true. */
|
||||||
|
stationId?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@
|
|||||||
"include": [
|
"include": [
|
||||||
"src/**/*"
|
"src/**/*"
|
||||||
],
|
],
|
||||||
|
"files": [
|
||||||
|
"src/types/express.d.ts"
|
||||||
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"**/*.spec.ts"
|
"**/*.spec.ts"
|
||||||
|
|||||||
Reference in New Issue
Block a user