Compare commits

...

4 Commits

Author SHA1 Message Date
niggl 80e7e7939c fix(CreateUser): Await password hashing in toEntity method 2026-02-20 22:28:33 +01:00
niggl 6caa1850e3 fix(deps): Add @types/bun dependency to devDependencies 2026-02-20 22:28:01 +01:00
niggl 7aaac65af4 chore(release): 1.8.1
Build release images / build-container (push) Failing after 1m4s
2026-02-20 22:18:36 +01:00
niggl 13e0c81957 perf(stats): Cache stats results for 60 seconds 2026-02-20 22:16:14 +01:00
6 changed files with 193 additions and 3 deletions
+7
View File
@@ -2,8 +2,15 @@
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
#### [1.8.1](https://git.odit.services/lfk/backend/compare/1.8.0...1.8.1)
- perf(stats): Cache stats results for 60 seconds [`13e0c81`](https://git.odit.services/lfk/backend/commit/13e0c81957768c1b380914a0b93d3617c60e08a0)
#### [1.8.0](https://git.odit.services/lfk/backend/compare/1.7.2...1.8.0)
> 20 February 2026
- chore(release): 1.8.0 [`329a29a`](https://git.odit.services/lfk/backend/commit/329a29aca70b8c779c592149dc1cfe197ab62463)
- refactor: Switch from official argon2 to Bun's implementation [`a1e697a`](https://git.odit.services/lfk/backend/commit/a1e697acb264a753534c5ff8f5f43357cbc287da)
- refactor: Replace uuid and dotenv with bun primitives [`abce517`](https://git.odit.services/lfk/backend/commit/abce517d86daa00d76d691081907cb832494cb91)
- refactor(deps): Remove unused glob dependency from package.json and bun.lock [`abdadb8`](https://git.odit.services/lfk/backend/commit/abdadb8e6419c5ec9f8cc0a9e5ebf68671d84a94)
+5
View File
@@ -34,6 +34,7 @@
"devDependencies": {
"@faker-js/faker": "7.6.0",
"@odit/license-exporter": "0.0.9",
"@types/bun": "^1.3.9",
"@types/cors": "2.8.19",
"@types/csvtojson": "1.1.5",
"@types/express": "5.0.6",
@@ -251,6 +252,8 @@
"@types/body-parser": ["@types/body-parser@1.19.0", "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/cacheable-request": ["@types/cacheable-request@6.0.1", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "*", "@types/node": "*", "@types/responselike": "*" } }, "sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ=="],
"@types/connect": ["@types/connect@3.4.34", "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.34.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ=="],
@@ -435,6 +438,8 @@
"buffer-writer": ["buffer-writer@2.0.0", "https://registry.yarnpkg.com/buffer-writer/-/buffer-writer-2.0.0.tgz", {}, "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"busboy": ["busboy@0.2.14", "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz", { "dependencies": { "dicer": "0.2.5", "readable-stream": "1.1.x" } }, "sha1-bCpiLvz0fFe7vh4qnDetNseSVFM="],
"bytes": ["bytes@3.1.0", "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz", {}, "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="],
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@odit/lfk-backend",
"version": "1.8.0",
"version": "1.8.1",
"main": "src/app.ts",
"repository": "https://git.odit.services/lfk/backend",
"author": {
@@ -51,6 +51,7 @@
"devDependencies": {
"@faker-js/faker": "7.6.0",
"@odit/license-exporter": "0.0.9",
"@types/bun": "^1.3.9",
"@types/cors": "2.8.19",
"@types/csvtojson": "1.1.5",
"@types/express": "5.0.6",
+92 -1
View File
@@ -14,6 +14,7 @@ import { ResponseStats } from '../models/responses/ResponseStats';
import { ResponseStatsOrgnisation } from '../models/responses/ResponseStatsOrganization';
import { ResponseStatsRunner } from '../models/responses/ResponseStatsRunner';
import { ResponseStatsTeam } from '../models/responses/ResponseStatsTeam';
import { getStatsCache, setStatsCache } from '../nats/StatsKV';
@JsonController('/stats')
export class StatsController {
@@ -22,6 +23,13 @@ export class StatsController {
@ResponseSchema(ResponseStats)
@OpenAPI({ description: "A very basic stats endpoint providing basic counters for a dashboard or simmilar" })
async get() {
// Try cache first
const cached = await getStatsCache<ResponseStats>('overview');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
const connection = getConnection();
const runnersViaSelfservice = await connection.getRepository(Runner).count({ where: { created_via: "selfservice" } });
const runnersViaKiosk = await connection.getRepository(Runner).count({ where: { created_via: "kiosk" } });
@@ -43,7 +51,12 @@ export class StatsController {
let donations = await connection.getRepository(Donation).find({ relations: ['runner', 'runner.scans', 'runner.scans.track'] });
const donors = await connection.getRepository(Donor).count();
return new ResponseStats(runnersViaSelfservice, runners, teams, orgs, users, scans, donations, distace, donors, runnersViaKiosk)
const result = new ResponseStats(runnersViaSelfservice, runners, teams, orgs, users, scans, donations, distace, donors, runnersViaKiosk);
// Store in cache for 60 seconds
await setStatsCache('overview', result);
return result;
}
@Get("/runners/distance")
@@ -51,6 +64,13 @@ export class StatsController {
@ResponseSchema(ResponseStatsRunner, { isArray: true })
@OpenAPI({ description: "Returns the top ten runners by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopRunnersByDistance() {
// Try cache first
const cached = await getStatsCache<ResponseStatsRunner[]>('runners.distance');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let runners = await getConnection().getRepository(Runner).find({ relations: ['scans', 'group', 'distanceDonations', 'scans.track'] });
if (!runners || runners.length == 0) {
return [];
@@ -60,6 +80,10 @@ export class StatsController {
topRunners.forEach(runner => {
responseRunners.push(new ResponseStatsRunner(runner));
});
// Store in cache for 60 seconds
await setStatsCache('runners.distance', responseRunners);
return responseRunners;
}
@@ -68,6 +92,13 @@ export class StatsController {
@ResponseSchema(ResponseStatsRunner, { isArray: true })
@OpenAPI({ description: "Returns the top ten runners by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopRunnersByDonations() {
// Try cache first
const cached = await getStatsCache<ResponseStatsRunner[]>('runners.donations');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let runners = await getConnection().getRepository(Runner).find({ relations: ['group', 'distanceDonations', 'distanceDonations.runner', 'distanceDonations.runner.scans', 'distanceDonations.runner.scans.track'] });
if (!runners || runners.length == 0) {
return [];
@@ -77,6 +108,10 @@ export class StatsController {
topRunners.forEach(runner => {
responseRunners.push(new ResponseStatsRunner(runner));
});
// Store in cache for 60 seconds
await setStatsCache('runners.donations', responseRunners);
return responseRunners;
}
@@ -85,6 +120,14 @@ export class StatsController {
@ResponseSchema(ResponseStatsRunner, { isArray: true })
@OpenAPI({ description: "Returns the top ten runners by fastest laptime on your selected track (track by id).", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopRunnersByLaptime(@QueryParam("track") track: number) {
// Try cache first (cache key includes track id, using dots for NATS KV compatibility)
const cacheKey = `runners.laptime.${track}`;
const cached = await getStatsCache<ResponseStatsRunner[]>(cacheKey);
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let scans = await getConnection().getRepository(TrackScan).find({ relations: ['track', 'runner', 'runner.group', 'runner.scans', 'runner.scans.track', 'runner.distanceDonations'] });
if (!scans || scans.length == 0) {
return [];
@@ -105,6 +148,10 @@ export class StatsController {
topScans.forEach(scan => {
responseRunners.push(new ResponseStatsRunner(scan.runner, scan.lapTime));
});
// Store in cache for 60 seconds
await setStatsCache(cacheKey, responseRunners);
return responseRunners;
}
@@ -121,6 +168,13 @@ export class StatsController {
@ResponseSchema(ResponseStatsTeam, { isArray: true })
@OpenAPI({ description: "Returns the top ten teams by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopTeamsByDistance() {
// Try cache first
const cached = await getStatsCache<ResponseStatsTeam[]>('teams.distance');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.scans.track'] });
if (!teams || teams.length == 0) {
return [];
@@ -130,6 +184,10 @@ export class StatsController {
topTeams.forEach(team => {
responseTeams.push(new ResponseStatsTeam(team));
});
// Store in cache for 60 seconds
await setStatsCache('teams.distance', responseTeams);
return responseTeams;
}
@@ -138,6 +196,13 @@ export class StatsController {
@ResponseSchema(ResponseStatsTeam, { isArray: true })
@OpenAPI({ description: "Returns the top ten teams by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopTeamsByDonations() {
// Try cache first
const cached = await getStatsCache<ResponseStatsTeam[]>('teams.donations');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let teams = await getConnection().getRepository(RunnerTeam).find({ relations: ['parentGroup', 'runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track'] });
if (!teams || teams.length == 0) {
return [];
@@ -147,6 +212,10 @@ export class StatsController {
topTeams.forEach(team => {
responseTeams.push(new ResponseStatsTeam(team));
});
// Store in cache for 60 seconds
await setStatsCache('teams.donations', responseTeams);
return responseTeams;
}
@@ -155,6 +224,13 @@ export class StatsController {
@ResponseSchema(ResponseStatsOrgnisation, { isArray: true })
@OpenAPI({ description: "Returns the top ten organizations by distance.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopOrgsByDistance() {
// Try cache first
const cached = await getStatsCache<ResponseStatsOrgnisation[]>('organizations.distance');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.scans', 'runners.distanceDonations', 'runners.scans.track', 'teams', 'teams.runners', 'teams.runners.scans', 'teams.runners.distanceDonations', 'teams.runners.scans.track'] });
if (!orgs || orgs.length == 0) {
return [];
@@ -164,6 +240,10 @@ export class StatsController {
topOrgs.forEach(org => {
responseOrgs.push(new ResponseStatsOrgnisation(org));
});
// Store in cache for 60 seconds
await setStatsCache('organizations.distance', responseOrgs);
return responseOrgs;
}
@@ -172,6 +252,13 @@ export class StatsController {
@ResponseSchema(ResponseStatsOrgnisation, { isArray: true })
@OpenAPI({ description: "Returns the top ten organizations by donations.", security: [{ "StatsApiToken": [] }, { "AuthToken": [] }, { "RefreshTokenCookie": [] }] })
async getTopOrgsByDonations() {
// Try cache first
const cached = await getStatsCache<ResponseStatsOrgnisation[]>('organizations.donations');
if (cached) {
return cached;
}
// Cache miss - compute fresh stats
let orgs = await getConnection().getRepository(RunnerOrganization).find({ relations: ['runners', 'runners.distanceDonations', 'runners.distanceDonations.runner', 'runners.distanceDonations.runner.scans', 'runners.distanceDonations.runner.scans.track', 'teams', 'teams.runners', 'teams.runners.distanceDonations', 'teams.runners.distanceDonations.runner', 'teams.runners.distanceDonations.runner.scans', 'teams.runners.distanceDonations.runner.scans.track'] });
if (!orgs || orgs.length == 0) {
return [];
@@ -181,6 +268,10 @@ export class StatsController {
topOrgs.forEach(org => {
responseOrgs.push(new ResponseStatsOrgnisation(org));
});
// Store in cache for 60 seconds
await setStatsCache('organizations.donations', responseOrgs);
return responseOrgs;
}
}
+1 -1
View File
@@ -109,7 +109,7 @@ export class CreateUser {
newUser.lastname = this.lastname
newUser.uuid = crypto.randomUUID()
newUser.phone = this.phone
newUser.password = Bun.password.hash(this.password + newUser.uuid);
newUser.password = await Bun.password.hash(this.password + newUser.uuid);
newUser.groups = await this.getGroups();
newUser.enabled = this.enabled;
+86
View File
@@ -0,0 +1,86 @@
import { KvEntry } from 'nats';
import NatsClient from './NatsClient';
const BUCKET = 'stats_cache';
const TTL_SECONDS = 60; // 60 second TTL
/**
* Stats cache stored in NATS KV with 60 second TTL.
* Used to cache expensive aggregation queries from the stats endpoints.
*/
async function getBucket() {
return NatsClient.getKV(BUCKET, { ttl: TTL_SECONDS * 1000 }); // TTL in milliseconds
}
/**
* Cache key patterns (using dots instead of colons for NATS KV compatibility):
* - "stats.overview" - main stats endpoint (GET /stats)
* - "stats.runners.distance" - top runners by distance
* - "stats.runners.donations" - top runners by donations
* - "stats.runners.laptime.{trackId}" - top runners by laptime for specific track
* - "stats.teams.distance" - top teams by distance
* - "stats.teams.donations" - top teams by donations
* - "stats.organizations.distance" - top organizations by distance
* - "stats.organizations.donations" - top organizations by donations
*/
function cacheKey(path: string): string {
// Replace colons with dots for NATS KV compatibility
return `stats.${path.replace(/:/g, '.')}`;
}
/**
* Returns the cached value for the given stats cache key, or null on a miss.
*/
export async function getStatsCache<T>(path: string): Promise<T | null> {
const bucket = await getBucket();
let entry: KvEntry | null = null;
try {
entry = await bucket.get(cacheKey(path));
} catch {
return null;
}
if (!entry || entry.operation === 'DEL' || entry.operation === 'PURGE') {
return null;
}
return JSON.parse(entry.string()) as T;
}
/**
* Stores a value in the stats cache with 60 second TTL.
* The TTL is applied at the bucket level, so all entries expire automatically.
*/
export async function setStatsCache<T>(path: string, value: T): Promise<void> {
const bucket = await getBucket();
await bucket.put(cacheKey(path), JSON.stringify(value));
}
/**
* Removes the cached entry for the given stats path.
* Useful for cache invalidation when data changes.
*/
export async function deleteStatsCache(path: string): Promise<void> {
const bucket = await getBucket();
try {
await bucket.delete(cacheKey(path));
} catch {
// Entry doesn't exist or already deleted - ignore
}
}
/**
* Removes all cached stats entries.
* Call this when runners, scans, or donations are modified to ensure fresh data.
*/
export async function invalidateAllStats(): Promise<void> {
const bucket = await getBucket();
try {
// Purge the entire bucket to clear all cached stats
await bucket.destroy();
// Recreate the bucket for future use
await NatsClient.getKV(BUCKET, { ttl: TTL_SECONDS * 1000 });
} catch {
// Bucket operations can fail if bucket doesn't exist - ignore
}
}