Compare commits

..

639 Commits

Author SHA1 Message Date
7910beb5cb chore(release): 1.8.3
All checks were successful
Build release images / build-container (push) Successful in 1m29s
2026-02-20 22:56:51 +01:00
1be8836df1 refactor(ScanController, ResponseScanIntake, RunnerKV): Rename totalDistance to distance for consistency 2026-02-20 22:56:34 +01:00
70d6091a6a fix(RunnerSelfServiceController): Update getStationMe method to use req.stationId instead of header 2026-02-20 22:52:29 +01:00
1d9c451dfb chore(release): 1.8.2
All checks were successful
Build release images / build-container (push) Successful in 1m28s
2026-02-20 22:39:31 +01:00
3197498ab3 refactor(Dockerfile): Update build process and entry point for TypeScript application 2026-02-20 22:39:11 +01:00
a1a2c2747c chore(PERFORMANCE_IDEAS): Remove outdated performance optimization ideas document 2026-02-20 22:30:21 +01:00
80e7e7939c fix(CreateUser): Await password hashing in toEntity method 2026-02-20 22:28:33 +01:00
6caa1850e3 fix(deps): Add @types/bun dependency to devDependencies 2026-02-20 22:28:01 +01:00
7aaac65af4 chore(release): 1.8.1
Some checks failed
Build release images / build-container (push) Failing after 1m4s
2026-02-20 22:18:36 +01:00
13e0c81957 perf(stats): Cache stats results for 60 seconds 2026-02-20 22:16:14 +01:00
329a29aca7 chore(release): 1.8.0
Some checks failed
Build release images / build-container (push) Failing after 59s
2026-02-20 22:08:57 +01:00
abdadb8e64 refactor(deps): Remove unused glob dependency from package.json and bun.lock 2026-02-20 22:06:21 +01:00
abce517d86 refactor: Replace uuid and dotenv with bun primitives 2026-02-20 22:05:23 +01:00
a1e697acb2 refactor: Switch from official argon2 to Bun's implementation 2026-02-20 21:59:56 +01:00
c9b8614f53 chore(release): 1.7.2
All checks were successful
Build release images / build-container (push) Successful in 2m12s
2026-02-20 21:50:46 +01:00
cbf1da31c9 docs: Added agents file to support ai assisted coding 2026-02-20 21:50:21 +01:00
fd18e56251 refactor(dev): Yeet the funky dev script out of this codebase 2026-02-20 21:49:52 +01:00
3bb8b202b0 fix(dev): We did it funky bun dev workarounds are no more 2026-02-20 21:45:15 +01:00
d1c4744231 chore(release): 1.7.1
All checks were successful
Build release images / build-container (push) Successful in 2m26s
2026-02-20 20:32:25 +01:00
fe90414dd9 fix(ci): Switch to bun in ci 2026-02-20 20:31:23 +01:00
21ceb9fa26 perf(db): Added indexes 2026-02-20 20:31:02 +01:00
5081819281 chore(release): 1.7.0
Some checks failed
Build release images / build-container (push) Failing after 8s
2026-02-20 20:19:31 +01:00
240bd9cba1 refactor: Bun by default 2026-02-20 20:16:37 +01:00
53fb0389cd chore(release): 1.6.0
All checks were successful
Build release images / build-container (push) Successful in 2m48s
2026-02-20 19:46:17 +01:00
d230350027 perf(nats): Implement bulk cache prewarming for runners to optimize startup performance 2026-02-20 19:41:44 +01:00
024e647295 perf(nats): Implement bulk cache prewarming for runners to optimize startup performance 2026-02-20 19:40:02 +01:00
d3e0206a3c refactor(scan): Implement KV-backed scan station submissions and response model 2026-02-20 19:36:38 +01:00
b0c6759813 feat(nats): Implement caching for card, runner, and station entries with improved key management 2026-02-20 19:36:23 +01:00
526738e487 feat(auth): Implement caching for scanauth 2026-02-20 19:27:00 +01:00
778f159405 fix(types): Add custom Express request types for station authentication 2026-02-20 19:24:41 +01:00
2da8247978 chore(deps): Bump typescript and get rid of now legacy imports 2026-02-20 19:18:25 +01:00
bbf6ea6c0f feat(data): Added nats jetstream dependency 2026-02-20 19:15:35 +01:00
3584b3facf feat(auth): Switch scanstation auth from argon2 to sha256 to improve performance 2026-02-20 19:10:15 +01:00
e27e819609 chore(release): 1.5.2
All checks were successful
Build release images / build-container (push) Successful in 1m9s
2025-05-26 19:30:33 +02:00
0f532b139c feat(mailer): Log error message when sending selfservice forgotten mail fails 2025-05-26 19:30:16 +02:00
eebcc2e328 feat(mailer): Add logging for selfservice forgotten mail requests 2025-05-26 19:29:37 +02:00
284954d064 chore(release): 1.5.1
All checks were successful
Build release images / build-container (push) Successful in 1m12s
2025-05-26 19:24:04 +02:00
401ca923a6 feat(mailer): Log error when sending selfservice forgotten mail fails 2025-05-26 19:23:30 +02:00
bf1f6411e0 chore(release): 1.5.0
All checks were successful
Build release images / build-container (push) Successful in 1m17s
2025-05-06 19:41:36 +02:00
f225cc4954 feat(responses): Added created_at/updated_at 2025-05-06 19:38:20 +02:00
728f8a14e9 feat(entities): Added created/updated at to all entities 2025-05-06 19:33:30 +02:00
a4480589a0 feat(participants): Added created/updated at 2025-05-06 19:29:46 +02:00
0ad9eeb52f chore(release): 1.4.3
All checks were successful
Build release images / build-container (push) Successful in 1m15s
2025-05-01 16:02:40 +02:00
4494afc64b feat(runners): Include collected distance donation amount in runner detail 2025-05-01 16:02:28 +02:00
f4747c51de chore(release): 1.4.2
All checks were successful
Build release images / build-container (push) Successful in 1m16s
2025-05-01 15:57:57 +02:00
07a0195f12 fix(donations): Fixed creation bug 2025-05-01 15:56:42 +02:00
7ac98229d1 chore(release): 1.4.1
All checks were successful
Build release images / build-container (push) Successful in 1m19s
2025-04-28 21:36:31 +02:00
dd5b538783 refactor(auth): Increased token timeouts to 24hrs/7days 2025-04-28 21:36:12 +02:00
8e6d67428c chore(release): 1.4.0
All checks were successful
Build release images / build-container (push) Successful in 1m14s
2025-04-28 19:41:55 +02:00
7ffb7523aa Merge branch 'CreateAnonymousDonation-dedicated-enitity-controller' into dev 2025-04-28 19:41:35 +02:00
f4bf309821 feat(donations): Implement response type to indicate possible missing donor 2025-04-28 19:35:07 +02:00
02b1cb9904 refactor(donations): Make anon prepaid 2025-04-28 19:32:06 +02:00
7697acff82 fix(donations): Move donor over to the types that need it 2025-04-28 19:25:41 +02:00
bacfc437f9 chore(release): 1.3.12
All checks were successful
Build release images / build-container (push) Successful in 1m24s
2025-04-28 11:05:10 +02:00
9875b4f392 wip 2025-04-28 11:04:22 +02:00
ce9b765b81 refactor(config): improve consola error logs 2025-04-28 11:03:33 +02:00
2ab6e985e3 refactor: make Donation.donor optional 2025-04-28 10:56:06 +02:00
d06f6a4407 chore(release): 1.3.11
All checks were successful
Build release images / build-container (push) Successful in 1m40s
2025-04-17 20:46:56 +02:00
a50d72f2f5 feat(RunnerController): add selfservice_links parameter to getRunners method 2025-04-17 20:45:28 +02:00
4723d9738e chore(release): 1.3.10
All checks were successful
Build release images / build-container (push) Successful in 1m19s
2025-04-11 12:11:08 +02:00
1a478bd784 feat(RunnerController.getAll): debug created_via query param filter 2025-04-11 12:09:10 +02:00
284cb0f8b3 chore(release): 1.3.9
All checks were successful
Build release images / build-container (push) Successful in 1m14s
2025-04-09 11:38:16 +02:00
6e63c57936 feat(RunnerController.getAll): add created_via query param filter 2025-04-09 11:37:49 +02:00
30b61db2c1 chore(release): 1.3.8
All checks were successful
Build release images / build-container (push) Successful in 1m20s
2025-04-09 10:23:48 +02:00
8237d5f210 feat(RunnerCardController): putByCode 2025-04-09 10:23:01 +02:00
03e0a29096 chore(release): 1.3.7
All checks were successful
Build release images / build-container (push) Successful in 1m16s
2025-04-08 21:15:59 +02:00
a6afba93e2 feat(stats): Publish runners by kiosk stat 2025-04-08 21:15:41 +02:00
a41758cd9c chore(release): 1.3.6
All checks were successful
Build release images / build-container (push) Successful in 1m32s
2025-04-08 21:06:01 +02:00
d6755ed134 feat(runners): Allow created via being set via api 2025-04-08 21:04:38 +02:00
599c75fc00 fix(participant): Switch to correct type 2025-04-08 21:03:23 +02:00
bb213f001e chore(release): 1.3.5
All checks were successful
Build release images / build-container (push) Successful in 1m16s
2025-04-08 20:01:13 +02:00
5415cd38a7 feat(runners): Generate selfservice urls on runner if requested or create/update/get single 2025-04-08 20:00:27 +02:00
175ba52ffa chore(release): 1.3.4
All checks were successful
Build release images / build-container (push) Successful in 1m10s
2025-03-28 21:49:42 +01:00
5c5000a218 feat: add runnersViaSelfservice to statsControllerGet 2025-03-28 21:49:19 +01:00
d559d04031 chore(release): 1.3.3
All checks were successful
Build release images / build-container (push) Successful in 1m11s
2025-03-28 21:20:39 +01:00
2af682d1dd ci: remove "v" prefix from tags 2025-03-28 21:20:30 +01:00
30905e481c chore(release): v1.3.2
All checks were successful
Build release images / build-container (push) Successful in 1m17s
2025-03-28 21:16:41 +01:00
752d405bda ci: pnpm@10.7 2025-03-28 21:16:28 +01:00
8fa4ed7c33 chore(release): v1.3.1
Some checks failed
Build release images / build-container (push) Failing after 6s
2025-03-28 21:15:27 +01:00
c4201e9a68 fix: TypeError: Cannot read properties of undefined (reading 'filter') - when trying to delete a org/team with runners
close #210
2025-03-28 21:14:14 +01:00
78dcad0857 pnpm@10.7, node@23, argon->@node-rs/argon2 2025-03-28 21:04:50 +01:00
93e0cdf577 chore(release): v1.3.0
Some checks failed
Build release images / build-container (push) Failing after 6s
2025-03-28 18:56:01 +01:00
6efcd94726 ci: change release commit message 2025-03-28 18:55:25 +01:00
2e271bcd52 fix: add .created_via to ResponseParticipant constructor 2025-03-28 18:24:53 +01:00
ebde8c6ffd ci: move to gitea workflows 2025-03-28 18:07:10 +01:00
a3639dd89b feat: created_via for tracking how runners got into the system (#212)
close #211

squash merge please:)

Reviewed-on: #212
2025-03-28 17:05:10 +00:00
0a43f1bb5b build: docker "AS" casing 2025-03-28 17:41:20 +01:00
8c6fdb2239 refactor(RunnerController.remove): only load necessary relations 2025-03-28 12:01:15 +01:00
c0d5af5d7a refactor(RunnerTeamController.remove): only load necessary relations 2025-03-28 12:00:55 +01:00
4008a5ee72 chore(release): 1.2.1
Some checks failed
ci/woodpecker/push/build Pipeline failed
2024-12-11 22:23:58 +01:00
07bf28b144 refactor: allow selfservice link every 30s
Some checks failed
ci/woodpecker/push/build Pipeline failed
2024-12-11 22:22:54 +01:00
6764bf80ea chore(release): 1.2.0
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
2024-12-11 20:05:31 +01:00
b3a73b25e8 refactor(ci): Switch to new woodpecker 2024-12-11 20:05:07 +01:00
bda1f971d1 Merge pull request 'refactor: move to new mailer' (#209) from refactor/new-mailer into dev
Reviewed-on: #209
Reviewed-by: Nicolai Ort <info@nicolai-ort.com>
2024-12-11 19:04:11 +00:00
765ef84903 SELFSERVICE_URL 2024-12-11 18:43:11 +01:00
296ba8ddab FRONTEND_URL env 2024-12-11 18:40:21 +01:00
6eff243803 feat: middlename 2024-12-11 18:34:40 +01:00
0f4c8b2051 refactor: move to new mailer 2024-12-11 18:26:57 +01:00
d842c14240 chore: update readme 2024-12-11 17:55:04 +01:00
a54cb287a4 🚀Bumped version to v1.1.4 2024-11-20 19:00:19 +01:00
74d334f9b7 fix(dependencies): Switch back to previous class-validator version to produce a working build 2024-11-20 18:59:50 +01:00
cd3cd81360 fix(deps): Bump sqlite3
All checks were successful
ci/woodpecker/push/build Pipeline was successful
2023-11-06 20:32:05 +01:00
cf48c00ddb fix(deps): Bumped argon2 to latest version for arm support
Some checks failed
ci/woodpecker/push/build Pipeline failed
2023-11-06 20:23:32 +01:00
3192365793 feat(ci)!: Switch to woodpecker
Some checks failed
ci/woodpecker/push/build Pipeline failed
2023-11-06 20:15:44 +01:00
075d484f11 ci: drop lfk-client-node 2023-11-06 18:09:31 +01:00
5082b1b8b1 fix: updated README for pnpm, typos 2023-11-06 18:04:32 +01:00
50dd703a1b build: package lock 2023-11-06 18:01:15 +01:00
057a8ee699 🚀Bumped version to v1.1.3
All checks were successful
continuous-integration/drone/push Build is passing
2023-05-10 13:38:14 +02:00
8d9418635d feat(orgs): Also resolve child-teams' distances and add them to org total 2023-05-10 13:37:54 +02:00
f2832a2dae fix(orgs): Removed unused log 2023-05-10 13:36:05 +02:00
0d21596e2b 🚀Bumped version to v1.1.2
All checks were successful
continuous-integration/drone/push Build is passing
2023-05-10 13:16:33 +02:00
245827e9c6 feat(groups): Resolve the total group distance on group get single (aka get org and get team) 2023-05-10 13:15:59 +02:00
4608a36df6 chore(package): Formatting 2023-05-10 13:15:21 +02:00
cb1305aa77 🚀Bumped version to v1.1.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-19 18:10:52 +02:00
12a9ae2493 feat(donors): Resolve donations with donors via pagination 2023-04-19 18:10:26 +02:00
b9fe9f1c24 🚀Bumped version to v1.1.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-19 15:48:16 +02:00
b25b0db760 Added hints 2023-04-19 15:47:54 +02:00
fe59e3a557 Added average donation per distance to stats 2023-04-19 15:46:50 +02:00
42c23a5883 Formatting 2023-04-19 15:45:34 +02:00
6ee5328dbc Added calls to controller 2023-04-19 15:41:49 +02:00
6f39ac42da feat(stats): Added donation count and donor count to stats 2023-04-19 15:41:43 +02:00
301f334674 🚀Bumped version to v1.0.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-18 20:09:58 +02:00
fcee3909f4 fix(pagination) page=0 resulted in false thx JS 2023-04-18 20:09:44 +02:00
f0e20e4130 🚀Bumped version to v1.0.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-18 20:03:51 +02:00
80de188565 Merge pull request 'feature/205-pagination' (#206) from feature/205-pagination into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #206
Reviewed-by: Philipp Dormann <philipp@noreply.git.odit.services>
2023-04-18 18:03:20 +00:00
2f305e127c Updated test for attribute
All checks were successful
continuous-integration/drone/pr Build is passing
2023-04-18 20:02:03 +02:00
513d7f6fba usergroup pagination
Some checks failed
continuous-integration/drone/pr Build is failing
ref #205
2023-04-18 18:44:15 +02:00
244da61892 users pagination
ref #205
2023-04-18 18:43:13 +02:00
2a72aea10e Track pagination
Some checks failed
continuous-integration/drone/pr Build is failing
ref #205
2023-04-18 18:41:57 +02:00
71ebce6f8e statsclient pagination
Some checks failed
continuous-integration/drone/pr Build is failing
ref #205
2023-04-18 18:40:45 +02:00
f60025b6de scanstation pagination
Some checks failed
continuous-integration/drone/pr Build is failing
ref #205
2023-04-18 18:39:37 +02:00
0fa663a341 RunnerTeam Pagination
Some checks failed
continuous-integration/drone/pr Build is failing
ref #205
2023-04-18 18:38:27 +02:00
538622aa18 Added pagination for runner orgs
ref #205
2023-04-18 18:37:09 +02:00
86a21dbfa4 Get all pagination for permissions
Some checks failed
continuous-integration/drone/pr Build is failing
ref #205
2023-04-18 18:35:25 +02:00
1e9e24d99d Pagination for group contacts
ref #205
2023-04-18 18:34:08 +02:00
4493c0e3d9 Added pagination for get all donors
Some checks failed
continuous-integration/drone/pr Build is failing
ref #205
2023-04-18 18:30:20 +02:00
f5d48fc638 Added pagination for donations
ref #205
2023-04-18 18:28:55 +02:00
b35a2dd2fa Added pagination for runnercards
ref #205
2023-04-18 18:27:11 +02:00
a28ffe06e5 Formatting
ref #205
2023-04-18 18:21:09 +02:00
d873674819 Added pagination for runners
ref #205
2023-04-18 18:20:56 +02:00
37b2ac974b Added pagination for get all scans
ref #205
2023-04-18 18:17:10 +02:00
81aed1de40 🚀Bumped version to v0.15.4
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-15 22:52:10 +02:00
0f0c3c7214 Fixed possible null
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-15 22:51:48 +02:00
3909ed34f7 🚀Bumped version to v0.15.3
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-15 22:36:09 +02:00
b2ac70e0ae Faster stats (not including donations) 2023-04-15 22:35:55 +02:00
5f17e7f783 🚀Bumped version to v0.15.2
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-15 21:16:56 +02:00
a5a56a263a Resolve groups again for card generation 2023-04-15 21:15:29 +02:00
2d8f7528d9 Don't resolve runner group and parten with get all card requests 2023-04-15 21:13:14 +02:00
9581185b24 🚀Bumped version to v0.15.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-15 21:11:56 +02:00
2905884c02 Log batch time in mass scan script 2023-04-15 21:11:32 +02:00
e9914e317b Faster trackscan creation by only loading the latest scan 2023-04-15 21:08:08 +02:00
702070da66 Dont load cards with get all runners request 2023-04-15 20:55:22 +02:00
cc89ba8afb 🚀Bumped version to v0.15.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-15 20:51:28 +02:00
7c4ff42a3b More scan request optimizations 2023-04-15 20:51:13 +02:00
8007117434 Added test script for creating mass scans 2023-04-15 20:50:48 +02:00
23fa78eb9d Get all scans speed improvement 2023-04-15 20:31:52 +02:00
3b3e68900b 🚀Bumped version to v0.14.6
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-15 18:20:54 +02:00
3ff666fd3e Missing orm file 2023-04-15 18:19:47 +02:00
4e4435010f 🚀Bumped version to v0.14.5
All checks were successful
continuous-integration/drone/push Build is passing
2023-04-15 18:16:09 +02:00
de9af5a909 Entrypoint fix 2023-04-15 18:15:57 +02:00
ac631f0af4 Fixed copy 2023-04-15 18:13:58 +02:00
6bbdd5bb04 🚀Bumped version to v0.14.4
Some checks failed
continuous-integration/drone/pr Build was killed
continuous-integration/drone/push Build is passing
2023-04-15 18:09:49 +02:00
a8fc755840 Back to ean13 based codes
Some checks failed
continuous-integration/drone/push Build was killed
continuous-integration/drone/pr Build is failing
2023-04-15 18:09:24 +02:00
27e74e824c pinned pnpm to 8
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-04-12 14:12:05 +02:00
b5c0a288ac coherent baseimage
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2023-03-29 20:35:16 +02:00
85dc3444ac custom pnpm cache
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is passing
2023-03-29 20:29:56 +02:00
d02743984d install prod in first step
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is passing
2023-03-29 20:29:08 +02:00
734c826fac added missing ci env
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-03-29 20:27:01 +02:00
33b25c9743 bumped final pnpm version
Some checks failed
continuous-integration/drone/pr Build was killed
continuous-integration/drone/push Build is passing
2023-03-29 20:03:38 +02:00
6275aaa326 Switched ci over to pnpm + cache
Some checks failed
continuous-integration/drone/pr Build was killed
continuous-integration/drone/push Build is failing
2023-03-29 19:56:05 +02:00
2a94bfa622 pinned pnpm version 2023-03-29 19:53:42 +02:00
a64f6c9822 COPY by stage name 2023-03-29 19:52:59 +02:00
93d43b7684 Switched dockerfile to pnpm 8 with cache 2023-03-29 19:52:31 +02:00
16ce0a8480 🚀Bumped version to v0.14.3
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-18 22:15:02 +01:00
9a8d618ae4 Adjusted modulo for new fixed card length 2023-03-18 22:14:50 +01:00
38da2d3318 🚀Bumped version to v0.14.2
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-18 21:55:23 +01:00
068deb4960 Back to modulo 2023-03-18 21:55:10 +01:00
13f093bb61 🚀Bumped version to v0.14.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-18 21:46:38 +01:00
6289f30740 Switched from card prefix replacement via modulo to regex 2023-03-18 21:46:21 +01:00
6ff764bc34 🚀Bumped version to v0.14.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-15 14:44:34 +01:00
ea87cc793b Updated default length 2023-03-15 14:44:19 +01:00
92517e3653 Removed sqlite journal 2023-03-15 14:39:40 +01:00
ffee887ddf breaking(runnercards): shorter runnercard codes (padding to 12 was a bit tooo ambitious) 2023-03-15 14:39:24 +01:00
3bac75e7ab 🚀Bumped version to v0.13.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2023-02-15 14:55:41 +01:00
d05eddcae1 Merge pull request 'feature/201-no_citizen-deletion' (#202) from feature/201-no_citizen-deletion into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #202
2023-02-15 13:54:54 +00:00
d5c689d693 Updated tests
All checks were successful
continuous-integration/drone/pr Build is passing
ref #201
2023-02-15 14:35:58 +01:00
8fedd4ef3b Added delete check for citizen org
ref #201
2023-02-15 14:34:12 +01:00
e8b2e6f261 🚀Bumped version to v0.13.2
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-03 16:12:20 +01:00
39f3b0e01f Merge pull request 'move selfservice magic link endpoint to 15min rate limit' (#200) from feature/runner-selfservice-login-link-rate-limit into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #200
2023-02-03 15:09:34 +00:00
edaf255e8f move to 15min limit
All checks were successful
continuous-integration/drone/pr Build is passing
2023-02-03 14:12:28 +01:00
41c4ed4d0f Merge pull request 'Releases 0.12.0 and 0.13.0' (#199) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #199
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2023-02-03 13:04:39 +00:00
f2bd88aadf 🚀Bumped version to v0.13.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2023-02-02 16:16:50 +01:00
67a3661448 Updated description 2023-02-02 16:16:36 +01:00
0c763a2dfd 🚀Bumped version to v0.13.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2023-02-02 12:58:19 +01:00
a7297ff933 Moved changelog generation to package script 2023-02-02 12:58:06 +01:00
4cdba8bc77 Updated readme 2023-02-02 12:56:23 +01:00
77c6303014 Moved license and changelog export to releaseit hooks 2023-02-02 12:55:42 +01:00
2b641faa29 📖New license file version [CI SKIP] [skip ci] 2023-02-02 11:52:17 +00:00
9fa8b93c08 🧾New changelog file version [CI SKIP] [skip ci] 2023-02-02 11:51:42 +00:00
4b676bc853 Merge pull request 'feature/197-duplicate_runner_mail' (#198) from feature/197-duplicate_runner_mail into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #198
2023-02-02 11:51:23 +00:00
4433ddb1e1 Updated logo url
All checks were successful
continuous-integration/drone/pr Build is passing
2023-02-02 11:24:04 +01:00
39aa7598b7 Updated tests for new login in selfservice
All checks were successful
continuous-integration/drone/pr Build is passing
ref #197
2023-02-02 11:18:45 +01:00
19a290c3a9 Fixed typo 2023-02-02 11:17:18 +01:00
9bc80aac8a Updated selfservice tests to prevent email duplication
ref #197
2023-02-02 11:14:48 +01:00
e184673963 Added faker for testing
ref #197
2023-02-02 11:10:04 +01:00
68cd746a9f Added selfservice runner create check to prevent duplicate email
ref #197
2023-02-02 11:08:36 +01:00
69651d9f6c Rename selfservice forgot to login
ref #197
2023-02-02 11:03:12 +01:00
6fd246f43c 📖New license file version [CI SKIP] [skip ci] 2023-02-02 09:24:02 +00:00
ae14d6c74f 🧾New changelog file version [CI SKIP] [skip ci] 2023-02-02 09:23:34 +00:00
2fa56b82d1 Add git for changelog fun
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-02 10:23:11 +01:00
9cc66eebdf depends_on: ["clone"]
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-02 10:21:19 +01:00
4c10e20b91 🚀Bumped version to v0.12.0
Some checks failed
continuous-integration/drone/push Build is failing
2023-02-02 10:19:57 +01:00
9217421221 Enabled tag via release script 2023-02-02 10:19:30 +01:00
4570845b3e Pinned pnpm for builds 2023-02-02 10:18:08 +01:00
0e78951300 Drone -> Kaniko based builds 2023-02-02 10:15:46 +01:00
6ad56b3126 Drone images to odit registry 2023-02-02 10:13:04 +01:00
d95c6d3365 Bumped container base images 2023-02-02 10:10:48 +01:00
1f2c8abb22 Ignore pnpm lock 2023-02-02 10:08:22 +01:00
a6d5693ccd Pinned versions 2023-02-02 10:05:23 +01:00
31b258b4ce 🧾New changelog file version [CI SKIP] [skip ci] 2021-04-22 18:21:52 +00:00
f19f2808d8 Merge pull request 'Release 0.11.1' (#196) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #196
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-04-22 18:21:16 +00:00
3b9cd2e1bb 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-04-22 17:59:02 +00:00
95320ca1bc 🚀Bumped version to v0.11.1
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2021-04-22 19:57:31 +02:00
f2d127fc98 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-04-22 17:53:09 +00:00
eb526fb57f Added fix for the appended 2
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-22 19:52:32 +02:00
348fe52c42 🧾New changelog file version [CI SKIP] [skip ci] 2021-04-22 17:42:09 +00:00
eef0fa6952 Merge branch 'dev' of git.odit.services:lfk/backend into dev
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-22 19:41:21 +02:00
8a82e059b7 Now prefixing runnercards with 2 2021-04-22 19:41:18 +02:00
2229cdf20d 🧾New changelog file version [CI SKIP] [skip ci] 2021-04-14 17:05:15 +00:00
3220b194d4 Merge pull request 'Release 0.11.0' (#195) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #195
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-04-14 17:04:29 +00:00
278c4a6a41 🧾New changelog file version [CI SKIP] [skip ci]
Some checks failed
continuous-integration/drone/pr Build is failing
2021-04-14 16:59:20 +00:00
ec50ac31c4 Merge branch 'dev' of git.odit.services:lfk/backend into dev
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2021-04-14 18:58:24 +02:00
a2f0d814fc 📖New license file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-04-14 16:58:11 +00:00
6468b35708 Merge branch 'dev' of git.odit.services:lfk/backend into dev 2021-04-14 18:57:45 +02:00
3558e99090 🚀Bumped version to v0.11.0 2021-04-14 18:57:26 +02:00
520608aef0 🧾New changelog file version [CI SKIP] [skip ci] 2021-04-14 16:57:03 +00:00
6df5f634f3 Merge pull request 'Donation payment management feature/193-donation_payments' (#194) from feature/193-donation_payments into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #194
2021-04-14 16:56:22 +00:00
da266a8dd6 Fixed spelling
All checks were successful
continuous-integration/drone/pr Build is passing
ref #193
2021-04-14 18:54:02 +02:00
8ae4b85827 Added payedDonationAmount to donor and responsedonor
All checks were successful
continuous-integration/drone/pr Build is passing
ref #193
2021-04-14 18:49:44 +02:00
8fe3243693 Saved missing file
All checks were successful
continuous-integration/drone/pr Build is passing
ref #193
2021-04-14 18:47:10 +02:00
49b174f29f No longer answering with null, but 0
Some checks failed
continuous-integration/drone/pr Build is failing
ref #193
2021-04-14 18:42:38 +02:00
30c6d3d8db Added status to tests
Some checks failed
continuous-integration/drone/pr Build was killed
ref #193
2021-04-14 18:41:20 +02:00
6c14ed9c89 Added mssing check to tests
ref #193
2021-04-14 18:36:59 +02:00
01ed51489e Updated tests
Some checks failed
continuous-integration/drone/pr Build is failing
ref #193
2021-04-14 18:34:15 +02:00
0636616dad Marked payedAmount as optional during creation and/or update
ref #193
2021-04-14 18:29:40 +02:00
34dbaaafe0 Responses now contain the donation status
ref #193
2021-04-14 18:28:08 +02:00
b4c31ee9b5 Added donation status enum
ref #193
2021-04-14 18:25:42 +02:00
99307423c5 Added payed amount to update classes
ref #193
2021-04-14 18:19:26 +02:00
71542bc388 Added payed amount to crealte classes
ref #193
2021-04-14 18:17:26 +02:00
d64f470b60 Added payed amount to response class
ref #193
2021-04-14 18:15:37 +02:00
b8fbb72fa0 Added payed amount fileld to donation class
ref #193
2021-04-14 18:12:45 +02:00
0c61ff457d 🧾New changelog file version [CI SKIP] [skip ci] 2021-04-07 17:28:37 +00:00
1d82f65b0d Merge pull request 'Release 0.10.2' (#192) from dev into main
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
Reviewed-on: #192
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-04-07 17:27:47 +00:00
610988ec16 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-04-07 17:20:21 +00:00
6e236ede14 🚀Bumped version to v0.10.2
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-07 19:18:21 +02:00
b7ad5d3a31 🧾New changelog file version [CI SKIP] [skip ci] 2021-04-07 17:17:14 +00:00
a694ad225c Merge pull request 'stats/runners/laptime feature/190-runners_laptime' (#191) from feature/190-runners_laptime into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #191
2021-04-07 17:16:33 +00:00
5633e85f41 added new ci secret
All checks were successful
continuous-integration/drone/pr Build is passing
ref #190
2021-04-07 18:13:00 +02:00
95e1eec313 Removed all useless console.logs
All checks were successful
continuous-integration/drone/pr Build is passing
ref #190
2021-04-07 16:41:10 +02:00
377d5dadb2 Potential fix for all remaining errors
All checks were successful
continuous-integration/drone/pr Build is passing
ref #190
2021-04-07 16:38:20 +02:00
4a294b1e17 Now resolving all relations for orgs by distance
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-07 16:34:09 +02:00
720774fcf4 Added temp console log
ref #190
2021-04-07 16:31:36 +02:00
dcdbdd15ac Ptotential fix for stats failing
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-07 16:28:48 +02:00
132b48cf2a Removed console log for passing tests
ref #190
2021-04-07 16:26:12 +02:00
23bd432c5f Resolved missing parentgroup relation
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-07 16:23:13 +02:00
71b33ab05b Removed console logs for now working tests
ref #190
2021-04-07 16:21:01 +02:00
87f444c30d At least one fewer test should fail now
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-07 16:17:37 +02:00
4a73eab134 Added temp console log for ci debugging
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-07 16:10:36 +02:00
f8baca5ab2 Updated default docker-compose
ref #190
2021-04-07 16:10:22 +02:00
10221b9f2e Pinned testing container tag to prod container tag
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-07 16:06:06 +02:00
1d8c8c8e9c Removed console log
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-06 10:45:06 +02:00
4603a84f16 Reverted temp bugfix
ref #190
2021-04-06 10:43:54 +02:00
2cd8f3f7f3 Merge branch 'feature/190-runners_laptime' of git.odit.services:lfk/backend into feature/190-runners_laptime
Some checks failed
continuous-integration/drone/pr Build is failing
2021-04-06 10:15:35 +02:00
107eeeae7f Merge branch 'feature/190-runners_laptime' of git.odit.services:lfk/backend into feature/190-runners_laptime 2021-04-06 10:15:32 +02:00
b8767b8bd4 Merge branch 'feature/190-runners_laptime' of git.odit.services:lfk/backend into feature/190-runners_laptime
Some checks failed
continuous-integration/drone/pr Build is failing
2021-04-06 10:08:00 +02:00
bf686e89e0 Temp test logging workaround
ref #190
2021-04-06 10:07:59 +02:00
6163f0a90b Temp test logging workaround
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-06 10:05:05 +02:00
8f0f795a70 Tried workaround for no availdable stats
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-06 09:34:12 +02:00
22cae39bd3 Added temp console log for test
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-06 09:29:23 +02:00
0b07a53ed2 Temp disabled runners by donations test
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-06 09:27:22 +02:00
d4a02e7db2 Added orgs by donations stats tests
ref #190
2021-04-06 09:26:19 +02:00
b9a7dc84f0 Added teams stats endpoint tests
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-06 09:21:23 +02:00
7111068361 Added runners stats tests
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-06 09:20:09 +02:00
63964fbf2c Removed test for content type
All checks were successful
continuous-integration/drone/pr Build is passing
ref #190
2021-04-06 09:14:45 +02:00
cbcb829fbd Fixed typo in test
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-06 09:12:03 +02:00
057ae0d797 Added first selfservice test
Some checks failed
continuous-integration/drone/pr Build is failing
ref #190
2021-04-06 09:09:01 +02:00
257f320ee3 Now resolving all missing relations
ref #190
2021-04-06 09:02:07 +02:00
7b15c2d88b Fixed sorting
ref #190
2021-04-06 08:58:22 +02:00
988f17a795 Fixed sorting algo
ref #190
2021-04-06 08:56:34 +02:00
4471e57438 First try of the laptime sort
ref #190
2021-04-06 08:44:14 +02:00
51daf969cf Added min laptime to StatsRunner
ref #190
2021-04-06 08:14:02 +02:00
cb71fcd13b Added basic laptime endpoint
ref #190
2021-04-06 08:13:43 +02:00
a6a526dc5d Fixed top-ten bein top 9
ref #190
2021-04-05 17:47:51 +02:00
dd6d799c84 🧾New changelog file version [CI SKIP] [skip ci] 2021-04-03 16:25:14 +00:00
e89e07d0fc Merge pull request 'Release 0.10.1' (#189) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #189
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-04-03 16:24:25 +00:00
c28843c405 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-04-03 16:17:51 +00:00
4834a6698b Removed duplicate openapi statement
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-03 18:16:56 +02:00
69afd4d587 🧾New changelog file version [CI SKIP] [skip ci] 2021-04-03 16:15:38 +00:00
24d152fdc8 🚀Bumped version to v0.10.1
Some checks failed
continuous-integration/drone/push Build was killed
2021-04-03 18:14:47 +02:00
4279e43743 🧾New changelog file version [CI SKIP] [skip ci] 2021-04-03 16:14:17 +00:00
d837654617 Merge pull request 'Selfservice donations reformatting feature/187-selfservice_donation' (#188) from feature/187-selfservice_donation into dev
Some checks failed
continuous-integration/drone/push Build was killed
Reviewed-on: #188
2021-04-03 16:13:34 +00:00
0767943721 Switched selfservice donation.donor from string to object
All checks were successful
continuous-integration/drone/pr Build is passing
ref #187
2021-04-03 17:07:44 +02:00
ca87774767 Adjusted runner property names
ref #187
2021-04-03 17:06:54 +02:00
f693f2cde9 Added new responsetype for new class
ref #187
2021-04-03 17:05:58 +02:00
d70c5b1bbc New class: ResponseSelfServiceDonor
ref #187
2021-04-03 17:05:10 +02:00
71e3d0efe2 🧾New changelog file version [CI SKIP] [skip ci] 2021-04-01 16:39:21 +00:00
b517dff8a8 Merge pull request 'Release 0.10.0' (#186) from dev into main
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
Reviewed-on: #186
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-04-01 16:38:30 +00:00
114c246ace 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-04-01 16:31:25 +00:00
d7703c9e07 Merge branch 'dev' of git.odit.services:lfk/backend into dev
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-01 18:30:38 +02:00
dc3071f7d2 🚀Bumped version to v0.10.0 2021-04-01 18:30:30 +02:00
5fb355f450 🧾New changelog file version [CI SKIP] [skip ci] 2021-04-01 16:30:20 +00:00
33c13de32c Merge pull request 'Mail locales feature/184-mail_locales' (#185) from feature/184-mail_locales into dev
Some checks failed
continuous-integration/drone/push Build was killed
Reviewed-on: #185
2021-04-01 16:29:39 +00:00
1be073a4fa Added locale to mail related user endpoints
All checks were successful
continuous-integration/drone/pr Build is passing
ref #184
2021-04-01 18:25:09 +02:00
b0d8249452 Merge branch 'feature/184-mail_locales' of git.odit.services:lfk/backend into feature/184-mail_locales 2021-04-01 18:23:21 +02:00
7af883f271 Added locale to mail related runner endpoints
ref #184
2021-04-01 18:23:19 +02:00
f5433076b0 Added locale to mail related runner endpoints
ref #84
2021-04-01 18:23:15 +02:00
6aafe4a6ae 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-29 16:43:52 +00:00
bdeeb03645 Merge pull request 'Release 0.9.2' (#183) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #183
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-03-29 16:42:59 +00:00
675c8762e8 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-29 16:32:26 +00:00
89e392473c 🚀Bumped version to v0.9.2
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-29 18:31:40 +02:00
6c9b91d75a Fixed bug in return creation 2021-03-29 18:31:27 +02:00
8c00aefd6c 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-29 16:13:02 +00:00
3afd785a54 Merge pull request 'Release v0.9.1' (#182) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #182
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-03-29 16:12:14 +00:00
8099999e2c 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-29 15:49:57 +00:00
a139554e05 🚀Bumped version to v0.9.1
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-29 17:48:53 +02:00
0290b0e5f5 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-29 15:48:47 +00:00
0f7fa990d4 Merge pull request 'Return cards generated in bulk feature/180-blank_generation_return' (#181) from feature/180-blank_generation_return into dev
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #181
2021-03-29 15:48:05 +00:00
2f568c9cb8 Fixed copy-paste oversight
All checks were successful
continuous-integration/drone/pr Build is passing
ref #180
2021-03-28 18:54:16 +02:00
1cb2dc9d53 Added test for returnCards=true array length
Some checks failed
continuous-integration/drone/pr Build is failing
ref #180
2021-03-28 18:47:32 +02:00
6005b0661f Added test for single card generation with returnCards=true
ref #180
2021-03-28 18:46:25 +02:00
5a36c8dcae Added query param to return created runenrcards
ref #180
2021-03-28 18:44:21 +02:00
58f4d2151f 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-26 20:36:54 +00:00
95135ddc89 Merge pull request 'Release 0.9.0' (#179) from dev into main
All checks were successful
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
Reviewed-on: #179
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-03-26 20:36:02 +00:00
a7fe1e1759 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-26 20:32:56 +00:00
56a5f41686 🚀Bumped version to v0.9.0
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-26 21:32:11 +01:00
c23b4d907f 🚀Bumped version to v0.8.0 2021-03-26 21:32:02 +01:00
bd7b81efe7 📖New license file version [CI SKIP] [skip ci] 2021-03-26 20:31:19 +00:00
274a146b9b 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-26 20:30:16 +00:00
5a3fc5b2bd Merge pull request 'Password security feature/99-password_checks' (#177) from feature/99-password_checks into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #177
2021-03-26 20:29:35 +00:00
070560e863 Fixed test params
All checks were successful
continuous-integration/drone/pr Build is passing
ref #99
2021-03-26 21:24:53 +01:00
536900091a Fixed empty object getting called
Some checks failed
continuous-integration/drone/pr Build is failing
ref #99
2021-03-26 21:19:58 +01:00
8154e715bb Now forceing user deletion in tests
Some checks failed
continuous-integration/drone/pr Build is failing
ref #99
2021-03-26 21:13:17 +01:00
4c6665062f Reenabled user tests
Some checks failed
continuous-integration/drone/pr Build is failing
ref #99
2021-03-26 21:08:50 +01:00
cb3ea9b1eb Fixed pw not getting hashed currectly;
All checks were successful
continuous-integration/drone/pr Build is passing
ref #99
2021-03-26 21:06:42 +01:00
7a64f23937 Moved to tmp files to better check for other problems
Some checks failed
continuous-integration/drone/pr Build is failing
ref #99
2021-03-26 20:57:42 +01:00
96ba25ec6c No longer using createuser in seeding process
Some checks failed
continuous-integration/drone/pr Build is failing
ref #99
2021-03-26 20:52:58 +01:00
e6a8ebcb5b Added user deletion tests
Some checks failed
continuous-integration/drone/pr Build was killed
ref #99
2021-03-26 20:44:28 +01:00
888cab5898 Added user creation invalid tests
ref #99
2021-03-26 20:41:36 +01:00
383a8095b8 Added user creation valid tests
ref #99
2021-03-26 20:41:25 +01:00
63f6526e4f Updated auth test to comply with the new pw requirements
ref #99
2021-03-26 20:28:08 +01:00
b24e24ff7d Added pw errors to user controller
ref #99
2021-03-26 20:24:08 +01:00
9ce35d8eb7 Added pw errors to me controller
ref #99
2021-03-26 20:23:29 +01:00
48a87e8936 Now checking password rules on user update
ref #99
2021-03-26 20:19:23 +01:00
b8c28ebb08 Formatting
ref #99
2021-03-26 20:18:39 +01:00
5daaa3a73c Now checking password rules on user creation
ref #99
2021-03-26 20:18:08 +01:00
24c38cce26 Added password errors
ref #99
2021-03-26 20:17:00 +01:00
bd00f4f8d5 Added password checker dependency
ref #99
2021-03-26 20:11:22 +01:00
03d76e6d0b 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-26 16:35:23 +00:00
3f8e8ce3a6 Merge pull request 'Release 0.8.0' (#176) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #176
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-03-26 16:34:31 +00:00
c9bd6de476 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-26 16:19:28 +00:00
e702118d4d Merge pull request 'Selfservice deletion feature/174-selfservice_deletion' (#175) from feature/174-selfservice_deletion into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #175
2021-03-26 16:18:28 +00:00
97159dd9f8 Removed param from test
All checks were successful
continuous-integration/drone/pr Build is passing
ref #174
2021-03-26 16:56:45 +01:00
942d9dbc76 Merge branch 'dev' into feature/174-selfservice_deletion
Some checks failed
continuous-integration/drone/pr Build was killed
2021-03-26 16:54:34 +01:00
88844e1a44 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-26 15:53:45 +00:00
e76a9cef95 Merge pull request 'Release 0.7.1' (#173) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #173
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-03-26 15:52:41 +00:00
20aeed8778 Added tests for the new endpoint
Some checks failed
continuous-integration/drone/pr Build is failing
ref #174
2021-03-26 16:50:12 +01:00
ccb7ae29a3 Fixed response bug
ref #174
2021-03-26 16:45:30 +01:00
dcb12b0ac2 Added selfservice deletion endpoint
ref #174
2021-03-26 16:42:14 +01:00
dd1258333e Updated old hint
ref #174
2021-03-26 16:42:01 +01:00
3ef3a94b20 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-26 14:24:07 +00:00
135852eb9a 🚀Bumped version to v0.7.1
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-26 15:23:05 +01:00
963253cbc8 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-26 14:22:55 +00:00
539a6509b1 Merge pull request 'RESPONSERUNNERCARD fix bugfix/171-responserunnercards' (#172) from bugfix/171-responserunnercards into dev
Some checks failed
continuous-integration/drone/push Build is failing
Thx @philipp :)

Reviewed-on: #172
2021-03-26 14:22:15 +00:00
f3d73d5346 Tests now keep the group
All checks were successful
continuous-integration/drone/pr Build is passing
ref #171
2021-03-26 15:17:05 +01:00
f159252651 Revert "Set timeout even higher b/c sqlite just kills itself during these tests"
Some checks failed
continuous-integration/drone/pr Build is failing
This reverts commit 6ab60998d4.
2021-03-26 15:14:17 +01:00
6ab60998d4 Set timeout even higher b/c sqlite just kills itself during these tests
Some checks failed
continuous-integration/drone/pr Build is failing
ref #171
2021-03-26 15:13:31 +01:00
30d220bc36 Adjusted jest timeout to mitigate sqlite from invalidateing all tests⏱
Some checks failed
continuous-integration/drone/pr Build is failing
ref #171
2021-03-26 15:06:20 +01:00
24aff3bac4 Now resolveing runnercards
ref #171
2021-03-26 14:56:21 +01:00
ce63043887 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-23 17:50:46 +00:00
e40017a6b8 Merge pull request 'Release 0.7.0' (#170) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #170
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-03-23 17:49:45 +00:00
e843a464e7 🧾New changelog file version [CI SKIP] [skip ci]
Some checks failed
continuous-integration/drone/pr Build is failing
2021-03-23 17:44:04 +00:00
d0ae50d557 🚀Bumped version to v0.7.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2021-03-23 18:42:16 +01:00
7a49e7c5c9 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-23 17:42:14 +00:00
1dd64204cc Merge pull request 'Bulk card creation feature/168-runnercards_bulk' (#169) from feature/168-runnercards_bulk into dev
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
Reviewed-on: #169
2021-03-23 17:41:31 +00:00
438ff0fc3f Added bulk card creation tests
All checks were successful
continuous-integration/drone/pr Build is passing
ref #168
2021-03-23 18:19:49 +01:00
c1bbda51f0 Added new "bulk" endpoint
ref #168
2021-03-23 18:16:55 +01:00
4705a39aab 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-19 16:41:43 +00:00
4d721f62d9 Merge pull request 'Release 0.6.4' (#167) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #167
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-03-19 16:40:45 +00:00
b0328ffdaf 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-19 16:34:14 +00:00
031cede542 🚀Bumped version to v0.6.4
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-19 17:33:17 +01:00
3c69f8c4a8 Adjsuted endpoint 2021-03-19 17:32:46 +01:00
cc6568c381 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-18 15:54:33 +00:00
a3a1395a46 Merge pull request 'Release 0.6.3' (#165) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #165
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-03-18 15:53:42 +00:00
b08acc6660 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-18 15:11:01 +00:00
7a303c2b2c 🚀Bumped version to v0.6.3
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-18 16:10:02 +01:00
3f9a7049e3 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-18 15:09:50 +00:00
6249419fae Merge pull request 'TrackScan Update bug 🐞bugfix/163-trackscan_updates' (#164) from bugfix/163-trackscan_updates into dev
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #164
2021-03-18 15:09:08 +00:00
f347b7ad49 Updated tests 🧪
All checks were successful
continuous-integration/drone/pr Build is passing
ref #163
2021-03-18 14:05:13 +01:00
74faec85c8 Merge branch 'bugfix/163-trackscan_updates' of git.odit.services:lfk/backend into bugfix/163-trackscan_updates 2021-03-18 14:00:16 +01:00
fbdadbef1f The basic bugfix 🐞
ref #163
2021-03-18 14:00:14 +01:00
c87c97c90f The basic bugfix 🐞
REF '!&§
2021-03-18 14:00:04 +01:00
a6bca59ffe 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-17 18:44:42 +00:00
732a1b88d9 Merge pull request 'Release 0.6.2' (#162) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #162
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-03-17 18:43:45 +00:00
4c960feeb2 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-17 18:42:50 +00:00
72fee96a08 Merge branch 'dev' of git.odit.services:lfk/backend into dev
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-17 19:41:41 +01:00
fcb43f92b0 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-17 18:41:40 +00:00
5ba8f1dd44 🚀Bumped version to v0.6.2 2021-03-17 19:41:35 +01:00
3d3790c2eb Merge pull request 'Bugfixes for trackscans feature/160-responseTrackScan_total_distance' (#161) from feature/160-responseTrackScan_total_distance into dev
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #161
2021-03-17 18:40:47 +00:00
1fa3fa75ee Fixed wrong error type 👀👀
All checks were successful
continuous-integration/drone/pr Build is passing
ref #160
2021-03-17 19:37:35 +01:00
c8882ae6a1 Removed duplicate openapi declarations 🗑
Some checks failed
continuous-integration/drone/pr Build is failing
ref #160
2021-03-17 19:33:44 +01:00
673e896aa3 Added missing discription
ref #160
2021-03-17 19:31:09 +01:00
0ed7f78b2c Fixed missing renameing🛠
ref #160
2021-03-17 19:30:08 +01:00
1d38d308ad Changed the method of getting a parameter from the headers🛠
ref #160
2021-03-17 19:29:12 +01:00
d709ee7479 Now defining security per endpoint 🔐
ref #160
2021-03-17 19:25:49 +01:00
aae042c041 Now auto-etting the station token🔥🔥🔥
ref #160
2021-03-17 19:24:25 +01:00
ca7a84eb3e Merge branch 'feature/160-responseTrackScan_total_distance' of git.odit.services:lfk/backend into feature/160-responseTrackScan_total_distance 2021-03-17 19:22:09 +01:00
1f32ed0727 Marked station as optional (quality of life improvements incoming)
ref #160
2021-03-17 19:22:04 +01:00
289f9e2196 Added comments✏
ref #160
2021-03-17 19:21:35 +01:00
937a9fad4d Added comments✏
ref #150
2021-03-17 19:21:29 +01:00
7c3a1b8fff Merge branch 'dev' into feature/160-responseTrackScan_total_distance 2021-03-17 19:16:54 +01:00
a8ea4fa659 Fixed trackscan vaildation
ref #160
2021-03-17 19:16:42 +01:00
c1dd4518d1 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-17 17:59:10 +00:00
bdc7bb67e7 Merge pull request 'Release v0.6.0' (#159) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #159
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-03-17 17:58:20 +00:00
54988ba0fe 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-17 16:41:01 +00:00
ce3ca9f1c8 🚀Bumped version to v0.6.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2021-03-17 17:40:13 +01:00
46b7aceb0b Scanauth return objects 2021-03-17 17:40:01 +01:00
486e450a58 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-17 16:13:23 +00:00
623b5a1873 🚀Bumped version to v0.6.0
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-17 17:12:01 +01:00
a7958eecd6 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-17 15:52:53 +00:00
13e839902c Merge pull request 'Scanstation "me" endpoint feature/157-scanstation_me' (#158) from feature/157-scanstation_me into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #158
2021-03-17 15:52:02 +00:00
94001a48f1 Updated description
All checks were successful
continuous-integration/drone/pr Build is passing
ref #157
2021-03-17 16:50:48 +01:00
2cb7ec7317 As requested by @philpp
All checks were successful
continuous-integration/drone/pr Build is passing
ref #157
2021-03-17 16:49:19 +01:00
757332ed2b Added tests for the new endpoint
All checks were successful
continuous-integration/drone/pr Build is passing
ref #157
2021-03-17 11:51:31 +01:00
8ba7ee1d48 Now adding station id to headers of request for scan auth
ref #157
2021-03-17 11:45:40 +01:00
c5178e0181 Added scanstation me endpoint
ref #157
2021-03-17 11:44:54 +01:00
a1a94ec9da 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-15 15:03:59 +00:00
f7af777104 Applied Docker MTU fix 🛠
All checks were successful
continuous-integration/drone/push Build is passing
2021-03-15 16:03:15 +01:00
076aa87dba 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-12 19:21:08 +00:00
ca6fa633a1 Revert "Switched normal images to chached registry"
Some checks failed
continuous-integration/drone/push Build was killed
This reverts commit cba4455d53.
2021-03-12 20:20:46 +01:00
641e2aed52 Merge branch 'dev' of git.odit.services:lfk/backend into dev
Some checks failed
continuous-integration/drone/push Build was killed
2021-03-12 20:09:36 +01:00
cba4455d53 Switched normal images to chached registry 2021-03-12 20:09:26 +01:00
d5930f7c46 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-12 18:03:04 +00:00
5541ae6ebd Updated ci with new kubernetes secrets 🚀🚀🚀
Some checks failed
continuous-integration/drone/push Build was killed
ref odit/org#12
2021-03-12 19:02:45 +01:00
6c43872198 Changed ci pipeline type to kubernetes
Some checks failed
continuous-integration/drone/push Build encountered an error
ref odit/org#12 (comment)
2021-03-10 20:07:46 +01:00
e4ed20da3e 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-06 13:22:37 +00:00
cb6e78fc17 Merge pull request 'selfservice forgotten mails feature/154-selfservice_forgotten' (#155) from feature/154-selfservice_forgotten into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #155
2021-03-06 13:22:16 +00:00
bf1ec976e3 Added selfservice forgott positive tests
Some checks failed
continuous-integration/drone/pr Build was killed
ref #154
2021-03-06 14:12:46 +01:00
d0a7e34de8 Added all "negative" tests
ref #154
2021-03-06 14:08:39 +01:00
08957d4dc2 Updated to new responsetype
ref #154
2021-03-06 14:02:58 +01:00
1d762f5662 Renamed test
ref #154
2021-03-06 13:57:25 +01:00
a95a9b4ec4 Added first selfservice forgotten test
ref #154
2021-03-06 13:56:55 +01:00
e5dab3469c Changed endpoint url to avoid conflicts
ref #154
2021-03-06 13:56:34 +01:00
c01233b4d6 Added console logging when a testing env get's discovered
ref #154
2021-03-06 13:51:24 +01:00
92920273be Adjusted tests for the new testing env
ref #154
2021-03-06 13:51:07 +01:00
6bb3ae8ba9 Mailer now ignores mailing erros when env is set to test
ref #154
2021-03-06 13:44:10 +01:00
cedc1750c2 Added readme description for testing env
ref #154
2021-03-06 13:42:10 +01:00
3f372123fd Added testing env check
ref #154
2021-03-06 13:41:10 +01:00
a3437475ca Runner controller now uses the Mailer functions
ref #154
2021-03-06 13:38:37 +01:00
83765136cc Added mailer functions
ref #154
2021-03-06 13:36:12 +01:00
e26b7d4923 Implemented the "real" errors
ref #154
2021-03-06 13:32:36 +01:00
e7f0cb45c9 Added not found error logic
ref #154
2021-03-06 13:29:44 +01:00
ffcd45e572 Updated request timeout
ref #154
2021-03-06 13:24:43 +01:00
d7099717c2 Created basic endpoint for user forgotten mails
ref #154
2021-03-06 13:23:01 +01:00
66d6023335 Added last reset requested timestamp to runners
ref #154
2021-03-06 13:15:27 +01:00
5f5c8a061e 📖New license file version [CI SKIP] [skip ci] 2021-03-04 16:27:55 +00:00
bf71e35ecd 🧾New changelog file version [CI SKIP] [skip ci] 2021-03-04 16:23:18 +00:00
64da0eadb3 Merge pull request 'Alpha Release 0.5.0' (#153) from dev into main
Some checks failed
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build was killed
Reviewed-on: #153
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-03-04 16:22:36 +00:00
52728290b4 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-03-04 16:07:53 +00:00
3f2a2d2929 🚀Bumped version to v0.5.0
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build was killed
2021-03-04 17:03:51 +01:00
f1d85cfb85 🚀Bumped version to v0.4.7 2021-03-04 17:03:41 +01:00
15356c1030 Merge pull request 'Features for the new selfservice feature/151-selfservice_scans_mails' (#152) from feature/151-selfservice_scans_mails into dev
Some checks failed
continuous-integration/drone/push Build was killed
Reviewed-on: #152
2021-03-04 16:03:04 +00:00
82c65b632c Added scans returns 200 test
All checks were successful
continuous-integration/drone/pr Build is passing
ref #151
2021-03-04 16:40:05 +01:00
ae7d617690 Updated auth reset test for new mailer
ref #151
2021-03-04 16:35:30 +01:00
bf6b70106e Now generateing bs mailer config in test env
ref #151
2021-03-04 16:32:06 +01:00
33310cdb44 Merge branch 'feature/151-selfservice_scans_mails' of git.odit.services:lfk/backend into feature/151-selfservice_scans_mails 2021-03-04 16:29:46 +01:00
db58a280b3 Updated readme env section
ref #151
2021-03-04 16:29:44 +01:00
149f3a83b2 Updated readme env section
ref #151
2021-03-04 16:28:13 +01:00
a5d2a6ecd3 Added locale to pw reset endpoint
ref #151
2021-03-04 16:25:23 +01:00
bb9bad6d90 Now checking for mails being set
ref #151
2021-03-04 16:19:19 +01:00
ada679823c Removed useless functions and updated comments
ref #151
2021-03-04 16:13:36 +01:00
9a1678acf0 Now using mailer as static funtion
ref #151
2021-03-04 16:11:35 +01:00
485c247cd3 Removed (now useless) mail controller
ref #151
2021-03-04 16:11:21 +01:00
ddea02db57 Added new mailer settings to config
ref #151
2021-03-04 16:11:05 +01:00
1551a444ba Added the new mailer code
ref #151
2021-03-04 16:10:43 +01:00
f289afd8bc Updated mail errors
ref #151
2021-03-04 16:10:26 +01:00
a9e06c9055 Promoted axios to dependency
ref #151
2021-03-04 15:56:24 +01:00
c2fdfeed4f Removed mail templates
ref #151
2021-03-04 15:54:47 +01:00
0342757d92 Removed mail config
ref #151
2021-03-04 15:54:27 +01:00
5833f4218f Removed nodemailer from backend
ref #151
2021-03-04 15:52:20 +01:00
0fcc729b56 Removed old mailer code
ref #151
2021-03-04 15:51:05 +01:00
a2c97a11a3 Laptime is now a part of the response
ref #151
2021-03-01 16:59:36 +01:00
aa833736d3 Trackscans now have a laptime that get's calculated on creation
ref #151
2021-03-01 16:58:34 +01:00
771a205fe6 Added new selfservice scans endpoint
ref #151
2021-03-01 16:49:37 +01:00
6074ac5b3a Added selfservice scan response class
ref #151
2021-03-01 16:47:54 +01:00
030b2255d4 Added another resonse type
ref #151
2021-03-01 16:47:05 +01:00
f7f6df41ff Added new selfservice response type
ref #151
2021-03-01 16:36:36 +01:00
be397c8899 🧾New changelog file version [CI SKIP] [skip ci] 2021-02-26 16:50:45 +00:00
dd3c9275d6 Merge pull request 'Alpha Release 0.4.6' (#148) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #148
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-02-26 16:50:03 +00:00
764b7ffe00 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-26 16:48:33 +00:00
d870b2fd01 Merge pull request 'Fixed wrong body acceptance type' (#150) from bugfix/146-usergroup_update into dev
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
Reviewed-on: #150
2021-02-26 16:48:16 +00:00
aaec09d2ab Fixed wrong body acceptance type
All checks were successful
continuous-integration/drone/pr Build is passing
ref #146
2021-02-26 17:32:26 +01:00
bce8811925 📖New license file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-25 19:41:04 +00:00
3afc207903 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-25 19:39:39 +00:00
fca997beb8 Merge branch 'dev' of git.odit.services:lfk/backend into dev
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2021-02-25 20:39:21 +01:00
39ebfbf0b6 Pinned package version to avoid dependency conflicts 📌
yeet
2021-02-25 20:39:16 +01:00
3736b29e54 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-25 19:33:24 +00:00
b4c9369a53 Merge branch 'dev' of git.odit.services:lfk/backend into dev
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is passing
2021-02-25 20:33:04 +01:00
5d6c8c957a Quick bugfix 2021-02-25 20:33:00 +01:00
09fe47b9aa 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-25 19:30:37 +00:00
b4acd157fc 🚀Bumped version to v0.4.6
Some checks failed
continuous-integration/drone/push Build is failing
2021-02-25 20:30:16 +01:00
b1fced7764 📖New license file version [CI SKIP] [skip ci] 2021-02-24 20:00:08 +00:00
c0cafb4d51 🧾New changelog file version [CI SKIP] [skip ci] 2021-02-24 19:59:05 +00:00
45d61b487e Merge pull request 'New org selfservice endpoint feature/146-more_selfservice_endpoints' (#147) from feature/146-more_selfservice_endpoints into dev
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #147
2021-02-24 19:58:47 +00:00
28ef139a70 Added tests for the new org selfservice endpoints
All checks were successful
continuous-integration/drone/pr Build is passing
ref #146
2021-02-24 18:32:56 +01:00
656f63dfd5 Added selfservice org info endpoint
ref #146
2021-02-24 18:23:08 +01:00
ba3b5eeefc Added selfservice org response model
ref #146
2021-02-24 18:20:31 +01:00
ba396e0eba Added selfservice team response model
ref #146
2021-02-24 18:18:10 +01:00
3c11d88557 Added new response types
ref #146
2021-02-24 18:17:30 +01:00
305fa0078d 🧾New changelog file version [CI SKIP] [skip ci] 2021-02-09 18:25:18 +00:00
a46d14278b Merge pull request 'Alpha release 0.4.5' (#145) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #145
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-02-09 18:24:30 +00:00
680ae8ebbb 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-09 18:19:21 +00:00
cc869f69ad 🚀Bumped version to v0.4.5
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2021-02-09 19:18:46 +01:00
b9aac71676 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-09 18:18:22 +00:00
a30a342e00 Merge pull request 'usergroups/permissions endpoint feature/143-usergroup_permissions_endpoint' (#144) from feature/143-usergroup_permissions_endpoint into dev
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
Reviewed-on: #144
2021-02-09 18:18:04 +00:00
bdcfce88cb Now all /usergroups endpoints return ResponseUserGroup
All checks were successful
continuous-integration/drone/pr Build is passing
ref #143
2021-02-09 18:27:11 +01:00
dd81f4c7e4 Merge branch 'feature/143-usergroup_permissions_endpoint' of git.odit.services:lfk/backend into feature/143-usergroup_permissions_endpoint
# Conflicts:
#	src/models/responses/ResponseUserGroup.ts
2021-02-09 18:26:33 +01:00
416f2a1366 The ResponseUserGroup now returns their permisssions as a string array
ref #143
2021-02-09 18:26:22 +01:00
5e353db206 The ResponseUserGroup now returns their permisssions as a string array
ref #143
2021-02-09 18:26:16 +01:00
0c9867d706 Implemented /groups/permissions endpoint
ref #143
2021-02-09 18:18:00 +01:00
8379c3e29c 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-09 16:47:57 +00:00
c4edccace7 Merge pull request 'Alpha release 0.4.4' (#142) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #142
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-02-09 16:47:31 +00:00
74de6559d7 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-08 16:34:37 +00:00
a6f73c733c 🚀Bumped version to v0.4.4
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2021-02-08 17:34:03 +01:00
ca3d093e54 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-08 16:33:37 +00:00
28cfbaa662 Merge pull request 'Expanded runner response feature/140-runner_group_parent' (#141) from feature/140-runner_group_parent into dev
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
Reviewed-on: #141
2021-02-08 16:33:21 +00:00
90e1ad7db7 Adjusted test for the new response depth
All checks were successful
continuous-integration/drone/pr Build is passing
ref #140
2021-02-08 16:51:09 +01:00
906a1dc9e7 The group/runners endpoints now also deliver the runner's group's parentGroup
ref #140
2021-02-08 16:48:20 +01:00
5872c6335b Adjusted test for the new response depth
ref #140
2021-02-08 16:46:07 +01:00
701706c028 Now loading runner's group's parentgroup with every runner controller request
ref #140
2021-02-08 16:40:17 +01:00
09bbc70f5f 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-07 12:43:57 +00:00
dd9cb6d3ef Merge pull request 'Alpha Release 0.4.3' (#139) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #139
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-02-07 12:43:30 +00:00
23c732b690 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-07 12:40:54 +00:00
656d564baa 🚀Bumped version to v0.4.3
All checks were successful
continuous-integration/drone/push Build is passing
2021-02-07 13:40:32 +01:00
f3f5cb462e 🧾New changelog file version [CI SKIP] [skip ci] 2021-02-07 12:38:54 +00:00
9959172f2a Merge pull request 'Bugfix for @lfk/frontend/#43' (#138) from bugfix/118-encode_jwt_in_mail into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #138
2021-02-07 12:38:34 +00:00
8f0a396dd0 Bugfix for @lfk/frontend/#43
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-07 13:37:01 +01:00
a18d4d3cee 🧾New changelog file version [CI SKIP] [skip ci] 2021-02-02 15:02:34 +00:00
390b36dfd4 Merge pull request 'Alpha Release 0.4.2' (#137) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #137
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-02-02 15:02:08 +00:00
3b718f3ce5 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-02-02 14:46:03 +00:00
321b20b073 🚀Bumped version to v0.4.2
All checks were successful
continuous-integration/drone/push Build is passing
2021-02-02 15:45:41 +01:00
f7a0ec7174 🧾New changelog file version [CI SKIP] [skip ci] 2021-02-02 14:45:15 +00:00
110a84783e Merge pull request 'Imprint&Privacy Links feature/135-imprint_and_privacy' (#136) from feature/135-imprint_and_privacy into dev
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #136
2021-02-02 14:45:00 +00:00
333e806da4 Added documentation about the new env vars to the readme
All checks were successful
continuous-integration/drone/pr Build is passing
ref #135
2021-02-02 15:10:04 +01:00
f4f621973a Added imprint and privacy to the api spec
ref #135
2021-02-02 15:07:33 +01:00
bcad691045 Added new url env vars to config
ref #135
2021-02-02 15:04:09 +01:00
74791df68b 📖New license file version [CI SKIP] [skip ci] 2021-01-30 17:13:12 +00:00
8425043099 🧾New changelog file version [CI SKIP] [skip ci] 2021-01-30 17:12:25 +00:00
74b982afba fixed license-exporter call
All checks were successful
continuous-integration/drone/push Build is passing
2021-01-30 18:12:09 +01:00
3aefa75412 🧾New changelog file version [CI SKIP] [skip ci] 2021-01-30 16:00:16 +00:00
71cab4e836 Merge pull request 'Alpha Release 0.4.1' (#134) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #134
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-01-30 15:59:51 +00:00
4e10077901 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-01-30 15:54:03 +00:00
c32fa93673 🚀Bumped version to v0.4.1
All checks were successful
continuous-integration/drone/push Build is passing
2021-01-30 16:53:38 +01:00
3d1baae0cc Dependency bump🔝 [skip ci] 2021-01-30 16:53:21 +01:00
94dd7963b7 Deleted useless file [ci skip] 2021-01-30 16:44:39 +01:00
7ba67b9dca 🧾New changelog file version [CI SKIP] [skip ci] 2021-01-30 15:40:39 +00:00
6e5f1bd5ff Merge pull request 'Response object types feature/132-object_types' (#133) from feature/132-object_types into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #133
2021-01-30 15:40:24 +00:00
60ee6ebc1e Merge branch 'dev' into feature/132-object_types
All checks were successful
continuous-integration/drone/pr Build is passing
2021-01-30 15:32:20 +00:00
02295346da 🧾New changelog file version [CI SKIP] [skip ci] 2021-01-30 15:27:16 +00:00
c4ea808e06 Merge pull request 'Alpha Release 0.4.0' (#131) from dev into main
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
Reviewed-on: #131
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-01-30 15:26:48 +00:00
ff7406e71a Cleaned up realations regarding response classes
All checks were successful
continuous-integration/drone/pr Build is passing
ref #132
2021-01-30 16:19:42 +01:00
8dc2810c0c Adjusted tests for the new responseType parameter (part 3)
ref #132
2021-01-30 16:19:20 +01:00
ff8af090e3 Adjusted tests for the new responseType parameter (part 2)
ref #132
2021-01-30 15:39:39 +01:00
bcc15e4286 Adjusted tests for the new responseType parameter (part 1)
ref #132
2021-01-29 18:46:15 +01:00
2a87819486 Fixed typos and missing types
ref #132
2021-01-29 17:06:57 +01:00
9d5e486c6d Implemented the interface in all responses
refr #132
2021-01-29 17:06:43 +01:00
e44cc4c4cb Added a Response interface
ref #132
2021-01-29 16:49:19 +01:00
581ca5ff6c Added Responseobjecttype enum
ref #132
2021-01-29 16:45:23 +01:00
b972395ae8 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-01-27 16:54:23 +00:00
e5f4f6ee59 🚀Bumped version to v0.4.0
All checks were successful
continuous-integration/drone/push Build is passing
2021-01-27 17:53:58 +01:00
fea4857685 🧾New changelog file version [CI SKIP] [skip ci] 2021-01-27 16:52:45 +00:00
f9e75d06b8 Merge pull request 'Implemented testmail endpoint feature/124-testmail' (#130) from feature/124-testmail into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #130
2021-01-27 16:52:28 +00:00
38223b194b Merge branch 'dev' into feature/124-testmail
All checks were successful
continuous-integration/drone/pr Build is passing
2021-01-27 17:47:29 +01:00
09b24aa609 🧾New changelog file version [CI SKIP] [skip ci] 2021-01-27 16:47:07 +00:00
348e6cdec7 Merge pull request 'Email Basics feature/118-emails' (#128) from feature/118-emails into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #128
2021-01-27 16:46:34 +00:00
bd1813a0e8 Merge branch 'dev' into feature/118-emails
All checks were successful
continuous-integration/drone/pr Build is passing
2021-01-27 16:45:17 +00:00
e07f258a31 🧾New changelog file version [CI SKIP] [skip ci] 2021-01-27 16:44:56 +00:00
61bbeb0d8f Merge pull request 'Mail+Env documentation feature/123-mail_documentation' (#129) from feature/123-mail_documentation into dev
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #129
2021-01-27 16:44:33 +00:00
650a55e586 Merge branch 'dev' into feature/123-mail_documentation
All checks were successful
continuous-integration/drone/pr Build is passing
2021-01-27 16:44:19 +00:00
2071c4db33 🧾New changelog file version [CI SKIP] [skip ci] 2021-01-27 16:43:56 +00:00
80e606aa96 Merge branch 'dev' into feature/123-mail_documentation
All checks were successful
continuous-integration/drone/pr Build is passing
2021-01-27 16:43:54 +00:00
20f960ed67 Merge pull request 'Alpha Release 0.3.1' (#127) from dev into main
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
Reviewed-on: #127
Reviewed-by: Philipp Dormann <philipp@philippdormann.de>
2021-01-27 16:43:26 +00:00
e6fe8fcd58 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-01-27 16:32:04 +00:00
870fd47c83 Merge pull request 'new advanced endpoints feature/125-team_runner' (#126) from feature/125-team_runner into dev
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
Reviewed-on: #126
2021-01-27 16:31:45 +00:00
644045db44 Merge branch 'dev' into feature/125-team_runner
All checks were successful
continuous-integration/drone/pr Build is passing
2021-01-27 16:31:16 +00:00
8611fcb849 Merge branch 'dev' into feature/124-testmail
All checks were successful
continuous-integration/drone/pr Build is passing
2021-01-27 11:08:37 +00:00
08e6e59655 Removed the duplicate env copy/create from ci tests
All checks were successful
continuous-integration/drone/pr Build is passing
ref #124
2021-01-27 12:06:00 +01:00
ae74b3963f Added test mail sending test
All checks were successful
continuous-integration/drone/pr Build is passing
ref #124
2021-01-27 12:03:02 +01:00
54ed313342 Implemented the test-mail endpoint via a new mailcontroller
ref #124
2021-01-27 11:59:32 +01:00
ad4b903c25 Added a Mail permisssion target
ref #124
2021-01-27 11:53:33 +01:00
9bd7636a23 Added comments
ref #124
2021-01-27 11:37:46 +01:00
b94179e3ca Added a test mail sending function
ref #124
2021-01-27 11:28:27 +01:00
827002989e Added test mail templates
ref #124
2021-01-27 11:28:02 +01:00
eeff67c192 Merge branch 'dev' into feature/123-mail_documentation
All checks were successful
continuous-integration/drone/pr Build is passing
2021-01-26 19:42:59 +00:00
583a4bc0dd Changed order
All checks were successful
continuous-integration/drone/pr Build is passing
ref #123
2021-01-26 20:41:44 +01:00
53fcff77d0 Added a hint to ethereal.email
ref #123
2021-01-26 20:41:08 +01:00
1f0c842d9e Table fix
ref #123
2021-01-26 20:39:38 +01:00
13ccab5e28 Added documentation for the env vars
ref #123
2021-01-26 20:38:53 +01:00
b5018eb114 Added the basics about mail templates to the readme
ref #123
2021-01-26 20:26:25 +01:00
aedfcfcc83 🧾New changelog file version [CI SKIP] [skip ci]
All checks were successful
continuous-integration/drone/pr Build is passing
2021-01-26 19:15:59 +00:00
db0876015b 🚀Bumped version to v0.3.1
All checks were successful
continuous-integration/drone/push Build is passing
2021-01-26 20:15:35 +01:00
69417e93c0 Added get runners by team test
All checks were successful
continuous-integration/drone/pr Build is passing
ref #125
2021-01-26 20:13:39 +01:00
f71a22f4dd Added get runners by org test
ref #125
2021-01-26 20:12:14 +01:00
570c34bed0 Created the organizations/runners endpoint
ref #125
2021-01-26 20:06:54 +01:00
7be2971a9e Created the runnerTeam/runners endpoint
ref #125
2021-01-26 19:57:35 +01:00
b92f633d68 Now also sending txt mail body
All checks were successful
continuous-integration/drone/pr Build is passing
ref #118
2021-01-26 18:45:19 +01:00
d3647e3399 Added a txt variant of the pw-reset mail
ref #118
2021-01-26 18:41:50 +01:00
389e423850 Cleaned up the replacements
ref #118
2021-01-26 18:41:21 +01:00
46af786516 Fixed missing app_url protocol
ref #118
2021-01-26 18:31:34 +01:00
b4c117b7dc Fixed wrong file location
ref #118
2021-01-26 18:31:17 +01:00
5cade25eeb Translated the pw reset mail to english
ref #118
2021-01-26 18:17:52 +01:00
fb77f4d798 Renamed the template
ref #118
2021-01-26 18:09:27 +01:00
c116338cd7 Added pw reset template provided by @philipp
ref #118
2021-01-26 18:09:00 +01:00
979d36ea91 Password reset now enforces email
ref #118
2021-01-26 18:07:56 +01:00
c43334bf96 The auth tests now use mail to identify the user
ref #118
2021-01-26 18:07:42 +01:00
71c4caae8b Removed bs console.log
ref #118
2021-01-26 17:55:41 +01:00
536de2a319 Implemented automatic ci env generation
ref #118
2021-01-26 17:54:25 +01:00
e26744b792 Implementes mail sending on pw reset request
ref #118
2021-01-26 17:35:03 +01:00
d02e9dec56 Removed tests working directly with the old pw-reset response
ref #118
2021-01-26 17:28:20 +01:00
637975305f Implemented a basic mailer with reset link sending
ref #118
2021-01-26 17:21:18 +01:00
c418603423 Added the first mail error
ref #118
2021-01-26 17:20:55 +01:00
78d2ac3027 Added nodemailer types
ref #118
2021-01-26 17:20:44 +01:00
470703c4de Added env vars
ref #118
2021-01-26 17:20:33 +01:00
e260e16d66 Merge branch 'feature/118-emails' of git.odit.services:lfk/backend into feature/118-emails 2021-01-26 16:10:26 +01:00
6b0155f014 Added a folder for the mail templates
ref #118
2021-01-26 16:10:24 +01:00
33890b544b Added a folder for the mail templates
ref #118
2021-01-26 16:10:02 +01:00
d7ea928714 Added mail env vars
ref #118
2021-01-26 16:09:11 +01:00
908ac4f1ce Added nodemailer dependecy
ref #118
2021-01-26 16:06:47 +01:00
cf012c0b7e Added a barebones class for handleing mail stuff
ref #118
2021-01-26 16:05:55 +01:00
71898d576c 🧾New changelog file version [CI SKIP] [skip ci] 2021-01-24 17:58:01 +00:00
199 changed files with 19688 additions and 7001 deletions

View File

@@ -1,164 +0,0 @@
---
kind: pipeline
name: tests:node_latest
clone:
disable: true
steps:
- name: checkout pr
image: alpine/git
commands:
- git clone $DRONE_REMOTE_URL .
- git checkout $DRONE_SOURCE_BRANCH
- mv .env.ci .env
- name: run tests
image: node:latest
commands:
- yarn
- yarn test:ci
trigger:
event:
- pull_request
---
kind: pipeline
type: docker
name: build:dev
clone:
disable: true
steps:
- name: clone
image: alpine/git
commands:
- git clone $DRONE_REMOTE_URL .
- git checkout dev
- name: build dev
image: plugins/docker
depends_on: [clone]
settings:
username:
from_secret: DOCKER_REGISTRY_USER
password:
from_secret: DOCKER_REGISTRY_PASSWORD
repo: registry.odit.services/lfk/backend
tags:
- dev
registry: registry.odit.services
- name: run changelog export
depends_on: ["clone"]
image: node:latest
commands:
- npx auto-changelog --commit-limit false -p -u --hide-credit
- name: push new changelog to repo
depends_on: ["run changelog export"]
image: appleboy/drone-git-push
settings:
branch: dev
commit: true
commit_message: 🧾New changelog file version [CI SKIP] [skip ci]
author_email: bot@odit.services
remote: git@git.odit.services:lfk/backend.git
ssh_key:
from_secret: GITLAB_SSHKEY
- name: run full license export
depends_on: ["clone"]
image: node:14.15.1-alpine3.12
commands:
- yarn
- yarn licenses:export
- name: push new licenses file to repo
depends_on: ["run full license export"]
image: appleboy/drone-git-push
settings:
branch: dev
commit: true
commit_message: 📖New license file version [CI SKIP] [skip ci]
author_email: bot@odit.services
remote: git@git.odit.services:lfk/backend.git
skip_verify: true
ssh_key:
from_secret: GITLAB_SSHKEY
trigger:
branch:
- dev
event:
- push
---
kind: pipeline
type: docker
name: build:latest
clone:
disable: true
steps:
- name: clone
image: alpine/git
commands:
- git clone $DRONE_REMOTE_URL .
- git checkout dev
- git merge main
- git checkout main
- name: build latest
depends_on: ["clone"]
image: plugins/docker
settings:
username:
from_secret: DOCKER_REGISTRY_USER
password:
from_secret: DOCKER_REGISTRY_PASSWORD
repo: registry.odit.services/lfk/backend
tags:
- latest
registry: registry.odit.services
- name: push merge to repo
depends_on: ["clone"]
image: appleboy/drone-git-push
settings:
branch: dev
commit: false
remote: git@git.odit.services:lfk/backend.git
ssh_key:
from_secret: GITLAB_SSHKEY
trigger:
branch:
- main
event:
- push
---
kind: pipeline
type: docker
name: build:tags
steps:
- name: build $DRONE_TAG
image: plugins/docker
depends_on: [clone]
settings:
username:
from_secret: DOCKER_REGISTRY_USER
password:
from_secret: DOCKER_REGISTRY_PASSWORD
repo: registry.odit.services/lfk/backend
tags:
- '${DRONE_TAG}'
registry: registry.odit.services
- name: trigger node lib build
image: idcooldi/drone-webhook
settings:
urls: https://ci.odit.services/api/repos/lfk/lfk-client-node/builds?SOURCE_TAG=${DRONE_TAG}
bearer:
from_secret: BOT_DRONE_KEY
- name: trigger js lib build
image: idcooldi/drone-webhook
settings:
urls: https://ci.odit.services/api/repos/lfk/lfk-client-js/builds?SOURCE_TAG=${DRONE_TAG}
bearer:
from_secret: BOT_DRONE_KEY
trigger:
event:
- tag

View File

@@ -7,4 +7,8 @@ DB_PASSWORD=bla
DB_NAME=./test.sqlite DB_NAME=./test.sqlite
NODE_ENV=production NODE_ENV=production
POSTALCODE_COUNTRYCODE=DE POSTALCODE_COUNTRYCODE=DE
SEED_TEST_DATA=false SEED_TEST_DATA=false
SELFSERVICE_URL=bla
STATION_TOKEN_SECRET=<replace-with-random-secret-min-32-chars>
NATS_URL=nats://localhost:4222
NATS_PREWARM=false

View File

@@ -0,0 +1,30 @@
name: Build release images
on:
push:
tags:
- "*.*.*"
jobs:
build-container:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun licenses:export
- name: Login to registry
uses: docker/login-action@v3
with:
registry: registry.odit.services
username: ${{ vars.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: |
${{ vars.REGISTRY }}/lfk/backend:${{ github.ref_name }}
platforms: linux/amd64,linux/arm64

6
.gitignore vendored
View File

@@ -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
@@ -135,4 +139,4 @@ build
/docs /docs
lib lib
/oss-attribution /oss-attribution
*.tmp *.tmp

View File

@@ -9,8 +9,7 @@
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features", "editor.defaultFormatter": "vscode.typescript-language-features",
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.organizeImports": true, "source.organizeImports": "explicit"
// "source.fixAll": true
} }
}, },
"javascript.preferences.quoteStyle": "single", "javascript.preferences.quoteStyle": "single",

282
AGENTS.md Normal file
View File

@@ -0,0 +1,282 @@
# AGENTS.md — LfK Backend
Guidance for agentic coding agents working in this repository.
---
## Project Overview
Express + [`routing-controllers`](https://github.com/typestack/routing-controllers) REST API written in TypeScript. Uses TypeORM for database access (SQLite in dev/test, PostgreSQL or MySQL in production). OpenAPI docs are auto-generated from decorators at startup.
**Runtime & Package Manager**: Bun (replaces Node.js + npm/pnpm).
---
## Build / Run / Test Commands
### Development
```sh
bun run dev # Start dev server with auto-reload (uses Bun's --watch)
```
**Auto-reload**: The `dev` script uses Bun's built-in `--watch` flag, which automatically restarts the server when TypeScript files in `src/` change. Bun runs TypeScript directly - no build step needed.
**Performance**: Bun delivers 8-15% better latency under concurrent load compared to Node.js. See `BUN_BENCHMARK_RESULTS.md` for details.
### Build
```sh
bun run build # rimraf dist && tsc && copy static assets → dist/
```
**Note**: The build script exists for legacy compatibility and type-checking, but is **not required** for development or production. Bun runs TypeScript source files directly.
### Production
```sh
bun start # bun src/app.ts (runs TypeScript directly)
```
### Tests
Tests are **integration tests** that hit a live running server via HTTP. The server must be started before Jest is invoked.
```sh
# Full CI test flow (generates .env, starts server, runs jest):
bun run test:ci
# Run Jest directly (server must already be running):
bun test
# Watch mode:
bun run test:watch
# Run a single test file:
bunx jest src/tests/runners/runner_add.spec.ts
# Run tests matching a name pattern:
bunx jest --testNamePattern="POST /api/runners"
# Run all tests in a subdirectory:
bunx jest src/tests/runners/
```
# Run all tests in a subdirectory:
bunx jest src/tests/runners/
```
> **Important:** `bun test` alone will fail unless the dev server is already running on `http://localhost:<config.internal_port>`. In CI, `start-server-and-test` handles this automatically via `bun run test:ci`.
### Other Utilities
```sh
bun run seed # Sync DB schema and run seeders
bun run openapi:export # Export OpenAPI spec to file
bun run docs # Generate TypeDoc documentation
bun run licenses:export # Export third-party license report
```
---
## TypeScript Configuration
- **Target:** ES2020, **Module:** CommonJS
- **`strict: false`** — TypeScript strictness is disabled; types are used but not exhaustively enforced
- **`experimentalDecorators: true`** and **`emitDecoratorMetadata: true`** — required by `routing-controllers`, `TypeORM`, and `class-validator`
- Spec files (`**/*.spec.ts`) are excluded from compilation
- Source root: `src/`, output: `dist/`
---
## Code Style Guidelines
### No Linter / Formatter Configured
There is no ESLint or Prettier configuration. Follow the patterns already established in the codebase rather than introducing new tooling.
### Imports
- Use named imports for decorator packages: `import { Get, JsonController, Param } from 'routing-controllers'`
- Use named imports for TypeORM: `import { Column, Entity, getConnectionManager } from 'typeorm'`
- Use named imports for class-validator: `import { IsInt, IsOptional, IsString } from 'class-validator'`
- Use `import * as X from 'module'` for modules without clean default exports (e.g., `import * as jwt from 'jsonwebtoken'`)
- Use default imports for simple modules (e.g., `import cookie from 'cookie'`)
- `reflect-metadata` is imported once at the top of `src/app.ts` — do not re-import it
- No barrel/index re-export files; import source files directly by path
### Naming Conventions
| Construct | Convention | Example |
|---|---|---|
| Classes | `PascalCase` | `RunnerController`, `CreateRunner` |
| Files | `PascalCase.ts` matching class name | `RunnerController.ts` |
| Local variables | `camelCase` (some `snake_case` in tests) | `accessToken`, `access_token` |
| DB entity fields | `snake_case` preferred | `created_at`, `updated_at` |
| Controller methods | REST-conventional | `getAll`, `getOne`, `post`, `put`, `remove` |
| Custom errors | `{Entity}{Issue}Error` | `RunnerNotFoundError`, `RunnerIdsNotMatchingError` |
| Response DTOs | `Response{Entity}` | `ResponseRunner`, `ResponseAuth` |
| Create DTOs | `Create{Entity}` | `CreateRunner` |
| Update DTOs | `Update{Entity}` | `UpdateRunner` |
| Enums | `PascalCase` | `ResponseObjectType`, `PermissionAction` |
### Formatting
- 4-space indentation (observed throughout the codebase)
- Single quotes for string literals in most files
- No trailing semicolons style inconsistency — follow what's already in the file you're editing
### Types
- Add TypeScript types to all function parameters and return values
- Use `class-validator` decorators (`@IsString`, `@IsInt`, `@IsOptional`, `@IsUUID`, etc.) on every DTO and response class field — these drive both runtime validation and OpenAPI schema generation
- Use abstract classes for shared entity base types (e.g., `abstract class Participant`)
- Use interfaces for response contracts (e.g., `interface IResponse`)
- Use enums for typed string/number constants
- Avoid `any` where possible; when unavoidable, keep it localised
- `strict` is off — but still annotate types explicitly rather than relying on inference
### Controller Pattern
```typescript
import { Authorized, Body, Delete, Get, JsonController, Param, Post, Put } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
@JsonController('/runners')
@Authorized()
export class RunnerController {
@Get('/')
@OpenAPI({ description: 'Returns all runners' })
@ResponseSchema(ResponseRunner, { isArray: true })
async getAll() { ... }
@Get('/:id')
@ResponseSchema(ResponseRunner)
async getOne(@Param('id') id: number) { ... }
@Post('/')
@ResponseSchema(ResponseRunner)
async post(@Body({ validate: true }) createRunner: CreateRunner) { ... }
@Put('/:id')
@ResponseSchema(ResponseRunner)
async put(@Param('id') id: number, @Body({ validate: true }) updateRunner: UpdateRunner) { ... }
@Delete('/:id')
@ResponseSchema(ResponseRunner)
async remove(@Param('id') id: number) { ... }
}
```
### Error Handling
- Define custom error classes in `src/errors/` extending `routing-controllers` error types (`NotFoundError`, `NotAcceptableError`, etc.)
- Every custom error class must include `@IsString()` decorated `name` and `message` fields for OpenAPI schema generation
- Throw custom errors directly in controllers: `throw new RunnerNotFoundError()`
- Use try/catch in controllers and re-throw meaningful errors; do not swallow errors silently
- The global `ErrorHandler` middleware (registered in `src/middlewares/`) catches all unhandled errors and serialises them as JSON — do not duplicate this logic in controllers
- Auth errors are thrown from `src/middlewares/authchecker.ts`, not from individual controllers
### Entity Pattern (TypeORM)
- Entities live in `src/models/entities/`
- Decorate every entity with `@Entity()` and every column with the appropriate `@Column`, `@PrimaryGeneratedColumn`, etc.
- Use `@CreateDateColumn()` / `@UpdateDateColumn()` for timestamp fields
- Use table inheritance (`@TableInheritance` + `@ChildEntity`) for polymorphic entities
- Access repositories via `getConnectionManager().get().getRepository(EntityClass)` — do not inject repositories as constructor dependencies
- Database schema is synchronised automatically on startup (`connection.synchronize()`) — no manual migration files
### DTO Pattern (Create / Update)
- Create DTOs in `src/models/actions/create/` and `src/models/actions/update/`
- Use `class-validator` decorators for every field
- `@IsOptional()` for fields that are not required on update; all fields on create DTOs should be mandatory unless explicitly optional in the API contract
- Response DTOs live in `src/models/responses/` and follow the `Response{Entity}` naming pattern
---
## Test Style Guidelines
> **IMPORTANT: Do not run existing tests and do not create new tests.** The existing test suite in `src/tests/` is outdated and no longer reflects the current state of the codebase. Ignore all test files when working in this repository. Do not write new tests for any changes or additions.
All tests are integration tests in `src/tests/` organised by domain entity:
```
src/tests/
auth/
auth_login.spec.ts
auth_refresh.spec.ts
runners/
runner_add.spec.ts
runner_get.spec.ts
runner_update.spec.ts
runner_delete.spec.ts
...
```
### Test File Template
```typescript
import axios from 'axios';
import { config } from '../../config';
const base = "http://localhost:" + config.internal_port;
let access_token: string;
let axios_config: object;
beforeAll(async () => {
jest.setTimeout(20000);
const res = await axios.post(base + '/api/auth/login', { username: "demo", password: "demo" });
access_token = res.data["access_token"];
axios_config = {
headers: { "authorization": "Bearer " + access_token },
validateStatus: undefined // prevents axios from throwing on non-2xx responses
};
});
describe('POST /api/runners working', () => {
it('creating a runner with required params should return 200', async () => {
const res = await axios.post(base + '/api/runners', { ... }, axios_config);
expect(res.status).toEqual(200);
expect(res.headers['content-type']).toContain("application/json");
});
});
describe('POST /api/runners failing', () => {
it('creating a runner without required params should return 400', async () => {
const res = await axios.post(base + '/api/runners', {}, axios_config);
expect(res.status).toEqual(400);
});
});
```
- Always set `validateStatus: undefined` in `axios_config` to prevent axios throwing on error responses
- Group tests by HTTP verb + route in `describe()` blocks; separate "working" and "failing" cases
- Use `jest.setTimeout(20000)` in `beforeAll` for slow integration tests
- Assert both `res.status` and `res.headers['content-type']` on success paths
---
## Environment Configuration
- Copy `.env.example` to `.env` and fill in values before running locally
- Database type is set via `DB_TYPE` env var (`sqlite`, `postgres`, or `mysql`)
- Server port is set via `INTERNAL_PORT` (accessed as `config.internal_port` in code)
- All config values are validated at startup in `src/config.ts`
- CI env is generated by `bun run test:ci:generate_env` (`scripts/create_testenv.ts`)
### NATS Configuration
The backend uses **NATS JetStream** as a KV cache for scan intake performance optimization.
- `NATS_URL` — connection URL for NATS server (default: `nats://localhost:4222`)
- `NATS_PREWARM` — if `true`, preloads all runner state into the KV cache at startup to eliminate DB reads from the first scan onward (default: `false`)
**KV buckets** (auto-created by `NatsClient` at startup):
- `station_state` — station token cache (1-hour TTL)
- `card_state` — card→runner mapping cache (1-hour TTL)
- `runner_state` — runner display name, total distance, latest scan timestamp (no TTL, CAS-based updates)
**Development**: NATS runs in Docker via `docker-compose.yml` (port 4222). The JetStream volume is persisted to `./nats-data/` to survive container restarts.
**Station intake hot path**: `POST /api/scans/trackscans` from scan stations uses a KV-first flow that eliminates DB reads on cache hits and prevents race conditions via compare-and-swap (CAS) updates. See `SCAN_NATS_PLAN.md` for full architecture details.

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,23 @@
# Typescript Build # Build stage - install dependencies
FROM node:14.15.1-alpine3.12 FROM registry.odit.services/hub/oven/bun:1.3.9-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json ./
RUN npm i -g pnpm COPY package.json bun.lockb* ./
RUN pnpm i RUN bun install --frozen-lockfile
COPY tsconfig.json ormconfig.js ./
# Production dependencies only
RUN rm -rf /app/node_modules \
&& bun install --production --frozen-lockfile
# Final image - run TypeScript directly
FROM registry.odit.services/hub/oven/bun:1.3.9-alpine AS final
WORKDIR /app
COPY --from=build /app/package.json /app/package.json
COPY --from=build /app/bun.lockb* /app/
COPY --from=build /app/node_modules /app/node_modules
COPY ormconfig.js bunfig.toml tsconfig.json ./
COPY src ./src COPY src ./src
RUN pnpm run build
# final image ENTRYPOINT ["bun", "/app/src/app.ts"]
FROM node:14.15.1-alpine3.12
COPY package.json ormconfig.js ./
RUN npm i -g pnpm
RUN pnpm i --prod
COPY --from=0 /app/dist dist
ENTRYPOINT ["node", "dist/app.js"]

123
README.md
View File

@@ -2,44 +2,121 @@
Backend Server Backend Server
## Quickstart 🐳 ## Prerequisites
> Use this to run the backend with a postgresql db in docker
1. Clone the repo or copy the docker-compose This project uses **Bun** as the runtime and package manager. Install Bun first:
2. Run in toe folder that contains the docker-compose file: `docker-compose up -d`
```bash
# macOS/Linux
curl -fsSL https://bun.sh/install | bash
# Windows
powershell -c "irm bun.sh/install.ps1 | iex"
```
Or visit [bun.sh](https://bun.sh) for other installation methods.
## Quickstart 🐳
> Use this to run the backend with a PostgreSQL db in Docker
1. Clone the repo or copy the docker-compose
2. Run in the folder that contains the docker-compose file: `docker-compose up -d`
3. Visit http://127.0.0.1:4010/api/docs to check if the server is running 3. Visit http://127.0.0.1:4010/api/docs to check if the server is running
4. You can now use the default admin user (`demo:demo`) 4. You can now use the default admin user (`demo:demo`)
## Dev Setup 🛠 ## Dev Setup 🛠
> Local dev setup utilizing sqlite3 as the database. > Local dev setup utilizing SQLite3 as the database and NATS for caching.
1. Rename the .env.example file to .env (you can adjust app port and other settings, if needed) 1. Rename the `.env.example` file to `.env` (you can adjust app port and other settings if needed)
2. Install Dependencies 2. Start NATS (required for KV cache):
```bash ```bash
yarn docker-compose up -d nats
``` ```
3. Start the server 3. Install dependencies:
```bash ```bash
yarn dev bun install
``` ```
4. Start the server:
```bash
bun run dev
```
**Note**: Bun cannot run TypeScript source files directly due to circular TypeORM dependencies. The `dev` script automatically builds and runs the compiled output. For hot-reload during development, you may need to rebuild manually after code changes.
### Run Tests ### Run Tests
```bash ```bash
# Run tests once (server has to run) # Run tests once (server has to be running)
yarn test bun test
# Run test in watch mode (reruns on change) # Run test in watch mode (reruns on change)
yarn test:watch bun run test:watch
# Run test in ci mode (automaticly starts the dev server) # Run test in CI mode (automatically starts the dev server)
yarn test:ci bun run test:ci
```
### Run Benchmarks
```bash
# Start the server first
bun run dev
# In another terminal:
bun run benchmark
``` ```
### Generate Docs ### Generate Docs
```bash ```bash
yarn docs bun run docs
``` ```
### Other Commands
```bash
# Build for production
bun run build
# Start production server
bun start
# Seed database with test data
bun run seed
# Export OpenAPI spec
bun run openapi:export
# Generate license report
bun run licenses:export
# Generate changelog
bun run changelog:export
```
## ENV Vars
> You can provide them via .env file or docker env vars.
> You can use the `test:ci:generate_env` package script to generate an example env (uses placeholder data for test server and ignores the errors).
| Name | Type | Default | Description |
| ------------------------- | ------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| APP_PORT | Number | 4010 | The port the backend server listens on. Is optional. |
| DB_TYPE | String | N/A | The type of the db you want to use. Supported by TypeORM. Possible: `sqlite`, `mysql`, `postgresql` |
| DB_HOST | String | N/A | The db's host IP address/FQDN or file path for sqlite |
| DB_PORT | String | N/A | The db's port |
| DB_USER | String | N/A | The user for accessing the db |
| DB_PASSWORD | String | N/A | The user's password for accessing the db |
| DB_NAME | String | N/A | The db's name |
| NODE_ENV | String | dev | The app's env - influences debug info. When set to "test", mailing errors get ignored. |
| POSTALCODE_COUNTRYCODE | String/CountryCode | N/A | The country code used to validate address postal codes |
| PHONE_COUNTRYCODE | String/CountryCode | null (international) | The country code used to validate phone numbers |
| SEED_TEST_DATA | Boolean | false | If you want the app to seed example data, set this to true |
| STATION_TOKEN_SECRET | String | N/A | Secret key for HMAC-SHA256 station token generation (min 32 chars). **Required.** |
| NATS_URL | String(URL) | nats://localhost:4222 | NATS server connection URL for KV cache |
| NATS_PREWARM | Boolean | false | Preload all runner state into NATS cache at startup (eliminates DB reads on first scan) |
| MAILER_URL | String(URL) | N/A | The mailer's base URL (no trailing slash) |
| MAILER_KEY | String | N/A | The mailer's API key |
| SELFSERVICE_URL | String(URL) | N/A | The link to selfservice (no trailing slash) |
| IMPRINT_URL | String(URL) | /imprint | The link to an imprint page for the system (defaults to the frontend's imprint) |
| PRIVACY_URL | String(URL) | /privacy | The link to a privacy page for the system (defaults to the frontend's privacy page) |
## Recommended Editor ## Recommended Editor
[Visual Studio Code](https://code.visualstudio.com/) [Visual Studio Code](https://code.visualstudio.com/)
@@ -55,10 +132,10 @@ yarn docs
* A new release tag automaticly triggers the release ci pipeline * A new release tag automaticly triggers the release ci pipeline
* main: Protected "release" branch * main: Protected "release" branch
* The latest tag of the docker image get's build from this * The latest tag of the docker image get's build from this
* New releases get created as tags from this
* dev: Current dev branch for merging the different feature branches and bugfixes * dev: Current dev branch for merging the different feature branches and bugfixes
* New releases get created as tags from this
* The dev tag of the docker image get's build from this * The dev tag of the docker image get's build from this
* Only push minor changes to this branch! * Only push minor changes to this branch!
* To merge a feature branch into this please create a pull request * To merge a feature branch into this please create a pull request
* feature/xyz: Feature branches - nameing scheme: `feature/issueid-title` * feature/xyz: Feature branches - naming scheme: `feature/issueid-title`
* bugfix/xyz: Branches for bugfixes - nameing scheme:`bugfix/issueid-title` * bugfix/xyz: Branches for bugfixes - naming scheme:`bugfix/issueid-title`

2933
bun.lock Normal file

File diff suppressed because it is too large Load Diff

13
bunfig.toml Normal file
View File

@@ -0,0 +1,13 @@
# Bun configuration
# See: https://bun.sh/docs/runtime/bunfig
[runtime]
# Enable Node.js compatibility mode
bun = true
# TypeScript transpiler settings
# Required for TypeORM decorators
[transpiler]
tsconfig = "tsconfig.json"
emitDecoratorMetadata = true
experimentalDecorators = true

View File

@@ -1,18 +1,30 @@
version: "3"
services: services:
backend_server: nats:
build: . image: mirror.gcr.io/library/nats:alpine
command: ["--jetstream", "--store_dir", "/data"]
ports: ports:
- 4010:4010 - "4222:4222"
environment: - "8222:8222"
APP_PORT: 4010 volumes:
DB_TYPE: sqlite - nats_data:/data
DB_HOST: bla
DB_PORT: bla # backend_server:
DB_USER: bla # build: .
DB_PASSWORD: bla # ports:
DB_NAME: dev.sqlite # - 4010:4010
NODE_ENV: production # environment:
# APP_PORT: 4010
# DB_TYPE: sqlite
# DB_HOST: bla
# 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
@@ -29,3 +41,6 @@ services:
# POSTGRES_USER: lfk # POSTGRES_USER: lfk
# ports: # ports:
# - 5432:5432 # - 5432:5432
volumes:
nats_data:

View File

@@ -1,12 +1,12 @@
# argon2 # @odit/class-validator-jsonschema
**Author**: Ranieri Althoff <ranisalt+argon2@gmail.com> **Author**: Aleksi Pekkala <aleksipekkala@gmail.com>
**Repo**: [object Object] **Repo**: git@github.com:epiphone/class-validator-jsonschema.git
**License**: MIT **License**: MIT
**Description**: An Argon2 library for Node **Description**: Convert class-validator-decorated classes into JSON schema
## License Text ## License Text
The MIT License (MIT) MIT License
Copyright (c) 2015 Ranieri Althoff Copyright (c) 2017 Aleksi Pekkala
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -25,7 +25,33 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
# axios
**Author**: Matt Zabriskie
**Repo**: [object Object]
**License**: MIT
**Description**: Promise based HTTP client for the browser and node.js
## License Text
Copyright (c) 2014-present Matt Zabriskie
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.
# body-parser # body-parser
@@ -59,6 +85,35 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# check-password-strength
**Author**: deanilvincent
**Repo**: [object Object]
**License**: MIT
**Description**: A NPM Password strength checker based from Javascript RegExp. Check passphrase if it's "Weak", "Medium" or "Strong"
## License Text
MIT License
Copyright (c) 2020 Mark Deanil Vicente
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.
# class-transformer # class-transformer
**Author**: [object Object] **Author**: [object Object]
**Repo**: [object Object] **Repo**: [object Object]
@@ -88,22 +143,15 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
# class-validator # class-validator
**Author**: [object Object] **Author**: TypeStack contributors
**Repo**: [object Object] **Repo**: [object Object]
**License**: MIT **License**: MIT
**Description**: Class-based validation with Typescript / ES6 / ES5 using decorators or validation schemas. Supports both node.js and browser **Description**: Decorator-based property validation for classes.
## License Text ## License Text
# class-validator-jsonschema The MIT License
**Author**: Aleksi Pekkala <aleksipekkala@gmail.com>
**Repo**: git@github.com:epiphone/class-validator-jsonschema.git
**License**: MIT
**Description**: Convert class-validator-decorated classes into JSON schema
## License Text
MIT License
Copyright (c) 2017 Aleksi Pekkala Copyright (c) 2015-2020 TypeStack
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -112,17 +160,16 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions: furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The above copyright notice and this permission notice shall be included in
copies or substantial portions of the Software. all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
SOFTWARE. THE SOFTWARE.
# consola # consola
**Author**: undefined **Author**: undefined
@@ -240,37 +287,6 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# dotenv
**Author**: undefined
**Repo**: [object Object]
**License**: BSD-2-Clause
**Description**: Loads environment variables from .env file
## License Text
Copyright (c) 2015, Scott Motte
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
# express # express
**Author**: TJ Holowaychuk <tj@vision-media.ca> **Author**: TJ Holowaychuk <tj@vision-media.ca>
**Repo**: expressjs/express **Repo**: expressjs/express
@@ -332,12 +348,269 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
# libphonenumber-js
**Author**: catamphetamine <purecatamphetamine@gmail.com>
**Repo**: [object Object]
**License**: MIT
**Description**: A simpler (and smaller) rewrite of Google Android's libphonenumber library in javascript
## License Text
(The MIT License)
Copyright (c) 2016 @catamphetamine <purecatamphetamine@gmail.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.
# mysql # mysql
**Author**: Felix Geisendörfer <felix@debuggable.com> (http://debuggable.com/) **Author**: Felix Geisendörfer <felix@debuggable.com> (http://debuggable.com/)
**Repo**: mysqljs/mysql **Repo**: mysqljs/mysql
**License**: MIT **License**: MIT
**Description**: A node.js driver for mysql. It is written in JavaScript, does not require compiling, and is 100% MIT licensed. **Description**: A node.js driver for mysql. It is written in JavaScript, does not require compiling, and is 100% MIT licensed.
## License Text ## License Text
Copyright (c) 2012 Felix Geisendörfer (felix@debuggable.com) and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
# 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
@@ -545,23 +818,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
# uuid
**Author**: undefined
**Repo**: [object Object]
**License**: MIT
**Description**: RFC4122 (v1, v4, and v5) UUIDs
## License Text
The MIT License (MIT)
Copyright (c) 2010-2020 Robert Kieffer and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# validator # validator
**Author**: Chris O'Hara <cohara87@gmail.com> **Author**: Chris O'Hara <cohara87@gmail.com>
**Repo**: [object Object] **Repo**: [object Object]
@@ -590,11 +846,80 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# @faker-js/faker
**Author**: undefined
**Repo**: [object Object]
**License**: MIT
**Description**: Generate massive amounts of fake contextual data
## License Text
Faker - Copyright (c) 2022
This software consists of voluntary contributions made by many individuals.
For exact contribution history, see the revision history
available at https://github.com/faker-js/faker
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.
===
From: https://github.com/faker-js/faker/commit/a9f98046c7d5eeaabe12fc587024c06d683800b8
To: https://github.com/faker-js/faker/commit/29234378807c4141588861f69421bf20b5ac635e
Based on faker.js, copyright Marak Squires and contributor, what follows below is the original license.
===
faker.js - Copyright (c) 2020
Marak Squires
http://github.com/marak/faker.js/
faker.js was inspired by and has used data definitions from:
* https://github.com/stympy/faker/ - Copyright (c) 2007-2010 Benjamin Curtis
* http://search.cpan.org/~jasonk/Data-Faker-0.07/ - Copyright 2004-2005 by Jason Kohles
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# @odit/license-exporter # @odit/license-exporter
**Author**: ODIT.Services **Author**: ODIT.Services
**Repo**: [object Object] **Repo**: [object Object]
**License**: MIT **License**: MIT
**Description**: A simple license crawler **Description**: A simple license crawler for crediting open source work
## License Text ## License Text
MIT License Copyright (c) 2020 ODIT.Services (info@odit.services) MIT License Copyright (c) 2020 ODIT.Services (info@odit.services)
@@ -617,6 +942,35 @@ 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. OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# @types/bun
**Author**: undefined
**Repo**: [object Object]
**License**: MIT
**Description**: TypeScript definitions for bun
## 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/cors # @types/cors
**Author**: undefined **Author**: undefined
**Repo**: [object Object] **Repo**: [object Object]
@@ -679,7 +1033,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
@@ -708,7 +1062,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
@@ -766,7 +1120,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 ## License Text
MIT License MIT License
@@ -791,42 +1145,15 @@ OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
SOFTWARE SOFTWARE
# @types/uuid # auto-changelog
**Author**: undefined **Author**: Pete Cook <pete@cookpete.com> (https://github.com/cookpete)
**Repo**: [object Object] **Repo**: [object Object]
**License**: MIT **License**: MIT
**Description**: TypeScript definitions for uuid **Description**: Command line tool for generating a changelog from git tags and commit history
## License Text ## License Text
MIT License The MIT License
Copyright (c) Microsoft Corporation. Copyright (c) 2017 Pete Cook https://cookpete.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
# axios
**Author**: Matt Zabriskie
**Repo**: [object Object]
**License**: MIT
**Description**: Promise based HTTP client for the browser and node.js
## License Text
Copyright (c) 2014-present Matt Zabriskie
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -905,15 +1232,15 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
# nodemon # release-it
**Author**: [object Object] **Author**: [object Object]
**Repo**: [object Object] **Repo**: [object Object]
**License**: MIT **License**: MIT
**Description**: Simple monitor script for use during development of a node.js app. **Description**: Generic CLI tool to automate versioning and package publishing related tasks.
## License Text ## License Text
MIT License MIT License
Copyright (c) 2010 - present, Remy Sharp, https://remysharp.com <remy@remysharp.com> Copyright (c) 2018 Lars Kappert
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -936,25 +1263,65 @@ SOFTWARE.
# rimraf # rimraf
**Author**: Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me/) **Author**: Isaac Z. Schlueter <i@izs.me> (http://blog.izs.me/)
**Repo**: git://github.com/isaacs/rimraf.git **Repo**: git@github.com:isaacs/rimraf.git
**License**: ISC **License**: BlueOak-1.0.0
**Description**: A deep deletion module for node (like `rm -rf`) **Description**: A deep deletion module for node (like `rm -rf`)
## License Text ## License Text
The ISC License # Blue Oak Model License
Copyright (c) Isaac Z. Schlueter and Contributors Version 1.0.0
Permission to use, copy, modify, and/or distribute this software for any ## Purpose
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES This license gives everyone as much permission to work with
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF this software as possible, while protecting contributors
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR from liability.
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ## Acceptance
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. In order to receive this license, you must agree to its
rules. The rules of this license are both obligations
under that agreement and conditions to your license.
You must not do anything with this software that triggers
a rule that you cannot or will not follow.
## Copyright
Each contributor licenses you to do everything with this
software that would otherwise infringe that contributor's
copyright in it.
## Notices
You must ensure that everyone who gets a copy of
any part of this software from you, with or without
changes, also gets the text of this license or a link to
<https://blueoakcouncil.org/license/1.0.0>.
## Excuse
If anyone notifies you in writing that you have not
complied with [Notices](#notices), you can keep your
license by taking all practical steps to comply within 30
days after the notice. If you do not do so, your license
ends immediately.
## Patent
Each contributor licenses you to do everything with this
software that would otherwise infringe any patent claims
they can license or become able to license.
## Reliability
No contributor can revoke this license.
## No Liability
***As far as the law allows, this software comes as is,
without any warranty or condition, and no contributor
will be liable to anyone for any damages related to this
software or this license, under any kind of legal claim.***
# start-server-and-test # start-server-and-test
@@ -994,35 +1361,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]

View File

@@ -1,7 +1,3 @@
const dotenv = require('dotenv');
dotenv.config();
//
const SOURCE_PATH = process.env.NODE_ENV === 'production' ? 'dist' : 'src';
module.exports = { module.exports = {
type: process.env.DB_TYPE, type: process.env.DB_TYPE,
host: process.env.DB_HOST, host: process.env.DB_HOST,
@@ -9,8 +5,7 @@ module.exports = {
username: process.env.DB_USER, username: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, database: process.env.DB_NAME,
// entities: ["src/**/entities/*.ts"], // Run directly from TypeScript source (Bun workflow)
entities: [ `${SOURCE_PATH}/**/entities/*{.ts,.js}` ], entities: ["src/models/entities/**/*.ts"],
seeds: [ `${SOURCE_PATH}/**/seeds/*{.ts,.js}` ] seeds: ["src/seeds/**/*.ts"]
// seeds: ['src/seeds/*.ts'],
}; };

View File

@@ -1,103 +1,105 @@
{ {
"name": "@odit/lfk-backend", "name": "@odit/lfk-backend",
"version": "0.3.0", "version": "1.8.3",
"main": "src/app.ts", "main": "src/app.ts",
"repository": "https://git.odit.services/lfk/backend", "repository": "https://git.odit.services/lfk/backend",
"author": { "author": {
"name": "ODIT.Services", "name": "ODIT.Services",
"email": "info@odit.services", "email": "info@odit.services",
"url": "https://odit.services" "url": "https://odit.services"
}, },
"contributors": [ "contributors": [
{ {
"name": "Philipp Dormann", "name": "Philipp Dormann",
"email": "philipp@philippdormann.de", "email": "philipp@philippdormann.de",
"url": "https://philippdormann.de" "url": "https://philippdormann.de"
}, },
{ {
"name": "Nicolai Ort", "name": "Nicolai Ort",
"email": "info@nicolai-ort.com", "email": "info@nicolai-ort.com",
"url": "https://nicolai-ort.com" "url": "https://nicolai-ort.com"
} }
], ],
"license": "CC-BY-NC-SA-4.0", "license": "CC-BY-NC-SA-4.0",
"dependencies": { "dependencies": {
"@odit/class-validator-jsonschema": "2.1.1", "@odit/class-validator-jsonschema": "2.1.1",
"argon2": "^0.27.1", "axios": "0.21.1",
"body-parser": "^1.19.0", "body-parser": "1.19.0",
"class-transformer": "0.3.1", "check-password-strength": "2.0.2",
"class-validator": "^0.13.1", "class-transformer": "0.3.1",
"consola": "^2.15.0", "class-validator": "0.13.0",
"cookie": "^0.4.1", "consola": "2.15.0",
"cookie-parser": "^1.4.5", "cookie": "0.4.1",
"cors": "^2.8.5", "cookie-parser": "1.4.5",
"csvtojson": "^2.0.10", "cors": "2.8.5",
"dotenv": "^8.2.0", "csvtojson": "2.0.10",
"express": "^4.17.1", "express": "4.17.1",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "8.5.1",
"libphonenumber-js": "^1.9.7", "libphonenumber-js": "1.9.9",
"mysql": "^2.18.1", "mysql": "2.18.1",
"pg": "^8.5.1", "nats": "^2.29.3",
"reflect-metadata": "^0.1.13", "pg": "8.5.1",
"routing-controllers": "^0.9.0-alpha.6", "reflect-metadata": "0.1.13",
"routing-controllers-openapi": "^2.2.0", "routing-controllers": "0.9.0-alpha.6",
"sqlite3": "5.0.0", "routing-controllers-openapi": "2.2.0",
"typeorm": "^0.2.29", "sqlite3": "5.1.7",
"typeorm-routing-controllers-extensions": "^0.2.0", "typeorm": "0.2.30",
"typeorm-seeding": "^1.6.1", "typeorm-routing-controllers-extensions": "0.2.0",
"uuid": "^8.3.2", "typeorm-seeding": "1.6.1",
"validator": "^13.5.2" "validator": "13.5.2"
}, },
"devDependencies": { "devDependencies": {
"@odit/license-exporter": "^0.0.9", "@faker-js/faker": "7.6.0",
"@types/cors": "^2.8.9", "@odit/license-exporter": "0.0.9",
"@types/csvtojson": "^1.1.5", "@types/bun": "^1.3.9",
"@types/express": "^4.17.9", "@types/cors": "2.8.19",
"@types/jest": "^26.0.16", "@types/csvtojson": "1.1.5",
"@types/jsonwebtoken": "^8.5.0", "@types/express": "5.0.6",
"@types/node": "^14.14.20", "@types/jest": "30.0.0",
"@types/uuid": "^8.3.0", "@types/jsonwebtoken": "9.0.10",
"axios": "^0.21.1", "@types/node": "25.3.0",
"cp-cli": "^2.0.0", "auto-changelog": "2.4.0",
"jest": "^26.6.3", "cp-cli": "2.0.0",
"nodemon": "^2.0.7", "jest": "26.6.3",
"release-it": "^14.2.2", "release-it": "14.2.2",
"rimraf": "^3.0.2", "rimraf": "^6.1.3",
"start-server-and-test": "^1.11.7", "start-server-and-test": "1.11.7",
"ts-jest": "^26.4.4", "ts-jest": "26.5.0",
"ts-node": "^9.1.1", "typedoc": "0.20.19",
"typedoc": "^0.20.14", "typescript": "5.9.3"
"typescript": "^4.1.3" },
}, "scripts": {
"scripts": { "dev": "bun --watch src/app.ts",
"dev": "nodemon src/app.ts", "start": "bun src/app.ts",
"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": "bun scripts/create_testenv.ts",
"test:ci": "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",
"seed": "ts-node ./node_modules/typeorm/cli.js schema:sync && ts-node ./node_modules/typeorm-seeding/dist/cli.js seed", "test:ci": "bun run test:ci:generate_env && bun run test:ci:run",
"openapi:export": "ts-node scripts/openapi_export.ts", "benchmark": "bun scripts/benchmark_scan_intake.ts",
"licenses:export": "license-exporter --md", "seed": "bun ./node_modules/typeorm/cli.js schema:sync && bun ./node_modules/typeorm-seeding/dist/cli.js seed",
"release": "release-it --only-version" "openapi:export": "bun scripts/openapi_export.ts",
}, "licenses:export": "license-exporter --markdown",
"release-it": { "changelog:export": "auto-changelog --commit-limit false -p -u --hide-credit",
"git": { "release": "release-it --only-version"
"commit": true, },
"requireCleanWorkingDir": false, "release-it": {
"commitMessage": "🚀Bumped version to v${version}", "git": {
"requireBranch": "dev", "commit": true,
"push": false, "requireCleanWorkingDir": false,
"tag": false "commitMessage": "chore(release): ${version}",
}, "requireBranch": "dev",
"npm": { "push": true,
"publish": false "tag": true,
} "tagName": "${version}",
}, "tagAnnotation": "${version}"
"nodemonConfig": { },
"ignore": [ "npm": {
"src/tests/*", "publish": false
"docs/*" },
] "hooks": {
} "after:bump": "bun run changelog:export && bun run licenses:export && git add CHANGELOG.md && git add licenses.md"
} }
}
}

9427
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- sqlite3

View 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);
});

24
scripts/create_testenv.ts Normal file
View File

@@ -0,0 +1,24 @@
import consola from "consola";
import fs from "fs";
const env = `
APP_PORT=4010
DB_TYPE=sqlite
DB_HOST=bla
DB_PORT=bla
DB_USER=bla
DB_PASSWORD=bla
DB_NAME=./test.sqlite
NODE_ENV=test
POSTALCODE_COUNTRYCODE=DE
SEED_TEST_DATA=true
MAILER_URL=https://dev.lauf-fuer-kaya.de/mailer
MAILER_KEY=asdasd`;
try {
fs.writeFileSync("./.env", env, { encoding: "utf-8" });
consola.success("Exported ci env to .env");
} catch (error) {
consola.error("Couldn't export the ci env");
}

View File

@@ -42,7 +42,7 @@ export function generateSpec(storage: MetadataArgsStorage, schemas) {
} }
}, },
info: { info: {
description: "The the backend API for the LfK! runner system.", description: `The the backend API for the LfK! runner system. <br>[Imprint](${config.imprint_url}) & [Privacy](${config.privacy_url})`,
title: "LfK! Backend API", title: "LfK! Backend API",
version: config.version version: config.version
}, },

View File

@@ -7,7 +7,28 @@ import authchecker from "./middlewares/authchecker";
import { ErrorHandler } from './middlewares/ErrorHandler'; import { ErrorHandler } from './middlewares/ErrorHandler';
import UserChecker from './middlewares/UserChecker'; import UserChecker from './middlewares/UserChecker';
const CONTROLLERS_FILE_EXTENSION = process.env.NODE_ENV === 'production' ? 'js' : 'ts'; // Import all controllers directly to avoid Bun + routing-controllers glob/require issues
import { AuthController } from './controllers/AuthController';
import { DonationController } from './controllers/DonationController';
import { DonorController } from './controllers/DonorController';
import { GroupContactController } from './controllers/GroupContactController';
import { ImportController } from './controllers/ImportController';
import { MeController } from './controllers/MeController';
import { PermissionController } from './controllers/PermissionController';
import { RunnerCardController } from './controllers/RunnerCardController';
import { RunnerController } from './controllers/RunnerController';
import { RunnerOrganizationController } from './controllers/RunnerOrganizationController';
import { RunnerSelfServiceController } from './controllers/RunnerSelfServiceController';
import { RunnerTeamController } from './controllers/RunnerTeamController';
import { ScanController } from './controllers/ScanController';
import { ScanStationController } from './controllers/ScanStationController';
import { StatsClientController } from './controllers/StatsClientController';
import { StatsController } from './controllers/StatsController';
import { StatusController } from './controllers/StatusController';
import { TrackController } from './controllers/TrackController';
import { UserController } from './controllers/UserController';
import { UserGroupController } from './controllers/UserGroupController';
const app = createExpressServer({ const app = createExpressServer({
authorizationChecker: authchecker, authorizationChecker: authchecker,
currentUserChecker: UserChecker, currentUserChecker: UserChecker,
@@ -15,11 +36,35 @@ const app = createExpressServer({
development: config.development, development: config.development,
cors: true, cors: true,
routePrefix: "/api", routePrefix: "/api",
controllers: [`${__dirname}/controllers/*.${CONTROLLERS_FILE_EXTENSION}`], controllers: [
AuthController,
DonationController,
DonorController,
GroupContactController,
ImportController,
MeController,
PermissionController,
RunnerCardController,
RunnerController,
RunnerOrganizationController,
RunnerSelfServiceController,
RunnerTeamController,
ScanController,
ScanStationController,
StatsClientController,
StatsController,
StatusController,
TrackController,
UserController,
UserGroupController,
],
}); });
async function main() { async function main() {
await loaders(app); await loaders(app);
if (config.testing) {
consola.info("🛠[config]: Discovered testing env. Mailing errors will get ignored!")
}
app.listen(config.internal_port, () => { app.listen(config.internal_port, () => {
consola.success( consola.success(
`⚡️[server]: Server is running at http://localhost:${config.internal_port}` `⚡️[server]: Server is running at http://localhost:${config.internal_port}`

View File

@@ -1,41 +1,59 @@
import { config as configDotenv } from 'dotenv'; import consola from 'consola';
import { CountryCode } from 'libphonenumber-js'; import { CountryCode } from 'libphonenumber-js';
import ValidatorJS from 'validator'; import ValidatorJS from 'validator';
configDotenv(); export const config = {
export const config = { internal_port: parseInt(process.env.APP_PORT) || 4010,
internal_port: parseInt(process.env.APP_PORT) || 4010, development: process.env.NODE_ENV === "production",
development: process.env.NODE_ENV === "production", testing: process.env.NODE_ENV === "test",
jwt_secret: process.env.JWT_SECRET || "secretjwtsecret", jwt_secret: process.env.JWT_SECRET || "secretjwtsecret",
phone_validation_countrycode: getPhoneCodeLocale(), station_token_secret: process.env.STATION_TOKEN_SECRET || "",
postalcode_validation_countrycode: getPostalCodeLocale(), nats_url: process.env.NATS_URL || "nats://localhost:4222",
version: process.env.VERSION || require('../package.json').version, nats_prewarm: process.env.NATS_PREWARM === "true",
seedTestData: getDataSeeding() phone_validation_countrycode: getPhoneCodeLocale(),
} postalcode_validation_countrycode: getPostalCodeLocale(),
let errors = 0 version: process.env.VERSION || require('../package.json').version,
if (typeof config.internal_port !== "number") { seedTestData: getDataSeeding(),
errors++ app_url: process.env.APP_URL || "http://localhost:8080",
} privacy_url: process.env.PRIVACY_URL || "/privacy",
if (typeof config.development !== "boolean") { imprint_url: process.env.IMPRINT_URL || "/imprint",
errors++ mailer_url: process.env.MAILER_URL || "",
} mailer_key: process.env.MAILER_KEY || ""
function getPhoneCodeLocale(): CountryCode { }
return (process.env.PHONE_COUNTRYCODE as CountryCode); let errors = 0
} if (typeof config.internal_port !== "number") {
function getPostalCodeLocale(): any { consola.error("Error: APP_PORT is not a number")
try { errors++
const stringArray: String[] = ValidatorJS.isPostalCodeLocales; }
let index = stringArray.indexOf(process.env.POSTALCODE_COUNTRYCODE); if (typeof config.development !== "boolean") {
return ValidatorJS.isPostalCodeLocales[index]; consola.error("Error: NODE_ENV is not a boolean")
} catch (error) { errors++
return null; }
} if (config.mailer_url == "" || config.mailer_key == "") {
} consola.error("Error: invalid mailer config")
function getDataSeeding(): Boolean { errors++;
try { }
return JSON.parse(process.env.SEED_TEST_DATA); if (config.station_token_secret.length < 32) {
} catch (error) { consola.error("Error: STATION_TOKEN_SECRET must be set and at least 32 characters long")
return false; errors++;
} }
} function getPhoneCodeLocale(): CountryCode {
return (process.env.PHONE_COUNTRYCODE as CountryCode);
}
function getPostalCodeLocale(): any {
try {
const stringArray: String[] = ValidatorJS.isPostalCodeLocales;
let index = stringArray.indexOf(process.env.POSTALCODE_COUNTRYCODE);
return ValidatorJS.isPostalCodeLocales[index];
} catch (error) {
return null;
}
}
function getDataSeeding(): Boolean {
try {
return JSON.parse(process.env.SEED_TEST_DATA);
} catch (error) {
return false;
}
}
export let e = errors export let e = errors

View File

@@ -1,103 +1,106 @@
import { Body, CookieParam, JsonController, Param, Post, Req, Res } from 'routing-controllers'; import { Body, CookieParam, JsonController, Param, Post, QueryParam, Req, Res } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { IllegalJWTError, InvalidCredentialsError, JwtNotProvidedError, PasswordNeededError, RefreshTokenCountInvalidError, UsernameOrEmailNeededError } from '../errors/AuthError'; import { IllegalJWTError, InvalidCredentialsError, JwtNotProvidedError, PasswordNeededError, RefreshTokenCountInvalidError, UsernameOrEmailNeededError } from '../errors/AuthError';
import { UserNotFoundError } from '../errors/UserErrors'; import { MailSendingError } from '../errors/MailErrors';
import { CreateAuth } from '../models/actions/create/CreateAuth'; import { UserNotFoundError } from '../errors/UserErrors';
import { CreateResetToken } from '../models/actions/create/CreateResetToken'; import { Mailer } from '../mailer';
import { HandleLogout } from '../models/actions/HandleLogout'; import { CreateAuth } from '../models/actions/create/CreateAuth';
import { RefreshAuth } from '../models/actions/RefreshAuth'; import { CreateResetToken } from '../models/actions/create/CreateResetToken';
import { ResetPassword } from '../models/actions/ResetPassword'; import { HandleLogout } from '../models/actions/HandleLogout';
import { ResponseAuth } from '../models/responses/ResponseAuth'; import { RefreshAuth } from '../models/actions/RefreshAuth';
import { Logout } from '../models/responses/ResponseLogout'; import { ResetPassword } from '../models/actions/ResetPassword';
import { ResponseAuth } from '../models/responses/ResponseAuth';
@JsonController('/auth') import { ResponseEmpty } from '../models/responses/ResponseEmpty';
export class AuthController { import { Logout } from '../models/responses/ResponseLogout';
constructor() {
} @JsonController('/auth')
export class AuthController {
@Post("/login")
@ResponseSchema(ResponseAuth) @Post("/login")
@ResponseSchema(InvalidCredentialsError) @ResponseSchema(ResponseAuth)
@ResponseSchema(UserNotFoundError) @ResponseSchema(InvalidCredentialsError)
@ResponseSchema(UsernameOrEmailNeededError) @ResponseSchema(UserNotFoundError)
@ResponseSchema(PasswordNeededError) @ResponseSchema(UsernameOrEmailNeededError)
@ResponseSchema(InvalidCredentialsError) @ResponseSchema(PasswordNeededError)
@OpenAPI({ description: 'Login with your username/email and password. <br> You will receive: \n * access token (use it as a bearer token) \n * refresh token (will also be sent as a cookie)' }) @ResponseSchema(InvalidCredentialsError)
async login(@Body({ validate: true }) createAuth: CreateAuth, @Res() response: any) { @OpenAPI({ description: 'Login with your username/email and password. <br> You will receive: \n * access token (use it as a bearer token) \n * refresh token (will also be sent as a cookie)' })
let auth; async login(@Body({ validate: true }) createAuth: CreateAuth, @Res() response: any) {
try { let auth;
auth = await createAuth.toAuth(); try {
response.cookie('lfk_backend__refresh_token', auth.refresh_token, { expires: new Date(auth.refresh_token_expires_at * 1000), httpOnly: true }); auth = await createAuth.toAuth();
response.cookie('lfk_backend__refresh_token_expires_at', auth.refresh_token_expires_at, { expires: new Date(auth.refresh_token_expires_at * 1000), httpOnly: true }); response.cookie('lfk_backend__refresh_token', auth.refresh_token, { expires: new Date(auth.refresh_token_expires_at * 1000), httpOnly: true });
return response.send(auth) response.cookie('lfk_backend__refresh_token_expires_at', auth.refresh_token_expires_at, { expires: new Date(auth.refresh_token_expires_at * 1000), httpOnly: true });
} catch (error) { return response.send(auth)
throw error; } catch (error) {
} throw error;
} }
}
@Post("/logout")
@ResponseSchema(Logout) @Post("/logout")
@ResponseSchema(InvalidCredentialsError) @ResponseSchema(Logout)
@ResponseSchema(UserNotFoundError) @ResponseSchema(InvalidCredentialsError)
@ResponseSchema(UsernameOrEmailNeededError) @ResponseSchema(UserNotFoundError)
@ResponseSchema(PasswordNeededError) @ResponseSchema(UsernameOrEmailNeededError)
@ResponseSchema(InvalidCredentialsError) @ResponseSchema(PasswordNeededError)
@OpenAPI({ description: 'Logout using your refresh token. <br> This instantly invalidates all your access and refresh tokens.', security: [{ "RefreshTokenCookie": [] }] }) @ResponseSchema(InvalidCredentialsError)
async logout(@Body({ validate: true }) handleLogout: HandleLogout, @CookieParam("lfk_backend__refresh_token") refresh_token: string, @Res() response: any) { @OpenAPI({ description: 'Logout using your refresh token. <br> This instantly invalidates all your access and refresh tokens.', security: [{ "RefreshTokenCookie": [] }] })
if (refresh_token && refresh_token.length != 0 && handleLogout.token == undefined) { async logout(@Body({ validate: true }) handleLogout: HandleLogout, @CookieParam("lfk_backend__refresh_token") refresh_token: string, @Res() response: any) {
handleLogout.token = refresh_token; if (refresh_token && refresh_token.length != 0 && handleLogout.token == undefined) {
} handleLogout.token = refresh_token;
}
let logout;
try { let logout;
logout = await handleLogout.logout() try {
await response.cookie('lfk_backend__refresh_token', "expired", { expires: new Date(Date.now()), httpOnly: true }); logout = await handleLogout.logout()
response.cookie('lfk_backend__refresh_token_expires_at', "expired", { expires: new Date(Date.now()), httpOnly: true }); await response.cookie('lfk_backend__refresh_token', "expired", { expires: new Date(Date.now()), httpOnly: true });
} catch (error) { response.cookie('lfk_backend__refresh_token_expires_at', "expired", { expires: new Date(Date.now()), httpOnly: true });
throw error; } catch (error) {
} throw error;
return response.send(logout) }
} return response.send(logout)
}
@Post("/refresh")
@ResponseSchema(ResponseAuth) @Post("/refresh")
@ResponseSchema(JwtNotProvidedError) @ResponseSchema(ResponseAuth)
@ResponseSchema(IllegalJWTError) @ResponseSchema(JwtNotProvidedError)
@ResponseSchema(UserNotFoundError) @ResponseSchema(IllegalJWTError)
@ResponseSchema(RefreshTokenCountInvalidError) @ResponseSchema(UserNotFoundError)
@OpenAPI({ description: 'Refresh your access and refresh tokens using a valid refresh token. <br> You will receive: \n * access token (use it as a bearer token) \n * refresh token (will also be sent as a cookie)', security: [{ "RefreshTokenCookie": [] }] }) @ResponseSchema(RefreshTokenCountInvalidError)
async refresh(@Body({ validate: true }) refreshAuth: RefreshAuth, @CookieParam("lfk_backend__refresh_token") refresh_token: string, @Res() response: any, @Req() req: any) { @OpenAPI({ description: 'Refresh your access and refresh tokens using a valid refresh token. <br> You will receive: \n * access token (use it as a bearer token) \n * refresh token (will also be sent as a cookie)', security: [{ "RefreshTokenCookie": [] }] })
if (refresh_token && refresh_token.length != 0 && refreshAuth.token == undefined) { async refresh(@Body({ validate: true }) refreshAuth: RefreshAuth, @CookieParam("lfk_backend__refresh_token") refresh_token: string, @Res() response: any, @Req() req: any) {
refreshAuth.token = refresh_token; if (refresh_token && refresh_token.length != 0 && refreshAuth.token == undefined) {
} refreshAuth.token = refresh_token;
let auth; }
try { let auth;
auth = await refreshAuth.toAuth(); try {
response.cookie('lfk_backend__refresh_token', auth.refresh_token, { expires: new Date(auth.refresh_token_expires_at * 1000), httpOnly: true }); auth = await refreshAuth.toAuth();
response.cookie('lfk_backend__refresh_token_expires_at', auth.refresh_token_expires_at, { expires: new Date(auth.refresh_token_expires_at * 1000), httpOnly: true }); response.cookie('lfk_backend__refresh_token', auth.refresh_token, { expires: new Date(auth.refresh_token_expires_at * 1000), httpOnly: true });
} catch (error) { response.cookie('lfk_backend__refresh_token_expires_at', auth.refresh_token_expires_at, { expires: new Date(auth.refresh_token_expires_at * 1000), httpOnly: true });
throw error; } catch (error) {
} throw error;
return response.send(auth) }
} return response.send(auth)
}
@Post("/reset")
@ResponseSchema(ResponseAuth) @Post("/reset")
@ResponseSchema(UserNotFoundError) @ResponseSchema(ResponseEmpty, { statusCode: 200 })
@ResponseSchema(UsernameOrEmailNeededError) @ResponseSchema(UserNotFoundError, { statusCode: 404 })
@OpenAPI({ description: "Request a password reset token. <br> This will provide you with a reset token that you can use by posting to /api/auth/reset/{token}." }) @ResponseSchema(UsernameOrEmailNeededError, { statusCode: 406 })
async getResetToken(@Body({ validate: true }) passwordReset: CreateResetToken) { @ResponseSchema(MailSendingError, { statusCode: 500 })
//This really shouldn't just get returned, but sent via mail or sth like that. But for dev only this is fine. @OpenAPI({ description: "Request a password reset token. <br> This will provide you with a reset token that you can use by posting to /api/auth/reset/{token}." })
return { "resetToken": await passwordReset.toResetToken() }; async getResetToken(@Body({ validate: true }) passwordReset: CreateResetToken, @QueryParam("locale") locale: string = "en") {
} const reset_token: string = await passwordReset.toResetToken();
await Mailer.sendResetMail(passwordReset.email, reset_token, locale);
@Post("/reset/:token") return new ResponseEmpty();
@ResponseSchema(ResponseAuth) }
@ResponseSchema(UserNotFoundError)
@ResponseSchema(UsernameOrEmailNeededError) @Post("/reset/:token")
@OpenAPI({ description: "Reset a user's utilising a valid password reset token. <br> This will set the user's password to the one you provided in the body. <br> To get a reset token post to /api/auth/reset with your username." }) @ResponseSchema(ResponseAuth)
async resetPassword(@Param("token") token: string, @Body({ validate: true }) passwordReset: ResetPassword) { @ResponseSchema(UserNotFoundError)
passwordReset.resetToken = token; @ResponseSchema(UsernameOrEmailNeededError)
return await passwordReset.resetPassword(); @OpenAPI({ description: "Reset a user's utilising a valid password reset token. <br> This will set the user's password to the one you provided in the body. <br> To get a reset token post to /api/auth/reset with your username." })
} async resetPassword(@Param("token") token: string, @Body({ validate: true }) passwordReset: ResetPassword) {
} passwordReset.resetToken = token;
return await passwordReset.resetPassword();
}
}

View File

@@ -1,9 +1,10 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { DonationIdsNotMatchingError, DonationNotFoundError } from '../errors/DonationErrors'; import { DonationIdsNotMatchingError, DonationNotFoundError } from '../errors/DonationErrors';
import { DonorNotFoundError } from '../errors/DonorErrors'; import { DonorNotFoundError } from '../errors/DonorErrors';
import { RunnerNotFoundError } from '../errors/RunnerErrors'; import { RunnerNotFoundError } from '../errors/RunnerErrors';
import { CreateAnonymousDonation } from '../models/actions/create/CreateAnonymousDonation';
import { CreateDistanceDonation } from '../models/actions/create/CreateDistanceDonation'; import { CreateDistanceDonation } from '../models/actions/create/CreateDistanceDonation';
import { CreateFixedDonation } from '../models/actions/create/CreateFixedDonation'; import { CreateFixedDonation } from '../models/actions/create/CreateFixedDonation';
import { UpdateDistanceDonation } from '../models/actions/update/UpdateDistanceDonation'; import { UpdateDistanceDonation } from '../models/actions/update/UpdateDistanceDonation';
@@ -11,6 +12,7 @@ import { UpdateFixedDonation } from '../models/actions/update/UpdateFixedDonatio
import { DistanceDonation } from '../models/entities/DistanceDonation'; import { DistanceDonation } from '../models/entities/DistanceDonation';
import { Donation } from '../models/entities/Donation'; import { Donation } from '../models/entities/Donation';
import { FixedDonation } from '../models/entities/FixedDonation'; import { FixedDonation } from '../models/entities/FixedDonation';
import { ResponseAnonymousDonation } from '../models/responses/ResponseAnonymousDonation';
import { ResponseDistanceDonation } from '../models/responses/ResponseDistanceDonation'; import { ResponseDistanceDonation } from '../models/responses/ResponseDistanceDonation';
import { ResponseDonation } from '../models/responses/ResponseDonation'; import { ResponseDonation } from '../models/responses/ResponseDonation';
import { ResponseEmpty } from '../models/responses/ResponseEmpty'; import { ResponseEmpty } from '../models/responses/ResponseEmpty';
@@ -35,10 +37,18 @@ export class DonationController {
@Authorized("DONATION:GET") @Authorized("DONATION:GET")
@ResponseSchema(ResponseDonation, { isArray: true }) @ResponseSchema(ResponseDonation, { isArray: true })
@ResponseSchema(ResponseDistanceDonation, { isArray: true }) @ResponseSchema(ResponseDistanceDonation, { isArray: true })
@ResponseSchema(ResponseAnonymousDonation, { isArray: true })
@OpenAPI({ description: 'Lists all donations (fixed or distance based) from all donors. <br> This includes the donations\'s runner\'s distance ran(if distance donation).' }) @OpenAPI({ description: 'Lists all donations (fixed or distance based) from all donors. <br> This includes the donations\'s runner\'s distance ran(if distance donation).' })
async getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
let responseDonations: ResponseDonation[] = new Array<ResponseDonation>(); let responseDonations: ResponseDonation[] = new Array<ResponseDonation>();
const donations = await this.donationRepository.find({ relations: ['runner', 'donor', 'runner.scans', 'runner.scans.track'] }); let donations: Array<Donation>;
if (page != undefined) {
donations = await this.donationRepository.find({ relations: ['runner', 'donor', 'runner.scans', 'runner.scans.track'], skip: page * page_size, take: page_size });
} else {
donations = await this.donationRepository.find({ relations: ['runner', 'donor', 'runner.scans', 'runner.scans.track'] });
}
donations.forEach(donation => { donations.forEach(donation => {
responseDonations.push(donation.toResponse()); responseDonations.push(donation.toResponse());
}); });
@@ -49,6 +59,7 @@ export class DonationController {
@Authorized("DONATION:GET") @Authorized("DONATION:GET")
@ResponseSchema(ResponseDonation) @ResponseSchema(ResponseDonation)
@ResponseSchema(ResponseDistanceDonation) @ResponseSchema(ResponseDistanceDonation)
@ResponseSchema(ResponseAnonymousDonation)
@ResponseSchema(DonationNotFoundError, { statusCode: 404 }) @ResponseSchema(DonationNotFoundError, { statusCode: 404 })
@OnUndefined(DonationNotFoundError) @OnUndefined(DonationNotFoundError)
@OpenAPI({ description: 'Lists all information about the donation whose id got provided. This includes the donation\'s runner\'s distance ran (if distance donation).' }) @OpenAPI({ description: 'Lists all information about the donation whose id got provided. This includes the donation\'s runner\'s distance ran (if distance donation).' })
@@ -69,6 +80,17 @@ export class DonationController {
return (await this.donationRepository.findOne({ id: donation.id }, { relations: ['donor'] })).toResponse(); return (await this.donationRepository.findOne({ id: donation.id }, { relations: ['donor'] })).toResponse();
} }
@Post('/anonymous')
@Authorized("DONATION:CREATE")
@ResponseSchema(ResponseDonation)
@ResponseSchema(DonorNotFoundError, { statusCode: 404 })
@OpenAPI({ description: 'Create a anonymous donation' })
async postAnonymous(@Body({ validate: true }) createDonation: CreateAnonymousDonation) {
let donation = await createDonation.toEntity();
donation = await this.fixedDonationRepository.save(donation);
return (await this.donationRepository.findOne({ id: donation.id })).toResponse();
}
@Post('/distance') @Post('/distance')
@Authorized("DONATION:CREATE") @Authorized("DONATION:CREATE")
@ResponseSchema(ResponseDistanceDonation) @ResponseSchema(ResponseDistanceDonation)
@@ -78,7 +100,7 @@ export class DonationController {
async postDistance(@Body({ validate: true }) createDonation: CreateDistanceDonation) { async postDistance(@Body({ validate: true }) createDonation: CreateDistanceDonation) {
let donation = await createDonation.toEntity(); let donation = await createDonation.toEntity();
donation = await this.distanceDonationRepository.save(donation); donation = await this.distanceDonationRepository.save(donation);
return (await this.donationRepository.findOne({ id: donation.id }, { relations: ['runner', 'donor', 'runner.scans', 'runner.scans.track'] })).toResponse(); return (await this.distanceDonationRepository.findOne({ id: donation.id }, { relations: ['runner', 'donor', 'runner.scans', 'runner.scans.track'] })).toResponse();
} }
@Put('/fixed/:id') @Put('/fixed/:id')
@@ -124,7 +146,7 @@ export class DonationController {
} }
await this.distanceDonationRepository.save(await donation.update(oldDonation)); await this.distanceDonationRepository.save(await donation.update(oldDonation));
return (await this.donationRepository.findOne({ id: donation.id }, { relations: ['runner', 'donor', 'runner.scans', 'runner.scans.track'] })).toResponse(); return (await this.distanceDonationRepository.findOne({ id: donation.id }, { relations: ['runner', 'donor', 'runner.scans', 'runner.scans.track'] })).toResponse();
} }
@Delete('/:id') @Delete('/:id')

View File

@@ -1,6 +1,6 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { DonorHasDonationsError, DonorIdsNotMatchingError, DonorNotFoundError } from '../errors/DonorErrors'; import { DonorHasDonationsError, DonorIdsNotMatchingError, DonorNotFoundError } from '../errors/DonorErrors';
import { CreateDonor } from '../models/actions/create/CreateDonor'; import { CreateDonor } from '../models/actions/create/CreateDonor';
import { UpdateDonor } from '../models/actions/update/UpdateDonor'; import { UpdateDonor } from '../models/actions/update/UpdateDonor';
@@ -25,9 +25,16 @@ export class DonorController {
@Authorized("DONOR:GET") @Authorized("DONOR:GET")
@ResponseSchema(ResponseDonor, { isArray: true }) @ResponseSchema(ResponseDonor, { isArray: true })
@OpenAPI({ description: 'Lists all donor. <br> This includes the donor\'s current donation amount.' }) @OpenAPI({ description: 'Lists all donor. <br> This includes the donor\'s current donation amount.' })
async getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
let responseDonors: ResponseDonor[] = new Array<ResponseDonor>(); let responseDonors: ResponseDonor[] = new Array<ResponseDonor>();
const donors = await this.donorRepository.find({ relations: ['donations', 'donations.runner', 'donations.runner.scans', 'donations.runner.scans.track'] }); let donors: Array<Donor>;
if (page != undefined) {
donors = await this.donorRepository.find({ relations: ['donations', 'donations.runner', 'donations.runner.scans', 'donations.runner.scans.track'], skip: page * page_size, take: page_size });
} else {
donors = await this.donorRepository.find({ relations: ['donations', 'donations.runner', 'donations.runner.scans', 'donations.runner.scans.track'] });
}
donors.forEach(donor => { donors.forEach(donor => {
responseDonors.push(new ResponseDonor(donor)); responseDonors.push(new ResponseDonor(donor));
}); });

View File

@@ -1,6 +1,6 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnection, getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnection, getConnectionManager } from 'typeorm';
import { GroupContactIdsNotMatchingError, GroupContactNotFoundError } from '../errors/GroupContactErrors'; import { GroupContactIdsNotMatchingError, GroupContactNotFoundError } from '../errors/GroupContactErrors';
import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors'; import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors';
import { CreateGroupContact } from '../models/actions/create/CreateGroupContact'; import { CreateGroupContact } from '../models/actions/create/CreateGroupContact';
@@ -26,9 +26,16 @@ export class GroupContactController {
@Authorized("CONTACT:GET") @Authorized("CONTACT:GET")
@ResponseSchema(ResponseGroupContact, { isArray: true }) @ResponseSchema(ResponseGroupContact, { isArray: true })
@OpenAPI({ description: 'Lists all contacts. <br> This includes the contact\'s associated groups.' }) @OpenAPI({ description: 'Lists all contacts. <br> This includes the contact\'s associated groups.' })
async getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
let responseContacts: ResponseGroupContact[] = new Array<ResponseGroupContact>(); let responseContacts: ResponseGroupContact[] = new Array<ResponseGroupContact>();
const contacts = await this.contactRepository.find({ relations: ['groups', 'groups.parentGroup'] }); let contacts: Array<GroupContact>;
if (page != undefined) {
contacts = await this.contactRepository.find({ relations: ['groups', 'groups.parentGroup'], skip: page * page_size, take: page_size });
} else {
contacts = await this.contactRepository.find({ relations: ['groups', 'groups.parentGroup'] });
}
contacts.forEach(contact => { contacts.forEach(contact => {
responseContacts.push(contact.toResponse()); responseContacts.push(contact.toResponse());
}); });

View File

@@ -1,7 +1,7 @@
import { Body, CurrentUser, Delete, Get, JsonController, OnUndefined, Put, QueryParam } from 'routing-controllers'; import { Body, CurrentUser, Delete, Get, JsonController, OnUndefined, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { getConnectionManager, Repository } from 'typeorm';
import { UserDeletionNotConfirmedError, UserIdsNotMatchingError, UsernameContainsIllegalCharacterError, UserNotFoundError } from '../errors/UserErrors'; import { PasswordMustContainLowercaseLetterError, PasswordMustContainNumberError, PasswordMustContainUppercaseLetterError, PasswordTooShortError, UserDeletionNotConfirmedError, UserIdsNotMatchingError, UsernameContainsIllegalCharacterError, UserNotFoundError } from '../errors/UserErrors';
import { UpdateUser } from '../models/actions/update/UpdateUser'; import { UpdateUser } from '../models/actions/update/UpdateUser';
import { User } from '../models/entities/User'; import { User } from '../models/entities/User';
import { ResponseUser } from '../models/responses/ResponseUser'; import { ResponseUser } from '../models/responses/ResponseUser';
@@ -32,7 +32,7 @@ export class MeController {
return new ResponseUser(user); return new ResponseUser(user);
} }
@Get('/') @Get('/permissions')
@ResponseSchema(ResponseUserPermissions) @ResponseSchema(ResponseUserPermissions)
@ResponseSchema(UserNotFoundError, { statusCode: 404 }) @ResponseSchema(UserNotFoundError, { statusCode: 404 })
@OnUndefined(UserNotFoundError) @OnUndefined(UserNotFoundError)
@@ -48,6 +48,10 @@ export class MeController {
@ResponseSchema(UserNotFoundError, { statusCode: 404 }) @ResponseSchema(UserNotFoundError, { statusCode: 404 })
@ResponseSchema(UserIdsNotMatchingError, { statusCode: 406 }) @ResponseSchema(UserIdsNotMatchingError, { statusCode: 406 })
@ResponseSchema(UsernameContainsIllegalCharacterError, { statusCode: 406 }) @ResponseSchema(UsernameContainsIllegalCharacterError, { statusCode: 406 })
@ResponseSchema(PasswordMustContainUppercaseLetterError, { statusCode: 406 })
@ResponseSchema(PasswordMustContainLowercaseLetterError, { statusCode: 406 })
@ResponseSchema(PasswordMustContainNumberError, { statusCode: 406 })
@ResponseSchema(PasswordTooShortError, { statusCode: 406 })
@OpenAPI({ description: "Update the yourself. <br> You can't edit your own permissions or group memberships here - Please use the /api/users/:id enpoint instead. <br> Please remember that ids can't be changed." }) @OpenAPI({ description: "Update the yourself. <br> You can't edit your own permissions or group memberships here - Please use the /api/users/:id enpoint instead. <br> Please remember that ids can't be changed." })
async put(@CurrentUser() currentUser: User, @Body({ validate: true }) updateUser: UpdateUser) { async put(@CurrentUser() currentUser: User, @Body({ validate: true }) updateUser: UpdateUser) {
let oldUser = await this.userRepository.findOne({ id: currentUser.id }, { relations: ['groups'] }); let oldUser = await this.userRepository.findOne({ id: currentUser.id }, { relations: ['groups'] });

View File

@@ -1,6 +1,6 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { PermissionIdsNotMatchingError, PermissionNeedsPrincipalError, PermissionNotFoundError } from '../errors/PermissionErrors'; import { PermissionIdsNotMatchingError, PermissionNeedsPrincipalError, PermissionNotFoundError } from '../errors/PermissionErrors';
import { PrincipalNotFoundError } from '../errors/PrincipalErrors'; import { PrincipalNotFoundError } from '../errors/PrincipalErrors';
import { CreatePermission } from '../models/actions/create/CreatePermission'; import { CreatePermission } from '../models/actions/create/CreatePermission';
@@ -27,9 +27,16 @@ export class PermissionController {
@Authorized("PERMISSION:GET") @Authorized("PERMISSION:GET")
@ResponseSchema(ResponsePermission, { isArray: true }) @ResponseSchema(ResponsePermission, { isArray: true })
@OpenAPI({ description: 'Lists all permissions for all users and groups.' }) @OpenAPI({ description: 'Lists all permissions for all users and groups.' })
async getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
let responsePermissions: ResponsePermission[] = new Array<ResponsePermission>(); let responsePermissions: ResponsePermission[] = new Array<ResponsePermission>();
const permissions = await this.permissionRepository.find({ relations: ['principal'] }); let permissions: Array<Permission>;
if (page != undefined) {
permissions = await this.permissionRepository.find({ relations: ['principal'], skip: page * page_size, take: page_size });
} else {
permissions = await this.permissionRepository.find({ relations: ['principal'] });
}
permissions.forEach(permission => { permissions.forEach(permission => {
responsePermissions.push(new ResponsePermission(permission)); responsePermissions.push(new ResponsePermission(permission));
}); });

View File

@@ -1,106 +1,164 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { RunnerCardHasScansError, RunnerCardIdsNotMatchingError, RunnerCardNotFoundError } from '../errors/RunnerCardErrors'; import { RunnerCardHasScansError, RunnerCardIdsNotMatchingError, RunnerCardNotFoundError } from '../errors/RunnerCardErrors';
import { RunnerNotFoundError } from '../errors/RunnerErrors'; import { RunnerNotFoundError } from '../errors/RunnerErrors';
import { CreateRunnerCard } from '../models/actions/create/CreateRunnerCard'; import { deleteCardEntry } from '../nats/CardKV';
import { UpdateRunnerCard } from '../models/actions/update/UpdateRunnerCard'; import { CreateRunnerCard } from '../models/actions/create/CreateRunnerCard';
import { RunnerCard } from '../models/entities/RunnerCard'; import { UpdateRunnerCard } from '../models/actions/update/UpdateRunnerCard';
import { ResponseEmpty } from '../models/responses/ResponseEmpty'; import { UpdateRunnerCardByCode } from '../models/actions/update/UpdateRunnerCardByCode';
import { ResponseRunnerCard } from '../models/responses/ResponseRunnerCard'; import { RunnerCard } from '../models/entities/RunnerCard';
import { ScanController } from './ScanController'; import { ResponseEmpty } from '../models/responses/ResponseEmpty';
import { ResponseRunnerCard } from '../models/responses/ResponseRunnerCard';
@JsonController('/cards') import { ScanController } from './ScanController';
@OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
export class RunnerCardController { @JsonController('/cards')
private cardRepository: Repository<RunnerCard>; @OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
export class RunnerCardController {
/** private cardRepository: Repository<RunnerCard>;
* Gets the repository of this controller's model/entity.
*/ /**
constructor() { * Gets the repository of this controller's model/entity.
this.cardRepository = getConnectionManager().get().getRepository(RunnerCard); */
} constructor() {
this.cardRepository = getConnectionManager().get().getRepository(RunnerCard);
@Get() }
@Authorized("CARD:GET")
@ResponseSchema(ResponseRunnerCard, { isArray: true }) @Get()
@OpenAPI({ description: 'Lists all card.' }) @Authorized("CARD:GET")
async getAll() { @ResponseSchema(ResponseRunnerCard, { isArray: true })
let responseCards: ResponseRunnerCard[] = new Array<ResponseRunnerCard>(); @OpenAPI({ description: 'Lists all card.' })
const cards = await this.cardRepository.find({ relations: ['runner'] }); async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
cards.forEach(card => { let responseCards: ResponseRunnerCard[] = new Array<ResponseRunnerCard>();
responseCards.push(new ResponseRunnerCard(card)); let cards: Array<RunnerCard>;
});
return responseCards; if (page != undefined) {
} cards = await this.cardRepository.find({ relations: ['runner', 'runner.group', 'runner.group.parentGroup'], skip: page * page_size, take: page_size });
} else {
@Get('/:id') cards = await this.cardRepository.find({ relations: ['runner', 'runner.group', 'runner.group.parentGroup'] });
@Authorized("CARD:GET") }
@ResponseSchema(ResponseRunnerCard)
@ResponseSchema(RunnerCardNotFoundError, { statusCode: 404 }) cards.forEach(card => {
@OnUndefined(RunnerCardNotFoundError) responseCards.push(new ResponseRunnerCard(card));
@OpenAPI({ description: "Lists all information about the card whose id got provided." }) });
async getOne(@Param('id') id: number) { return responseCards;
let card = await this.cardRepository.findOne({ id: id }, { relations: ['runner'] }); }
if (!card) { throw new RunnerCardNotFoundError(); }
return card.toResponse(); @Get('/:id')
} @Authorized("CARD:GET")
@ResponseSchema(ResponseRunnerCard)
@Post() @ResponseSchema(RunnerCardNotFoundError, { statusCode: 404 })
@Authorized("CARD:CREATE") @OnUndefined(RunnerCardNotFoundError)
@ResponseSchema(ResponseRunnerCard) @OpenAPI({ description: "Lists all information about the card whose id got provided." })
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 }) async getOne(@Param('id') id: number) {
@OpenAPI({ description: "Create a new card. <br> You can provide a associated runner by id but you don't have to." }) let card = await this.cardRepository.findOne({ id: id }, { relations: ['runner', 'runner.group', 'runner.group.parentGroup'] });
async post(@Body({ validate: true }) createCard: CreateRunnerCard) { if (!card) { throw new RunnerCardNotFoundError(); }
let card = await createCard.toEntity(); return card.toResponse();
card = await this.cardRepository.save(card); }
return (await this.cardRepository.findOne({ id: card.id }, { relations: ['runner'] })).toResponse();
} @Post('/bulk')
@Authorized("CARD:CREATE")
@Put('/:id') @ResponseSchema(ResponseEmpty, { statusCode: 200 })
@Authorized("CARD:UPDATE") @OpenAPI({ description: "Create blank cards in bulk. <br> Just provide the count as a query param and wait for the 200 response. <br> You can provide the 'returnCards' query param if you want to receive the RESPONSERUNNERCARD objects in the response." })
@ResponseSchema(ResponseRunnerCard) async postBlancoBulk(@QueryParam("count") count: number, @QueryParam("returnCards") returnCards: boolean = false) {
@ResponseSchema(RunnerCardNotFoundError, { statusCode: 404 }) let createPromises = new Array<any>();
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 }) for (let index = 0; index < count; index++) {
@ResponseSchema(RunnerCardIdsNotMatchingError, { statusCode: 406 }) createPromises.push(this.cardRepository.save({ runner: null, enabled: true }))
@OpenAPI({ description: "Update the card whose id you provided. <br> Scans created via this card will still be associated with the old runner. <br> Please remember that ids can't be changed." }) }
async put(@Param('id') id: number, @Body({ validate: true }) card: UpdateRunnerCard) {
let oldCard = await this.cardRepository.findOne({ id: id }); const cards = await Promise.all(createPromises);
if (!oldCard) { if (returnCards) {
throw new RunnerCardNotFoundError(); let responseCards: ResponseRunnerCard[] = new Array<ResponseRunnerCard>();
} for await (let card of cards) {
let dbCard = await this.cardRepository.findOne({ id: card.id });
if (oldCard.id != card.id) { responseCards.push(new ResponseRunnerCard(dbCard));
throw new RunnerCardIdsNotMatchingError(); }
} return responseCards;
}
let response = new ResponseEmpty();
response.response = `Created ${count} new blanco cards.`
return response;
}
@Post()
@Authorized("CARD:CREATE")
@ResponseSchema(ResponseRunnerCard)
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
@OpenAPI({ description: "Create a new card. <br> You can provide a associated runner by id but you don't have to." })
async post(@Body({ validate: true }) createCard: CreateRunnerCard) {
let card = await createCard.toEntity();
card = await this.cardRepository.save(card);
return (await this.cardRepository.findOne({ id: card.id }, { relations: ['runner', 'runner.group', 'runner.group.parentGroup'] })).toResponse();
}
@Put('/:id')
@Authorized("CARD:UPDATE")
@ResponseSchema(ResponseRunnerCard)
@ResponseSchema(RunnerCardNotFoundError, { statusCode: 404 })
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
@ResponseSchema(RunnerCardIdsNotMatchingError, { statusCode: 406 })
@OpenAPI({ description: "Update the card whose id you provided. <br> Scans created via this card will still be associated with the old runner. <br> Please remember that ids can't be changed." })
async put(@Param('id') id: number, @Body({ validate: true }) card: UpdateRunnerCard) {
let oldCard = await this.cardRepository.findOne({ id: id });
if (!oldCard) {
throw new RunnerCardNotFoundError();
}
if (oldCard.id != card.id) {
throw new RunnerCardIdsNotMatchingError();
}
await this.cardRepository.save(await card.update(oldCard)); await this.cardRepository.save(await card.update(oldCard));
return (await this.cardRepository.findOne({ id: id }, { relations: ['runner'] })).toResponse(); await deleteCardEntry(id);
} return (await this.cardRepository.findOne({ id: id }, { relations: ['runner', 'runner.group', 'runner.group.parentGroup'] })).toResponse();
}
@Delete('/:id')
@Authorized("CARD:DELETE") @Put('/:code')
@ResponseSchema(ResponseRunnerCard) @Authorized("CARD:UPDATE")
@ResponseSchema(ResponseEmpty, { statusCode: 204 }) @ResponseSchema(ResponseRunnerCard)
@ResponseSchema(RunnerCardHasScansError, { statusCode: 406 }) @ResponseSchema(RunnerCardNotFoundError, { statusCode: 404 })
@OnUndefined(204) @ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
@OpenAPI({ description: "Delete the card whose id you provided. <br> If no card with this id exists it will just return 204(no content). <br> If the card still has scans associated you have to provide the force=true query param (warning: this deletes all scans associated with by this card - please disable it instead or just remove the runner association)." }) @ResponseSchema(RunnerCardIdsNotMatchingError, { statusCode: 406 })
async remove(@Param("id") id: number, @QueryParam("force") force: boolean) { @OpenAPI({ description: "Update the card whose code you provided." })
let card = await this.cardRepository.findOne({ id: id }); async putByCode(@Param('code') code: string, @Body({ validate: true }) card: UpdateRunnerCardByCode) {
if (!card) { return null; } let oldCard = await this.cardRepository.findOne({ code: code });
const cardScans = (await this.cardRepository.findOne({ id: id }, { relations: ["scans"] })).scans; if (!oldCard) {
if (cardScans.length != 0 && !force) { throw new RunnerCardNotFoundError();
throw new RunnerCardHasScansError(); }
}
const scanController = new ScanController; if (oldCard.code != card.code) {
throw new RunnerCardIdsNotMatchingError();
}
await this.cardRepository.save(await card.update(oldCard));
return (await this.cardRepository.findOne({ code: code }, { relations: ['runner', 'runner.group', 'runner.group.parentGroup'] })).toResponse();
}
@Delete('/:id')
@Authorized("CARD:DELETE")
@ResponseSchema(ResponseRunnerCard)
@ResponseSchema(ResponseEmpty, { statusCode: 204 })
@ResponseSchema(RunnerCardHasScansError, { statusCode: 406 })
@OnUndefined(204)
@OpenAPI({ description: "Delete the card whose id you provided. <br> If no card with this id exists it will just return 204(no content). <br> If the card still has scans associated you have to provide the force=true query param (warning: this deletes all scans associated with by this card - please disable it instead or just remove the runner association)." })
async remove(@Param("id") id: number, @QueryParam("force") force: boolean) {
let card = await this.cardRepository.findOne({ id: id });
if (!card) { return null; }
const cardScans = (await this.cardRepository.findOne({ id: id }, { relations: ["scans"] })).scans;
if (cardScans.length != 0 && !force) {
throw new RunnerCardHasScansError();
}
const scanController = new ScanController;
for (let scan of cardScans) { for (let scan of cardScans) {
await scanController.remove(scan.id, force); await scanController.remove(scan.id, force);
} }
await deleteCardEntry(id);
await this.cardRepository.delete(card); await this.cardRepository.delete(card);
return card.toResponse(); return card.toResponse();
} }
} }

View File

@@ -1,18 +1,19 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { RunnerGroupNeededError, RunnerHasDistanceDonationsError, RunnerIdsNotMatchingError, RunnerNotFoundError } from '../errors/RunnerErrors'; import { RunnerGroupNeededError, RunnerHasDistanceDonationsError, RunnerIdsNotMatchingError, RunnerNotFoundError } from '../errors/RunnerErrors';
import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors'; import { RunnerGroupNotFoundError } from '../errors/RunnerGroupErrors';
import { CreateRunner } from '../models/actions/create/CreateRunner'; import { deleteRunnerEntry } from '../nats/RunnerKV';
import { UpdateRunner } from '../models/actions/update/UpdateRunner'; import { CreateRunner } from '../models/actions/create/CreateRunner';
import { Runner } from '../models/entities/Runner'; import { UpdateRunner } from '../models/actions/update/UpdateRunner';
import { ResponseEmpty } from '../models/responses/ResponseEmpty'; import { Runner } from '../models/entities/Runner';
import { ResponseRunner } from '../models/responses/ResponseRunner'; import { ResponseEmpty } from '../models/responses/ResponseEmpty';
import { ResponseScan } from '../models/responses/ResponseScan'; import { ResponseRunner } from '../models/responses/ResponseRunner';
import { ResponseTrackScan } from '../models/responses/ResponseTrackScan'; import { ResponseScan } from '../models/responses/ResponseScan';
import { DonationController } from './DonationController'; import { ResponseTrackScan } from '../models/responses/ResponseTrackScan';
import { RunnerCardController } from './RunnerCardController'; import { DonationController } from './DonationController';
import { ScanController } from './ScanController'; import { RunnerCardController } from './RunnerCardController';
import { ScanController } from './ScanController';
@JsonController('/runners') @JsonController('/runners')
@OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
@@ -30,11 +31,25 @@ export class RunnerController {
@Authorized("RUNNER:GET") @Authorized("RUNNER:GET")
@ResponseSchema(ResponseRunner, { isArray: true }) @ResponseSchema(ResponseRunner, { isArray: true })
@OpenAPI({ description: 'Lists all runners from all teams/orgs. <br> This includes the runner\'s group and distance ran.' }) @OpenAPI({ description: 'Lists all runners from all teams/orgs. <br> This includes the runner\'s group and distance ran.' })
async getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100, @QueryParam("created_via", { required: false }) created_via: string = "all", @QueryParam("selfservice_links", { required: false }) selfservice_links: boolean = false) {
let responseRunners: ResponseRunner[] = new Array<ResponseRunner>(); let responseRunners: ResponseRunner[] = new Array<ResponseRunner>();
const runners = await this.runnerRepository.find({ relations: ['scans', 'group', 'scans.track', 'cards'] }); let runners: Array<Runner>;
console.log("call to RunnerController.getAll() with page: " + page + " and page_size: " + page_size + " and created_via: " + created_via + " and selfservice_links: " + selfservice_links);
if (page != undefined) {
runners = await this.runnerRepository.find({ relations: ['scans', 'group', 'group.parentGroup', 'scans.track'], skip: page * page_size, take: page_size });
} else {
runners = await this.runnerRepository.find({ relations: ['scans', 'group', 'group.parentGroup', 'scans.track'] });
}
runners.forEach(runner => { runners.forEach(runner => {
responseRunners.push(new ResponseRunner(runner)); if (created_via === "all") {
responseRunners.push(new ResponseRunner(runner, selfservice_links));
} else {
if (runner.created_via === created_via) {
responseRunners.push(new ResponseRunner(runner, selfservice_links));
}
}
}); });
return responseRunners; return responseRunners;
} }
@@ -46,9 +61,9 @@ export class RunnerController {
@OnUndefined(RunnerNotFoundError) @OnUndefined(RunnerNotFoundError)
@OpenAPI({ description: 'Lists all information about the runner whose id got provided.' }) @OpenAPI({ description: 'Lists all information about the runner whose id got provided.' })
async getOne(@Param('id') id: number) { async getOne(@Param('id') id: number) {
let runner = await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'scans.track', 'cards'] }) let runner = await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards', 'distanceDonations'] })
if (!runner) { throw new RunnerNotFoundError(); } if (!runner) { throw new RunnerNotFoundError(); }
return new ResponseRunner(runner); return new ResponseRunner(runner, true);
} }
@Get('/:id/scans') @Get('/:id/scans')
@@ -91,7 +106,7 @@ export class RunnerController {
} }
runner = await this.runnerRepository.save(runner) runner = await this.runnerRepository.save(runner)
return new ResponseRunner(await this.runnerRepository.findOne(runner, { relations: ['scans', 'group', 'scans.track', 'cards'] })); return new ResponseRunner(await this.runnerRepository.findOne(runner, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] }), true);
} }
@Put('/:id') @Put('/:id')
@@ -111,8 +126,9 @@ export class RunnerController {
throw new RunnerIdsNotMatchingError(); throw new RunnerIdsNotMatchingError();
} }
await this.runnerRepository.save(await runner.update(oldRunner)); await this.runnerRepository.save(await runner.update(oldRunner));
return new ResponseRunner(await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'scans.track', 'cards'] })); await deleteRunnerEntry(id);
return new ResponseRunner(await this.runnerRepository.findOne({ id: id }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards'] }), true);
} }
@Delete('/:id') @Delete('/:id')
@@ -125,7 +141,7 @@ export class RunnerController {
async remove(@Param("id") id: number, @QueryParam("force") force: boolean) { async remove(@Param("id") id: number, @QueryParam("force") force: boolean) {
let runner = await this.runnerRepository.findOne({ id: id }); let runner = await this.runnerRepository.findOne({ id: id });
if (!runner) { return null; } if (!runner) { return null; }
const responseRunner = await this.runnerRepository.findOne(runner, { relations: ['scans', 'group', 'scans.track', 'cards'] }); const responseRunner = await this.runnerRepository.findOne(runner);
if (!runner) { if (!runner) {
throw new RunnerNotFoundError(); throw new RunnerNotFoundError();

View File

@@ -1,11 +1,13 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { Authorized, BadRequestError, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { RunnerOrganizationHasRunnersError, RunnerOrganizationHasTeamsError, RunnerOrganizationIdsNotMatchingError, RunnerOrganizationNotFoundError } from '../errors/RunnerOrganizationErrors'; import { RunnerOrganizationHasRunnersError, RunnerOrganizationHasTeamsError, RunnerOrganizationIdsNotMatchingError, RunnerOrganizationNotFoundError } from '../errors/RunnerOrganizationErrors';
import { CreateRunnerOrganization } from '../models/actions/create/CreateRunnerOrganization'; import { CreateRunnerOrganization } from '../models/actions/create/CreateRunnerOrganization';
import { UpdateRunnerOrganization } from '../models/actions/update/UpdateRunnerOrganization'; import { UpdateRunnerOrganization } from '../models/actions/update/UpdateRunnerOrganization';
import { Runner } from '../models/entities/Runner';
import { RunnerOrganization } from '../models/entities/RunnerOrganization'; import { RunnerOrganization } from '../models/entities/RunnerOrganization';
import { ResponseEmpty } from '../models/responses/ResponseEmpty'; import { ResponseEmpty } from '../models/responses/ResponseEmpty';
import { ResponseRunner } from '../models/responses/ResponseRunner';
import { ResponseRunnerOrganization } from '../models/responses/ResponseRunnerOrganization'; import { ResponseRunnerOrganization } from '../models/responses/ResponseRunnerOrganization';
import { RunnerController } from './RunnerController'; import { RunnerController } from './RunnerController';
import { RunnerTeamController } from './RunnerTeamController'; import { RunnerTeamController } from './RunnerTeamController';
@@ -27,13 +29,20 @@ export class RunnerOrganizationController {
@Authorized("ORGANIZATION:GET") @Authorized("ORGANIZATION:GET")
@ResponseSchema(ResponseRunnerOrganization, { isArray: true }) @ResponseSchema(ResponseRunnerOrganization, { isArray: true })
@OpenAPI({ description: 'Lists all organizations. <br> This includes their address, contact and teams (if existing/associated).' }) @OpenAPI({ description: 'Lists all organizations. <br> This includes their address, contact and teams (if existing/associated).' })
async getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
let responseTeams: ResponseRunnerOrganization[] = new Array<ResponseRunnerOrganization>(); let responseOrgs: ResponseRunnerOrganization[] = new Array<ResponseRunnerOrganization>();
const runners = await this.runnerOrganizationRepository.find({ relations: ['contact', 'teams'] }); let orgs: Array<RunnerOrganization>;
runners.forEach(runner => {
responseTeams.push(new ResponseRunnerOrganization(runner)); if (page != undefined) {
orgs = await this.runnerOrganizationRepository.find({ relations: ['contact', 'teams'], skip: page * page_size, take: page_size });
} else {
orgs = await this.runnerOrganizationRepository.find({ relations: ['contact', 'teams'] });
}
orgs.forEach(org => {
responseOrgs.push(new ResponseRunnerOrganization(org));
}); });
return responseTeams; return responseOrgs;
} }
@Get('/:id') @Get('/:id')
@@ -43,11 +52,27 @@ export class RunnerOrganizationController {
@OnUndefined(RunnerOrganizationNotFoundError) @OnUndefined(RunnerOrganizationNotFoundError)
@OpenAPI({ description: 'Lists all information about the organization whose id got provided.' }) @OpenAPI({ description: 'Lists all information about the organization whose id got provided.' })
async getOne(@Param('id') id: number) { async getOne(@Param('id') id: number) {
let runnerOrg = await this.runnerOrganizationRepository.findOne({ id: id }, { relations: ['contact', 'teams'] }); let runnerOrg = await this.runnerOrganizationRepository.findOne({ id: id }, { relations: ['contact', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.scans.track', 'runners', 'runners.scans', 'runners.scans.track'] });
if (!runnerOrg) { throw new RunnerOrganizationNotFoundError(); } if (!runnerOrg) { throw new RunnerOrganizationNotFoundError(); }
return new ResponseRunnerOrganization(runnerOrg); return new ResponseRunnerOrganization(runnerOrg);
} }
@Get('/:id/runners')
@Authorized(["RUNNER:GET", "SCAN:GET"])
@ResponseSchema(ResponseRunner, { isArray: true })
@ResponseSchema(RunnerOrganizationNotFoundError, { statusCode: 404 })
@OpenAPI({ description: 'Lists all runners from this org and it\'s teams (if you don\'t provide the ?onlyDirect=true param). <br> This includes the runner\'s group and distance ran.' })
async getRunners(@Param('id') id: number, @QueryParam('onlyDirect') onlyDirect: boolean, @QueryParam("selfservice_links", { required: false }) selfservice_links: boolean = false) {
let responseRunners: ResponseRunner[] = new Array<ResponseRunner>();
let runners: Runner[];
if (!onlyDirect) { runners = (await this.runnerOrganizationRepository.findOne({ id: id }, { relations: ['runners', 'runners.group', 'runners.group.parentGroup', 'runners.scans', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.group', 'teams.runners.group.parentGroup', 'teams.runners.scans', 'teams.runners.scans.track'] })).allRunners; }
else { runners = (await this.runnerOrganizationRepository.findOne({ id: id }, { relations: ['runners', 'runners.group', 'runners.group.parentGroup', 'runners.scans', 'runners.scans.track'] })).runners; }
runners.forEach(runner => {
responseRunners.push(new ResponseRunner(runner, selfservice_links));
});
return responseRunners;
}
@Post() @Post()
@Authorized("ORGANIZATION:CREATE") @Authorized("ORGANIZATION:CREATE")
@ResponseSchema(ResponseRunnerOrganization) @ResponseSchema(ResponseRunnerOrganization)
@@ -96,6 +121,10 @@ export class RunnerOrganizationController {
@OnUndefined(204) @OnUndefined(204)
@OpenAPI({ description: 'Delete the organsisation whose id you provided. <br> If the organization still has runners and/or teams associated this will fail. <br> To delete the organization with all associated runners and teams set the force QueryParam to true (cascading deletion might take a while). <br> This won\'t delete the associated contact. <br> If no organization with this id exists it will just return 204(no content).' }) @OpenAPI({ description: 'Delete the organsisation whose id you provided. <br> If the organization still has runners and/or teams associated this will fail. <br> To delete the organization with all associated runners and teams set the force QueryParam to true (cascading deletion might take a while). <br> This won\'t delete the associated contact. <br> If no organization with this id exists it will just return 204(no content).' })
async remove(@Param("id") id: number, @QueryParam("force") force: boolean) { async remove(@Param("id") id: number, @QueryParam("force") force: boolean) {
if (id == 1) {
throw new BadRequestError("You can't delete the citizen runner org.");
}
let organization = await this.runnerOrganizationRepository.findOne({ id: id }); let organization = await this.runnerOrganizationRepository.findOne({ id: id });
if (!organization) { return null; } if (!organization) { return null; }
let runnerOrganization = await this.runnerOrganizationRepository.findOne(organization, { relations: ['contact', 'runners', 'teams'] }); let runnerOrganization = await this.runnerOrganizationRepository.findOne(organization, { relations: ['contact', 'runners', 'teams'] });

View File

@@ -1,98 +1,248 @@
import * as jwt from "jsonwebtoken"; import type { Request } from "express";
import { Body, Get, JsonController, OnUndefined, Param, Post } from 'routing-controllers'; import * as jwt from "jsonwebtoken";
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { BadRequestError, Body, Delete, Get, JsonController, OnUndefined, Param, Post, QueryParam, Req, UseBefore } from 'routing-controllers';
import { getConnectionManager, Repository } from 'typeorm'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { config } from '../config'; import { getConnectionManager, Repository } from 'typeorm';
import { InvalidCredentialsError, JwtNotProvidedError } from '../errors/AuthError'; import { config } from '../config';
import { RunnerEmailNeededError, RunnerNotFoundError } from '../errors/RunnerErrors'; import { InvalidCredentialsError, JwtNotProvidedError } from '../errors/AuthError';
import { RunnerOrganizationNotFoundError } from '../errors/RunnerOrganizationErrors'; import { MailSendingError } from '../errors/MailErrors';
import { JwtCreator } from '../jwtcreator'; import { RunnerEmailNeededError, RunnerHasDistanceDonationsError, RunnerNotFoundError, RunnerSelfserviceTimeoutError } from '../errors/RunnerErrors';
import { CreateSelfServiceCitizenRunner } from '../models/actions/create/CreateSelfServiceCitizenRunner'; import { RunnerOrganizationNotFoundError } from '../errors/RunnerOrganizationErrors';
import { CreateSelfServiceRunner } from '../models/actions/create/CreateSelfServiceRunner'; import { ScanStationNotFoundError } from '../errors/ScanStationErrors';
import { Runner } from '../models/entities/Runner'; import { JwtCreator } from '../jwtcreator';
import { RunnerGroup } from '../models/entities/RunnerGroup'; import { Mailer } from '../mailer';
import { RunnerOrganization } from '../models/entities/RunnerOrganization'; import ScanAuth from '../middlewares/ScanAuth';
import { ResponseSelfServiceRunner } from '../models/responses/ResponseSelfServiceRunner'; import { CreateSelfServiceCitizenRunner } from '../models/actions/create/CreateSelfServiceCitizenRunner';
import { CreateSelfServiceRunner } from '../models/actions/create/CreateSelfServiceRunner';
import { Runner } from '../models/entities/Runner';
@JsonController('/runners') import { RunnerGroup } from '../models/entities/RunnerGroup';
export class RunnerSelfServiceController { import { RunnerOrganization } from '../models/entities/RunnerOrganization';
private runnerRepository: Repository<Runner>; import { ScanStation } from '../models/entities/ScanStation';
private orgRepository: Repository<RunnerOrganization>; import { ResponseEmpty } from '../models/responses/ResponseEmpty';
import { ResponseScanStation } from '../models/responses/ResponseScanStation';
/** import { ResponseSelfServiceOrganisation } from '../models/responses/ResponseSelfServiceOrganisation';
* Gets the repository of this controller's model/entity. import { ResponseSelfServiceRunner } from '../models/responses/ResponseSelfServiceRunner';
*/ import { ResponseSelfServiceScan } from '../models/responses/ResponseSelfServiceScan';
constructor() { import { DonationController } from './DonationController';
this.runnerRepository = getConnectionManager().get().getRepository(Runner); import { RunnerCardController } from './RunnerCardController';
this.orgRepository = getConnectionManager().get().getRepository(RunnerOrganization); import { ScanController } from './ScanController';
}
@JsonController()
@Get('/me/:jwt') export class RunnerSelfServiceController {
@ResponseSchema(ResponseSelfServiceRunner) private runnerRepository: Repository<Runner>;
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 }) private orgRepository: Repository<RunnerOrganization>;
@OnUndefined(RunnerNotFoundError) private stationRepository: Repository<ScanStation>;
@OpenAPI({ description: 'Lists all information about yourself. <br> Please provide your runner jwt(that code we gave you during registration) for auth. <br> If you lost your jwt/personalized link please contact support.' })
async get(@Param('jwt') token: string) { /**
return (new ResponseSelfServiceRunner(await this.getRunner(token))); * Gets the repository of this controller's model/entity.
} */
constructor() {
@Post('/register') this.runnerRepository = getConnectionManager().get().getRepository(Runner);
@ResponseSchema(ResponseSelfServiceRunner) this.orgRepository = getConnectionManager().get().getRepository(RunnerOrganization);
@ResponseSchema(RunnerEmailNeededError, { statusCode: 406 }) this.stationRepository = getConnectionManager().get().getRepository(ScanStation);
@OpenAPI({ description: 'Create a new selfservice runner in the citizen org. <br> This endpoint shoud be used to allow "everyday citizen" to register themselves. <br> You have to provide a mail address, b/c the future we\'ll implement email verification.' }) }
async registerRunner(@Body({ validate: true }) createRunner: CreateSelfServiceCitizenRunner) {
let runner = await createRunner.toEntity(); @Get('/runners/me/:jwt')
@ResponseSchema(ResponseSelfServiceRunner)
runner = await this.runnerRepository.save(runner); @ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
let response = new ResponseSelfServiceRunner(await this.runnerRepository.findOne(runner, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards', 'distanceDonations', 'distanceDonations.donor', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] })); @OnUndefined(RunnerNotFoundError)
response.token = JwtCreator.createSelfService(runner); @OpenAPI({ description: 'Lists all information about yourself. <br> Please provide your runner jwt(that code we gave you during registration) for auth. <br> If you lost your jwt/personalized link please use the forgot endpoint.' })
return response; async get(@Param('jwt') token: string) {
} return (new ResponseSelfServiceRunner(await this.getRunner(token)));
}
@Post('/register/:token')
@ResponseSchema(ResponseSelfServiceRunner) @Delete('/runners/me/:jwt')
@ResponseSchema(RunnerOrganizationNotFoundError, { statusCode: 404 }) @ResponseSchema(ResponseSelfServiceRunner)
@OpenAPI({ description: 'Create a new selfservice runner in a provided org. <br> The orgs get provided and authorized via api tokens that can be optained via the /organizations endpoint.' }) @ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
async registerOrganizationRunner(@Param('token') token: string, @Body({ validate: true }) createRunner: CreateSelfServiceRunner) { @OnUndefined(RunnerNotFoundError)
const org = await this.getOrgansisation(token); @OpenAPI({ description: 'Deletes all information about yourself. <br> Please provide your runner jwt(that code we gave you during registration) for auth. <br> If you lost your jwt/personalized link please use the forgot endpoint.' })
async remove(@Param('jwt') token: string, @QueryParam("force") force: boolean) {
let runner = await createRunner.toEntity(org); const responseRunner = await this.getRunner(token);
runner = await this.runnerRepository.save(runner); let runner = await this.runnerRepository.findOne({ id: responseRunner.id });
let response = new ResponseSelfServiceRunner(await this.runnerRepository.findOne(runner, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards', 'distanceDonations', 'distanceDonations.donor', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] })); if (!runner) { return null; }
response.token = JwtCreator.createSelfService(runner); if (!runner) {
return response; throw new RunnerNotFoundError();
} }
/** const runnerDonations = (await this.runnerRepository.findOne({ id: runner.id }, { relations: ["distanceDonations"] })).distanceDonations;
* Get's a runner by a provided jwt token. if (runnerDonations.length > 0 && !force) {
* @param token The runner jwt provided by the runner to identitfy themselves. throw new RunnerHasDistanceDonationsError();
*/ }
private async getRunner(token: string): Promise<Runner> { const donationController = new DonationController();
if (token == "") { throw new JwtNotProvidedError(); } for (let donation of runnerDonations) {
let jwtPayload = undefined await donationController.remove(donation.id, force);
try { }
jwtPayload = <any>jwt.verify(token, config.jwt_secret);
} catch (error) { const runnerCards = (await this.runnerRepository.findOne({ id: runner.id }, { relations: ["cards"] })).cards;
throw new InvalidCredentialsError(); const cardController = new RunnerCardController;
for (let card of runnerCards) {
await cardController.remove(card.id, force);
}
const runnerScans = (await this.runnerRepository.findOne({ id: runner.id }, { relations: ["scans"] })).scans;
const scanController = new ScanController;
for (let scan of runnerScans) {
await scanController.remove(scan.id, force);
}
await this.runnerRepository.delete(runner);
return new ResponseSelfServiceRunner(responseRunner);
}
@Get('/runners/me/:jwt/scans')
@ResponseSchema(ResponseSelfServiceScan, { isArray: true })
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
@OnUndefined(RunnerNotFoundError)
@OpenAPI({ description: 'Lists all your (runner) scans. <br> Please provide your runner jwt(that code we gave you during registration) for auth. <br> If you lost your jwt/personalized link please contact support.' })
async getScans(@Param('jwt') token: string) {
const scans = (await this.getRunner(token)).scans;
let responseScans = new Array<ResponseSelfServiceScan>()
for (let scan of scans) {
responseScans.push(new ResponseSelfServiceScan(scan));
}
return responseScans;
}
@Get('/stations/me')
@UseBefore(ScanAuth)
@ResponseSchema(ResponseScanStation)
@ResponseSchema(ScanStationNotFoundError, { statusCode: 404 })
@OnUndefined(ScanStationNotFoundError)
@OpenAPI({ description: 'Lists basic information about the station whose token got provided. <br> This includes it\'s associated track.', security: [{ "StationApiToken": [] }] })
async getStationMe(@Req() req: Request) {
// ScanAuth middleware sets req.stationId (not a header)
if (!req.stationId) {
throw new ScanStationNotFoundError();
} }
let scan = await this.stationRepository.findOne({ id: req.stationId }, { relations: ['track'] })
const runner = await this.runnerRepository.findOne({ id: jwtPayload["id"] }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards', 'distanceDonations', 'distanceDonations.donor', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] }); if (!scan) { throw new ScanStationNotFoundError(); }
if (!runner) { throw new RunnerNotFoundError() } return scan.toResponse();
return runner;
}
/**
* Get's a runner org by a provided registration api key.
* @param token The organization's registration api token.
*/
private async getOrgansisation(token: string): Promise<RunnerGroup> {
token = Buffer.from(token, 'base64').toString('utf8');
const organization = await this.orgRepository.findOne({ key: token });
if (!organization) { throw new RunnerOrganizationNotFoundError; }
return organization;
} }
@Post('/runners/login')
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
@OnUndefined(ResponseEmpty)
@OpenAPI({ description: 'Use this endpoint to reuqest a new selfservice magic-login-link to be sent to your mail address (rate limited to one mail every 15mins).' })
async requestNewToken(@QueryParam('mail') mail: string, @QueryParam("locale") locale: string = "en") {
if (!mail) {
throw new RunnerNotFoundError();
}
const runner = await this.runnerRepository.findOne({ email: mail });
if (!runner) { throw new RunnerNotFoundError(); }
if (runner.resetRequestedTimestamp > (Math.floor(Date.now() / 1000) - 30)) { throw new RunnerSelfserviceTimeoutError(); }
const token = JwtCreator.createSelfService(runner);
try {
await Mailer.sendSelfserviceForgottenMail(runner.email, runner.id, runner.firstname, runner.middlename, runner.lastname, token, locale)
} catch (error) {
throw new MailSendingError();
}
runner.resetRequestedTimestamp = Math.floor(Date.now() / 1000);
await this.runnerRepository.save(runner);
return { token };
}
@Post('/runners/register')
@ResponseSchema(ResponseSelfServiceRunner)
@ResponseSchema(RunnerEmailNeededError, { statusCode: 406 })
@OpenAPI({ description: 'Create a new selfservice runner in the citizen org. <br> This endpoint shoud be used to allow "everyday citizen" to register themselves. <br> You have to provide a mail address, b/c the future we\'ll implement email verification.' })
async registerRunner(@Body({ validate: true }) createRunner: CreateSelfServiceCitizenRunner, @QueryParam("locale") locale: string = "en") {
let runner = await createRunner.toEntity();
if (await this.getRunnerExistsByMail(runner.email)) {
throw new BadRequestError("E-Mail already registered")
}
runner = await this.runnerRepository.save(runner);
let response = new ResponseSelfServiceRunner(await this.runnerRepository.findOne(runner, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards', 'distanceDonations', 'distanceDonations.donor', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] }));
response.token = JwtCreator.createSelfService(runner);
try {
await Mailer.sendSelfserviceWelcomeMail(runner.email, runner.id, runner.firstname, runner.middlename, runner.lastname, response.token, locale)
} catch (error) {
throw new MailSendingError();
}
return response;
}
@Post('/runners/register/:token')
@ResponseSchema(ResponseSelfServiceRunner)
@ResponseSchema(RunnerOrganizationNotFoundError, { statusCode: 404 })
@OpenAPI({ description: 'Create a new selfservice runner in a provided org. <br> The orgs get provided and authorized via api tokens that can be optained via the /organizations endpoint.' })
async registerOrganizationRunner(@Param('token') token: string, @Body({ validate: true }) createRunner: CreateSelfServiceRunner, @QueryParam("locale") locale: string = "en") {
const org = await this.getOrgansisation(token);
let runner = await createRunner.toEntity(org);
if (await this.getRunnerExistsByMail(runner.email)) {
throw new BadRequestError("E-Mail already registered")
}
runner = await this.runnerRepository.save(runner);
let response = new ResponseSelfServiceRunner(await this.runnerRepository.findOne(runner, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards', 'distanceDonations', 'distanceDonations.donor', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] }));
response.token = JwtCreator.createSelfService(runner);
try {
await Mailer.sendSelfserviceWelcomeMail(runner.email, runner.id, runner.firstname, runner.middlename, runner.lastname, response.token, locale)
} catch (error) {
throw new MailSendingError();
}
return response;
}
@Get('/organizations/selfservice/:token')
@ResponseSchema(ResponseSelfServiceOrganisation, { isArray: false })
@ResponseSchema(RunnerOrganizationNotFoundError, { statusCode: 404 })
@OpenAPI({ description: 'Get the basic info and teams for a org.' })
async getSelfserviceOrg(@Param('token') token: string) {
const orgid = (await this.getOrgansisation(token)).id;
const org = await this.orgRepository.findOne({ id: orgid }, { relations: ['teams'] })
return new ResponseSelfServiceOrganisation(<RunnerOrganization>org);
}
/**
* Get's a runner by a provided jwt token.
* @param token The runner jwt provided by the runner to identitfy themselves.
*/
private async getRunner(token: string): Promise<Runner> {
if (token == "") { throw new JwtNotProvidedError(); }
let jwtPayload = undefined
try {
jwtPayload = <any>jwt.verify(token, config.jwt_secret);
} catch (error) {
throw new InvalidCredentialsError();
}
const runner = await this.runnerRepository.findOne({ id: jwtPayload["id"] }, { relations: ['scans', 'group', 'group.parentGroup', 'scans.track', 'cards', 'distanceDonations', 'distanceDonations.donor', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] });
if (!runner) { throw new RunnerNotFoundError() }
return runner;
}
/**
* Get's a runner org by a provided registration api key.
* @param token The organization's registration api token.
*/
private async getOrgansisation(token: string): Promise<RunnerGroup> {
token = Buffer.from(token, 'base64').toString('utf8');
const organization = await this.orgRepository.findOne({ key: token });
if (!organization) { throw new RunnerOrganizationNotFoundError; }
return organization;
}
/**
* Checks if a runner already exists
* @param email The runner's email address
* @returns Boolean (true if exists, false if not)
*/
private async getRunnerExistsByMail(email: string): Promise<boolean> {
const runner = await this.runnerRepository.findOne({ email });
return runner != undefined
}
} }

View File

@@ -1,11 +1,12 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { RunnerTeamHasRunnersError, RunnerTeamIdsNotMatchingError, RunnerTeamNotFoundError } from '../errors/RunnerTeamErrors'; import { RunnerTeamHasRunnersError, RunnerTeamIdsNotMatchingError, RunnerTeamNotFoundError } from '../errors/RunnerTeamErrors';
import { CreateRunnerTeam } from '../models/actions/create/CreateRunnerTeam'; import { CreateRunnerTeam } from '../models/actions/create/CreateRunnerTeam';
import { UpdateRunnerTeam } from '../models/actions/update/UpdateRunnerTeam'; import { UpdateRunnerTeam } from '../models/actions/update/UpdateRunnerTeam';
import { RunnerTeam } from '../models/entities/RunnerTeam'; import { RunnerTeam } from '../models/entities/RunnerTeam';
import { ResponseEmpty } from '../models/responses/ResponseEmpty'; import { ResponseEmpty } from '../models/responses/ResponseEmpty';
import { ResponseRunner } from '../models/responses/ResponseRunner';
import { ResponseRunnerTeam } from '../models/responses/ResponseRunnerTeam'; import { ResponseRunnerTeam } from '../models/responses/ResponseRunnerTeam';
import { RunnerController } from './RunnerController'; import { RunnerController } from './RunnerController';
@@ -26,11 +27,18 @@ export class RunnerTeamController {
@Authorized("TEAM:GET") @Authorized("TEAM:GET")
@ResponseSchema(ResponseRunnerTeam, { isArray: true }) @ResponseSchema(ResponseRunnerTeam, { isArray: true })
@OpenAPI({ description: 'Lists all teams. <br> This includes their parent organization and contact (if existing/associated).' }) @OpenAPI({ description: 'Lists all teams. <br> This includes their parent organization and contact (if existing/associated).' })
async getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
let responseTeams: ResponseRunnerTeam[] = new Array<ResponseRunnerTeam>(); let responseTeams: ResponseRunnerTeam[] = new Array<ResponseRunnerTeam>();
const runners = await this.runnerTeamRepository.find({ relations: ['parentGroup', 'contact'] }); let teams: Array<RunnerTeam>;
runners.forEach(runner => {
responseTeams.push(new ResponseRunnerTeam(runner)); if (page != undefined) {
teams = await this.runnerTeamRepository.find({ relations: ['parentGroup', 'contact'], skip: page * page_size, take: page_size });
} else {
teams = await this.runnerTeamRepository.find({ relations: ['parentGroup', 'contact'] });
}
teams.forEach(team => {
responseTeams.push(new ResponseRunnerTeam(team));
}); });
return responseTeams; return responseTeams;
} }
@@ -42,11 +50,25 @@ export class RunnerTeamController {
@OnUndefined(RunnerTeamNotFoundError) @OnUndefined(RunnerTeamNotFoundError)
@OpenAPI({ description: 'Lists all information about the team whose id got provided.' }) @OpenAPI({ description: 'Lists all information about the team whose id got provided.' })
async getOne(@Param('id') id: number) { async getOne(@Param('id') id: number) {
let runnerTeam = await this.runnerTeamRepository.findOne({ id: id }, { relations: ['parentGroup', 'contact'] }); let runnerTeam = await this.runnerTeamRepository.findOne({ id: id }, { relations: ['parentGroup', 'contact', 'runners', 'runners.scans', 'runners.scans.track'] });
if (!runnerTeam) { throw new RunnerTeamNotFoundError(); } if (!runnerTeam) { throw new RunnerTeamNotFoundError(); }
return new ResponseRunnerTeam(runnerTeam); return new ResponseRunnerTeam(runnerTeam);
} }
@Get('/:id/runners')
@Authorized(["RUNNER:GET", "SCAN:GET"])
@ResponseSchema(ResponseRunner, { isArray: true })
@ResponseSchema(RunnerTeamNotFoundError, { statusCode: 404 })
@OpenAPI({ description: 'Lists all runners from this team. <br> This includes the runner\'s group and distance ran.' })
async getRunners(@Param('id') id: number, @QueryParam("selfservice_links", { required: false }) selfservice_links: boolean = false) {
let responseRunners: ResponseRunner[] = new Array<ResponseRunner>();
const runners = (await this.runnerTeamRepository.findOne({ id: id }, { relations: ['runners', 'runners.group', 'runners.group.parentGroup', 'runners.scans', 'runners.scans.track'] })).runners;
runners.forEach(runner => {
responseRunners.push(new ResponseRunner(runner, selfservice_links));
});
return responseRunners;
}
@Post() @Post()
@Authorized("TEAM:CREATE") @Authorized("TEAM:CREATE")
@ResponseSchema(ResponseRunnerTeam) @ResponseSchema(ResponseRunnerTeam)
@@ -97,7 +119,7 @@ export class RunnerTeamController {
async remove(@Param("id") id: number, @QueryParam("force") force: boolean) { async remove(@Param("id") id: number, @QueryParam("force") force: boolean) {
let team = await this.runnerTeamRepository.findOne({ id: id }); let team = await this.runnerTeamRepository.findOne({ id: id });
if (!team) { return null; } if (!team) { return null; }
let runnerTeam = await this.runnerTeamRepository.findOne(team, { relations: ['parentGroup', 'contact', 'runners'] }); let runnerTeam = await this.runnerTeamRepository.findOne(team, { relations: ['runners'] });
if (!force) { if (!force) {
if (runnerTeam.runners.length != 0) { if (runnerTeam.runners.length != 0) {

View File

@@ -1,6 +1,7 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam, UseBefore } from 'routing-controllers'; import type { Request } from "express";
import { Authorized, Body, Delete, Get, HttpError, JsonController, OnUndefined, Param, Post, Put, QueryParam, Req, UseBefore } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { getConnection, getConnectionManager, Repository } 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';
@@ -9,12 +10,16 @@ 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';
import { getCardEntry, setCardEntry } from '../nats/CardKV';
import { deleteRunnerEntry, getRunnerEntry, RunnerKVEntry, setRunnerEntry, warmRunner } from '../nats/RunnerKV';
import { getStationEntryById } from '../nats/StationKV';
@JsonController('/scans') @JsonController('/scans')
@OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ security: [{ "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
export class ScanController { export class ScanController {
@@ -34,9 +39,16 @@ export class ScanController {
@ResponseSchema(ResponseScan, { isArray: true }) @ResponseSchema(ResponseScan, { isArray: true })
@ResponseSchema(ResponseTrackScan, { isArray: true }) @ResponseSchema(ResponseTrackScan, { isArray: true })
@OpenAPI({ description: 'Lists all scans (normal or track) from all runners. <br> This includes the scan\'s runner\'s distance ran.' }) @OpenAPI({ description: 'Lists all scans (normal or track) from all runners. <br> This includes the scan\'s runner\'s distance ran.' })
async getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
let responseScans: ResponseScan[] = new Array<ResponseScan>(); let responseScans: ResponseScan[] = new Array<ResponseScan>();
const scans = await this.scanRepository.find({ relations: ['runner', 'track', 'runner.scans', 'runner.scans.track', 'card', 'station'] }); let scans: Array<Scan>;
if (page != undefined) {
scans = await this.scanRepository.find({ relations: ['runner', 'track'], skip: page * page_size, take: page_size });
} else {
scans = await this.scanRepository.find({ relations: ['runner', 'track'] });
}
scans.forEach(scan => { scans.forEach(scan => {
responseScans.push(scan.toResponse()); responseScans.push(scan.toResponse());
}); });
@@ -51,7 +63,7 @@ export class ScanController {
@OnUndefined(ScanNotFoundError) @OnUndefined(ScanNotFoundError)
@OpenAPI({ description: 'Lists all information about the scan whose id got provided. This includes the scan\'s runner\'s distance ran.' }) @OpenAPI({ description: 'Lists all information about the scan whose id got provided. This includes the scan\'s runner\'s distance ran.' })
async getOne(@Param('id') id: number) { async getOne(@Param('id') id: number) {
let scan = await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.scans.track', 'card', 'station'] }) let scan = await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.group', 'card', 'station'] })
if (!scan) { throw new ScanNotFoundError(); } if (!scan) { throw new ScanNotFoundError(); }
return scan.toResponse(); return scan.toResponse();
} }
@@ -60,22 +72,120 @@ export class ScanController {
@UseBefore(ScanAuth) @UseBefore(ScanAuth)
@ResponseSchema(ResponseScan) @ResponseSchema(ResponseScan)
@ResponseSchema(RunnerNotFoundError, { statusCode: 404 }) @ResponseSchema(RunnerNotFoundError, { statusCode: 404 })
@OpenAPI({ description: 'Create a new scan (not track scan - use /scans/trackscans instead). <br> Please rmemember to provide the scan\'s runner\'s id and distance.', security: [{ "ScanApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: 'Create a new scan (not track scan - use /scans/trackscans instead). <br> Please rmemember to provide the scan\'s runner\'s id and distance.', security: [{ "StationApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async post(@Body({ validate: true }) createScan: CreateScan) { async post(@Body({ validate: true }) createScan: CreateScan) {
let scan = await createScan.toEntity(); let scan = await createScan.toEntity();
scan = await this.scanRepository.save(scan); scan = await this.scanRepository.save(scan);
return (await this.scanRepository.findOne({ id: scan.id }, { relations: ['runner', 'track', 'runner.scans', '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();
} }
@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: [{ "ScanApiToken": [] }, { "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) { async postTrackScans(@Body({ validate: true }) createScan: CreateTrackScan, @Req() req: Request) {
// Station token path — KV-backed intake flow
if (req.isStationAuth) {
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.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.distance + (valid ? trackDistance : 0);
const newTimestamp = valid ? now : entry.latestTimestamp;
const updated: RunnerKVEntry = {
displayName: entry.displayName,
distance: 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.distance = 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')
@@ -86,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();
@@ -96,8 +206,10 @@ 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));
return (await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.scans.track', 'card', 'station'] })).toResponse(); 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();
} }
@Put('/trackscans/:id') @Put('/trackscans/:id')
@@ -109,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();
@@ -119,8 +231,10 @@ 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));
return (await this.scanRepository.findOne({ id: id }, { relations: ['runner', 'track', 'runner.scans', 'runner.scans.track', 'card', 'station'] })).toResponse(); 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();
} }
@Delete('/:id') @Delete('/:id')
@@ -132,7 +246,7 @@ export class ScanController {
async remove(@Param("id") id: number, @QueryParam("force") force: boolean) { async remove(@Param("id") id: number, @QueryParam("force") force: boolean) {
let scan = await this.scanRepository.findOne({ id: id }); let scan = await this.scanRepository.findOne({ id: id });
if (!scan) { return null; } if (!scan) { return null; }
const responseScan = await this.scanRepository.findOne({ id: scan.id }, { relations: ['runner', 'track', 'runner.scans', 'runner.scans.track', 'card', 'station'] }); const responseScan = await this.scanRepository.findOne({ id: scan.id }, { relations: ['runner', 'track', 'runner.scans', 'runner.group', 'runner.scans.track', 'card', 'station'] });
await this.scanRepository.delete(scan); await this.scanRepository.delete(scan);
return responseScan.toResponse(); return responseScan.toResponse();

View File

@@ -1,8 +1,9 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { ScanStationHasScansError, ScanStationIdsNotMatchingError, ScanStationNotFoundError } from '../errors/ScanStationErrors'; import { ScanStationHasScansError, ScanStationIdsNotMatchingError, ScanStationNotFoundError } from '../errors/ScanStationErrors';
import { TrackNotFoundError } from '../errors/TrackErrors'; import { TrackNotFoundError } from '../errors/TrackErrors';
import { deleteStationEntry } from '../nats/StationKV';
import { CreateScanStation } from '../models/actions/create/CreateScanStation'; import { CreateScanStation } from '../models/actions/create/CreateScanStation';
import { UpdateScanStation } from '../models/actions/update/UpdateScanStation'; import { UpdateScanStation } from '../models/actions/update/UpdateScanStation';
import { ScanStation } from '../models/entities/ScanStation'; import { ScanStation } from '../models/entities/ScanStation';
@@ -26,9 +27,16 @@ export class ScanStationController {
@Authorized("STATION:GET") @Authorized("STATION:GET")
@ResponseSchema(ResponseScanStation, { isArray: true }) @ResponseSchema(ResponseScanStation, { isArray: true })
@OpenAPI({ description: 'Lists all stations. <br> This includes their associated tracks.' }) @OpenAPI({ description: 'Lists all stations. <br> This includes their associated tracks.' })
async getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
let responseStations: ResponseScanStation[] = new Array<ResponseScanStation>(); let responseStations: ResponseScanStation[] = new Array<ResponseScanStation>();
const stations = await this.stationRepository.find({ relations: ['track'] }); let stations: Array<ScanStation>;
if (page != undefined) {
stations = await this.stationRepository.find({ relations: ['track'], skip: page * page_size, take: page_size });
} else {
stations = await this.stationRepository.find({ relations: ['track'] });
}
stations.forEach(station => { stations.forEach(station => {
responseStations.push(station.toResponse()); responseStations.push(station.toResponse());
}); });
@@ -78,6 +86,7 @@ export class ScanStationController {
} }
await this.stationRepository.save(await station.update(oldStation)); await this.stationRepository.save(await station.update(oldStation));
await deleteStationEntry(oldStation.prefix);
return (await this.stationRepository.findOne({ id: id }, { relations: ['track'] })).toResponse(); return (await this.stationRepository.findOne({ id: id }, { relations: ['track'] })).toResponse();
} }
@@ -102,6 +111,7 @@ export class ScanStationController {
} }
const responseStation = await this.stationRepository.findOne({ id: station.id }, { relations: ["track"] }); const responseStation = await this.stationRepository.findOne({ id: station.id }, { relations: ["track"] });
await deleteStationEntry(station.prefix);
await this.stationRepository.delete(station); await this.stationRepository.delete(station);
return responseStation.toResponse(); return responseStation.toResponse();
} }

View File

@@ -1,6 +1,6 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, QueryParam } from 'routing-controllers'; import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { StatsClientNotFoundError } from '../errors/StatsClientErrors'; import { StatsClientNotFoundError } from '../errors/StatsClientErrors';
import { TrackNotFoundError } from "../errors/TrackErrors"; import { TrackNotFoundError } from "../errors/TrackErrors";
import { CreateStatsClient } from '../models/actions/create/CreateStatsClient'; import { CreateStatsClient } from '../models/actions/create/CreateStatsClient';
@@ -24,9 +24,16 @@ export class StatsClientController {
@Authorized("STATSCLIENT:GET") @Authorized("STATSCLIENT:GET")
@ResponseSchema(ResponseStatsClient, { isArray: true }) @ResponseSchema(ResponseStatsClient, { isArray: true })
@OpenAPI({ description: 'Lists all stats clients. Please remember that the key can only be viewed on creation.' }) @OpenAPI({ description: 'Lists all stats clients. Please remember that the key can only be viewed on creation.' })
async getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
let responseClients: ResponseStatsClient[] = new Array<ResponseStatsClient>(); let responseClients: ResponseStatsClient[] = new Array<ResponseStatsClient>();
const clients = await this.clientRepository.find(); let clients: Array<StatsClient>;
if (page != undefined) {
clients = await this.clientRepository.find({ skip: page * page_size, take: page_size });
} else {
clients = await this.clientRepository.find();
}
clients.forEach(clients => { clients.forEach(clients => {
responseClients.push(new ResponseStatsClient(clients)); responseClients.push(new ResponseStatsClient(clients));
}); });

View File

@@ -1,17 +1,20 @@
import { Get, JsonController, UseBefore } from 'routing-controllers'; import { Get, JsonController, QueryParam, UseBefore } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnection } from 'typeorm'; import { getConnection } from 'typeorm';
import StatsAuth from '../middlewares/StatsAuth'; import StatsAuth from '../middlewares/StatsAuth';
import { Donation } from '../models/entities/Donation'; import { Donation } from '../models/entities/Donation';
import { Donor } from '../models/entities/Donor';
import { Runner } from '../models/entities/Runner'; import { Runner } from '../models/entities/Runner';
import { RunnerOrganization } from '../models/entities/RunnerOrganization'; import { RunnerOrganization } from '../models/entities/RunnerOrganization';
import { RunnerTeam } from '../models/entities/RunnerTeam'; import { RunnerTeam } from '../models/entities/RunnerTeam';
import { Scan } from '../models/entities/Scan'; import { Scan } from '../models/entities/Scan';
import { TrackScan } from '../models/entities/TrackScan';
import { User } from '../models/entities/User'; import { User } from '../models/entities/User';
import { ResponseStats } from '../models/responses/ResponseStats'; import { ResponseStats } from '../models/responses/ResponseStats';
import { ResponseStatsOrgnisation } from '../models/responses/ResponseStatsOrganization'; import { ResponseStatsOrgnisation } from '../models/responses/ResponseStatsOrganization';
import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner'; import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner';
import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam'; import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam';
import { getStatsCache, setStatsCache } from '../nats/StatsKV';
@JsonController('/stats') @JsonController('/stats')
export class StatsController { export class StatsController {
@@ -20,14 +23,40 @@ export class StatsController {
@ResponseSchema(ResponseStats) @ResponseSchema(ResponseStats)
@OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" }) @OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" })
async get() { async get() {
let connection = getConnection(); // Try cache first
let runners = await connection.getRepository(Runner).find({ relations: ['scans', 'scans.track'] }); const cached = await getStatsCache<ResponseStats>('overview');
let teams = await connection.getRepository(RunnerTeam).find(); if (cached) {
let orgs = await connection.getRepository(RunnerOrganization).find(); return cached;
let users = await connection.getRepository(User).find(); }
let scans = await connection.getRepository(Scan).find();
// Cache miss - compute fresh stats
const connection = getConnection();
const runnersViaSelfservice = await connection.getRepository(Runner).count({ where: { created_via: "selfservice" } });
const runnersViaKiosk = await connection.getRepository(Runner).count({ where: { created_via: "kiosk" } });
const runners = await connection.getRepository(Runner).count();
const teams = await connection.getRepository(RunnerTeam).count();
const orgs = await connection.getRepository(RunnerOrganization).count();
const users = await connection.getRepository(User).count();
const scans = await connection.getRepository(Scan).count({ where: { valid: true } });
const distance_query = await connection.getRepository(Scan).createQueryBuilder('scan')
.leftJoinAndSelect("scan.track", "track").where("scan.valid = TRUE")
.select("SUM(track.distance)", "sum_track").addSelect("SUM(_distance)", "sum_distance")
.getRawOne();
let distace = parseInt(distance_query.sum_track)
if (distance_query.sum_distance) {
distace += parseInt(distance_query.sum_distance)
}
let donations = await connection.getRepository(Donation).find({ relations: ['runner', 'runner.scans', 'runner.scans.track'] }); let donations = await connection.getRepository(Donation).find({ relations: ['runner', 'runner.scans', 'runner.scans.track'] });
return new ResponseStats(runners, teams, orgs, users, scans, donations) const donors = await connection.getRepository(Donor).count();
const result = new ResponseStats(runnersViaSelfservice, runners, teams, orgs, users, scans, donations, distace, donors, runnersViaKiosk);
// Store in cache for 60 seconds
await setStatsCache('overview', result);
return result;
} }
@Get("/runners/distance") @Get("/runners/distance")
@@ -35,12 +64,26 @@ export class StatsController {
@ResponseSchema(ResponseStatsRunner, { isArray: true }) @ResponseSchema(ResponseStatsRunner, { isArray: true })
@OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopRunnersByDistance() { async getTopRunnersByDistance() {
// Try cache first
const cached = await getStatsCache<ResponseStatsRunner[]>('runners.distance');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] }); let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] });
let topRunners = runners.sort((runner1, runner2) => runner1.distance - runner2.distance).slice(0, 9); if (!runners || runners.length == 0) {
return [];
}
let topRunners = runners.sort((runner1, runner2) => runner2.distance - runner1.distance).slice(0, 10);
let responseRunners: ResponseStatsRunner[] = new Array<ResponseStatsRunner>(); let responseRunners: ResponseStatsRunner[] = new Array<ResponseStatsRunner>();
topRunners.forEach(runner => { topRunners.forEach(runner => {
responseRunners.push(new ResponseStatsRunner(runner)); responseRunners.push(new ResponseStatsRunner(runner));
}); });
// Store in cache for 60 seconds
await setStatsCache('runners.distance', responseRunners);
return responseRunners; return responseRunners;
} }
@@ -49,12 +92,66 @@ export class StatsController {
@ResponseSchema(ResponseStatsRunner, { isArray: true }) @ResponseSchema(ResponseStatsRunner, { isArray: true })
@OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopRunnersByDonations() { async getTopRunnersByDonations() {
let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] }); // Try cache first
let topRunners = runners.sort((runner1, runner2) => runner1.distanceDonationAmount - runner2.distanceDonationAmount).slice(0, 9); const cached = await getStatsCache<ResponseStatsRunner[]>('runners.donations');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let runners = await getConnection().getRepository(Runner).find({ relations: ['group', 'distanceDonations', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] });
if (!runners || runners.length == 0) {
return [];
}
let topRunners = runners.sort((runner1, runner2) => runner2.distanceDonationAmount - runner1.distanceDonationAmount).slice(0, 10);
let responseRunners: ResponseStatsRunner[] = new Array<ResponseStatsRunner>(); let responseRunners: ResponseStatsRunner[] = new Array<ResponseStatsRunner>();
topRunners.forEach(runner => { topRunners.forEach(runner => {
responseRunners.push(new ResponseStatsRunner(runner)); responseRunners.push(new ResponseStatsRunner(runner));
}); });
// Store in cache for 60 seconds
await setStatsCache('runners.donations', responseRunners);
return responseRunners;
}
@Get("/runners/laptime")
@UseBefore(StatsAuth)
@ResponseSchema(ResponseStatsRunner, { isArray: true })
@OpenAPI({ description: "Returns the top ten runners by fastest laptime on your selected track (track by id).", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopRunnersByLaptime(@QueryParam("track") track: number) {
// Try cache first (cache key includes track id, using dots for NATS KV compatibility)
const cacheKey = `runners.laptime.${track}`;
const cached = await getStatsCache<ResponseStatsRunner[]>(cacheKey);
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let scans = await getConnection().getRepository(TrackScan).find({ relations: ['track', 'runner', 'runner.group', 'runner.scans', 'runner.scans.track', 'runner.distanceDonations'] });
if (!scans || scans.length == 0) {
return [];
}
scans = scans.filter((s) => { return s.track.id == track && s.valid == true && s.lapTime != 0 }).sort((scan1, scan2) => scan1.lapTime - scan2.lapTime);
let topScans = new Array<TrackScan>();
let knownRunners = new Array<number>();
for (let i = 0; i < scans.length && topScans.length < 10; i++) {
const element = scans[i];
if (!knownRunners.includes(element.runner.id)) {
topScans.push(element);
knownRunners.push(element.runner.id);
}
}
let responseRunners: ResponseStatsRunner[] = new Array<ResponseStatsRunner>();
topScans.forEach(scan => {
responseRunners.push(new ResponseStatsRunner(scan.runner, scan.lapTime));
});
// Store in cache for 60 seconds
await setStatsCache(cacheKey, responseRunners);
return responseRunners; return responseRunners;
} }
@@ -71,12 +168,26 @@ export class StatsController {
@ResponseSchema(ResponseStatsTeam, { isArray: true }) @ResponseSchema(ResponseStatsTeam, { isArray: true })
@OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopTeamsByDistance() { async getTopTeamsByDistance() {
let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] }); // Try cache first
let topTeams = teams.sort((team1, team2) => team1.distance - team2.distance).slice(0, 9); const cached = await getStatsCache<ResponseStatsTeam[]>('teams.distance');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.scans.track'] });
if (!teams || teams.length == 0) {
return [];
}
let topTeams = teams.sort((team1, team2) => team2.distance - team1.distance).slice(0, 10);
let responseTeams: ResponseStatsTeam[] = new Array<ResponseStatsTeam>(); let responseTeams: ResponseStatsTeam[] = new Array<ResponseStatsTeam>();
topTeams.forEach(team => { topTeams.forEach(team => {
responseTeams.push(new ResponseStatsTeam(team)); responseTeams.push(new ResponseStatsTeam(team));
}); });
// Store in cache for 60 seconds
await setStatsCache('teams.distance', responseTeams);
return responseTeams; return responseTeams;
} }
@@ -85,12 +196,26 @@ export class StatsController {
@ResponseSchema(ResponseStatsTeam, { isArray: true }) @ResponseSchema(ResponseStatsTeam, { isArray: true })
@OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopTeamsByDonations() { async getTopTeamsByDonations() {
let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] }); // Try cache first
let topTeams = teams.sort((team1, team2) => team1.distanceDonationAmount - team2.distanceDonationAmount).slice(0, 9); const cached = await getStatsCache<ResponseStatsTeam[]>('teams.donations');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] });
if (!teams || teams.length == 0) {
return [];
}
let topTeams = teams.sort((team1, team2) => team2.distanceDonationAmount - team1.distanceDonationAmount).slice(0, 10);
let responseTeams: ResponseStatsTeam[] = new Array<ResponseStatsTeam>(); let responseTeams: ResponseStatsTeam[] = new Array<ResponseStatsTeam>();
topTeams.forEach(team => { topTeams.forEach(team => {
responseTeams.push(new ResponseStatsTeam(team)); responseTeams.push(new ResponseStatsTeam(team));
}); });
// Store in cache for 60 seconds
await setStatsCache('teams.donations', responseTeams);
return responseTeams; return responseTeams;
} }
@@ -99,12 +224,26 @@ export class StatsController {
@ResponseSchema(ResponseStatsOrgnisation, { isArray: true }) @ResponseSchema(ResponseStatsOrgnisation, { isArray: true })
@OpenAPI({ description: "Returns the top ten organizations by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: "Returns the top ten organizations by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopOrgsByDistance() { async getTopOrgsByDistance() {
// Try cache first
const cached = await getStatsCache<ResponseStatsOrgnisation[]>('organizations.distance');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] }); let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] });
let topOrgs = orgs.sort((org1, org2) => org1.distance - org2.distance).slice(0, 9); if (!orgs || orgs.length == 0) {
return [];
}
let topOrgs = orgs.sort((org1, org2) => org2.distance - org1.distance).slice(0, 10);
let responseOrgs: ResponseStatsOrgnisation[] = new Array<ResponseStatsOrgnisation>(); let responseOrgs: ResponseStatsOrgnisation[] = new Array<ResponseStatsOrgnisation>();
topOrgs.forEach(org => { topOrgs.forEach(org => {
responseOrgs.push(new ResponseStatsOrgnisation(org)); responseOrgs.push(new ResponseStatsOrgnisation(org));
}); });
// Store in cache for 60 seconds
await setStatsCache('organizations.distance', responseOrgs);
return responseOrgs; return responseOrgs;
} }
@@ -113,12 +252,26 @@ export class StatsController {
@ResponseSchema(ResponseStatsOrgnisation, { isArray: true }) @ResponseSchema(ResponseStatsOrgnisation, { isArray: true })
@OpenAPI({ description: "Returns the top ten organizations by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] }) @OpenAPI({ description: "Returns the top ten organizations by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopOrgsByDonations() { async getTopOrgsByDonations() {
let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] }); // Try cache first
let topOrgs = orgs.sort((org1, org2) => org1.distanceDonationAmount - org2.distanceDonationAmount).slice(0, 9); const cached = await getStatsCache<ResponseStatsOrgnisation[]>('organizations.donations');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.distanceDonations', 'runners.distanceDonations.runner', 'runners.distanceDonations.runner.scans', 'runners.distanceDonations.runner.scans.track', 'teams', 'teams.runners', 'teams.runners.distanceDonations', 'teams.runners.distanceDonations.runner', 'teams.runners.distanceDonations.runner.scans', 'teams.runners.distanceDonations.runner.scans.track'] });
if (!orgs || orgs.length == 0) {
return [];
}
let topOrgs = orgs.sort((org1, org2) => org2.distanceDonationAmount - org1.distanceDonationAmount).slice(0, 10);
let responseOrgs: ResponseStatsOrgnisation[] = new Array<ResponseStatsOrgnisation>(); let responseOrgs: ResponseStatsOrgnisation[] = new Array<ResponseStatsOrgnisation>();
topOrgs.forEach(org => { topOrgs.forEach(org => {
responseOrgs.push(new ResponseStatsOrgnisation(org)); responseOrgs.push(new ResponseStatsOrgnisation(org));
}); });
// Store in cache for 60 seconds
await setStatsCache('organizations.donations', responseOrgs);
return responseOrgs; return responseOrgs;
} }
} }

View File

@@ -1,6 +1,6 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { TrackHasScanStationsError, TrackIdsNotMatchingError, TrackLapTimeCantBeNegativeError, TrackNotFoundError } from "../errors/TrackErrors"; import { TrackHasScanStationsError, TrackIdsNotMatchingError, TrackLapTimeCantBeNegativeError, TrackNotFoundError } from "../errors/TrackErrors";
import { CreateTrack } from '../models/actions/create/CreateTrack'; import { CreateTrack } from '../models/actions/create/CreateTrack';
import { UpdateTrack } from '../models/actions/update/UpdateTrack'; import { UpdateTrack } from '../models/actions/update/UpdateTrack';
@@ -25,9 +25,17 @@ export class TrackController {
@Authorized("TRACK:GET") @Authorized("TRACK:GET")
@ResponseSchema(ResponseTrack, { isArray: true }) @ResponseSchema(ResponseTrack, { isArray: true })
@OpenAPI({ description: 'Lists all tracks.' }) @OpenAPI({ description: 'Lists all tracks.' })
async getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
let responseTracks: ResponseTrack[] = new Array<ResponseTrack>(); let responseTracks: ResponseTrack[] = new Array<ResponseTrack>();
const tracks = await this.trackRepository.find(); let tracks: Array<Track>;
if (page != undefined) {
tracks = await this.trackRepository.find({ skip: page * page_size, take: page_size });
}
else {
tracks = await this.trackRepository.find();
}
tracks.forEach(track => { tracks.forEach(track => {
responseTracks.push(new ResponseTrack(track)); responseTracks.push(new ResponseTrack(track));
}); });

View File

@@ -1,7 +1,7 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { UserDeletionNotConfirmedError, UserIdsNotMatchingError, UsernameContainsIllegalCharacterError, UserNotFoundError } from '../errors/UserErrors'; import { PasswordMustContainLowercaseLetterError, PasswordMustContainNumberError, PasswordMustContainUppercaseLetterError, PasswordTooShortError, UserDeletionNotConfirmedError, UserIdsNotMatchingError, UserNotFoundError, UsernameContainsIllegalCharacterError } from '../errors/UserErrors';
import { UserGroupNotFoundError } from '../errors/UserGroupErrors'; import { UserGroupNotFoundError } from '../errors/UserGroupErrors';
import { CreateUser } from '../models/actions/create/CreateUser'; import { CreateUser } from '../models/actions/create/CreateUser';
import { UpdateUser } from '../models/actions/update/UpdateUser'; import { UpdateUser } from '../models/actions/update/UpdateUser';
@@ -28,9 +28,17 @@ export class UserController {
@Authorized("USER:GET") @Authorized("USER:GET")
@ResponseSchema(ResponseUser, { isArray: true }) @ResponseSchema(ResponseUser, { isArray: true })
@OpenAPI({ description: 'Lists all users. <br> This includes their groups and permissions granted to them.' }) @OpenAPI({ description: 'Lists all users. <br> This includes their groups and permissions granted to them.' })
async getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
let responseUsers: ResponseUser[] = new Array<ResponseUser>(); let responseUsers: ResponseUser[] = new Array<ResponseUser>();
const users = await this.userRepository.find({ relations: ['permissions', 'groups', 'groups.permissions'] }); let users: Array<User>;
if (page != undefined) {
users = await this.userRepository.find({ relations: ['permissions', 'groups', 'groups.permissions'], skip: page * page_size, take: page_size });
}
else {
users = await this.userRepository.find({ relations: ['permissions', 'groups', 'groups.permissions'] });
}
users.forEach(user => { users.forEach(user => {
responseUsers.push(new ResponseUser(user)); responseUsers.push(new ResponseUser(user));
}); });
@@ -66,6 +74,10 @@ export class UserController {
@ResponseSchema(ResponseUser) @ResponseSchema(ResponseUser)
@ResponseSchema(UserGroupNotFoundError, { statusCode: 404 }) @ResponseSchema(UserGroupNotFoundError, { statusCode: 404 })
@ResponseSchema(UsernameContainsIllegalCharacterError, { statusCode: 406 }) @ResponseSchema(UsernameContainsIllegalCharacterError, { statusCode: 406 })
@ResponseSchema(PasswordMustContainUppercaseLetterError, { statusCode: 406 })
@ResponseSchema(PasswordMustContainLowercaseLetterError, { statusCode: 406 })
@ResponseSchema(PasswordMustContainNumberError, { statusCode: 406 })
@ResponseSchema(PasswordTooShortError, { statusCode: 406 })
@OpenAPI({ description: 'Create a new user. <br> If you want to grant permissions to the user you have to create them seperately by posting to /api/permissions after creating the user.' }) @OpenAPI({ description: 'Create a new user. <br> If you want to grant permissions to the user you have to create them seperately by posting to /api/permissions after creating the user.' })
async post(@Body({ validate: true }) createUser: CreateUser) { async post(@Body({ validate: true }) createUser: CreateUser) {
let user; let user;
@@ -85,6 +97,10 @@ export class UserController {
@ResponseSchema(UserNotFoundError, { statusCode: 404 }) @ResponseSchema(UserNotFoundError, { statusCode: 404 })
@ResponseSchema(UserIdsNotMatchingError, { statusCode: 406 }) @ResponseSchema(UserIdsNotMatchingError, { statusCode: 406 })
@ResponseSchema(UsernameContainsIllegalCharacterError, { statusCode: 406 }) @ResponseSchema(UsernameContainsIllegalCharacterError, { statusCode: 406 })
@ResponseSchema(PasswordMustContainUppercaseLetterError, { statusCode: 406 })
@ResponseSchema(PasswordMustContainLowercaseLetterError, { statusCode: 406 })
@ResponseSchema(PasswordMustContainNumberError, { statusCode: 406 })
@ResponseSchema(PasswordTooShortError, { statusCode: 406 })
@OpenAPI({ description: "Update the user whose id you provided. <br> To change the permissions directly granted to the user please use /api/permissions instead. <br> Please remember that ids can't be changed." }) @OpenAPI({ description: "Update the user whose id you provided. <br> To change the permissions directly granted to the user please use /api/permissions instead. <br> Please remember that ids can't be changed." })
async put(@Param('id') id: number, @Body({ validate: true }) updateUser: UpdateUser) { async put(@Param('id') id: number, @Body({ validate: true }) updateUser: UpdateUser) {
let oldUser = await this.userRepository.findOne({ id: id }); let oldUser = await this.userRepository.findOne({ id: id });

View File

@@ -1,13 +1,13 @@
import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers'; import { Authorized, Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put, QueryParam } from 'routing-controllers';
import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi'; import { OpenAPI, ResponseSchema } from 'routing-controllers-openapi';
import { getConnectionManager, Repository } from 'typeorm'; import { Repository, getConnectionManager } from 'typeorm';
import { EntityFromBody } from 'typeorm-routing-controllers-extensions';
import { UserGroupIdsNotMatchingError, UserGroupNotFoundError } from '../errors/UserGroupErrors'; import { UserGroupIdsNotMatchingError, UserGroupNotFoundError } from '../errors/UserGroupErrors';
import { CreateUserGroup } from '../models/actions/create/CreateUserGroup'; import { CreateUserGroup } from '../models/actions/create/CreateUserGroup';
import { UpdateUserGroup } from '../models/actions/update/UpdateUserGroup'; import { UpdateUserGroup } from '../models/actions/update/UpdateUserGroup';
import { UserGroup } from '../models/entities/UserGroup'; import { UserGroup } from '../models/entities/UserGroup';
import { ResponseEmpty } from '../models/responses/ResponseEmpty'; import { ResponseEmpty } from '../models/responses/ResponseEmpty';
import { ResponseUserGroup } from '../models/responses/ResponseUserGroup'; import { ResponseUserGroup } from '../models/responses/ResponseUserGroup';
import { ResponseUserGroupPermissions } from '../models/responses/ResponseUserGroupPermissions';
import { PermissionController } from './PermissionController'; import { PermissionController } from './PermissionController';
@@ -25,20 +25,44 @@ export class UserGroupController {
@Get() @Get()
@Authorized("USERGROUP:GET") @Authorized("USERGROUP:GET")
@ResponseSchema(UserGroup, { isArray: true }) @ResponseSchema(ResponseUserGroup, { isArray: true })
@OpenAPI({ description: 'Lists all groups. <br> The information provided might change while the project continues to evolve.' }) @OpenAPI({ description: 'Lists all groups. <br> The information provided might change while the project continues to evolve.' })
getAll() { async getAll(@QueryParam("page", { required: false }) page: number, @QueryParam("page_size", { required: false }) page_size: number = 100) {
return this.userGroupsRepository.find({ relations: ["permissions"] }); let responseGroups: ResponseUserGroup[] = new Array<ResponseUserGroup>();
let groups: Array<UserGroup>;
if (page != undefined) {
groups = await this.userGroupsRepository.find({ relations: ['permissions'], skip: page * page_size, take: page_size });
} else {
groups = await this.userGroupsRepository.find({ relations: ['permissions'] });
}
groups.forEach(group => {
responseGroups.push(group.toResponse());
});
return responseGroups;
} }
@Get('/:id') @Get('/:id')
@Authorized("USERGROUP:GET") @Authorized("USERGROUP:GET")
@ResponseSchema(UserGroup) @ResponseSchema(ResponseUserGroup)
@ResponseSchema(UserGroupNotFoundError, { statusCode: 404 }) @ResponseSchema(UserGroupNotFoundError, { statusCode: 404 })
@OnUndefined(UserGroupNotFoundError) @OnUndefined(UserGroupNotFoundError)
@OpenAPI({ description: 'Lists all information about the group whose id got provided. <br> The information provided might change while the project continues to evolve.' }) @OpenAPI({ description: 'Lists all information about the group whose id got provided. <br> The information provided might change while the project continues to evolve.' })
getOne(@Param('id') id: number) { async getOne(@Param('id') id: number) {
return this.userGroupsRepository.findOne({ id: id }, { relations: ["permissions"] }); return await (await (this.userGroupsRepository.findOne({ id: id }, { relations: ["permissions"] }))).toResponse();
}
@Get('/:id/permissions')
@Authorized("USERGROUP:GET")
@ResponseSchema(ResponseUserGroupPermissions)
@ResponseSchema(UserGroupNotFoundError, { statusCode: 404 })
@OnUndefined(UserGroupNotFoundError)
@OpenAPI({ description: 'Lists all permissions granted to the group as permission response objects.' })
async getPermissions(@Param('id') id: number) {
let group = await this.userGroupsRepository.findOne({ id: id }, { relations: ['permissions', 'permissions.principal'] })
if (!group) { throw new UserGroupNotFoundError(); }
return new ResponseUserGroupPermissions(group);
} }
@Post() @Post()
@@ -54,7 +78,8 @@ export class UserGroupController {
throw error; throw error;
} }
return this.userGroupsRepository.save(userGroup); userGroup = await this.userGroupsRepository.save(userGroup);
return (await (this.userGroupsRepository.findOne({ id: userGroup.id }, { relations: ["permissions"] }))).toResponse();
} }
@Put('/:id') @Put('/:id')
@@ -63,7 +88,7 @@ export class UserGroupController {
@ResponseSchema(UserGroupNotFoundError, { statusCode: 404 }) @ResponseSchema(UserGroupNotFoundError, { statusCode: 404 })
@ResponseSchema(UserGroupIdsNotMatchingError, { statusCode: 406 }) @ResponseSchema(UserGroupIdsNotMatchingError, { statusCode: 406 })
@OpenAPI({ description: "Update the group whose id you provided. <br> To change the permissions granted to the group please use /api/permissions instead. <br> Please remember that ids can't be changed." }) @OpenAPI({ description: "Update the group whose id you provided. <br> To change the permissions granted to the group please use /api/permissions instead. <br> Please remember that ids can't be changed." })
async put(@Param('id') id: number, @EntityFromBody() updateGroup: UpdateUserGroup) { async put(@Param('id') id: number, @Body({ validate: true }) updateGroup: UpdateUserGroup) {
let oldGroup = await this.userGroupsRepository.findOne({ id: id }); let oldGroup = await this.userGroupsRepository.findOne({ id: id });
if (!oldGroup) { if (!oldGroup) {
@@ -75,7 +100,7 @@ export class UserGroupController {
} }
await this.userGroupsRepository.save(await updateGroup.update(oldGroup)); await this.userGroupsRepository.save(await updateGroup.update(oldGroup));
return (await this.userGroupsRepository.findOne({ id: id }, { relations: ['permissions', 'groups'] })).toResponse(); return (await this.userGroupsRepository.findOne({ id: id }, { relations: ['permissions'] })).toResponse();
} }
@Delete('/:id') @Delete('/:id')
@@ -85,7 +110,7 @@ export class UserGroupController {
@OnUndefined(204) @OnUndefined(204)
@OpenAPI({ description: 'Delete the group whose id you provided. <br> If there are any permissions directly granted to the group they will get deleted as well. <br> Users associated with this group won\'t get deleted - just deassociated. <br> If no group with this id exists it will just return 204(no content).' }) @OpenAPI({ description: 'Delete the group whose id you provided. <br> If there are any permissions directly granted to the group they will get deleted as well. <br> Users associated with this group won\'t get deleted - just deassociated. <br> If no group with this id exists it will just return 204(no content).' })
async remove(@Param("id") id: number, @QueryParam("force") force: boolean) { async remove(@Param("id") id: number, @QueryParam("force") force: boolean) {
let group = await this.userGroupsRepository.findOne({ id: id }, { relations: ["permissions"] }); let group = await this.userGroupsRepository.findOne({ id: id });
if (!group) { return null; } if (!group) { return null; }
const responseGroup = await this.userGroupsRepository.findOne({ id: id }, { relations: ['permissions'] }); const responseGroup = await this.userGroupsRepository.findOne({ id: id }, { relations: ['permissions'] });

17
src/errors/MailErrors.ts Normal file
View File

@@ -0,0 +1,17 @@
import { IsString } from 'class-validator';
import { InternalServerError } from 'routing-controllers';
/**
* Error to throw when a permission couldn't be found.
*/
export class MailSendingError extends InternalServerError {
@IsString()
name = "MailSendingError"
@IsString()
message = "We had a problem sending the mail!"
constructor() {
super("We had a problem sending the mail!");
}
}

View File

@@ -46,6 +46,17 @@ export class RunnerEmailNeededError extends NotAcceptableError {
message = "Citizenrunners have to provide an email address for verification and contacting." message = "Citizenrunners have to provide an email address for verification and contacting."
} }
/**
* Error to throw when a runner already requested a new selfservice link in the last 30s.
*/
export class RunnerSelfserviceTimeoutError extends NotAcceptableError {
@IsString()
name = "RunnerSelfserviceTimeoutError"
@IsString()
message = "You can only reqest a new token every 30s."
}
/** /**
* Error to throw when a runner still has distance donations associated. * Error to throw when a runner still has distance donations associated.
*/ */

View File

@@ -71,4 +71,33 @@ export class UserDeletionNotConfirmedError extends NotAcceptableError {
@IsString() @IsString()
message = "You are trying to delete a user! \n If you're sure about doing this: provide the ?force=true query param." message = "You are trying to delete a user! \n If you're sure about doing this: provide the ?force=true query param."
}
export class PasswordMustContainUppercaseLetterError extends NotAcceptableError {
@IsString()
name = "PasswordMustContainUppercaseLetterError"
@IsString()
message = "Passwords must contain at least one uppercase letter."
}
export class PasswordMustContainLowercaseLetterError extends NotAcceptableError {
@IsString()
name = "PasswordMustContainLowercaseLetterError"
@IsString()
message = "Passwords must contain at least one lowercase letter."
}
export class PasswordMustContainNumberError extends NotAcceptableError {
@IsString()
name = "PasswordMustContainNumberError"
@IsString()
message = "Passwords must contain at least one number."
}
export class PasswordTooShortError extends NotAcceptableError {
@IsString()
name = "PasswordTooShortError"
@IsString()
message = "Passwords must be at least ten characters long."
} }

View File

@@ -1,5 +1,6 @@
import { createConnection } from "typeorm"; import { createConnection } from "typeorm";
import { runSeeder } from 'typeorm-seeding'; import { runSeeder } from 'typeorm-seeding';
import consola from 'consola';
import { config } from '../config'; import { config } from '../config';
import { ConfigFlag } from '../models/entities/ConfigFlags'; import { ConfigFlag } from '../models/entities/ConfigFlags';
import SeedPublicOrg from '../seeds/SeedPublicOrg'; import SeedPublicOrg from '../seeds/SeedPublicOrg';
@@ -11,6 +12,11 @@ import SeedUsers from '../seeds/SeedUsers';
*/ */
export default async () => { export default async () => {
const connection = await createConnection(); const connection = await createConnection();
// Log discovered entities for debugging
consola.info(`TypeORM discovered ${connection.entityMetadatas.length} entities:`);
consola.info(connection.entityMetadatas.map(m => m.name).sort().join(', '));
await connection.synchronize(); await connection.synchronize();
//The data seeding part //The data seeding part

View File

@@ -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;

118
src/mailer.ts Normal file
View File

@@ -0,0 +1,118 @@
import axios from 'axios';
import { config } from './config';
import { MailSendingError } from './errors/MailErrors';
/**
* This class is responsible for all things mail sending.
* This uses axios to communicate with the mailer api (https://git.odit.services/lfk/mailer).
*/
export class Mailer {
public static base: string = config.mailer_url;
public static key: string = config.mailer_key;
public static testing: boolean = config.testing;
/**
* Function for sending a password reset mail.
* @param to_address The address the mail will be sent to. Should always get pulled from a user object.
* @param token The requested password reset token - will be combined with the app_url to generate a password reset link.
*/
public static async sendResetMail(to_address: string, token: string, locale: string = "en") {
try {
await axios.request({
method: 'POST',
url: `${Mailer.base}/api/v1/email`,
headers: {
authorization: `Bearer ${Mailer.key}`,
'content-type': 'application/json'
},
data: {
to: to_address,
templateName: 'password-reset',
language: locale,
data: { token: token }
}
});
} catch (error) {
if (Mailer.testing) { return true; }
throw new MailSendingError();
}
}
/**
* Function for sending a runner selfservice welcome mail.
* @param to_address The address the mail will be sent to. Should always get pulled from a runner object.
* @param token The requested selfservice token - will be combined with the app_url to generate a selfservice profile link.
*/
public static async sendSelfserviceWelcomeMail(to_address: string, runner_id: number, firstname: string, middlename: string, lastname: string, token: string, locale: string = "en") {
try {
await axios.request({
method: 'POST',
url: `${Mailer.base}/api/v1/email`,
headers: {
authorization: `Bearer ${Mailer.key}`,
'content-type': 'application/json'
},
data: {
to: to_address,
templateName: 'welcome',
language: locale,
data: {
name: `${firstname} ${middlename} ${lastname}`,
barcode_content: `${runner_id}`,
link: `${process.env.SELFSERVICE_URL}/profile/${token}`
}
}
});
} catch (error) {
if (Mailer.testing) { return true; }
throw new MailSendingError();
}
}
/**
* Function for sending a runner selfservice link forgotten mail.
* @param to_address The address the mail will be sent to. Should always get pulled from a runner object.
* @param token The requested selfservice token - will be combined with the app_url to generate a selfservice profile link.
*/
public static async sendSelfserviceForgottenMail(to_address: string, runner_id: number, firstname: string, middlename: string, lastname: string, token: string, locale: string = "en") {
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({
method: 'POST',
url: `${Mailer.base}/api/v1/email`,
headers: {
authorization: `Bearer ${Mailer.key}`,
'content-type': 'application/json'
},
data: {
to: to_address,
templateName: 'welcome',
language: locale,
data: {
name: `${firstname} ${middlename} ${lastname}`,
barcode_content: `${runner_id}`,
link: `${process.env.SELFSERVICE_URL}/profile/${token}`
}
}
});
} catch (error) {
if (Mailer.testing) { return true; }
console.error("Error while sending selfservice forgotten mail:", error.message);
throw new MailSendingError();
}
}
}

View File

@@ -1,69 +1,129 @@
import * as argon2 from "argon2"; import crypto from 'crypto';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { getConnectionManager } from 'typeorm'; import { getConnectionManager } from 'typeorm';
import { config } from '../config';
import { deleteStationEntry, getStationEntry, setStationEntry, StationKVEntry } from '../nats/StationKV';
import { ScanStation } from '../models/entities/ScanStation'; import { ScanStation } from '../models/entities/ScanStation';
import authchecker from './authchecker'; import authchecker from './authchecker';
/**
* Computes the HMAC-SHA256 of the provided token using the station token secret.
*/
function computeHmac(token: string): string {
return crypto.createHmac('sha256', config.station_token_secret).update(token).digest('hex');
}
/**
* Constant-time comparison of two hex HMAC strings.
* Returns true if they match.
*/
function verifyHmac(provided_token: string, storedHash: string): boolean {
const expectedHash = computeHmac(provided_token);
const expectedBuf = Buffer.from(expectedHash);
const storedBuf = Buffer.from(storedHash);
return expectedBuf.length === storedBuf.length && crypto.timingSafeEqual(expectedBuf, storedBuf);
}
/** /**
* This middleware handles the authentication of scan station api tokens. * This middleware handles the authentication of scan station api tokens.
* The tokens have to be provided via Bearer authorization header. * The tokens have to be provided via Bearer authorization header.
*
* Auth flow:
* 1. Extract prefix from token (PREFIX.KEY format)
* 2. Try NATS KV cache lookup by prefix — warm path: HMAC verify, no DB
* 3. On cache miss: DB lookup → HMAC verify → write to KV cache
* 4. On no station match at all: fall back to JWT auth (SCAN:CREATE permission)
*
* On success sets req.isStationAuth = true and req.stationId on the request object.
* These are internal server-side properties — not HTTP headers, not spoofable by clients.
*
* You have to manually use this middleware via @UseBefore(ScanAuth) instead of using @Authorized(). * You have to manually use this middleware via @UseBefore(ScanAuth) instead of using @Authorized().
* @param req Express request object. * @param req Express request object.
* @param res Express response object. * @param res Express response object.
* @param next Next function to call on success. * @param next Next function to call on success.
*/ */
const ScanAuth = async (req: Request, res: Response, next: () => void) => { const ScanAuth = async (req: Request, res: Response, next: () => void) => {
let provided_token: string = req.headers["authorization"]; let provided_token: string = req.headers['authorization'];
if (provided_token == "" || provided_token === undefined || provided_token === null) { if (!provided_token) {
res.status(401).send("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("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("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("Api token non-existent or invalid syntax.");
return; return;
} }
else { next();
next();
}
} }
return;
} }
else {
if (station.enabled == false) {
res.status(401).send("Station disabled.");
}
if (!(await argon2.verify(station.key, provided_token))) {
res.status(401).send("Api token invalid.");
return;
}
next(); // Station found — verify token before caching
const tokenHash = computeHmac(provided_token);
const storedBuf = Buffer.from(station.key);
const computedBuf = Buffer.from(tokenHash);
const valid = computedBuf.length === storedBuf.length && crypto.timingSafeEqual(computedBuf, storedBuf);
if (!valid) {
res.status(401).send({ http_code: 401, short: 'invalid_token', message: 'Api token non-existent or invalid syntax.' });
return;
} }
}
export default ScanAuth; if (!station.enabled) {
res.status(401).send({ http_code: 401, short: 'station_disabled', message: 'Station is disabled.' });
return;
}
// Write to KV cache for subsequent requests
const entry: StationKVEntry = {
id: station.id,
enabled: station.enabled,
tokenHash,
trackId: station.track.id,
trackDistance: station.track.distance,
minimumLapTime: station.track.minimumLapTime ?? 0,
};
await setStationEntry(prefix, entry);
req.isStationAuth = true;
req.stationId = station.id;
next();
};
export default ScanAuth;
export { deleteStationEntry };

View File

@@ -1,4 +1,4 @@
import * as argon2 from "argon2"; import * as Bun from 'bun';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { getConnectionManager } from 'typeorm'; import { getConnectionManager } from 'typeorm';
import { StatsClient } from '../models/entities/StatsClient'; import { StatsClient } from '../models/entities/StatsClient';
@@ -55,7 +55,7 @@ const StatsAuth = async (req: Request, res: Response, next: () => void) => {
} }
} }
else { else {
if (!(await argon2.verify(client.key, provided_token))) { if (!(await Bun.password.verify(provided_token, client.key))) {
res.status(401).send("Api token invalid."); res.status(401).send("Api token invalid.");
return; return;
} }

View File

@@ -1,4 +1,4 @@
import * as argon2 from "argon2"; import * as Bun from 'bun';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import * as jsonwebtoken from 'jsonwebtoken'; import * as jsonwebtoken from 'jsonwebtoken';
import { getConnectionManager } from 'typeorm'; import { getConnectionManager } from 'typeorm';
@@ -49,7 +49,7 @@ export class ResetPassword {
if (found_user.refreshTokenCount !== decoded["refreshTokenCount"]) { throw new RefreshTokenCountInvalidError(); } if (found_user.refreshTokenCount !== decoded["refreshTokenCount"]) { throw new RefreshTokenCountInvalidError(); }
found_user.refreshTokenCount = found_user.refreshTokenCount + 1; found_user.refreshTokenCount = found_user.refreshTokenCount + 1;
found_user.password = await argon2.hash(this.password + found_user.uuid); found_user.password = await Bun.password.hash(this.password + found_user.uuid);
await getConnectionManager().get().getRepository(User).save(found_user); await getConnectionManager().get().getRepository(User).save(found_user);
return "password reset successfull"; return "password reset successfull";

View File

@@ -0,0 +1,29 @@
import { IsInt, IsPositive } from 'class-validator';
import { FixedDonation } from '../../entities/FixedDonation';
import { CreateDonation } from './CreateDonation';
/**
* This class is used to create a new FixedDonation entity from a json body (post request).
*/
export class CreateAnonymousDonation extends CreateDonation {
/**
* The donation's amount.
* The unit is your currency's smallest unit (default: euro cent).
*/
@IsInt()
@IsPositive()
amount: number;
/**
* Creates a new FixedDonation entity from this.
*/
public async toEntity(): Promise<FixedDonation> {
let newDonation = new FixedDonation;
newDonation.amount = this.amount;
newDonation.paidAmount = this.amount;
return newDonation;
}
}

View File

@@ -1,4 +1,4 @@
import * as argon2 from "argon2"; import * as Bun from 'bun';
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { getConnectionManager } from 'typeorm'; import { getConnectionManager } from 'typeorm';
import { InvalidCredentialsError, PasswordNeededError, UserDisabledError, UserNotFoundError } from '../../../errors/AuthError'; import { InvalidCredentialsError, PasswordNeededError, UserDisabledError, UserNotFoundError } from '../../../errors/AuthError';
@@ -56,16 +56,16 @@ export class CreateAuth {
throw new UserNotFoundError(); throw new UserNotFoundError();
} }
if (found_user.enabled == false) { throw new UserDisabledError(); } if (found_user.enabled == false) { throw new UserDisabledError(); }
if (!(await argon2.verify(found_user.password, this.password + found_user.uuid))) { if (!(await Bun.password.verify(this.password + found_user.uuid, found_user.password))) {
throw new InvalidCredentialsError(); throw new InvalidCredentialsError();
} }
//Create the access token //Create the access token
const timestamp_accesstoken_expiry = Math.floor(Date.now() / 1000) + 5 * 60 const timestamp_accesstoken_expiry = Math.floor(Date.now() / 1000) + 24 * 60 * 60
newAuth.access_token = JwtCreator.createAccess(found_user, timestamp_accesstoken_expiry); newAuth.access_token = JwtCreator.createAccess(found_user, timestamp_accesstoken_expiry);
newAuth.access_token_expires_at = timestamp_accesstoken_expiry newAuth.access_token_expires_at = timestamp_accesstoken_expiry
//Create the refresh token //Create the refresh token
const timestamp_refresh_expiry = Math.floor(Date.now() / 1000) + 10 * 36000 const timestamp_refresh_expiry = Math.floor(Date.now() / 1000) + 7 * 24 * 60 * 60
newAuth.refresh_token = JwtCreator.createRefresh(found_user, timestamp_refresh_expiry); newAuth.refresh_token = JwtCreator.createRefresh(found_user, timestamp_refresh_expiry);
newAuth.refresh_token_expires_at = timestamp_refresh_expiry newAuth.refresh_token_expires_at = timestamp_refresh_expiry
return newAuth; return newAuth;

View File

@@ -1,4 +1,4 @@
import { IsInt, IsPositive } from 'class-validator'; import { IsInt, IsOptional, IsPositive } from 'class-validator';
import { getConnection } from 'typeorm'; import { getConnection } from 'typeorm';
import { RunnerNotFoundError } from '../../../errors/RunnerErrors'; import { RunnerNotFoundError } from '../../../errors/RunnerErrors';
import { DistanceDonation } from '../../entities/DistanceDonation'; import { DistanceDonation } from '../../entities/DistanceDonation';
@@ -10,6 +10,21 @@ import { CreateDonation } from './CreateDonation';
*/ */
export class CreateDistanceDonation extends CreateDonation { export class CreateDistanceDonation extends CreateDonation {
/**
* The donation's associated donor's id.
* This is important to link donations to donors.
*/
@IsInt()
@IsPositive()
donor: number;
/**
* The donation's paid amount in the smalles unit of your currency (default: euro cent).
*/
@IsInt()
@IsOptional()
paidAmount?: number;
/** /**
* The donation's associated runner's id. * The donation's associated runner's id.
* This is important to link the runner's distance ran to the donation. * This is important to link the runner's distance ran to the donation.
@@ -33,6 +48,7 @@ export class CreateDistanceDonation extends CreateDonation {
let newDonation = new DistanceDonation; let newDonation = new DistanceDonation;
newDonation.amountPerDistance = this.amountPerDistance; newDonation.amountPerDistance = this.amountPerDistance;
newDonation.paidAmount = this.paidAmount;
newDonation.donor = await this.getDonor(); newDonation.donor = await this.getDonor();
newDonation.runner = await this.getRunner(); newDonation.runner = await this.getRunner();

View File

@@ -1,6 +1,5 @@
import { IsInt, IsPositive } from 'class-validator'; import { IsInt, IsOptional } from 'class-validator';
import { getConnection } from 'typeorm'; import { getConnection } from 'typeorm';
import { DonorNotFoundError } from '../../../errors/DonorErrors';
import { Donation } from '../../entities/Donation'; import { Donation } from '../../entities/Donation';
import { Donor } from '../../entities/Donor'; import { Donor } from '../../entities/Donor';
@@ -8,14 +7,14 @@ import { Donor } from '../../entities/Donor';
* This class is used to create a new Donation entity from a json body (post request). * This class is used to create a new Donation entity from a json body (post request).
*/ */
export abstract class CreateDonation { export abstract class CreateDonation {
/**
* The donation's associated donor's id.
* This is important to link donations to donors.
*/
@IsInt() @IsInt()
@IsPositive() @IsOptional()
donor: number; donor: number;
@IsInt()
@IsOptional()
paidAmount?: number;
/** /**
* Creates a new Donation entity from this. * Creates a new Donation entity from this.
*/ */
@@ -26,9 +25,6 @@ export abstract class CreateDonation {
*/ */
public async getDonor(): Promise<Donor> { public async getDonor(): Promise<Donor> {
const donor = await getConnection().getRepository(Donor).findOne({ id: this.donor }); const donor = await getConnection().getRepository(Donor).findOne({ id: this.donor });
if (!donor) {
throw new DonorNotFoundError();
}
return donor; return donor;
} }
} }

View File

@@ -6,6 +6,21 @@ import { CreateDonation } from './CreateDonation';
* This class is used to create a new FixedDonation entity from a json body (post request). * This class is used to create a new FixedDonation entity from a json body (post request).
*/ */
export class CreateFixedDonation extends CreateDonation { export class CreateFixedDonation extends CreateDonation {
/**
* The donation's associated donor's id.
* This is important to link donations to donors.
*/
@IsInt()
@IsPositive()
donor: number;
/**
* The donation's paid amount in the smalles unit of your currency (default: euro cent).
*/
@IsInt()
paidAmount?: number;
/** /**
* The donation's amount. * The donation's amount.
* The unit is your currency's smallest unit (default: euro cent). * The unit is your currency's smallest unit (default: euro cent).
@@ -21,6 +36,7 @@ export class CreateFixedDonation extends CreateDonation {
let newDonation = new FixedDonation; let newDonation = new FixedDonation;
newDonation.amount = this.amount; newDonation.amount = this.amount;
newDonation.paidAmount = this.paidAmount;
newDonation.donor = await this.getDonor(); newDonation.donor = await this.getDonor();
return newDonation; return newDonation;

View File

@@ -50,4 +50,11 @@ export abstract class CreateParticipant {
@IsOptional() @IsOptional()
@IsObject() @IsObject()
address?: Address; address?: Address;
/**
* how the participant got into the system
*/
@IsOptional()
@IsString()
created_via?: string;
} }

View File

@@ -1,39 +1,33 @@
import { IsEmail, IsOptional, IsString } from 'class-validator'; import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { getConnectionManager } from 'typeorm'; import { getConnectionManager } from 'typeorm';
import { ResetAlreadyRequestedError, UserDisabledError, UserNotFoundError } from '../../../errors/AuthError'; import { ResetAlreadyRequestedError, UserDisabledError, UserNotFoundError } from '../../../errors/AuthError';
import { UsernameOrEmailNeededError } from '../../../errors/UserErrors'; import { UserEmailNeededError } from '../../../errors/UserErrors';
import { JwtCreator } from '../../../jwtcreator'; import { JwtCreator } from '../../../jwtcreator';
import { User } from '../../entities/User'; import { User } from '../../entities/User';
/** /**
* This calss is used to create password reset tokens for users. * This class is used to create password reset tokens for users.
* These password reset token can be used to set a new password for the user for the next 15mins. * These password reset token can be used to set a new password for the user for the next 15mins.
*/ */
export class CreateResetToken { export class CreateResetToken {
/**
* The username of the user that wants to reset their password.
*/
@IsOptional()
@IsString()
username?: string;
/** /**
* The email address of the user that wants to reset their password. * The email address of the user that wants to reset their password.
*/ */
@IsOptional() @IsNotEmpty()
@IsEmail() @IsEmail()
@IsString() @IsString()
email?: string; email: string;
/** /**
* Create a password reset token based on this. * Create a password reset token based on this.
*/ */
public async toResetToken(): Promise<any> { public async toResetToken(): Promise<string> {
if (this.email === undefined && this.username === undefined) { if (!this.email) {
throw new UsernameOrEmailNeededError(); throw new UserEmailNeededError();
} }
let found_user = await getConnectionManager().get().getRepository(User).findOne({ where: [{ username: this.username }, { email: this.email }] }); let found_user = await getConnectionManager().get().getRepository(User).findOne({ where: [{ email: this.email }] });
if (!found_user) { throw new UserNotFoundError(); } if (!found_user) { throw new UserNotFoundError(); }
if (found_user.enabled == false) { throw new UserDisabledError(); } if (found_user.enabled == false) { throw new UserDisabledError(); }
if (found_user.resetRequestedTimestamp > (Math.floor(Date.now() / 1000) - 15 * 60)) { throw new ResetAlreadyRequestedError(); } if (found_user.resetRequestedTimestamp > (Math.floor(Date.now() / 1000) - 15 * 60)) { throw new ResetAlreadyRequestedError(); }
@@ -43,7 +37,7 @@ export class CreateResetToken {
await getConnectionManager().get().getRepository(User).save(found_user); await getConnectionManager().get().getRepository(User).save(found_user);
//Create the reset token //Create the reset token
let reset_token = JwtCreator.createReset(found_user); let reset_token: string = JwtCreator.createReset(found_user);
return reset_token; return reset_token;
} }

View File

@@ -32,6 +32,9 @@ export class CreateRunner extends CreateParticipant {
newRunner.email = this.email; newRunner.email = this.email;
newRunner.group = await this.getGroup(); newRunner.group = await this.getGroup();
newRunner.address = this.address; newRunner.address = this.address;
if (this.created_via) {
newRunner.created_via = this.created_via;
}
Address.validate(newRunner.address); Address.validate(newRunner.address);
return newRunner; return newRunner;

View File

@@ -1,5 +1,4 @@
import { IsBoolean, IsObject, IsOptional } from 'class-validator'; import { IsBoolean, IsObject, IsOptional } from 'class-validator';
import * as uuid from 'uuid';
import { Address } from '../../entities/Address'; import { Address } from '../../entities/Address';
import { RunnerOrganization } from '../../entities/RunnerOrganization'; import { RunnerOrganization } from '../../entities/RunnerOrganization';
import { CreateRunnerGroup } from './CreateRunnerGroup'; import { CreateRunnerGroup } from './CreateRunnerGroup';
@@ -35,7 +34,7 @@ export class CreateRunnerOrganization extends CreateRunnerGroup {
Address.validate(newRunnerOrganization.address); Address.validate(newRunnerOrganization.address);
if (this.registrationEnabled) { if (this.registrationEnabled) {
newRunnerOrganization.key = uuid.v4().toUpperCase(); newRunnerOrganization.key = crypto.randomUUID()
} }
return newRunnerOrganization; return newRunnerOrganization;

View File

@@ -1,8 +1,7 @@
import * as argon2 from "argon2";
import { IsBoolean, IsInt, IsOptional, IsPositive, IsString } from 'class-validator'; import { IsBoolean, IsInt, IsOptional, IsPositive, IsString } from 'class-validator';
import crypto from 'crypto'; import crypto from 'crypto';
import { getConnection } from 'typeorm'; import { getConnection } from 'typeorm';
import * as uuid from 'uuid'; import { 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';
@@ -42,10 +41,10 @@ export class CreateScanStation {
newStation.enabled = this.enabled; newStation.enabled = this.enabled;
newStation.track = await this.getTrack(); newStation.track = await this.getTrack();
let newUUID = uuid.v4().toUpperCase(); let newUUID = crypto.randomUUID().toUpperCase();
newStation.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase(); newStation.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase();
newStation.key = await argon2.hash(newStation.prefix + "." + newUUID);
newStation.cleartextkey = newStation.prefix + "." + newUUID; newStation.cleartextkey = newStation.prefix + "." + newUUID;
newStation.key = crypto.createHmac("sha256", config.station_token_secret).update(newStation.cleartextkey).digest('hex');
return newStation; return newStation;
} }

View File

@@ -26,6 +26,7 @@ export class CreateSelfServiceCitizenRunner extends CreateParticipant {
public async toEntity(): Promise<Runner> { public async toEntity(): Promise<Runner> {
let newRunner: Runner = new Runner(); let newRunner: Runner = new Runner();
newRunner.created_via = "selfservice";
newRunner.firstname = this.firstname; newRunner.firstname = this.firstname;
newRunner.middlename = this.middlename; newRunner.middlename = this.middlename;
newRunner.lastname = this.lastname; newRunner.lastname = this.lastname;

View File

@@ -28,6 +28,7 @@ export class CreateSelfServiceRunner extends CreateParticipant {
public async toEntity(group: RunnerGroup): Promise<Runner> { public async toEntity(group: RunnerGroup): Promise<Runner> {
let newRunner: Runner = new Runner(); let newRunner: Runner = new Runner();
newRunner.created_via = "selfservice";
newRunner.firstname = this.firstname; newRunner.firstname = this.firstname;
newRunner.middlename = this.middlename; newRunner.middlename = this.middlename;
newRunner.lastname = this.lastname; newRunner.lastname = this.lastname;

View File

@@ -1,7 +1,6 @@
import * as argon2 from "argon2"; import * as Bun from 'bun';
import { IsOptional, IsString } from 'class-validator'; import { IsOptional, IsString } from 'class-validator';
import crypto from 'crypto'; import crypto from 'crypto';
import * as uuid from 'uuid';
import { StatsClient } from '../../entities/StatsClient'; import { StatsClient } from '../../entities/StatsClient';
/** /**
@@ -23,9 +22,9 @@ export class CreateStatsClient {
newClient.description = this.description; newClient.description = this.description;
let newUUID = uuid.v4().toUpperCase(); let newUUID = crypto.randomUUID().toUpperCase();
newClient.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase(); newClient.prefix = crypto.createHash("sha3-512").update(newUUID).digest('hex').substring(0, 7).toUpperCase();
newClient.key = await argon2.hash(newClient.prefix + "." + newUUID); newClient.key = await Bun.password.hash(newClient.prefix + "." + newUUID);
newClient.cleartextkey = newClient.prefix + "." + newUUID; newClient.cleartextkey = newClient.prefix + "." + newUUID;
return newClient; return newClient;

View File

@@ -1,4 +1,5 @@
import { IsInt, IsPositive } from 'class-validator'; import { IsInt, IsOptional, IsPositive } from 'class-validator';
import { BadRequestError } from 'routing-controllers';
import { getConnection } from 'typeorm'; import { getConnection } from 'typeorm';
import { RunnerCardNotFoundError } from '../../../errors/RunnerCardErrors'; import { RunnerCardNotFoundError } from '../../../errors/RunnerCardErrors';
import { RunnerNotFoundError } from '../../../errors/RunnerErrors'; import { RunnerNotFoundError } from '../../../errors/RunnerErrors';
@@ -22,10 +23,12 @@ export class CreateTrackScan {
/** /**
* The scanning station's id that created the scan. * The scanning station's id that created the scan.
* Mainly used for logging and traceing back scans (or errors). * Mainly used for logging and traceing back scans (or errors).
* You don't have to provide the station if you're authenticateing via a scanstation token (The server takes care of it for you).
*/ */
@IsInt() @IsInt()
@IsPositive() @IsPositive()
station: number; @IsOptional()
station?: number;
/** /**
* Creates a new Track entity from this. * Creates a new Track entity from this.
@@ -44,20 +47,32 @@ export class CreateTrackScan {
} }
newScan.timestamp = Math.round(new Date().getTime() / 1000); newScan.timestamp = Math.round(new Date().getTime() / 1000);
newScan.valid = await this.validateScan(newScan); newScan = await this.validateScan(newScan);
return newScan; return newScan;
} }
/**
* Get's a runnerCard entity via the provided id.
* @returns The runnerCard whom's id you provided.
*/
public async getCard(): Promise<RunnerCard> { public async getCard(): Promise<RunnerCard> {
const track = await getConnection().getRepository(RunnerCard).findOne({ id: this.card }, { relations: ["runner"] }); const id = this.card % 200000000000;
if (!track) { const runnerCard = await getConnection().getRepository(RunnerCard).findOne({ id: id }, { relations: ["runner"] });
if (!runnerCard) {
throw new RunnerCardNotFoundError(); throw new RunnerCardNotFoundError();
} }
return track; return runnerCard;
} }
/**
* Get's a scanstation entity via the provided id.
* @returns The scanstation whom's id you provided.
*/
public async getStation(): Promise<ScanStation> { public async getStation(): Promise<ScanStation> {
if (!this.station) {
throw new BadRequestError("You are missing the station's id!")
}
const station = await getConnection().getRepository(ScanStation).findOne({ id: this.station }, { relations: ["track"] }); const station = await getConnection().getRepository(ScanStation).findOne({ id: this.station }, { relations: ["track"] });
if (!station) { if (!station) {
throw new ScanStationNotFoundError(); throw new ScanStationNotFoundError();
@@ -65,15 +80,21 @@ export class CreateTrackScan {
return station; return station;
} }
public async validateScan(scan: TrackScan): Promise<boolean> { /**
const scans = await getConnection().getRepository(TrackScan).find({ where: { runner: scan.runner, valid: true }, relations: ["track"] }); * Validates the scan and sets it's lap time;
if (scans.length == 0) { return true; } * @param scan The scan you want to validate
* @returns The validated scan with it's laptime set.
const newestScan = scans[scans.length - 1]; */
if ((scan.timestamp - newestScan.timestamp) > scan.track.minimumLapTime) { public async validateScan(scan: TrackScan): Promise<TrackScan> {
return true; const latestScan = await getConnection().getRepository(TrackScan).findOne({ where: { runner: scan.runner, valid: true }, relations: ["track"], order: { id: 'DESC' } });
if (!latestScan) {
scan.lapTime = 0;
scan.valid = true;
} }
else {
return false; scan.lapTime = scan.timestamp - latestScan.timestamp;
scan.valid = (scan.lapTime > scan.track.minimumLapTime);
}
return scan;
} }
} }

View File

@@ -1,9 +1,9 @@
import * as argon2 from "argon2"; import * as Bun from 'bun';
import { passwordStrength } from "check-password-strength";
import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator'; import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator';
import { getConnectionManager } from 'typeorm'; import { getConnectionManager } from 'typeorm';
import * as uuid from 'uuid';
import { config } from '../../../config'; import { config } from '../../../config';
import { UserEmailNeededError, UsernameContainsIllegalCharacterError } from '../../../errors/UserErrors'; import { PasswordMustContainLowercaseLetterError, PasswordMustContainNumberError, PasswordMustContainUppercaseLetterError, PasswordTooShortError, UserEmailNeededError, UsernameContainsIllegalCharacterError } from '../../../errors/UserErrors';
import { UserGroupNotFoundError } from '../../../errors/UserGroupErrors'; import { UserGroupNotFoundError } from '../../../errors/UserGroupErrors';
import { User } from '../../entities/User'; import { User } from '../../entities/User';
import { UserGroup } from '../../entities/UserGroup'; import { UserGroup } from '../../entities/UserGroup';
@@ -94,20 +94,26 @@ export class CreateUser {
if (!this.email) { if (!this.email) {
throw new UserEmailNeededError(); throw new UserEmailNeededError();
} }
if (this.username.includes("@")) { throw new UsernameContainsIllegalCharacterError(); } if (this.username?.includes("@")) { throw new UsernameContainsIllegalCharacterError(); }
let password_strength = passwordStrength(this.password);
if (!password_strength.contains.includes("uppercase")) { throw new PasswordMustContainUppercaseLetterError(); }
if (!password_strength.contains.includes("lowercase")) { throw new PasswordMustContainLowercaseLetterError(); }
if (!password_strength.contains.includes("number")) { throw new PasswordMustContainNumberError(); }
if (!(password_strength.length > 9)) { throw new PasswordTooShortError(); }
newUser.email = this.email newUser.email = this.email
newUser.username = this.username newUser.username = this.username
newUser.firstname = this.firstname newUser.firstname = this.firstname
newUser.middlename = this.middlename newUser.middlename = this.middlename
newUser.lastname = this.lastname newUser.lastname = this.lastname
newUser.uuid = uuid.v4() newUser.uuid = crypto.randomUUID()
newUser.phone = this.phone newUser.phone = this.phone
newUser.password = await argon2.hash(this.password + newUser.uuid); newUser.password = await Bun.password.hash(this.password + newUser.uuid);
newUser.groups = await this.getGroups(); newUser.groups = await this.getGroups();
newUser.enabled = this.enabled; newUser.enabled = this.enabled;
if (!this.profilePic) { newUser.profilePic = `https://dev.lauf-fuer-kaya.de/lfk-logo.png`; } if (!this.profilePic) { newUser.profilePic = `https://lauf-fuer-kaya.de/lfk-logo.png`; }
else { newUser.profilePic = this.profilePic; } else { newUser.profilePic = this.profilePic; }
return newUser; return newUser;

View File

@@ -32,6 +32,7 @@ export class UpdateDistanceDonation extends UpdateDonation {
*/ */
public async update(donation: DistanceDonation): Promise<DistanceDonation> { public async update(donation: DistanceDonation): Promise<DistanceDonation> {
donation.amountPerDistance = this.amountPerDistance; donation.amountPerDistance = this.amountPerDistance;
donation.paidAmount = this.paidAmount;
donation.donor = await this.getDonor(); donation.donor = await this.getDonor();
donation.runner = await this.getRunner(); donation.runner = await this.getRunner();

View File

@@ -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 { DonorNotFoundError } from '../../../errors/DonorErrors'; import { DonorNotFoundError } from '../../../errors/DonorErrors';
import { Donation } from '../../entities/Donation'; import { Donation } from '../../entities/Donation';
@@ -23,6 +23,13 @@ export abstract class UpdateDonation {
@IsPositive() @IsPositive()
donor: number; donor: number;
/**
* The donation's paid amount in the smalles unit of your currency (default: euro cent).
*/
@IsInt()
@IsOptional()
paidAmount?: number;
/** /**
* Creates a new Donation entity from this. * Creates a new Donation entity from this.
*/ */

View File

@@ -20,6 +20,7 @@ export class UpdateFixedDonation extends UpdateDonation {
*/ */
public async update(donation: FixedDonation): Promise<FixedDonation> { public async update(donation: FixedDonation): Promise<FixedDonation> {
donation.amount = this.amount; donation.amount = this.amount;
donation.paidAmount = this.paidAmount;
donation.donor = await this.getDonor(); donation.donor = await this.getDonor();
return donation; return donation;

View File

@@ -0,0 +1,50 @@
import { IsBoolean, IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { getConnection } from 'typeorm';
import { RunnerNotFoundError } from '../../../errors/RunnerErrors';
import { Runner } from '../../entities/Runner';
import { RunnerCard } from '../../entities/RunnerCard';
/**
* This class is used to update a RunnerCard entity (via put request).
*/
export class UpdateRunnerCardByCode {
/**
* The card's code.
*/
@IsString()
@IsNotEmpty()
code?: string;
/**
* The runner's id.
*/
@IsInt()
@IsOptional()
runner?: number;
/**
* Is the updated card enabled (for fraud reasons)?
* Default: true
*/
@IsBoolean()
enabled: boolean = true;
/**
* Creates a new RunnerCard entity from this.
*/
public async update(card: RunnerCard): Promise<RunnerCard> {
card.enabled = this.enabled;
card.runner = await this.getRunner();
return card;
}
public async getRunner(): Promise<Runner> {
if (!this.runner) { return null; }
const runner = await getConnection().getRepository(Runner).findOne({ id: this.runner });
if (!runner) {
throw new RunnerNotFoundError();
}
return runner;
}
}

View File

@@ -1,5 +1,4 @@
import { IsBoolean, IsInt, IsObject, IsOptional } from 'class-validator'; import { IsBoolean, IsInt, IsObject, IsOptional } from 'class-validator';
import * as uuid from 'uuid';
import { Address } from '../../entities/Address'; import { Address } from '../../entities/Address';
import { RunnerOrganization } from '../../entities/RunnerOrganization'; import { RunnerOrganization } from '../../entities/RunnerOrganization';
import { CreateRunnerGroup } from '../create/CreateRunnerGroup'; import { CreateRunnerGroup } from '../create/CreateRunnerGroup';
@@ -42,7 +41,7 @@ export class UpdateRunnerOrganization extends CreateRunnerGroup {
Address.validate(organization.address); Address.validate(organization.address);
if (this.registrationEnabled && !organization.key) { if (this.registrationEnabled && !organization.key) {
organization.key = uuid.v4().toUpperCase(); organization.key = crypto.randomUUID().toUpperCase();
} }
else { else {
organization.key = null; organization.key = null;

View File

@@ -1,9 +1,9 @@
import { IsBoolean, IsInt, IsOptional, IsPositive } from 'class-validator'; import { IsBoolean, 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 { ScanStationNotFoundError } from '../../../errors/ScanStationErrors'; import { TrackNotFoundError } from '../../../errors/TrackErrors';
import { Runner } from '../../entities/Runner'; import { Runner } from '../../entities/Runner';
import { ScanStation } from '../../entities/ScanStation'; import { Track } from '../../entities/Track';
import { TrackScan } from '../../entities/TrackScan'; import { TrackScan } from '../../entities/TrackScan';
/** /**
@@ -38,7 +38,7 @@ export abstract class UpdateTrackScan {
*/ */
@IsInt() @IsInt()
@IsPositive() @IsPositive()
public station: number; public track: number;
/** /**
* Update a TrackScan entity based on this. * Update a TrackScan entity based on this.
@@ -47,8 +47,7 @@ export abstract class UpdateTrackScan {
public async update(scan: TrackScan): Promise<TrackScan> { public async update(scan: TrackScan): Promise<TrackScan> {
scan.valid = this.valid; scan.valid = this.valid;
scan.runner = await this.getRunner(); scan.runner = await this.getRunner();
scan.station = await this.getStation(); scan.track = await this.getTrack();
scan.track = scan.station.track;
return scan; return scan;
} }
@@ -67,11 +66,11 @@ export abstract class UpdateTrackScan {
/** /**
* Gets a runner based on the runner id provided via this.runner. * Gets a runner based on the runner id provided via this.runner.
*/ */
public async getStation(): Promise<ScanStation> { public async getTrack(): Promise<Track> {
const station = await getConnection().getRepository(ScanStation).findOne({ id: this.station }, { relations: ['track'] }); const track = await getConnection().getRepository(Track).findOne({ id: this.track });
if (!station) { if (!track) {
throw new ScanStationNotFoundError(); throw new TrackNotFoundError();
} }
return station; return track;
} }
} }

View File

@@ -1,12 +1,14 @@
import * as argon2 from "argon2"; import * as Bun from 'bun';
import { passwordStrength } from "check-password-strength";
import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator'; import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl } from 'class-validator';
import { getConnectionManager } from 'typeorm'; import { getConnectionManager } from 'typeorm';
import { config } from '../../../config'; import { config } from '../../../config';
import { UserEmailNeededError, UsernameContainsIllegalCharacterError } from '../../../errors/UserErrors'; import { PasswordMustContainLowercaseLetterError, PasswordMustContainNumberError, PasswordMustContainUppercaseLetterError, PasswordTooShortError, UserEmailNeededError, UsernameContainsIllegalCharacterError } from '../../../errors/UserErrors';
import { UserGroupNotFoundError } from '../../../errors/UserGroupErrors'; import { UserGroupNotFoundError } from '../../../errors/UserGroupErrors';
import { User } from '../../entities/User'; import { User } from '../../entities/User';
import { UserGroup } from '../../entities/UserGroup'; import { UserGroup } from '../../entities/UserGroup';
/** /**
* This class is used to update a User entity (via put request). * This class is used to update a User entity (via put request).
*/ */
@@ -104,7 +106,12 @@ export class UpdateUser {
if (this.username.includes("@")) { throw new UsernameContainsIllegalCharacterError(); } if (this.username.includes("@")) { throw new UsernameContainsIllegalCharacterError(); }
if (this.password) { if (this.password) {
user.password = await argon2.hash(this.password + user.uuid); let password_strength = passwordStrength(this.password);
if (!password_strength.contains.includes("uppercase")) { throw new PasswordMustContainUppercaseLetterError(); }
if (!password_strength.contains.includes("lowercase")) { throw new PasswordMustContainLowercaseLetterError(); }
if (!password_strength.contains.includes("number")) { throw new PasswordMustContainNumberError(); }
if (!(password_strength.length > 9)) { throw new PasswordTooShortError(); }
user.password = await Bun.password.hash(this.password + user.uuid);
user.refreshTokenCount = user.refreshTokenCount + 1; user.refreshTokenCount = user.refreshTokenCount + 1;
} }
@@ -117,7 +124,7 @@ export class UpdateUser {
user.phone = this.phone; user.phone = this.phone;
user.groups = await this.getGroups(); user.groups = await this.getGroups();
if (!this.profilePic) { user.profilePic = `https://dev.lauf-fuer-kaya.de/lfk-logo.png`; } if (!this.profilePic) { user.profilePic = `https://lauf-fuer-kaya.de/lfk-logo.png`; }
else { user.profilePic = this.profilePic; } else { user.profilePic = this.profilePic; }
return user; return user;

View File

@@ -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);
}
} }

View File

@@ -1,22 +1,23 @@
import { IsInt, IsNotEmpty, IsPositive } from "class-validator"; import { IsInt, IsNotEmpty, IsPositive } from "class-validator";
import { ChildEntity, Column, ManyToOne } from "typeorm"; import { ChildEntity, Column, Index, ManyToOne } from "typeorm";
import { ResponseDistanceDonation } from '../responses/ResponseDistanceDonation'; import { ResponseDistanceDonation } from '../responses/ResponseDistanceDonation';
import { Donation } from "./Donation"; import { Donation } from "./Donation";
import { Runner } from "./Runner"; import type { Runner } from "./Runner";
/** /**
* Defines the DistanceDonation entity. * Defines the DistanceDonation entity.
* For distanceDonations a donor pledges to donate a certain amount for each kilometer ran by a runner. * For distanceDonations a donor pledges to donate a certain amount for each kilometer ran by a runner.
*/ */
@ChildEntity() @ChildEntity()
@Index(['runner'])
export class DistanceDonation extends Donation { export class DistanceDonation extends Donation {
/** /**
* The donation's associated runner. * The donation's associated runner.
* Used as the source of the donation's distance. * Used as the source of the donation's distance.
*/ */
@IsNotEmpty() @IsNotEmpty()
@ManyToOne(() => Runner, runner => runner.distanceDonations) @ManyToOne(() => require("./Runner").Runner, (runner: Runner) => runner.distanceDonations)
runner: Runner; runner!: Runner;
/** /**
* The donation's amount donated per distance. * The donation's amount donated per distance.

View File

@@ -1,18 +1,19 @@
import { import {
IsInt, IsInt,
IsNotEmpty IsPositive
} from "class-validator"; } from "class-validator";
import { Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm"; import { BeforeInsert, BeforeUpdate, Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
import { ResponseDonation } from '../responses/ResponseDonation'; import { ResponseDonation } from '../responses/ResponseDonation';
import { Donor } from './Donor'; import type { Donor } from './Donor';
/** /**
* Defines the Donation entity. * Defines the Donation entity.
* A donation just associates a donor with a donation amount. * A donation just associates a donor with a donation amount.
* The specifics of the amoun's determination has to be implemented in child classes. * The specifics of the amoun's determination has to be implemented in child classes.
*/ */
@Entity() @Entity()
@TableInheritance({ column: { name: "type", type: "varchar" } }) @TableInheritance({ column: { name: "type", type: "varchar" } })
@Index(['donor'])
export abstract class Donation { export abstract class Donation {
/** /**
* Autogenerated unique id (primary key). * Autogenerated unique id (primary key).
@@ -24,9 +25,8 @@ export abstract class Donation {
/** /**
* The donations's donor. * The donations's donor.
*/ */
@IsNotEmpty() @ManyToOne(() => require("./Donor").Donor, (donor: Donor) => donor.donations)
@ManyToOne(() => Donor, donor => donor.donations) donor!: Donor;
donor: Donor;
/** /**
* The donation's amount in cents (or whatever your currency's smallest unit is.). * The donation's amount in cents (or whatever your currency's smallest unit is.).
@@ -34,6 +34,34 @@ export abstract class Donation {
*/ */
public abstract get amount(): number; public abstract get amount(): number;
/**
* The donation's paid amount in cents (or whatever your currency's smallest unit is.).
* Used to mark donations as paid.
*/
@Column({ nullable: true })
@IsInt()
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.

View File

@@ -1,7 +1,7 @@
import { IsBoolean, IsInt } from "class-validator"; import { IsBoolean, IsInt } from "class-validator";
import { ChildEntity, Column, OneToMany } from "typeorm"; import { ChildEntity, Column, OneToMany } from "typeorm";
import { ResponseDonor } from '../responses/ResponseDonor'; import { ResponseDonor } from '../responses/ResponseDonor';
import { Donation } from './Donation'; import type { Donation } from './Donation';
import { Participant } from "./Participant"; import { Participant } from "./Participant";
/** /**
@@ -21,8 +21,8 @@ export class Donor extends Participant {
* Used to link the participant as the donor of a donation. * Used to link the participant as the donor of a donation.
* Attention: Only runner's can be associated as a distanceDonations distance source. * Attention: Only runner's can be associated as a distanceDonations distance source.
*/ */
@OneToMany(() => Donation, donation => donation.donor, { nullable: true }) @OneToMany(() => require("./Donation").Donation, (donation: Donation) => donation.donor, { nullable: true })
donations: Donation[]; donations!: Donation[];
/** /**
* Returns the total donations of a donor based on his linked donations. * Returns the total donations of a donor based on his linked donations.
@@ -33,6 +33,15 @@ export class Donor extends Participant {
return this.donations.reduce((sum, current) => sum + current.amount, 0); return this.donations.reduce((sum, current) => sum + current.amount, 0);
} }
/**
* Returns the total paid donations of a donor based on his linked donations.
*/
@IsInt()
public get paidDonationAmount(): number {
if (!this.donations) { return 0; }
return this.donations.reduce((sum, current) => sum + current.paidAmount, 0);
}
/** /**
* Turns this entity into it's response class. * Turns this entity into it's response class.
*/ */

View File

@@ -1,17 +1,19 @@
import { import {
IsEmail, IsEmail,
IsInt, IsInt,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsPhoneNumber, IsPhoneNumber,
IsString IsPositive,
} from "class-validator";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; IsString
import { config } from '../../config'; } from "class-validator";
import { ResponseGroupContact } from '../responses/ResponseGroupContact'; import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { Address } from "./Address"; import { config } from '../../config';
import { RunnerGroup } from "./RunnerGroup"; import { ResponseGroupContact } from '../responses/ResponseGroupContact';
import { Address } from "./Address";
import type { RunnerGroup } from "./RunnerGroup";
/** /**
* Defines the GroupContact entity. * Defines the GroupContact entity.
@@ -75,11 +77,32 @@ export class GroupContact {
@IsEmail() @IsEmail()
email?: string; email?: string;
/** /**
* Used to link contacts to groups. * Used to link contacts to groups.
*/ */
@OneToMany(() => RunnerGroup, group => group.contact, { nullable: true }) @OneToMany(() => require("./RunnerGroup").RunnerGroup, (group: RunnerGroup) => group.contact, { nullable: true })
groups: RunnerGroup[]; groups!: RunnerGroup[];
@Column({ type: 'bigint', nullable: true, readonly: true })
@IsInt()
@IsPositive()
created_at: number;
@Column({ type: 'bigint', nullable: true })
@IsInt()
@IsPositive()
updated_at: number;
@BeforeInsert()
public setCreatedAt() {
this.created_at = Math.floor(Date.now() / 1000);
this.updated_at = Math.floor(Date.now() / 1000);
}
@BeforeUpdate()
public setUpdatedAt() {
this.updated_at = Math.floor(Date.now() / 1000);
}
/** /**
* Turns this entity into it's response class. * Turns this entity into it's response class.

View File

@@ -5,20 +5,23 @@ import {
IsOptional, IsOptional,
IsPhoneNumber, IsPhoneNumber,
IsPositive,
IsString IsString
} from "class-validator"; } from "class-validator";
import { Column, Entity, PrimaryGeneratedColumn, TableInheritance } from "typeorm"; import { BeforeInsert, BeforeUpdate, Column, Entity, Index, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
import { config } from '../../config'; import { config } from '../../config';
import { ResponseParticipant } from '../responses/ResponseParticipant'; import { ResponseParticipant } from '../responses/ResponseParticipant';
import { Address } from "./Address"; import { Address } from "./Address";
/** /**
* Defines the Participant entity. * Defines the Participant entity.
* Participans can donate and therefor be associated with donation entities. * Participans can donate and therefor be associated with donation entities.
*/ */
@Entity() @Entity()
@TableInheritance({ column: { name: "type", type: "varchar" } }) @TableInheritance({ column: { name: "type", type: "varchar" } })
export abstract class Participant { @Index(['email'])
export abstract class Participant {
/** /**
* Autogenerated unique id (primary key). * Autogenerated unique id (primary key).
*/ */
@@ -75,6 +78,35 @@ export abstract class Participant {
@IsEmail() @IsEmail()
email?: string; email?: string;
/**
* how the participant got into the system
*/
@Column({ nullable: true, default: "backend" })
@IsOptional()
@IsString()
created_via?: string;
@Column({ type: 'bigint', nullable: true, readonly: true })
@IsInt()
@IsPositive()
created_at: number;
@Column({ type: 'bigint', nullable: true })
@IsInt()
@IsPositive()
updated_at: number;
@BeforeInsert()
public setCreatedAt() {
this.created_at = Math.floor(Date.now() / 1000);
this.updated_at = Math.floor(Date.now() / 1000);
}
@BeforeUpdate()
public setUpdatedAt() {
this.updated_at = Math.floor(Date.now() / 1000);
}
/** /**
* Turns this entity into it's response class. * Turns this entity into it's response class.
*/ */

View File

@@ -1,13 +1,14 @@
import { import {
IsEnum, IsEnum,
IsInt, IsInt,
IsNotEmpty IsNotEmpty,
IsPositive
} from "class-validator"; } from "class-validator";
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { PermissionAction } from '../enums/PermissionAction'; import { PermissionAction } from '../enums/PermissionAction';
import { PermissionTarget } from '../enums/PermissionTargets'; import { PermissionTarget } from '../enums/PermissionTargets';
import { ResponsePermission } from '../responses/ResponsePermission'; import { ResponsePermission } from '../responses/ResponsePermission';
import { Principal } from './Principal'; import type { Principal } from './Principal';
/** /**
* Defines the Permission entity. * Defines the Permission entity.
* Permissions can be granted to principals. * Permissions can be granted to principals.
@@ -25,8 +26,8 @@ export class Permission {
/** /**
* The permission's principal. * The permission's principal.
*/ */
@ManyToOne(() => Principal, principal => principal.permissions) @ManyToOne(() => require("./Principal").Principal, (principal: Principal) => principal.permissions)
principal: Principal; principal!: Principal;
/** /**
* The permission's target. * The permission's target.
@@ -45,6 +46,27 @@ export class Permission {
@IsEnum(PermissionAction) @IsEnum(PermissionAction)
action: PermissionAction; action: PermissionAction;
@Column({ type: 'bigint', nullable: true, readonly: true })
@IsInt()
@IsPositive()
created_at: number;
@Column({ type: 'bigint', nullable: true })
@IsInt()
@IsPositive()
updated_at: number;
@BeforeInsert()
public setCreatedAt() {
this.created_at = Math.floor(Date.now() / 1000);
this.updated_at = Math.floor(Date.now() / 1000);
}
@BeforeUpdate()
public setUpdatedAt() {
this.updated_at = Math.floor(Date.now() / 1000);
}
/** /**
* Turn this into a string for exporting and jwts. * Turn this into a string for exporting and jwts.
* Mainly used to shrink the size of jwts (otherwise the would contain entire objects). * Mainly used to shrink the size of jwts (otherwise the would contain entire objects).

View File

@@ -1,7 +1,7 @@
import { IsInt } from 'class-validator'; import { IsInt, IsPositive } from 'class-validator';
import { Entity, OneToMany, PrimaryGeneratedColumn, TableInheritance } from 'typeorm'; import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn, TableInheritance } from 'typeorm';
import { ResponsePrincipal } from '../responses/ResponsePrincipal'; import { ResponsePrincipal } from '../responses/ResponsePrincipal';
import { Permission } from './Permission'; import type { Permission } from './Permission';
/** /**
* Defines the principal entity. * Defines the principal entity.
@@ -20,8 +20,29 @@ export abstract class Principal {
/** /**
* The participant's permissions. * The participant's permissions.
*/ */
@OneToMany(() => Permission, permission => permission.principal, { nullable: true }) @OneToMany(() => require("./Permission").Permission, (permission: Permission) => permission.principal, { nullable: true })
permissions: Permission[]; permissions!: Permission[];
@Column({ type: 'bigint', nullable: true, readonly: true })
@IsInt()
@IsPositive()
created_at: number;
@Column({ type: 'bigint', nullable: true })
@IsInt()
@IsPositive()
updated_at: number;
@BeforeInsert()
public setCreatedAt() {
this.created_at = Math.floor(Date.now() / 1000);
this.updated_at = Math.floor(Date.now() / 1000);
}
@BeforeUpdate()
public setUpdatedAt() {
this.updated_at = Math.floor(Date.now() / 1000);
}
/** /**
* Turns this entity into it's response class. * Turns this entity into it's response class.

View File

@@ -1,18 +1,19 @@
import { IsInt, IsNotEmpty } from "class-validator"; import { IsInt, IsNotEmpty, IsOptional, IsString } from "class-validator";
import { ChildEntity, ManyToOne, OneToMany } from "typeorm"; import { ChildEntity, Column, Index, ManyToOne, OneToMany } from "typeorm";
import { ResponseRunner } from '../responses/ResponseRunner'; import { ResponseRunner } from '../responses/ResponseRunner';
import { DistanceDonation } from "./DistanceDonation"; import type { DistanceDonation } from "./DistanceDonation";
import { Participant } from "./Participant"; import { Participant } from "./Participant";
import { RunnerCard } from "./RunnerCard"; import type { RunnerCard } from "./RunnerCard";
import { RunnerGroup } from "./RunnerGroup"; import { RunnerGroup } from "./RunnerGroup";
import { Scan } from "./Scan"; import type { Scan } from "./Scan";
/** /**
* Defines the runner entity. * Defines the runner entity.
* Runners differ from participants in being able to actually accumulate a ran distance through scans. * Runners differ from participants in being able to actually accumulate a ran distance through scans.
* Runner's get organized in groups. * Runner's get organized in groups.
*/ */
@ChildEntity() @ChildEntity()
@Index(['group'])
export class Runner extends Participant { export class Runner extends Participant {
/** /**
* The runner's associated group. * The runner's associated group.
@@ -26,29 +27,41 @@ export class Runner extends Participant {
* The runner's associated distanceDonations. * The runner's associated distanceDonations.
* Used to link runners to distanceDonations in order to calculate the donation's amount based on the distance the runner ran. * Used to link runners to distanceDonations in order to calculate the donation's amount based on the distance the runner ran.
*/ */
@OneToMany(() => DistanceDonation, distanceDonation => distanceDonation.runner, { nullable: true }) @OneToMany(() => require("./DistanceDonation").DistanceDonation, (distanceDonation: DistanceDonation) => distanceDonation.runner, { nullable: true })
distanceDonations: DistanceDonation[]; distanceDonations!: DistanceDonation[];
/** /**
* The runner's associated cards. * The runner's associated cards.
* Used to link runners to cards - yes a runner be associated with multiple cards this came in handy in the past. * Used to link runners to cards - yes a runner be associated with multiple cards this came in handy in the past.
*/ */
@OneToMany(() => RunnerCard, card => card.runner, { nullable: true }) @OneToMany(() => require("./RunnerCard").RunnerCard, (card: RunnerCard) => card.runner, { nullable: true })
cards: RunnerCard[]; cards!: RunnerCard[];
/** /**
* The runner's associated scans. * The runner's associated scans.
* Used to link runners to scans (valid and fraudulant). * Used to link runners to scans (valid and fraudulant).
*/ */
@OneToMany(() => Scan, scan => scan.runner, { nullable: true }) @OneToMany(() => require("./Scan").Scan, (scan: Scan) => scan.runner, { nullable: true })
scans: Scan[]; scans!: Scan[];
/**
* The last time the runner requested a selfservice link.
* Used to prevent spamming of the selfservice link forgotten route.
*/
@Column({ nullable: true, unique: false })
@IsString()
@IsOptional()
resetRequestedTimestamp?: number;
/** /**
* Returns all valid scans associated with this runner. * Returns all valid scans associated with this runner.
* This is implemented here to avoid duplicate code in other files. * This is implemented here to avoid duplicate code in other files.
*/ */
public get validScans(): Scan[] { public get validScans(): Scan[] {
return this.scans.filter(scan => scan.valid == true); if (this.scans) {
return this.scans.filter(scan => scan.valid == true);
}
return []
} }
/** /**
@@ -72,6 +85,6 @@ export class Runner extends Participant {
* Turns this entity into it's response class. * Turns this entity into it's response class.
*/ */
public toResponse(): ResponseRunner { public toResponse(): ResponseRunner {
return new ResponseRunner(this); return new ResponseRunner(this, true);
} }
} }

View File

@@ -3,20 +3,23 @@ import {
IsInt, IsInt,
IsOptional IsOptional,
IsPositive
} from "class-validator"; } from "class-validator";
import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm"; import { BeforeInsert, BeforeUpdate, Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { RunnerCardIdOutOfRangeError } from '../../errors/RunnerCardErrors'; import { RunnerCardIdOutOfRangeError } from '../../errors/RunnerCardErrors';
import { ResponseRunnerCard } from '../responses/ResponseRunnerCard'; import { ResponseRunnerCard } from '../responses/ResponseRunnerCard';
import { Runner } from "./Runner"; import type { Runner } from "./Runner";
import { TrackScan } from "./TrackScan"; import type { TrackScan } from "./TrackScan";
/** /**
* Defines the RunnerCard entity. * Defines the RunnerCard entity.
* A runnerCard is a physical representation for a runner. * A runnerCard is a physical representation for a runner.
* It can be associated with a runner to create scans via the scan station's. * It can be associated with a runner to create scans via the scan station's.
*/ */
@Entity() @Entity()
@Index(['runner'])
@Index(['enabled'])
export class RunnerCard { export class RunnerCard {
/** /**
* Autogenerated unique id (primary key). * Autogenerated unique id (primary key).
@@ -30,8 +33,8 @@ export class RunnerCard {
* To increase reusability a card can be reassigned. * To increase reusability a card can be reassigned.
*/ */
@IsOptional() @IsOptional()
@ManyToOne(() => Runner, runner => runner.cards, { nullable: true }) @ManyToOne(() => require("./Runner").Runner, (runner: Runner) => runner.cards, { nullable: true })
runner: Runner; runner!: Runner;
/** /**
* Is the card enabled (for fraud reasons)? * Is the card enabled (for fraud reasons)?
@@ -45,20 +48,35 @@ export class RunnerCard {
* The card's associated scans. * The card's associated scans.
* Used to link cards to track scans. * Used to link cards to track scans.
*/ */
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true }) @OneToMany(() => require("./TrackScan").TrackScan, (scan: TrackScan) => scan.card, { nullable: true })
scans: TrackScan[]; scans!: TrackScan[];
@Column({ type: 'bigint', nullable: true, readonly: true })
@IsInt()
@IsPositive()
created_at: number;
@Column({ type: 'bigint', nullable: true })
@IsInt()
@IsPositive()
updated_at: number;
@BeforeInsert()
public setCreatedAt() {
this.created_at = Math.floor(Date.now() / 1000);
this.updated_at = Math.floor(Date.now() / 1000);
}
@BeforeUpdate()
public setUpdatedAt() {
this.updated_at = Math.floor(Date.now() / 1000);
}
/** /**
* Generates a ean-13 compliant string for barcode generation. * Generates a ean-13 compliant string for barcode generation.
*/ */
public get code(): string { public get code(): string {
const multiply = [1, 3]; return this.paddedId
let total = 0;
this.paddedId.split('').forEach((letter, index) => {
total += parseInt(letter, 10) * multiply[index % 2];
});
const checkSum = (Math.ceil(total / 10) * 10) - total;
return this.paddedId + checkSum.toString();
} }
/** /**
@@ -67,10 +85,11 @@ export class RunnerCard {
private get paddedId(): string { private get paddedId(): string {
let id: string = this.id.toString(); let id: string = this.id.toString();
if (id.length > 12) { if (id.length > 11) {
throw new RunnerCardIdOutOfRangeError(); throw new RunnerCardIdOutOfRangeError();
} }
while (id.length < 12) { id = '0' + id; } while (id.length < 11) { id = '0' + id; }
id = '2' + id;
return id; return id;
} }

View File

@@ -2,12 +2,13 @@ import {
IsInt, IsInt,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsPositive,
IsString IsString
} from "class-validator"; } from "class-validator";
import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, TableInheritance } from "typeorm"; import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
import { ResponseRunnerGroup } from '../responses/ResponseRunnerGroup'; import { ResponseRunnerGroup } from '../responses/ResponseRunnerGroup';
import { GroupContact } from "./GroupContact"; import { GroupContact } from "./GroupContact";
import { Runner } from "./Runner"; import type { Runner } from "./Runner";
/** /**
* Defines the RunnerGroup entity. * Defines the RunnerGroup entity.
@@ -43,14 +44,38 @@ export abstract class RunnerGroup {
* The group's associated runners. * The group's associated runners.
* Used to link runners to a runner group. * Used to link runners to a runner group.
*/ */
@OneToMany(() => Runner, runner => runner.group, { nullable: true }) @OneToMany(() => require("./Runner").Runner, (runner: Runner) => runner.group, { nullable: true })
runners: Runner[]; runners!: Runner[];
@Column({ type: 'bigint', nullable: true, readonly: true })
@IsInt()
@IsPositive()
created_at: number;
@Column({ type: 'bigint', nullable: true })
@IsInt()
@IsPositive()
updated_at: number;
@BeforeInsert()
public setCreatedAt() {
this.created_at = Math.floor(Date.now() / 1000);
this.updated_at = Math.floor(Date.now() / 1000);
}
@BeforeUpdate()
public setUpdatedAt() {
this.updated_at = Math.floor(Date.now() / 1000);
}
/** /**
* Returns the total distance ran by this group's runners based on all their valid scans. * Returns the total distance ran by this group's runners based on all their valid scans.
*/ */
@IsInt() @IsInt()
public get distance(): number { public get distance(): number {
if (!this.runners || this.runners.length == 0) {
return 0;
}
return this.runners.reduce((sum, current) => sum + current.distance, 0); return this.runners.reduce((sum, current) => sum + current.distance, 0);
} }

View File

@@ -4,7 +4,7 @@ import { ResponseRunnerOrganization } from '../responses/ResponseRunnerOrganizat
import { Address } from './Address'; import { Address } from './Address';
import { Runner } from './Runner'; import { Runner } from './Runner';
import { RunnerGroup } from "./RunnerGroup"; import { RunnerGroup } from "./RunnerGroup";
import { RunnerTeam } from "./RunnerTeam"; import type { RunnerTeam } from "./RunnerTeam";
/** /**
* Defines the RunnerOrganization entity. * Defines the RunnerOrganization entity.
@@ -24,8 +24,8 @@ export class RunnerOrganization extends RunnerGroup {
* The organization's teams. * The organization's teams.
* Used to link teams to a organization. * Used to link teams to a organization.
*/ */
@OneToMany(() => RunnerTeam, team => team.parentGroup, { nullable: true }) @OneToMany(() => require("./RunnerTeam").RunnerTeam, (team: RunnerTeam) => team.parentGroup, { nullable: true })
teams: RunnerTeam[]; teams!: RunnerTeam[];
/** /**
* The organization's api key for self-service registration. * The organization's api key for self-service registration.

View File

@@ -1,14 +1,15 @@
import { IsNotEmpty } from "class-validator"; import { IsNotEmpty } from "class-validator";
import { ChildEntity, ManyToOne } from "typeorm"; import { ChildEntity, Index, ManyToOne } from "typeorm";
import { ResponseRunnerTeam } from '../responses/ResponseRunnerTeam'; import { ResponseRunnerTeam } from '../responses/ResponseRunnerTeam';
import { RunnerGroup } from "./RunnerGroup"; import { RunnerGroup } from "./RunnerGroup";
import { RunnerOrganization } from "./RunnerOrganization"; import type { RunnerOrganization } from "./RunnerOrganization";
/** /**
* Defines the RunnerTeam entity. * Defines the RunnerTeam entity.
* This usually is a school class or department in a company. * This usually is a school class or department in a company.
*/ */
@ChildEntity() @ChildEntity()
@Index(['parentGroup'])
export class RunnerTeam extends RunnerGroup { export class RunnerTeam extends RunnerGroup {
/** /**
@@ -16,7 +17,7 @@ export class RunnerTeam extends RunnerGroup {
* Every team has to be part of a runnerOrganization - this get's checked on creation and update. * Every team has to be part of a runnerOrganization - this get's checked on creation and update.
*/ */
@IsNotEmpty() @IsNotEmpty()
@ManyToOne(() => RunnerOrganization, org => org.teams, { nullable: true }) @ManyToOne(() => require("./RunnerOrganization").RunnerOrganization, (org: RunnerOrganization) => org.teams, { nullable: true })
parentGroup?: RunnerOrganization; parentGroup?: RunnerOrganization;
/** /**

View File

@@ -5,16 +5,19 @@ import {
IsPositive IsPositive
} from "class-validator"; } from "class-validator";
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm"; import { BeforeInsert, BeforeUpdate, Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, TableInheritance } from "typeorm";
import { ResponseScan } from '../responses/ResponseScan'; import { ResponseScan } from '../responses/ResponseScan';
import { Runner } from "./Runner"; import type { Runner } from "./Runner";
/** /**
* Defines the Scan entity. * Defines the Scan entity.
* A scan basicly adds a certain distance to a runner's total ran distance. * A scan basicly adds a certain distance to a runner's total ran distance.
*/ */
@Entity() @Entity()
@TableInheritance({ column: { name: "type", type: "varchar" } }) @TableInheritance({ column: { name: "type", type: "varchar" } })
@Index(['runner'])
@Index(['runner', 'created_at'])
@Index(['valid'])
export class Scan { export class Scan {
/** /**
* Autogenerated unique id (primary key). * Autogenerated unique id (primary key).
@@ -28,8 +31,8 @@ export class Scan {
* This is important to link ran distances to runners. * This is important to link ran distances to runners.
*/ */
@IsNotEmpty() @IsNotEmpty()
@ManyToOne(() => Runner, runner => runner.scans, { nullable: false }) @ManyToOne(() => require("./Runner").Runner, (runner: Runner) => runner.scans, { nullable: false })
runner: Runner; runner!: Runner;
/** /**
* Is the scan valid (for fraud reasons). * Is the scan valid (for fraud reasons).
@@ -40,6 +43,27 @@ export class Scan {
@IsBoolean() @IsBoolean()
valid: boolean = true; valid: boolean = true;
@Column({ type: 'bigint', nullable: true, readonly: true })
@IsInt()
@IsPositive()
created_at: number;
@Column({ type: 'bigint', nullable: true })
@IsInt()
@IsPositive()
updated_at: number;
@BeforeInsert()
public setCreatedAt() {
this.created_at = Math.floor(Date.now() / 1000);
this.updated_at = Math.floor(Date.now() / 1000);
}
@BeforeUpdate()
public setUpdatedAt() {
this.updated_at = Math.floor(Date.now() / 1000);
}
/** /**
* The scan's distance in meters. * The scan's distance in meters.
* This is the "real" value used by "normal" scans.. * This is the "real" value used by "normal" scans..

View File

@@ -3,18 +3,22 @@ import {
IsInt, IsInt,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsPositive,
IsString IsString
} from "class-validator"; } from "class-validator";
import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm"; import { BeforeInsert, BeforeUpdate, Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { ResponseScanStation } from '../responses/ResponseScanStation'; import { ResponseScanStation } from '../responses/ResponseScanStation';
import { Track } from "./Track"; import type { Track } from "./Track";
import { TrackScan } from "./TrackScan"; import type { TrackScan } from "./TrackScan";
/** /**
* Defines the ScanStation entity. * Defines the ScanStation entity.
* ScanStations get used to create TrackScans for runners based on a scan of their runnerCard. * ScanStations get used to create TrackScans for runners based on a scan of their runnerCard.
*/ */
@Entity() @Entity()
@Index(['track'])
@Index(['prefix'])
@Index(['enabled'])
export class ScanStation { export class ScanStation {
/** /**
* Autogenerated unique id (primary key). * Autogenerated unique id (primary key).
@@ -37,8 +41,8 @@ export class ScanStation {
* All scans created by this station will also be associated with this track. * All scans created by this station will also be associated with this track.
*/ */
@IsNotEmpty() @IsNotEmpty()
@ManyToOne(() => Track, track => track.stations, { nullable: false }) @ManyToOne(() => require("./Track").Track, (track: Track) => track.stations, { nullable: false })
track: Track; track!: Track;
/** /**
* The client's api key prefix. * The client's api key prefix.
@@ -68,8 +72,8 @@ export class ScanStation {
/** /**
* Used to link track scans to a scan station. * Used to link track scans to a scan station.
*/ */
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true }) @OneToMany(() => require("./TrackScan").TrackScan, (scan: TrackScan) => scan.station, { nullable: true })
scans: TrackScan[]; scans!: TrackScan[];
/** /**
* Is this station enabled? * Is this station enabled?
@@ -78,6 +82,27 @@ export class ScanStation {
@IsBoolean() @IsBoolean()
enabled?: boolean = true; enabled?: boolean = true;
@Column({ type: 'bigint', nullable: true, readonly: true })
@IsInt()
@IsPositive()
created_at: number;
@Column({ type: 'bigint', nullable: true })
@IsInt()
@IsPositive()
updated_at: number;
@BeforeInsert()
public setCreatedAt() {
this.created_at = Math.floor(Date.now() / 1000);
this.updated_at = Math.floor(Date.now() / 1000);
}
@BeforeUpdate()
public setUpdatedAt() {
this.updated_at = Math.floor(Date.now() / 1000);
}
/** /**
* Turns this entity into it's response class. * Turns this entity into it's response class.
*/ */

View File

@@ -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.
*/ */

View File

@@ -5,10 +5,10 @@ import {
IsPositive, IsPositive,
IsString IsString
} from "class-validator"; } from "class-validator";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm"; import { BeforeInsert, BeforeUpdate, Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { ResponseTrack } from '../responses/ResponseTrack'; import { ResponseTrack } from '../responses/ResponseTrack';
import { ScanStation } from "./ScanStation"; import type { ScanStation } from "./ScanStation";
import { TrackScan } from "./TrackScan"; import type { TrackScan } from "./TrackScan";
/** /**
* Defines the Track entity. * Defines the Track entity.
@@ -53,15 +53,36 @@ export class Track {
* Used to link scan stations to a certain track. * Used to link scan stations to a certain track.
* This makes the configuration of the scan stations easier. * This makes the configuration of the scan stations easier.
*/ */
@OneToMany(() => ScanStation, station => station.track, { nullable: true }) @OneToMany(() => require("./ScanStation").ScanStation, (station: ScanStation) => station.track, { nullable: true })
stations: ScanStation[]; stations!: ScanStation[];
/** /**
* Used to link track scans to a track. * Used to link track scans to a track.
* The scan will derive it's distance from the track's distance. * The scan will derive it's distance from the track's distance.
*/ */
@OneToMany(() => TrackScan, scan => scan.track, { nullable: true }) @OneToMany(() => require("./TrackScan").TrackScan, (scan: TrackScan) => scan.track, { nullable: true })
scans: TrackScan[]; scans!: TrackScan[];
@Column({ type: 'bigint', nullable: true, readonly: true })
@IsInt()
@IsPositive()
created_at: number;
@Column({ type: 'bigint', nullable: true })
@IsInt()
@IsPositive()
updated_at: number;
@BeforeInsert()
public setCreatedAt() {
this.created_at = Math.floor(Date.now() / 1000);
this.updated_at = Math.floor(Date.now() / 1000);
}
@BeforeUpdate()
public setUpdatedAt() {
this.updated_at = Math.floor(Date.now() / 1000);
}
/** /**
* Turns this entity into it's response class. * Turns this entity into it's response class.

View File

@@ -2,44 +2,51 @@ import {
IsInt, IsInt,
IsNotEmpty, IsNotEmpty,
IsNumber,
IsPositive IsPositive
} from "class-validator"; } from "class-validator";
import { ChildEntity, Column, ManyToOne } from "typeorm"; import { ChildEntity, Column, Index, ManyToOne } from "typeorm";
import { ResponseTrackScan } from '../responses/ResponseTrackScan'; import { ResponseTrackScan } from '../responses/ResponseTrackScan';
import { RunnerCard } from "./RunnerCard"; import type { RunnerCard } from "./RunnerCard";
import { Scan } from "./Scan"; import { Scan } from "./Scan";
import { ScanStation } from "./ScanStation"; import type { ScanStation } from "./ScanStation";
import { Track } from "./Track"; import type { Track } from "./Track";
/** /**
* Defines the TrackScan entity. * Defines the TrackScan entity.
* A track scan usaually get's generated by a scan station. * A track scan usaually get's generated by a scan station.
*/ */
@ChildEntity() @ChildEntity()
@Index(['track'])
@Index(['card'])
@Index(['station'])
@Index(['timestamp'])
@Index(['station', 'timestamp'])
export class TrackScan extends Scan { export class TrackScan extends Scan {
/** /**
* The scan's associated track. * The scan's associated track.
* This is used to determine the scan's distance. * This is used to determine the scan's distance.
*/ */
@IsNotEmpty() @IsNotEmpty()
@ManyToOne(() => Track, track => track.scans, { nullable: true }) @ManyToOne(() => require("./Track").Track, (track: Track) => track.scans, { nullable: true })
track: Track; track!: Track;
/** /**
* The runnerCard associated with the scan. * The runnerCard associated with the scan.
* This get's saved for documentation and management purposes. * This get's saved for documentation and management purposes.
*/ */
@IsNotEmpty() @IsNotEmpty()
@ManyToOne(() => RunnerCard, card => card.scans, { nullable: true }) @ManyToOne(() => require("./RunnerCard").RunnerCard, (card: RunnerCard) => card.scans, { nullable: true })
card: RunnerCard; card!: RunnerCard;
/** /**
* The scanning station that created the scan. * The scanning station that created the scan.
* Mainly used for logging and traceing back scans (or errors) * Mainly used for logging and traceing back scans (or errors)
*/ */
@IsNotEmpty() @IsNotEmpty()
@ManyToOne(() => ScanStation, station => station.scans, { nullable: true }) @ManyToOne(() => require("./ScanStation").ScanStation, (station: ScanStation) => station.scans, { nullable: true })
station: ScanStation; station!: ScanStation;
/** /**
* The scan's distance in meters. * The scan's distance in meters.
@@ -59,6 +66,14 @@ export class TrackScan extends Scan {
@IsInt() @IsInt()
timestamp: number; timestamp: number;
/**
* The scan's lap time.
* This simply get's calculated from the last lap time;
*/
@Column()
@IsNumber()
lapTime: number;
/** /**
* Turns this entity into it's response class. * Turns this entity into it's response class.
*/ */

View File

@@ -1,19 +1,20 @@
import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl, IsUUID } from "class-validator"; import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsPhoneNumber, IsString, IsUrl, IsUUID } from "class-validator";
import { ChildEntity, Column, JoinTable, ManyToMany, OneToMany } from "typeorm"; import { ChildEntity, Column, Index, JoinTable, ManyToMany, OneToMany } from "typeorm";
import { config } from '../../config'; import { config } from '../../config';
import { ResponsePrincipal } from '../responses/ResponsePrincipal'; import { ResponsePrincipal } from '../responses/ResponsePrincipal';
import { ResponseUser } from '../responses/ResponseUser'; import { ResponseUser } from '../responses/ResponseUser';
import { Permission } from './Permission'; import { Permission } from './Permission';
import { Principal } from './Principal'; import { Principal } from './Principal';
import { UserAction } from './UserAction'; import type { UserAction } from './UserAction';
import { UserGroup } from './UserGroup'; import { UserGroup } from './UserGroup';
/** /**
* Defines the User entity. * Defines the User entity.
* Users are the ones that can use the "admin" webui and do stuff in the backend. * Users are the ones that can use the "admin" webui and do stuff in the backend.
*/ */
@ChildEntity() @ChildEntity()
export class User extends Principal { @Index(['enabled'])
export class User extends Principal {
/** /**
* The user's uuid. * The user's uuid.
* Mainly gets used as a per-user salt for the password hash. * Mainly gets used as a per-user salt for the password hash.
@@ -124,11 +125,11 @@ export class User extends Principal {
/** /**
* The actions performed by this user. * The actions performed by this user.
* For documentation purposes only, will be implemented later. * For documentation purposes only, will be implemented later.
*/ */
@IsOptional() @IsOptional()
@OneToMany(() => UserAction, action => action.user, { nullable: true }) @OneToMany(() => require("./UserAction").UserAction, (action: UserAction) => action.user, { nullable: true })
actions: UserAction[] actions!: UserAction[]
/** /**
* Resolves all permissions granted to this user through groups. * Resolves all permissions granted to this user through groups.

View File

@@ -3,11 +3,12 @@ import {
IsInt, IsInt,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsPositive,
IsString IsString
} from "class-validator"; } from "class-validator";
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm"; import { BeforeInsert, BeforeUpdate, Column, Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";
import { PermissionAction } from '../enums/PermissionAction'; import { PermissionAction } from '../enums/PermissionAction';
import { User } from './User'; import type { User } from './User';
/** /**
* Defines the UserAction entity. * Defines the UserAction entity.
@@ -25,8 +26,8 @@ export class UserAction {
/** /**
* The user that performed the action. * The user that performed the action.
*/ */
@ManyToOne(() => User, user => user.actions) @ManyToOne(() => require("./User").User, (user: User) => user.actions)
user: User user!: User
/** /**
* The actions's target (e.g. Track#2) * The actions's target (e.g. Track#2)
@@ -53,6 +54,27 @@ export class UserAction {
@IsString() @IsString()
changed: string; changed: string;
@Column({ type: 'bigint', nullable: true, readonly: true })
@IsInt()
@IsPositive()
created_at: number;
@Column({ type: 'bigint', nullable: true })
@IsInt()
@IsPositive()
updated_at: number;
@BeforeInsert()
public setCreatedAt() {
this.created_at = Math.floor(Date.now() / 1000);
this.updated_at = Math.floor(Date.now() / 1000);
}
@BeforeUpdate()
public setUpdatedAt() {
this.updated_at = Math.floor(Date.now() / 1000);
}
/** /**
* Turns this entity into it's response class. * Turns this entity into it's response class.
*/ */

View File

@@ -4,7 +4,6 @@ import {
IsString IsString
} from "class-validator"; } from "class-validator";
import { ChildEntity, Column } from "typeorm"; import { ChildEntity, Column } from "typeorm";
import { ResponsePrincipal } from '../responses/ResponsePrincipal';
import { ResponseUserGroup } from '../responses/ResponseUserGroup'; import { ResponseUserGroup } from '../responses/ResponseUserGroup';
import { Principal } from './Principal'; import { Principal } from './Principal';
@@ -34,7 +33,7 @@ export class UserGroup extends Principal {
/** /**
* Turns this entity into it's response class. * Turns this entity into it's response class.
*/ */
public toResponse(): ResponsePrincipal { public toResponse(): ResponseUserGroup {
return new ResponseUserGroup(this); return new ResponseUserGroup(this);
} }
} }

View 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';

View File

@@ -0,0 +1,7 @@
/**
* This enum contains all status a donation can inherit regarding it's payment status.
*/
export enum DonationStatus {
OPEN = 'OPEN',
PAID = 'PAID'
}

View File

@@ -15,5 +15,6 @@ export enum PermissionTarget {
STATION = 'STATION', STATION = 'STATION',
CARD = 'CARD', CARD = 'CARD',
DONATION = 'DONATION', DONATION = 'DONATION',
CONTACT = 'CONTACT' CONTACT = 'CONTACT',
MAIL = 'MAIL'
} }

View File

@@ -0,0 +1,39 @@
/**
* This enum contains all object types/entities a response can contain.
*/
export enum ResponseObjectType {
AUTH = 'AUTH',
DISTANCEDONATION = 'DISTANCEDONATION',
DONATION = 'DONATION',
DONOR = 'DONOR',
EMPTY = 'EMPTY',
GROUPCONTACT = 'GROUPCONTACT',
LOGOUT = 'LOGOUT',
PARTICIPANT = 'PARTICIPANT',
PERMISSION = 'PERMISSION',
PRINCIPAL = 'PRINCIPAL',
RUNNER = 'RUNNER',
RUNNERCARD = 'RUNNERCARD',
RUNNERGROUP = 'RUNNERGROUP',
RUNNERORGANIZATION = 'RUNNERORGANIZATION',
RUNNERTEAM = 'RUNNERTEAM',
SCAN = 'SCAN',
SCANSTATION = 'SCANSTATION',
SELFSERVICEDONATION = 'SELFSERVICEDONATION',
SELFSERVICERUNNER = 'SELFSERVICRUNNER',
SELFSERVICESCAN = 'SELFSERVICESCAN',
SELFSERVICETRACKSCAN = 'SELFSERVICETRACKSCAN',
SELFSERVICETEAM = 'SELFSERVICETEAM',
SELFSERVICEORGANIZATION = 'SELFSERVICEORGANIZATION',
STATS = 'STATS',
STATSCLIENT = 'STATSCLIENT',
STATSORGANIZATION = 'STATSORGANIZATION',
STATSRUNNER = 'STATSRUNNER',
STATSTEAM = 'STATSTEAM',
TRACK = 'TRACK',
TRACKSCAN = 'TRACKSCAN',
USER = 'USER',
USERGROUP = 'USERGROUP',
USERPERMISSIONS = 'USERPERMISSIONS',
SELFSERVICEDONOR = 'SELFSERVICEDONOR'
}

View File

@@ -0,0 +1,13 @@
import { ResponseObjectType } from '../enums/ResponseObjectType';
/**
* Defines the repsonse interface.
* This forces all response classes to implement the interfaces properties.
*/
export interface IResponse {
/**
* The responseType.
* This contains the type of class/entity this response contains.
*/
responseType: ResponseObjectType;
}

View File

@@ -0,0 +1,68 @@
import { IsInt, IsPositive } from "class-validator";
import { Donation } from '../entities/Donation';
import { DonationStatus } from '../enums/DonationStatus';
import { ResponseObjectType } from '../enums/ResponseObjectType';
import { IResponse } from './IResponse';
/**
* Defines the donation response.
*/
export class ResponseAnonymousDonation implements IResponse {
/**
* The responseType.
* This contains the type of class/entity this response contains.
*/
responseType: ResponseObjectType = ResponseObjectType.DONATION;
/**
* The donation's payment status.
* Provides you with a quick indicator of it's payment status.
*/
status: DonationStatus;
/**
* The donation's id.
*/
@IsInt()
@IsPositive()
id: number;
/**
* The donation's amount in the smalles unit of your currency (default: euro cent).
*/
@IsInt()
amount: number;
/**
* The donation's paid amount in the smalles unit of your currency (default: euro cent).
*/
@IsInt()
paidAmount: number;
@IsInt()
@IsPositive()
created_at: number;
@IsInt()
@IsPositive()
updated_at: number;
/**
* Creates a ResponseDonation object from a scan.
* @param donation The donation the response shall be build for.
*/
public constructor(donation: Donation) {
this.id = donation.id;
this.amount = donation.amount;
this.paidAmount = donation.paidAmount || 0;
if (this.paidAmount < this.amount) {
this.status = DonationStatus.OPEN;
}
else {
this.status = DonationStatus.PAID;
}
this.created_at = donation.created_at;
this.updated_at = donation.updated_at;
}
}

View File

@@ -1,9 +1,18 @@
import { IsInt, IsString } from 'class-validator'; import { IsInt, IsString } from 'class-validator';
import { ResponseObjectType } from '../enums/ResponseObjectType';
import { IResponse } from './IResponse';
/** /**
* Defines the repsonse auth. * Defines the repsonse auth.
*/ */
export class ResponseAuth { export class ResponseAuth implements IResponse {
/**
* The responseType.
* This contains the type of class/entity this response contains.
*/
responseType: ResponseObjectType = ResponseObjectType.AUTH;
/** /**
* The access_token - JWT shortterm access token. * The access_token - JWT shortterm access token.
*/ */

View File

@@ -1,12 +1,19 @@
import { IsInt, IsObject, IsPositive } from 'class-validator'; import { IsInt, IsObject, IsPositive } from 'class-validator';
import { DistanceDonation } from '../entities/DistanceDonation'; import { DistanceDonation } from '../entities/DistanceDonation';
import { ResponseObjectType } from '../enums/ResponseObjectType';
import { IResponse } from './IResponse';
import { ResponseDonation } from './ResponseDonation'; import { ResponseDonation } from './ResponseDonation';
import { ResponseRunner } from './ResponseRunner'; import { ResponseRunner } from './ResponseRunner';
/** /**
* Defines the distance donation response. * Defines the distance donation response.
*/ */
export class ResponseDistanceDonation extends ResponseDonation { export class ResponseDistanceDonation extends ResponseDonation implements IResponse {
/**
* The responseType.
* This contains the type of class/entity this response contains.
*/
responseType: ResponseObjectType = ResponseObjectType.DISTANCEDONATION;
/** /**
* The donation's associated runner. * The donation's associated runner.
@@ -29,7 +36,7 @@ export class ResponseDistanceDonation extends ResponseDonation {
*/ */
public constructor(donation: DistanceDonation) { public constructor(donation: DistanceDonation) {
super(donation); super(donation);
this.runner = donation.runner.toResponse(); if (donation.runner) { this.runner = donation.runner.toResponse(); }
this.amountPerDistance = donation.amountPerDistance; this.amountPerDistance = donation.amountPerDistance;
} }
} }

Some files were not shown because too many files have changed in this diff Show More