From c648df0303c4a453d8ff01ee29ba6c1a98de2c53 Mon Sep 17 00:00:00 2001 From: Punkerschaf <105040919+Punkerschaf@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:18:35 +0100 Subject: [PATCH 01/12] switch to SQLite, make it production ready --- .dockerignore | 16 + Dockerfile | 50 ++ README.md | 54 +- backend/.env.example | 18 +- backend/package-lock.json | 618 ++++++++++++++++++++++- backend/package.json | 8 +- backend/src/config.js | 24 +- backend/src/dal.js | 426 ++++++++++++++++ backend/src/db.js | 70 +-- backend/src/index.js | 55 +- backend/src/middleware/auth.js | 4 +- backend/src/middleware/error-handler.js | 13 + backend/src/middleware/request-logger.js | 15 + backend/src/migrate-from-lowdb.js | 151 ++++++ backend/src/routes/auth.js | 57 ++- backend/src/routes/cashBook.js | 159 +++--- backend/src/routes/machines.js | 77 +-- backend/src/routes/settings.js | 56 +- backend/src/routes/stats.js | 72 ++- backend/src/routes/terminalActions.js | 243 +++++---- backend/src/routes/terminals.js | 158 +++--- backend/src/routes/users.js | 199 ++++---- backend/src/schema.sql | 96 ++++ backend/src/validators/index.js | 96 ++++ docker-compose.yml | 13 + docs/deployment-docker.md | 104 ++++ docs/deployment-raspi.md | 166 ++++++ 27 files changed, 2410 insertions(+), 608 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 backend/src/dal.js create mode 100644 backend/src/middleware/error-handler.js create mode 100644 backend/src/middleware/request-logger.js create mode 100644 backend/src/migrate-from-lowdb.js create mode 100644 backend/src/schema.sql create mode 100644 backend/src/validators/index.js create mode 100644 docker-compose.yml create mode 100644 docs/deployment-docker.md create mode 100644 docs/deployment-raspi.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a8a9940 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +node_modules +npm-debug.log* +.git +.gitignore +.env +.env.* +!.env.example +backend/data/*.db +backend/data/*.json +frontend/dist +frontend/node_modules +*.md +docs/ +LICENSE +.vscode +.idea diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b0a736a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +# ─── Stage 1: Build frontend ──────────────────────────────── +FROM node:20-alpine AS build + +WORKDIR /app + +# Install frontend dependencies +COPY frontend/package.json frontend/package-lock.json* frontend/ +RUN cd frontend && npm ci + +# Copy frontend source and build +COPY frontend/ frontend/ +RUN cd frontend && npm run build + +# ─── Stage 2: Production ──────────────────────────────────── +FROM node:20-alpine + +RUN apk add --no-cache curl + +WORKDIR /app + +# Install backend dependencies only +COPY backend/package.json backend/package-lock.json* backend/ +RUN cd backend && npm ci --omit=dev + +# Copy backend source +COPY backend/src/ backend/src/ + +# Copy built frontend from stage 1 +COPY --from=build /app/frontend/dist/ frontend/dist/ + +# Copy version file (needed by health endpoint) +COPY version.json . + +# Data directory for SQLite DB (mount as volume) +RUN mkdir -p backend/data + +# Run as non-root user +RUN addgroup -S cuptrack && adduser -S cuptrack -G cuptrack +RUN chown -R cuptrack:cuptrack /app +USER cuptrack + +ENV NODE_ENV=production +ENV PORT=3000 + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:3000/api/health || exit 1 + +CMD ["node", "backend/src/index.js"] diff --git a/README.md b/README.md index f939a5c..be1aedc 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,61 @@ # CupTrack + CupTrack is a digital coffee fund for shared coffee machines, such as those in the office. It simply tracks which user has drunk how many coffees and deducts the corresponding amounts from their account. CupTrack does not control the coffee machines. The system relies on honesty – just like a haptic coffee fund. It is web based with a terminal view and an admin's dashboard. ## How it works -Drinking users have account balance and a four digit PIN. -On a machines terminal user select their user, enter their PIN and count a coffee. The predefined amount is deducted from their balance. + +Drinking users have an account balance and a four digit PIN. +On a machine's terminal users select their user, enter their PIN and count a coffee. The predefined amount is deducted from their balance. They also can top up their balance. -> [!TIP] -> This README and the apps documentation is still under construction. Feel free to send me a message, if have questions. +## Tech Stack + +- **Frontend:** React 18, TypeScript, Vite, Material-UI +- **Backend:** Express, Node.js (ESM) +- **Database:** SQLite (better-sqlite3, WAL mode) +- **Security:** Helmet, rate-limiting, Zod validation, JWT auth ## Development -The root user on the local dev-server is `root` with password `Coffee`. Change this as well as JWT secret in the .env-file of backend before deploying to production. + +```bash +# Backend +cd backend +cp .env.example .env # adjust values +npm install +npm run dev + +# Frontend (separate terminal) +cd frontend +npm install +npm run dev +``` + +The default root user is `root` / `Coffee`. Change credentials in `.env` before deploying. + There is a file `architecture.md` for AI coding agents to understand the architecture of the project without needing to read the entire codebase. +## Deployment + +- **Docker:** See [docs/deployment-docker.md](docs/deployment-docker.md) +- **Raspberry Pi:** See [docs/deployment-raspi.md](docs/deployment-raspi.md) + +Quick start with Docker: + +```bash +cp backend/.env.example backend/.env +# edit backend/.env – set JWT_SECRET and ROOT_PASSWORD +docker compose up -d --build +``` + +## Migration from lowdb + +If upgrading from a version that used `db.json`: + +```bash +node backend/src/migrate-from-lowdb.js +``` + > [!NOTE] -> This application is vibe coded in most parts. Documenation may be chaos. Structure may doesn't make any sense. +> This application is vibe coded in most parts. Documentation may be chaotic. Structure may not always make sense. diff --git a/backend/.env.example b/backend/.env.example index 05e2318..c734cd7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,4 +1,16 @@ +# Server PORT=3000 -JWT_SECRET=change-this-in-production -ROOT_USERNAME=root -ROOT_PASSWORD=Coffee +NODE_ENV=production +LOG_LEVEL=info + +# Security – MUST be changed in production (≥32 characters) +JWT_SECRET=change-me-to-a-long-random-secret-at-least-32-chars + +# Root admin account +# Will be created on first start, if no root user exists. +# You can't change it here afterwards. +ROOT_USERNAME=admin +ROOT_PASSWORD=change-me-in-production + +# CORS – comma-separated origins, or * for development only +CORS_ORIGIN=http://localhost:3000 diff --git a/backend/package-lock.json b/backend/package-lock.json index ab4d4b2..f09242b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,14 +9,23 @@ "version": "0.1.0", "dependencies": { "bcryptjs": "^2.4.3", + "better-sqlite3": "^12.8.0", "cors": "^2.8.5", "dotenv": "^16.4.0", "express": "^4.21.0", + "express-rate-limit": "^8.3.1", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", - "lowdb": "^7.0.1", - "uuid": "^10.0.0" - }, - "devDependencies": {} + "pino": "^10.3.1", + "uuid": "^10.0.0", + "zod": "^4.3.6" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" }, "node_modules/accepts": { "version": "1.3.8", @@ -37,12 +46,75 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bcryptjs": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -67,6 +139,30 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -111,6 +207,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -173,6 +275,30 @@ "ms": "2.0.0" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -192,6 +318,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -242,6 +377,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -287,6 +431,15 @@ "node": ">= 0.6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -333,6 +486,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.1.tgz", + "integrity": "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -369,6 +546,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -415,6 +598,12 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -451,6 +640,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -483,12 +681,47 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -589,21 +822,6 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, - "node_modules/lowdb": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz", - "integrity": "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==", - "license": "MIT", - "dependencies": { - "steno": "^4.0.2" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/typicode" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -673,12 +891,45 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -688,6 +939,18 @@ "node": ">= 0.6" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -709,6 +972,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -721,6 +993,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -736,6 +1017,86 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -749,6 +1110,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.14.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", @@ -764,6 +1135,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -788,6 +1165,44 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -808,6 +1223,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -949,6 +1373,69 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -958,16 +1445,62 @@ "node": ">= 0.8" } }, - "node_modules/steno": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz", - "integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==", + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" }, - "funding": { - "url": "https://github.com/sponsors/typicode" + "engines": { + "node": ">=6" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/toidentifier": { @@ -979,6 +1512,18 @@ "node": ">=0.6" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1001,6 +1546,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -1031,6 +1582,21 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 2706cc5..7ac9e93 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,11 +9,15 @@ }, "dependencies": { "bcryptjs": "^2.4.3", + "better-sqlite3": "^12.8.0", "cors": "^2.8.5", "dotenv": "^16.4.0", "express": "^4.21.0", + "express-rate-limit": "^8.3.1", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", - "lowdb": "^7.0.1", - "uuid": "^10.0.0" + "pino": "^10.3.1", + "uuid": "^10.0.0", + "zod": "^4.3.6" } } diff --git a/backend/src/config.js b/backend/src/config.js index 38c81b9..2523437 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -1,8 +1,28 @@ -import 'dotenv/config'; +import dotenv from 'dotenv'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; -export default { +const __dirname = dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: join(__dirname, '..', '.env') }); + +const config = { + nodeEnv: process.env.NODE_ENV || 'development', port: parseInt(process.env.PORT || '3000', 10), jwtSecret: process.env.JWT_SECRET || 'cuptrack-dev-secret-change-in-production', rootUsername: process.env.ROOT_USERNAME || 'root', rootPassword: process.env.ROOT_PASSWORD || 'Coffee', + corsOrigin: process.env.CORS_ORIGIN || '*', + logLevel: process.env.LOG_LEVEL || 'info', }; + +if (config.nodeEnv === 'production') { + if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) { + console.error('FATAL: JWT_SECRET muss in Produktion gesetzt sein (mind. 32 Zeichen)'); + process.exit(1); + } + if (!process.env.ROOT_PASSWORD || process.env.ROOT_PASSWORD === 'Coffee') { + console.warn('WARNUNG: ROOT_PASSWORD sollte in Produktion geändert werden'); + } +} + +export default config; diff --git a/backend/src/dal.js b/backend/src/dal.js new file mode 100644 index 0000000..628fec9 --- /dev/null +++ b/backend/src/dal.js @@ -0,0 +1,426 @@ +import db from './db.js'; +import { v4 as uuidv4 } from 'uuid'; + +// ─── Helpers ──────────────────────────────────────────────── + +function attachIdentifiers(row) { + if (!row) return null; + const identifiers = db.prepare('SELECT id, type, value FROM identifiers WHERE userId = ?').all(row.id); + return { ...row, isRoot: !!row.isRoot, identifiers }; +} + +function stripPassword(user) { + if (!user) return null; + const { password, ...safe } = user; + return safe; +} + +function parseLog(row) { + if (!row) return null; + return { ...row, details: row.details ? JSON.parse(row.details) : null }; +} + +function toTerminal(row) { + if (!row) return null; + return { + id: row.id, + name: row.name, + slug: row.slug, + machineId: row.machineId, + type: row.type, + quickButtons: { + enabled: !!row.quickButtonsEnabled, + button1: row.quickButton1, + button2: row.quickButton2, + }, + alphabetFilter: { + enabled: !!row.alphabetFilterEnabled, + }, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +// ─── Users ────────────────────────────────────────────────── + +export const users = { + findAll() { + const rows = db.prepare('SELECT * FROM users').all(); + return rows.map(r => stripPassword(attachIdentifiers(r))); + }, + + findById(id) { + const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id); + return attachIdentifiers(row); + }, + + findByIdSafe(id) { + const user = users.findById(id); + return stripPassword(user); + }, + + findByUsername(username) { + const row = db.prepare("SELECT * FROM users WHERE username = ? AND type != 'api'").get(username); + return attachIdentifiers(row); + }, + + findDrinkersForTerminal() { + const rows = db.prepare("SELECT id, displayName FROM users WHERE type = 'drinker'").all(); + return rows.filter(u => { + const ids = db.prepare("SELECT 1 FROM identifiers WHERE userId = ? AND type IN ('pin', 'kaba_nfc') LIMIT 1").get(u.id); + return !!ids; + }); + }, + + findByNfcSerial(serial) { + const normalized = serial.toLowerCase().trim(); + const identifier = db.prepare( + "SELECT userId FROM identifiers WHERE type = 'kaba_nfc' AND LOWER(TRIM(value)) = ?", + ).get(normalized); + if (!identifier) return null; + const row = db.prepare("SELECT * FROM users WHERE id = ? AND type = 'drinker'").get(identifier.userId); + return attachIdentifiers(row); + }, + + usernameExists(username) { + return !!db.prepare('SELECT 1 FROM users WHERE username = ?').get(username); + }, + + pinExists(pin) { + return !!db.prepare("SELECT 1 FROM identifiers WHERE type = 'pin' AND value = ?").get(pin); + }, + + create(userData) { + const { identifiers: ids, ...user } = userData; + db.prepare( + `INSERT INTO users (id, username, displayName, password, type, isRoot, balance, apiKey, createdAt, updatedAt) + VALUES (@id, @username, @displayName, @password, @type, @isRoot, @balance, @apiKey, @createdAt, @updatedAt)`, + ).run({ ...user, isRoot: user.isRoot ? 1 : 0 }); + + if (ids && ids.length > 0) { + const stmt = db.prepare('INSERT INTO identifiers (id, type, value, userId) VALUES (?, ?, ?, ?)'); + for (const ident of ids) { + stmt.run(ident.id, ident.type, ident.value, user.id); + } + } + return users.findById(user.id); + }, + + update(id, fields) { + const setClauses = []; + const values = {}; + for (const [key, val] of Object.entries(fields)) { + if (key === 'identifiers') continue; + setClauses.push(`${key} = @${key}`); + values[key] = key === 'isRoot' ? (val ? 1 : 0) : val; + } + if (setClauses.length > 0) { + values.id = id; + db.prepare(`UPDATE users SET ${setClauses.join(', ')} WHERE id = @id`).run(values); + } + return users.findById(id); + }, + + replaceIdentifiers(userId, identifiers) { + db.prepare('DELETE FROM identifiers WHERE userId = ?').run(userId); + if (identifiers && identifiers.length > 0) { + const stmt = db.prepare('INSERT INTO identifiers (id, type, value, userId) VALUES (?, ?, ?, ?)'); + for (const ident of identifiers) { + stmt.run(ident.id || uuidv4(), ident.type, ident.value, userId); + } + } + }, + + delete(id) { + db.prepare('DELETE FROM users WHERE id = ?').run(id); + }, + + countDrinkers() { + return db.prepare("SELECT COUNT(*) as count FROM users WHERE type = 'drinker'").get().count; + }, +}; + +// ─── Machines ─────────────────────────────────────────────── + +export const machines = { + findAll() { + return db.prepare('SELECT * FROM machines').all(); + }, + + findById(id) { + return db.prepare('SELECT * FROM machines WHERE id = ?').get(id) || null; + }, + + create(data) { + db.prepare( + `INSERT INTO machines (id, name, room, pricePerCoffee, createdAt, updatedAt) + VALUES (@id, @name, @room, @pricePerCoffee, @createdAt, @updatedAt)`, + ).run(data); + return machines.findById(data.id); + }, + + update(id, fields) { + const setClauses = []; + const values = {}; + for (const [key, val] of Object.entries(fields)) { + setClauses.push(`${key} = @${key}`); + values[key] = val; + } + if (setClauses.length > 0) { + values.id = id; + db.prepare(`UPDATE machines SET ${setClauses.join(', ')} WHERE id = @id`).run(values); + } + return machines.findById(id); + }, + + delete(id) { + db.prepare('DELETE FROM machines WHERE id = ?').run(id); + }, + + count() { + return db.prepare('SELECT COUNT(*) as count FROM machines').get().count; + }, +}; + +// ─── Terminals ────────────────────────────────────────────── + +export const terminals = { + findAll() { + const rows = db.prepare('SELECT * FROM terminals').all(); + return rows.map(row => { + const t = toTerminal(row); + const machine = machines.findById(t.machineId); + return { ...t, machine: machine || null }; + }); + }, + + findById(id) { + const row = db.prepare('SELECT * FROM terminals WHERE id = ?').get(id); + return toTerminal(row); + }, + + findByIdWithMachine(id) { + const t = terminals.findById(id); + if (!t) return null; + const machine = machines.findById(t.machineId); + return { ...t, machine: machine || null }; + }, + + findBySlug(slug) { + const row = db.prepare('SELECT * FROM terminals WHERE slug = ?').get(slug); + return toTerminal(row); + }, + + slugExists(slug, excludeId) { + if (excludeId) { + return !!db.prepare('SELECT 1 FROM terminals WHERE slug = ? AND id != ?').get(slug, excludeId); + } + return !!db.prepare('SELECT 1 FROM terminals WHERE slug = ?').get(slug); + }, + + machineInUse(machineId) { + return db.prepare('SELECT id FROM terminals WHERE machineId = ?').get(machineId) || null; + }, + + exists(id) { + return !!db.prepare('SELECT 1 FROM terminals WHERE id = ?').get(id); + }, + + create(data) { + db.prepare( + `INSERT INTO terminals (id, name, slug, machineId, type, quickButtonsEnabled, quickButton1, quickButton2, alphabetFilterEnabled, createdAt, updatedAt) + VALUES (@id, @name, @slug, @machineId, @type, @quickButtonsEnabled, @quickButton1, @quickButton2, @alphabetFilterEnabled, @createdAt, @updatedAt)`, + ).run(data); + return terminals.findByIdWithMachine(data.id); + }, + + update(id, fields) { + const setClauses = []; + const values = {}; + for (const [key, val] of Object.entries(fields)) { + setClauses.push(`${key} = @${key}`); + values[key] = val; + } + if (setClauses.length > 0) { + values.id = id; + db.prepare(`UPDATE terminals SET ${setClauses.join(', ')} WHERE id = @id`).run(values); + } + return terminals.findByIdWithMachine(id); + }, + + delete(id) { + db.prepare('DELETE FROM terminals WHERE id = ?').run(id); + }, + + count() { + return db.prepare('SELECT COUNT(*) as count FROM terminals').get().count; + }, +}; + +// ─── Logs ─────────────────────────────────────────────────── + +export const logs = { + create(entry) { + const row = { + id: entry.id || uuidv4(), + type: entry.type, + userId: entry.userId || null, + machineId: entry.machineId || null, + terminalId: entry.terminalId || null, + details: entry.details ? JSON.stringify(entry.details) : null, + createdAt: entry.createdAt || new Date().toISOString(), + }; + db.prepare( + `INSERT INTO logs (id, type, userId, machineId, terminalId, details, createdAt) + VALUES (@id, @type, @userId, @machineId, @terminalId, @details, @createdAt)`, + ).run(row); + }, + + findByUserId(userId) { + return db.prepare('SELECT * FROM logs WHERE userId = ? ORDER BY createdAt DESC').all(userId).map(parseLog); + }, + + findByMachineId(machineId) { + return db.prepare('SELECT * FROM logs WHERE machineId = ? ORDER BY createdAt DESC').all(machineId).map(parseLog); + }, + + findByTerminalId(terminalId) { + return db.prepare('SELECT * FROM logs WHERE terminalId = ? ORDER BY createdAt DESC').all(terminalId).map(parseLog); + }, + + getCoffeeLogs() { + return db.prepare("SELECT * FROM logs WHERE type = 'coffee'").all().map(parseLog); + }, + + countCoffeesForDate(dateStr) { + return db.prepare("SELECT COUNT(*) as count FROM logs WHERE type = 'coffee' AND createdAt LIKE ?") + .get(dateStr + '%').count; + }, + + coffeesPerDay(startDateStr) { + return db.prepare( + "SELECT DATE(createdAt) as date, COUNT(*) as count FROM logs WHERE type = 'coffee' AND createdAt >= ? GROUP BY DATE(createdAt)", + ).all(startDateStr + 'T00:00:00.000Z'); + }, + + coffeeCountsByUser() { + return db.prepare("SELECT userId, COUNT(*) as count FROM logs WHERE type = 'coffee' GROUP BY userId").all(); + }, + + coffeeCountsByMachine() { + return db.prepare( + "SELECT machineId, COUNT(*) as count FROM logs WHERE type = 'coffee' AND machineId IS NOT NULL GROUP BY machineId", + ).all(); + }, + + deleteOlderThan(cutoffISO) { + const old = db.prepare('SELECT * FROM logs WHERE createdAt < ?').all(cutoffISO).map(parseLog); + if (old.length > 0) { + db.prepare('DELETE FROM logs WHERE createdAt < ?').run(cutoffISO); + } + return old; + }, +}; + +// ─── Cash Book ────────────────────────────────────────────── + +export const cashBook = { + findAll() { + return db.prepare('SELECT * FROM cash_book ORDER BY createdAt DESC').all(); + }, + + findById(id) { + return db.prepare('SELECT * FROM cash_book WHERE id = ?').get(id) || null; + }, + + create(entry) { + const row = { + id: entry.id || uuidv4(), + type: entry.type, + amount: entry.amount, + comment: entry.comment || '', + machineId: entry.machineId || null, + terminalId: entry.terminalId || null, + performedBy: entry.performedBy, + createdAt: entry.createdAt || new Date().toISOString(), + }; + db.prepare( + `INSERT INTO cash_book (id, type, amount, comment, machineId, terminalId, performedBy, createdAt) + VALUES (@id, @type, @amount, @comment, @machineId, @terminalId, @performedBy, @createdAt)`, + ).run(row); + return row; + }, + + delete(id) { + db.prepare('DELETE FROM cash_book WHERE id = ?').run(id); + }, + + computeBalance() { + const result = db.prepare( + `SELECT + COALESCE(SUM(CASE WHEN type IN ('deposit', 'anonymous_coffee') THEN amount ELSE 0 END), 0) + - COALESCE(SUM(CASE WHEN type = 'withdrawal' THEN amount ELSE 0 END), 0) as balance + FROM cash_book`, + ).get(); + return Math.round(result.balance * 100) / 100; + }, +}; + +// ─── Settings ─────────────────────────────────────────────── + +export const settings = { + get() { + const rows = db.prepare('SELECT key, value FROM settings').all(); + const obj = {}; + for (const row of rows) obj[row.key] = row.value; + return Object.keys(obj).length > 0 ? obj : { language: 'de' }; + }, + + update(data) { + const upsert = db.prepare( + 'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + ); + for (const [key, value] of Object.entries(data)) { + upsert.run(key, value); + } + return settings.get(); + }, +}; + +// ─── Archived Stats ───────────────────────────────────────── + +export const archivedStats = { + get() { + const rows = db.prepare('SELECT key, value FROM archived_stats').all(); + const obj = { totalCoffees: 0, coffeesByUser: {}, coffeesByMachine: {} }; + for (const row of rows) { + if (row.key === 'totalCoffees') obj.totalCoffees = parseInt(row.value, 10) || 0; + else obj[row.key] = JSON.parse(row.value); + } + return obj; + }, + + update(stats) { + const upsert = db.prepare( + 'INSERT INTO archived_stats (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + ); + upsert.run('totalCoffees', String(stats.totalCoffees)); + upsert.run('coffeesByUser', JSON.stringify(stats.coffeesByUser)); + upsert.run('coffeesByMachine', JSON.stringify(stats.coffeesByMachine)); + }, +}; + +// ─── Log Cleanups ─────────────────────────────────────────── + +export const logCleanups = { + findAll() { + return db.prepare('SELECT * FROM log_cleanups ORDER BY deletedAt DESC').all(); + }, + + create(entry) { + db.prepare( + `INSERT INTO log_cleanups (deletedAt, deletedBy, deletedCount, periodFrom, periodTo) + VALUES (@deletedAt, @deletedBy, @deletedCount, @periodFrom, @periodTo)`, + ).run(entry); + }, +}; diff --git a/backend/src/db.js b/backend/src/db.js index 6228ffa..5e82052 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1,51 +1,51 @@ +import Database from 'better-sqlite3'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { mkdirSync } from 'fs'; -import { JSONFilePreset } from 'lowdb/node'; -import { v4 as uuidv4 } from 'uuid'; +import { mkdirSync, readFileSync } from 'fs'; import bcrypt from 'bcryptjs'; +import { v4 as uuidv4 } from 'uuid'; import config from './config.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dataDir = join(__dirname, '..', 'data'); mkdirSync(dataDir, { recursive: true }); -const defaultData = { - users: [], - machines: [], - terminals: [], - logs: [], - cashBook: [], - settings: { language: 'de' }, - archivedStats: { - totalCoffees: 0, - coffeesByUser: {}, - coffeesByMachine: {}, - }, - logCleanups: [], -}; - -const db = await JSONFilePreset(join(dataDir, 'db.json'), defaultData); +const db = new Database(join(dataDir, 'cuptrack.db')); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +// Run schema +const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8'); +db.exec(schema); + +// Seed default settings +const settingsExist = db.prepare('SELECT 1 FROM settings WHERE key = ?').get('language'); +if (!settingsExist) { + db.prepare('INSERT INTO settings (key, value) VALUES (?, ?)').run('language', 'de'); +} + +// Seed default archived_stats +for (const key of ['totalCoffees', 'coffeesByUser', 'coffeesByMachine']) { + const exists = db.prepare('SELECT 1 FROM archived_stats WHERE key = ?').get(key); + if (!exists) { + const val = key === 'totalCoffees' ? '0' : '{}'; + db.prepare('INSERT INTO archived_stats (key, value) VALUES (?, ?)').run(key, val); + } +} // Bootstrap root user if not exists -const rootExists = db.data.users.some(u => u.isRoot); +const rootExists = db.prepare('SELECT 1 FROM users WHERE isRoot = 1').get(); if (!rootExists) { - const hashedPassword = await bcrypt.hash(config.rootPassword, 10); - db.data.users.push({ - id: uuidv4(), - username: config.rootUsername, - displayName: 'Root Admin', - password: hashedPassword, - type: 'admin', - isRoot: true, - balance: 0, - identifiers: [], - apiKey: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - await db.write(); + const hashedPassword = bcrypt.hashSync(config.rootPassword, 10); + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO users (id, username, displayName, password, type, isRoot, balance, apiKey, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run(uuidv4(), config.rootUsername, 'Root Admin', hashedPassword, 'admin', 1, 0, null, now, now); console.log('Root-Benutzer erstellt.'); } +// Graceful shutdown +process.on('exit', () => db.close()); + export default db; diff --git a/backend/src/index.js b/backend/src/index.js index d441721..d72998b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,10 +1,15 @@ import express from 'express'; import cors from 'cors'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import pino from 'pino'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { readFileSync } from 'fs'; import config from './config.js'; import './db.js'; +import { requestLogger } from './middleware/request-logger.js'; +import { errorHandler } from './middleware/error-handler.js'; import authRoutes from './routes/auth.js'; import userRoutes from './routes/users.js'; import machineRoutes from './routes/machines.js'; @@ -14,15 +19,46 @@ import statsRoutes from './routes/stats.js'; import settingsRoutes from './routes/settings.js'; import cashBookRoutes from './routes/cashBook.js'; +const logger = pino({ level: config.logLevel }); + const __dirname = dirname(fileURLToPath(import.meta.url)); const version = JSON.parse( readFileSync(join(__dirname, '..', '..', 'version.json'), 'utf-8'), ); const app = express(); -app.use(cors()); + +// Security headers +app.use(helmet({ contentSecurityPolicy: false })); + +// CORS +const corsOptions = config.corsOrigin === '*' + ? {} + : { origin: config.corsOrigin.split(',').map(s => s.trim()) }; +app.use(cors(corsOptions)); + app.use(express.json()); +// Request logging +app.use(requestLogger(logger)); + +// Rate limiting +const globalLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 300, + standardHeaders: true, + legacyHeaders: false, +}); +app.use(globalLimiter); + +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Zu viele Anmeldeversuche, bitte später erneut versuchen' }, +}); + // Health check app.get('/api/health', (_req, res) => { res.json({ @@ -33,7 +69,7 @@ app.get('/api/health', (_req, res) => { }); // API routes -app.use('/api/auth', authRoutes); +app.use('/api/auth', authLimiter, authRoutes); app.use('/api/users', userRoutes); app.use('/api/machines', machineRoutes); app.use('/api/terminals', terminalRoutes); @@ -51,8 +87,21 @@ app.get('*', (_req, res, next) => { }); }); +// Central error handler (must be last) +app.use(errorHandler(logger)); + +// Unhandled errors +process.on('uncaughtException', (err) => { + logger.fatal({ err: err.message, stack: err.stack }, 'Uncaught Exception'); + process.exit(1); +}); +process.on('unhandledRejection', (reason) => { + logger.fatal({ err: String(reason) }, 'Unhandled Rejection'); + process.exit(1); +}); + app.listen(config.port, '0.0.0.0', () => { - console.log( + logger.info( `CupTrack v${version.major}.${version.minor}.${version.patch} "${version.codeName}" auf Port ${config.port}`, ); }); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 6479d2c..7d4fb95 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -1,6 +1,6 @@ import jwt from 'jsonwebtoken'; import config from '../config.js'; -import db from '../db.js'; +import { users } from '../dal.js'; export function authenticateToken(req, res, next) { const authHeader = req.headers['authorization']; @@ -9,7 +9,7 @@ export function authenticateToken(req, res, next) { try { const decoded = jwt.verify(token, config.jwtSecret); - const user = db.data.users.find(u => u.id === decoded.userId); + const user = users.findById(decoded.userId); if (!user) return res.status(401).json({ error: 'Benutzer nicht gefunden' }); req.user = user; next(); diff --git a/backend/src/middleware/error-handler.js b/backend/src/middleware/error-handler.js new file mode 100644 index 0000000..0c0a5ae --- /dev/null +++ b/backend/src/middleware/error-handler.js @@ -0,0 +1,13 @@ +import { ZodError } from 'zod'; + +export function errorHandler(logger) { + return (err, _req, res, _next) => { + if (err instanceof ZodError) { + const messages = err.errors.map(e => e.message); + return res.status(400).json({ error: messages.join(', ') }); + } + + logger.error({ err: err.message, stack: err.stack }, 'Unerwarteter Fehler'); + res.status(500).json({ error: 'Interner Serverfehler' }); + }; +} diff --git a/backend/src/middleware/request-logger.js b/backend/src/middleware/request-logger.js new file mode 100644 index 0000000..7d4cccb --- /dev/null +++ b/backend/src/middleware/request-logger.js @@ -0,0 +1,15 @@ +export function requestLogger(logger) { + return (req, res, next) => { + const start = Date.now(); + res.on('finish', () => { + const duration = Date.now() - start; + logger.info({ + method: req.method, + url: req.originalUrl, + status: res.statusCode, + duration, + }); + }); + next(); + }; +} diff --git a/backend/src/migrate-from-lowdb.js b/backend/src/migrate-from-lowdb.js new file mode 100644 index 0000000..bb92e93 --- /dev/null +++ b/backend/src/migrate-from-lowdb.js @@ -0,0 +1,151 @@ +/** + * Migration: lowdb (db.json) → SQLite (cuptrack.db) + * + * Usage: node backend/src/migrate-from-lowdb.js + * + * Reads backend/data/db.json and writes all data into the SQLite database. + * The SQLite DB is initialized by importing db.js (schema + root bootstrap). + * Run this ONCE after switching to the SQLite backend. + */ + +import { readFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import db from './db.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const dbJsonPath = join(__dirname, '..', 'data', 'db.json'); + +if (!existsSync(dbJsonPath)) { + console.error('db.json nicht gefunden unter:', dbJsonPath); + process.exit(1); +} + +const data = JSON.parse(readFileSync(dbJsonPath, 'utf-8')); +console.log('db.json geladen. Starte Migration...'); + +const migrate = db.transaction(() => { + // ─── Users ────────────────────────────────────────────── + let userCount = 0; + const insertUser = db.prepare( + `INSERT OR IGNORE INTO users (id, username, displayName, password, type, isRoot, balance, apiKey, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + const insertIdentifier = db.prepare( + 'INSERT OR IGNORE INTO identifiers (id, type, value, userId) VALUES (?, ?, ?, ?)', + ); + + for (const u of data.users || []) { + insertUser.run( + u.id, u.username, u.displayName, u.password || null, + u.type, u.isRoot ? 1 : 0, u.balance || 0, u.apiKey || null, + u.createdAt, u.updatedAt, + ); + for (const ident of u.identifiers || []) { + insertIdentifier.run(ident.id, ident.type, ident.value, u.id); + } + userCount++; + } + console.log(` Users: ${userCount}`); + + // ─── Machines ─────────────────────────────────────────── + let machineCount = 0; + const insertMachine = db.prepare( + `INSERT OR IGNORE INTO machines (id, name, room, pricePerCoffee, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?)`, + ); + for (const m of data.machines || []) { + insertMachine.run(m.id, m.name, m.room || '', m.pricePerCoffee, m.createdAt, m.updatedAt); + machineCount++; + } + console.log(` Machines: ${machineCount}`); + + // ─── Terminals ────────────────────────────────────────── + let terminalCount = 0; + const insertTerminal = db.prepare( + `INSERT OR IGNORE INTO terminals (id, name, slug, machineId, type, quickButtonsEnabled, quickButton1, quickButton2, alphabetFilterEnabled, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const t of data.terminals || []) { + const qb = t.quickButtons || { enabled: false, button1: 5, button2: 10 }; + const af = t.alphabetFilter || { enabled: true }; + insertTerminal.run( + t.id, t.name, t.slug, t.machineId, t.type || 'web', + qb.enabled ? 1 : 0, qb.button1 || 5, qb.button2 || 10, + af.enabled ? 1 : 0, t.createdAt, t.updatedAt, + ); + terminalCount++; + } + console.log(` Terminals: ${terminalCount}`); + + // ─── Logs ─────────────────────────────────────────────── + let logCount = 0; + const insertLog = db.prepare( + `INSERT OR IGNORE INTO logs (id, type, userId, machineId, terminalId, details, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ); + for (const l of data.logs || []) { + const details = l.details ? JSON.stringify(l.details) : null; + insertLog.run(l.id, l.type, l.userId || null, l.machineId || null, l.terminalId || null, details, l.createdAt); + logCount++; + } + console.log(` Logs: ${logCount}`); + + // ─── Cash Book ────────────────────────────────────────── + let cashBookCount = 0; + const insertCashBook = db.prepare( + `INSERT OR IGNORE INTO cash_book (id, type, amount, comment, machineId, terminalId, performedBy, createdAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ); + for (const e of data.cashBook || []) { + insertCashBook.run( + e.id, e.type, e.amount, e.comment || '', e.machineId || null, + e.terminalId || null, e.performedBy, e.createdAt, + ); + cashBookCount++; + } + console.log(` Cash Book: ${cashBookCount}`); + + // ─── Settings ─────────────────────────────────────────── + const upsertSetting = db.prepare( + 'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + ); + if (data.settings) { + for (const [key, value] of Object.entries(data.settings)) { + upsertSetting.run(key, String(value)); + } + console.log(` Settings: ${Object.keys(data.settings).length} Einträge`); + } + + // ─── Archived Stats ──────────────────────────────────── + const upsertArchived = db.prepare( + 'INSERT INTO archived_stats (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', + ); + if (data.archivedStats) { + upsertArchived.run('totalCoffees', String(data.archivedStats.totalCoffees || 0)); + upsertArchived.run('coffeesByUser', JSON.stringify(data.archivedStats.coffeesByUser || {})); + upsertArchived.run('coffeesByMachine', JSON.stringify(data.archivedStats.coffeesByMachine || {})); + console.log(' Archived Stats: migriert'); + } + + // ─── Log Cleanups ────────────────────────────────────── + let cleanupCount = 0; + const insertCleanup = db.prepare( + `INSERT INTO log_cleanups (deletedAt, deletedBy, deletedCount, periodFrom, periodTo) + VALUES (?, ?, ?, ?, ?)`, + ); + for (const c of data.logCleanups || []) { + insertCleanup.run(c.deletedAt, c.deletedBy, c.deletedCount, c.periodFrom, c.periodTo); + cleanupCount++; + } + if (cleanupCount > 0) console.log(` Log Cleanups: ${cleanupCount}`); +}); + +try { + migrate(); + console.log('\nMigration erfolgreich abgeschlossen!'); + console.log('Die SQLite-Datenbank liegt unter: backend/data/cuptrack.db'); +} catch (err) { + console.error('Migration fehlgeschlagen:', err.message); + process.exit(1); +} diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 3855bb3..3eb2cc3 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -3,42 +3,43 @@ import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; import { v4 as uuidv4 } from 'uuid'; import config from '../config.js'; -import db from '../db.js'; +import { users, logs } from '../dal.js'; import { authenticateToken } from '../middleware/auth.js'; +import { loginSchema } from '../validators/index.js'; const router = Router(); -router.post('/login', async (req, res) => { - const { username, password } = req.body; - if (!username || !password) { - return res.status(400).json({ error: 'Benutzername und Passwort erforderlich' }); - } +router.post('/login', async (req, res, next) => { + try { + const { username, password } = loginSchema.parse(req.body); - const user = db.data.users.find(u => u.username === username && u.type !== 'api'); - if (!user) return res.status(401).json({ error: 'Ungültige Anmeldedaten' }); + const user = users.findByUsername(username); + if (!user) return res.status(401).json({ error: 'Ungültige Anmeldedaten' }); - const valid = await bcrypt.compare(password, user.password); - if (!valid) return res.status(401).json({ error: 'Ungültige Anmeldedaten' }); + const valid = await bcrypt.compare(password, user.password); + if (!valid) return res.status(401).json({ error: 'Ungültige Anmeldedaten' }); - if (user.type !== 'admin') { - return res.status(403).json({ error: 'Nur Admins können sich am Dashboard anmelden' }); - } + if (user.type !== 'admin') { + return res.status(403).json({ error: 'Nur Admins können sich am Dashboard anmelden' }); + } + + const token = jwt.sign({ userId: user.id }, config.jwtSecret, { expiresIn: '24h' }); - const token = jwt.sign({ userId: user.id }, config.jwtSecret, { expiresIn: '24h' }); - - db.data.logs.push({ - id: uuidv4(), - type: 'login', - userId: user.id, - machineId: null, - terminalId: null, - details: { method: 'dashboard' }, - createdAt: new Date().toISOString(), - }); - await db.write(); - - const { password: _, ...safe } = user; - res.json({ token, user: safe }); + logs.create({ + id: uuidv4(), + type: 'login', + userId: user.id, + machineId: null, + terminalId: null, + details: { method: 'dashboard' }, + createdAt: new Date().toISOString(), + }); + + const { password: _, ...safe } = user; + res.json({ token, user: safe }); + } catch (err) { + next(err); + } }); router.get('/me', authenticateToken, (req, res) => { diff --git a/backend/src/routes/cashBook.js b/backend/src/routes/cashBook.js index 9e6fe53..c3b155c 100644 --- a/backend/src/routes/cashBook.js +++ b/backend/src/routes/cashBook.js @@ -1,124 +1,98 @@ import { Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; -import db from '../db.js'; +import { cashBook, logs } from '../dal.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; +import { cashBookEntrySchema } from '../validators/index.js'; const router = Router(); router.use(authenticateToken, requireAdmin); // Get all cash book entries (newest first) -router.get('/', (req, res) => { - const entries = [...(db.data.cashBook || [])].sort( - (a, b) => new Date(b.createdAt) - new Date(a.createdAt), - ); - res.json(entries); +router.get('/', (_req, res) => { + res.json(cashBook.findAll()); }); // Get current balance (computed from all entries) -router.get('/balance', (req, res) => { - const entries = db.data.cashBook || []; - let balance = 0; - for (const entry of entries) { - if (entry.type === 'deposit' || entry.type === 'anonymous_coffee') { - balance += entry.amount; - } else if (entry.type === 'withdrawal') { - balance -= entry.amount; - } - } - res.json({ balance: Math.round(balance * 100) / 100 }); +router.get('/balance', (_req, res) => { + res.json({ balance: cashBook.computeBalance() }); }); // Create deposit -router.post('/deposit', async (req, res) => { - const { amount, comment } = req.body; - - if (amount === undefined || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { - return res.status(400).json({ error: 'Gültiger Betrag erforderlich' }); - } - if (!comment || !comment.trim()) { - return res.status(400).json({ error: 'Kommentar erforderlich' }); +router.post('/deposit', (req, res, next) => { + try { + const { amount, comment } = cashBookEntrySchema.parse(req.body); + + const entry = cashBook.create({ + id: uuidv4(), + type: 'deposit', + amount: Math.round(amount * 100) / 100, + comment, + machineId: null, + terminalId: null, + performedBy: req.user.id, + createdAt: new Date().toISOString(), + }); + + logs.create({ + id: uuidv4(), + type: 'cashbook', + userId: req.user.id, + machineId: null, + terminalId: null, + details: { action: 'deposit', amount: entry.amount, comment: entry.comment }, + createdAt: entry.createdAt, + }); + + res.status(201).json(entry); + } catch (err) { + next(err); } - - const entry = { - id: uuidv4(), - type: 'deposit', - amount: Math.round(parseFloat(amount) * 100) / 100, - comment: comment.trim(), - machineId: null, - terminalId: null, - performedBy: req.user.id, - createdAt: new Date().toISOString(), - }; - - if (!db.data.cashBook) db.data.cashBook = []; - db.data.cashBook.push(entry); - - db.data.logs.push({ - id: uuidv4(), - type: 'cashbook', - userId: req.user.id, - machineId: null, - terminalId: null, - details: { action: 'deposit', amount: entry.amount, comment: entry.comment }, - createdAt: entry.createdAt, - }); - - await db.write(); - res.status(201).json(entry); }); // Create withdrawal -router.post('/withdrawal', async (req, res) => { - const { amount, comment } = req.body; - - if (amount === undefined || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { - return res.status(400).json({ error: 'Gültiger Betrag erforderlich' }); - } - if (!comment || !comment.trim()) { - return res.status(400).json({ error: 'Kommentar erforderlich' }); +router.post('/withdrawal', (req, res, next) => { + try { + const { amount, comment } = cashBookEntrySchema.parse(req.body); + + const entry = cashBook.create({ + id: uuidv4(), + type: 'withdrawal', + amount: Math.round(amount * 100) / 100, + comment, + machineId: null, + terminalId: null, + performedBy: req.user.id, + createdAt: new Date().toISOString(), + }); + + logs.create({ + id: uuidv4(), + type: 'cashbook', + userId: req.user.id, + machineId: null, + terminalId: null, + details: { action: 'withdrawal', amount: entry.amount, comment: entry.comment }, + createdAt: entry.createdAt, + }); + + res.status(201).json(entry); + } catch (err) { + next(err); } - - const entry = { - id: uuidv4(), - type: 'withdrawal', - amount: Math.round(parseFloat(amount) * 100) / 100, - comment: comment.trim(), - machineId: null, - terminalId: null, - performedBy: req.user.id, - createdAt: new Date().toISOString(), - }; - - if (!db.data.cashBook) db.data.cashBook = []; - db.data.cashBook.push(entry); - - db.data.logs.push({ - id: uuidv4(), - type: 'cashbook', - userId: req.user.id, - machineId: null, - terminalId: null, - details: { action: 'withdrawal', amount: entry.amount, comment: entry.comment }, - createdAt: entry.createdAt, - }); - - await db.write(); - res.status(201).json(entry); }); // Delete entry (only manual deposit/withdrawal, not anonymous_coffee) -router.delete('/:id', async (req, res) => { - const entries = db.data.cashBook || []; - const entry = entries.find(e => e.id === req.params.id); +router.delete('/:id', (req, res) => { + const entry = cashBook.findById(req.params.id); if (!entry) return res.status(404).json({ error: 'Eintrag nicht gefunden' }); if (entry.type === 'anonymous_coffee') { return res.status(403).json({ error: 'Gast-Kaffee-Einträge können nicht gelöscht werden' }); } - db.data.cashBook = entries.filter(e => e.id !== req.params.id); + cashBook.delete(req.params.id); - db.data.logs.push({ + logs.create({ id: uuidv4(), type: 'cashbook', userId: req.user.id, @@ -128,7 +102,6 @@ router.delete('/:id', async (req, res) => { createdAt: new Date().toISOString(), }); - await db.write(); res.json({ success: true }); }); diff --git a/backend/src/routes/machines.js b/backend/src/routes/machines.js index 7c487ee..66c0249 100644 --- a/backend/src/routes/machines.js +++ b/backend/src/routes/machines.js @@ -1,75 +1,78 @@ import { Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; -import db from '../db.js'; +import { machines, terminals, logs } from '../dal.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; +import { createMachineSchema, updateMachineSchema } from '../validators/index.js'; const router = Router(); router.use(authenticateToken, requireAdmin); // List all machines -router.get('/', (req, res) => { - res.json(db.data.machines); +router.get('/', (_req, res) => { + res.json(machines.findAll()); }); // Get single machine router.get('/:id', (req, res) => { - const machine = db.data.machines.find(m => m.id === req.params.id); + const machine = machines.findById(req.params.id); if (!machine) return res.status(404).json({ error: 'Maschine nicht gefunden' }); res.json(machine); }); // Get machine activity log router.get('/:id/log', (req, res) => { - const logs = db.data.logs.filter(l => l.machineId === req.params.id); - res.json(logs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))); + res.json(logs.findByMachineId(req.params.id)); }); // Create machine -router.post('/', async (req, res) => { - const { name, room, pricePerCoffee } = req.body; - if (!name || pricePerCoffee === undefined) { - return res.status(400).json({ error: 'Name und Preis pro Kaffee erforderlich' }); +router.post('/', (req, res, next) => { + try { + const data = createMachineSchema.parse(req.body); + const now = new Date().toISOString(); + const machine = machines.create({ + id: uuidv4(), + name: data.name, + room: data.room || '', + pricePerCoffee: data.pricePerCoffee, + createdAt: now, + updatedAt: now, + }); + res.status(201).json(machine); + } catch (err) { + next(err); } - const machine = { - id: uuidv4(), - name, - room: room || '', - pricePerCoffee: parseFloat(pricePerCoffee), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - db.data.machines.push(machine); - await db.write(); - res.status(201).json(machine); }); // Update machine -router.put('/:id', async (req, res) => { - const machine = db.data.machines.find(m => m.id === req.params.id); - if (!machine) return res.status(404).json({ error: 'Maschine nicht gefunden' }); +router.put('/:id', (req, res, next) => { + try { + const data = updateMachineSchema.parse(req.body); + const machine = machines.findById(req.params.id); + if (!machine) return res.status(404).json({ error: 'Maschine nicht gefunden' }); - const { name, room, pricePerCoffee } = req.body; - if (name !== undefined) machine.name = name; - if (room !== undefined) machine.room = room; - if (pricePerCoffee !== undefined) machine.pricePerCoffee = parseFloat(pricePerCoffee); - machine.updatedAt = new Date().toISOString(); + const updates = {}; + if (data.name !== undefined) updates.name = data.name; + if (data.room !== undefined) updates.room = data.room; + if (data.pricePerCoffee !== undefined) updates.pricePerCoffee = data.pricePerCoffee; + updates.updatedAt = new Date().toISOString(); - await db.write(); - res.json(machine); + const updated = machines.update(req.params.id, updates); + res.json(updated); + } catch (err) { + next(err); + } }); // Delete machine -router.delete('/:id', async (req, res) => { - const machine = db.data.machines.find(m => m.id === req.params.id); +router.delete('/:id', (req, res) => { + const machine = machines.findById(req.params.id); if (!machine) return res.status(404).json({ error: 'Maschine nicht gefunden' }); - const terminalUsingMachine = db.data.terminals.find(t => t.machineId === req.params.id); - if (terminalUsingMachine) { + if (terminals.machineInUse(req.params.id)) { return res.status(409).json({ error: 'Maschine wird noch von einem Terminal verwendet' }); } - db.data.machines = db.data.machines.filter(m => m.id !== req.params.id); - await db.write(); + machines.delete(req.params.id); res.json({ success: true }); }); diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index b86e6d0..f162c5f 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -1,41 +1,33 @@ import { Router } from 'express'; -import db from '../db.js'; +import { settings, logs, archivedStats, logCleanups } from '../dal.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; +import { settingsSchema } from '../validators/index.js'; const router = Router(); // GET /api/settings — public, no auth required (needed for terminal views too) -router.get('/', async (_req, res) => { - await db.read(); - const settings = db.data.settings || { language: 'de' }; - res.json(settings); +router.get('/', (_req, res) => { + res.json(settings.get()); }); // PUT /api/settings — admin only -router.put('/', authenticateToken, requireAdmin, async (req, res) => { - const { language } = req.body; - const allowedLanguages = ['de', 'en']; - if (!language || !allowedLanguages.includes(language)) { - return res.status(400).json({ error: 'Ungültige Sprache' }); +router.put('/', authenticateToken, requireAdmin, (req, res, next) => { + try { + const data = settingsSchema.parse(req.body); + const updated = settings.update(data); + res.json(updated); + } catch (err) { + next(err); } - - await db.read(); - db.data.settings = { ...(db.data.settings || {}), language }; - await db.write(); - - res.json(db.data.settings); }); // DELETE /api/settings/cleanup-logs — admin only, deletes logs older than 1 year -router.delete('/cleanup-logs', authenticateToken, requireAdmin, async (req, res) => { - await db.read(); - +router.delete('/cleanup-logs', authenticateToken, requireAdmin, (req, res) => { const oneYearAgo = new Date(); oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); const cutoffISO = oneYearAgo.toISOString(); - const oldLogs = db.data.logs.filter(l => l.createdAt < cutoffISO); - const remainingLogs = db.data.logs.filter(l => l.createdAt >= cutoffISO); + const oldLogs = logs.deleteOlderThan(cutoffISO); if (oldLogs.length === 0) { return res.json({ deletedCount: 0, message: 'Keine Logs älter als ein Jahr gefunden.' }); @@ -46,12 +38,9 @@ router.delete('/cleanup-logs', authenticateToken, requireAdmin, async (req, res) const oldestLog = timestamps[0]; const newestLog = timestamps[timestamps.length - 1]; - // Aggregate coffee stats from old logs before deleting + // Aggregate coffee stats from old logs before losing them const oldCoffeeLogs = oldLogs.filter(l => l.type === 'coffee'); - if (!db.data.archivedStats) { - db.data.archivedStats = { totalCoffees: 0, coffeesByUser: {}, coffeesByMachine: {} }; - } - const archived = db.data.archivedStats; + const archived = archivedStats.get(); oldCoffeeLogs.forEach(l => { archived.totalCoffees += 1; if (l.userId) { @@ -61,13 +50,9 @@ router.delete('/cleanup-logs', authenticateToken, requireAdmin, async (req, res) archived.coffeesByMachine[l.machineId] = (archived.coffeesByMachine[l.machineId] || 0) + 1; } }); + archivedStats.update(archived); - // Replace logs with only the remaining ones - db.data.logs = remainingLogs; - - // Record the cleanup event - if (!db.data.logCleanups) db.data.logCleanups = []; - db.data.logCleanups.push({ + logCleanups.create({ deletedAt: new Date().toISOString(), deletedBy: req.user.username, deletedCount: oldLogs.length, @@ -75,8 +60,6 @@ router.delete('/cleanup-logs', authenticateToken, requireAdmin, async (req, res) periodTo: newestLog, }); - await db.write(); - res.json({ deletedCount: oldLogs.length, periodFrom: oldestLog, @@ -85,9 +68,8 @@ router.delete('/cleanup-logs', authenticateToken, requireAdmin, async (req, res) }); // GET /api/settings/log-cleanups — admin only, returns cleanup history -router.get('/log-cleanups', authenticateToken, requireAdmin, async (_req, res) => { - await db.read(); - res.json(db.data.logCleanups || []); +router.get('/log-cleanups', authenticateToken, requireAdmin, (_req, res) => { + res.json(logCleanups.findAll()); }); export default router; diff --git a/backend/src/routes/stats.js b/backend/src/routes/stats.js index 95b4dd6..9e11dc6 100644 --- a/backend/src/routes/stats.js +++ b/backend/src/routes/stats.js @@ -1,63 +1,81 @@ import { Router } from 'express'; -import db from '../db.js'; +import { logs, users, machines, terminals, archivedStats } from '../dal.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; const router = Router(); router.use(authenticateToken, requireAdmin); -router.get('/dashboard', (req, res) => { +router.get('/dashboard', (_req, res) => { const now = new Date(); - const coffeeLogs = db.data.logs.filter(l => l.type === 'coffee'); - const archived = db.data.archivedStats || { totalCoffees: 0, coffeesByUser: {}, coffeesByMachine: {} }; - - // Total coffees (current + archived) - const totalCoffees = coffeeLogs.length + archived.totalCoffees; + const archived = archivedStats.get(); // Coffees today const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate()) .toISOString() .split('T')[0]; - const coffeesToday = coffeeLogs.filter(l => l.createdAt.startsWith(todayStr)).length; + const coffeesToday = logs.countCoffeesForDate(todayStr); + + // Total coffees (current + archived) — we need the count of current coffee logs + const currentCoffeeCounts = logs.coffeeCountsByUser(); + const currentTotal = currentCoffeeCounts.reduce((sum, row) => sum + row.count, 0); + const totalCoffees = currentTotal + archived.totalCoffees; // Coffees per day (last 30 days) + const startDate = new Date(now); + startDate.setDate(startDate.getDate() - 29); + const startDateStr = startDate.toISOString().split('T')[0]; + + const dbCoffeesPerDay = logs.coffeesPerDay(startDateStr); + const dayMap = {}; + for (const row of dbCoffeesPerDay) dayMap[row.date] = row.count; + const coffeesPerDay = []; for (let i = 29; i >= 0; i--) { const date = new Date(now); date.setDate(date.getDate() - i); const dayStr = date.toISOString().split('T')[0]; - const count = coffeeLogs.filter(l => l.createdAt.startsWith(dayStr)).length; - coffeesPerDay.push({ date: dayStr, count }); + coffeesPerDay.push({ date: dayStr, count: dayMap[dayStr] || 0 }); } // Top drinkers (current + archived) const drinkerCounts = { ...archived.coffeesByUser }; - coffeeLogs.forEach(l => { - drinkerCounts[l.userId] = (drinkerCounts[l.userId] || 0) + 1; - }); + for (const row of currentCoffeeCounts) { + drinkerCounts[row.userId] = (drinkerCounts[row.userId] || 0) + row.count; + } + const allUsers = users.findAll(); + const userMap = {}; + for (const u of allUsers) userMap[u.id] = u.displayName; + const topDrinkers = Object.entries(drinkerCounts) - .map(([userId, count]) => { - const user = db.data.users.find(u => u.id === userId); - return { userId, displayName: user?.displayName || 'Unbekannt', count }; - }) + .map(([userId, count]) => ({ + userId, + displayName: userMap[userId] || 'Unbekannt', + count, + })) .sort((a, b) => b.count - a.count) .slice(0, 5); // Popular machines (current + archived) const machineCounts = { ...archived.coffeesByMachine }; - coffeeLogs.forEach(l => { - if (l.machineId) machineCounts[l.machineId] = (machineCounts[l.machineId] || 0) + 1; - }); + for (const row of logs.coffeeCountsByMachine()) { + machineCounts[row.machineId] = (machineCounts[row.machineId] || 0) + row.count; + } + const allMachines = machines.findAll(); + const machineMap = {}; + for (const m of allMachines) machineMap[m.id] = m.name; + const popularMachines = Object.entries(machineCounts) - .map(([machineId, count]) => { - const machine = db.data.machines.find(m => m.id === machineId); - return { machineId, name: machine?.name || 'Unbekannt', count }; - }) + .map(([machineId, count]) => ({ + machineId, + name: machineMap[machineId] || 'Unbekannt', + count, + })) .sort((a, b) => b.count - a.count); // Totals - const totalUsers = db.data.users.filter(u => u.type === 'drinker').length; - const totalMachines = db.data.machines.length; - const totalTerminals = db.data.terminals.length; + const totalUsers = users.countDrinkers(); + const totalMachines = machines.count(); + const totalTerminals = terminals.count(); res.json({ totalCoffees, diff --git a/backend/src/routes/terminalActions.js b/backend/src/routes/terminalActions.js index 33015aa..4cf816f 100644 --- a/backend/src/routes/terminalActions.js +++ b/backend/src/routes/terminalActions.js @@ -2,20 +2,18 @@ import { Router } from 'express'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import config from '../config.js'; -import db from '../db.js'; +import { users, machines, terminals, logs, cashBook } from '../dal.js'; +import { verifyPinSchema, verifyNfcSchema, sessionTokenSchema, updateBalanceSchema } from '../validators/index.js'; const router = Router(); // Get terminal info + eligible users (public – no JWT required) router.get('/:slug', (req, res) => { - const terminal = db.data.terminals.find(t => t.slug === req.params.slug); + const terminal = terminals.findBySlug(req.params.slug); if (!terminal) return res.status(404).json({ error: 'Terminal nicht gefunden' }); - const machine = db.data.machines.find(m => m.id === terminal.machineId); - - const users = db.data.users - .filter(u => u.type === 'drinker' && u.identifiers.some(i => i.type === 'pin' || i.type === 'kaba_nfc')) - .map(u => ({ id: u.id, displayName: u.displayName })); + const machine = machines.findById(terminal.machineId); + const eligibleUsers = users.findDrinkersForTerminal(); res.json({ terminal: { @@ -28,78 +26,79 @@ router.get('/:slug', (req, res) => { machine: machine ? { id: machine.id, name: machine.name, room: machine.room, pricePerCoffee: machine.pricePerCoffee } : null, - users, + users: eligibleUsers, }); }); // Verify NFC serial number → returns short-lived session token (no PIN needed) -router.post('/:slug/verify-nfc', (req, res) => { - const { serialNumber } = req.body; - if (!serialNumber || typeof serialNumber !== 'string') { - return res.status(400).json({ error: 'Seriennummer erforderlich' }); - } - - const terminal = db.data.terminals.find(t => t.slug === req.params.slug); - if (!terminal) return res.status(404).json({ error: 'Terminal nicht gefunden' }); +router.post('/:slug/verify-nfc', (req, res, next) => { + try { + const { serialNumber } = verifyNfcSchema.parse(req.body); - // Normalize: lowercase, trimmed - const normalized = serialNumber.toLowerCase().trim(); + const terminal = terminals.findBySlug(req.params.slug); + if (!terminal) return res.status(404).json({ error: 'Terminal nicht gefunden' }); - const user = db.data.users.find( - u => u.type === 'drinker' && - u.identifiers.some(i => i.type === 'kaba_nfc' && i.value.toLowerCase().trim() === normalized), - ); - if (!user) return res.status(404).json({ error: 'Kein Benutzer mit dieser NFC-Seriennummer gefunden' }); + const user = users.findByNfcSerial(serialNumber); + if (!user) return res.status(404).json({ error: 'Kein Benutzer mit dieser NFC-Seriennummer gefunden' }); - const sessionToken = jwt.sign( - { userId: user.id, terminalId: terminal.id, purpose: 'terminal-session' }, - config.jwtSecret, - { expiresIn: '5m' }, - ); + const sessionToken = jwt.sign( + { userId: user.id, terminalId: terminal.id, purpose: 'terminal-session' }, + config.jwtSecret, + { expiresIn: '5m' }, + ); - res.json({ - success: true, - sessionToken, - user: { id: user.id, displayName: user.displayName, balance: user.balance }, - }); + res.json({ + success: true, + sessionToken, + user: { id: user.id, displayName: user.displayName, balance: user.balance }, + }); + } catch (err) { + next(err); + } }); // Verify PIN → returns short-lived session token -router.post('/:slug/verify-pin', (req, res) => { - const { userId, pin } = req.body; - const terminal = db.data.terminals.find(t => t.slug === req.params.slug); - if (!terminal) return res.status(404).json({ error: 'Terminal nicht gefunden' }); +router.post('/:slug/verify-pin', (req, res, next) => { + try { + const { userId, pin } = verifyPinSchema.parse(req.body); - const user = db.data.users.find(u => u.id === userId && u.type === 'drinker'); - if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + const terminal = terminals.findBySlug(req.params.slug); + if (!terminal) return res.status(404).json({ error: 'Terminal nicht gefunden' }); - const pinMatch = user.identifiers.find(i => i.type === 'pin' && i.value === pin); - if (!pinMatch) return res.status(401).json({ error: 'Ungültige PIN' }); + const user = users.findById(userId); + if (!user || user.type !== 'drinker') return res.status(404).json({ error: 'Benutzer nicht gefunden' }); - const sessionToken = jwt.sign( - { userId: user.id, terminalId: terminal.id, purpose: 'terminal-session' }, - config.jwtSecret, - { expiresIn: '5m' }, - ); + const pinMatch = user.identifiers.find(i => i.type === 'pin' && i.value === pin); + if (!pinMatch) return res.status(401).json({ error: 'Ungültige PIN' }); - res.json({ - success: true, - sessionToken, - user: { id: user.id, displayName: user.displayName, balance: user.balance }, - }); + const sessionToken = jwt.sign( + { userId: user.id, terminalId: terminal.id, purpose: 'terminal-session' }, + config.jwtSecret, + { expiresIn: '5m' }, + ); + + res.json({ + success: true, + sessionToken, + user: { id: user.id, displayName: user.displayName, balance: user.balance }, + }); + } catch (err) { + next(err); + } }); // Anonymous coffee (guest – no auth required) -router.post('/:slug/anonymous-coffee', async (req, res) => { - const terminal = db.data.terminals.find(t => t.slug === req.params.slug); +router.post('/:slug/anonymous-coffee', (req, res) => { + const terminal = terminals.findBySlug(req.params.slug); if (!terminal) return res.status(404).json({ error: 'Terminal nicht gefunden' }); - const machine = db.data.machines.find(m => m.id === terminal.machineId); + const machine = machines.findById(terminal.machineId); if (!machine) return res.status(500).json({ error: 'Maschine nicht gefunden' }); const price = machine.pricePerCoffee; + const now = new Date().toISOString(); - const entry = { + cashBook.create({ id: uuidv4(), type: 'anonymous_coffee', amount: price, @@ -107,23 +106,19 @@ router.post('/:slug/anonymous-coffee', async (req, res) => { machineId: machine.id, terminalId: terminal.id, performedBy: 'terminal', - createdAt: new Date().toISOString(), - }; - - if (!db.data.cashBook) db.data.cashBook = []; - db.data.cashBook.push(entry); + createdAt: now, + }); - db.data.logs.push({ + logs.create({ id: uuidv4(), type: 'anonymous_coffee', userId: null, machineId: machine.id, terminalId: terminal.id, details: { price }, - createdAt: entry.createdAt, + createdAt: now, }); - await db.write(); res.json({ success: true, price }); }); @@ -138,12 +133,12 @@ function verifySession(req, res) { const decoded = jwt.verify(sessionToken, config.jwtSecret); if (decoded.purpose !== 'terminal-session') throw new Error('Invalid purpose'); - const terminal = db.data.terminals.find(t => t.slug === req.params.slug); + const terminal = terminals.findBySlug(req.params.slug); if (!terminal || terminal.id !== decoded.terminalId) { res.status(403).json({ error: 'Ungültige Session' }); return null; } - const user = db.data.users.find(u => u.id === decoded.userId); + const user = users.findById(decoded.userId); if (!user) { res.status(404).json({ error: 'Benutzer nicht gefunden' }); return null; @@ -156,91 +151,91 @@ function verifySession(req, res) { } // Count coffee -router.post('/:slug/count-coffee', async (req, res) => { +router.post('/:slug/count-coffee', (req, res) => { const session = verifySession(req, res); if (!session) return; const { terminal, user } = session; - const machine = db.data.machines.find(m => m.id === terminal.machineId); + const machine = machines.findById(terminal.machineId); if (!machine) return res.status(500).json({ error: 'Maschine nicht gefunden' }); - user.balance -= machine.pricePerCoffee; - user.updatedAt = new Date().toISOString(); + const newBalance = user.balance - machine.pricePerCoffee; + const now = new Date().toISOString(); + + users.update(user.id, { balance: newBalance, updatedAt: now }); - db.data.logs.push({ + logs.create({ id: uuidv4(), type: 'coffee', userId: user.id, machineId: machine.id, terminalId: terminal.id, - details: { price: machine.pricePerCoffee, balanceAfter: user.balance }, - createdAt: new Date().toISOString(), + details: { price: machine.pricePerCoffee, balanceAfter: newBalance }, + createdAt: now, }); - await db.write(); - res.json({ success: true, newBalance: user.balance }); + res.json({ success: true, newBalance }); }); // Update balance -router.post('/:slug/update-balance', async (req, res) => { - const session = verifySession(req, res); - if (!session) return; - - const { user, terminal } = session; - const { amount, mode } = req.body; - - const oldBalance = user.balance; - - if (mode === 'reset') { - user.balance = 0; - } else { - // mode === 'add' (default) - if (amount === undefined || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { - return res.status(400).json({ error: 'Gültiger Betrag erforderlich' }); +router.post('/:slug/update-balance', (req, res, next) => { + try { + const session = verifySession(req, res); + if (!session) return; + + const { user, terminal } = session; + const { amount, mode } = req.body; + + const oldBalance = user.balance; + let newBalance; + + if (mode === 'reset') { + newBalance = 0; + } else { + if (amount === undefined || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0) { + return res.status(400).json({ error: 'Gültiger Betrag erforderlich' }); + } + newBalance = oldBalance + parseFloat(amount); } - user.balance += parseFloat(amount); - } - - user.updatedAt = new Date().toISOString(); - const now = new Date().toISOString(); + const now = new Date().toISOString(); + users.update(user.id, { balance: newBalance, updatedAt: now }); - db.data.logs.push({ - id: uuidv4(), - type: 'balance', - userId: user.id, - machineId: null, - terminalId: terminal.id, - details: { oldBalance, newBalance: user.balance, method: mode === 'reset' ? 'terminal-reset' : 'terminal-add' }, - createdAt: now, - }); - - // Cash book: terminal top-ups = real money deposited into the cash box - let cashBookAmount = 0; - if (mode === 'reset' && oldBalance < 0) { - // Resetting a negative balance means the user paid off their debt - cashBookAmount = Math.round(Math.abs(oldBalance) * 100) / 100; - } else if (mode !== 'reset') { - // Regular top-up at terminal - cashBookAmount = Math.round(parseFloat(amount) * 100) / 100; - } - - if (cashBookAmount > 0) { - if (!db.data.cashBook) db.data.cashBook = []; - db.data.cashBook.push({ + logs.create({ id: uuidv4(), - type: 'deposit', - amount: cashBookAmount, - comment: '', + type: 'balance', + userId: user.id, machineId: null, terminalId: terminal.id, - performedBy: user.id, + details: { oldBalance, newBalance, method: mode === 'reset' ? 'terminal-reset' : 'terminal-add' }, createdAt: now, }); - } - await db.write(); - res.json({ success: true, newBalance: user.balance }); + // Cash book: terminal top-ups = real money deposited into the cash box + let cashBookAmount = 0; + if (mode === 'reset' && oldBalance < 0) { + cashBookAmount = Math.round(Math.abs(oldBalance) * 100) / 100; + } else if (mode !== 'reset') { + cashBookAmount = Math.round(parseFloat(amount) * 100) / 100; + } + + if (cashBookAmount > 0) { + cashBook.create({ + id: uuidv4(), + type: 'deposit', + amount: cashBookAmount, + comment: '', + machineId: null, + terminalId: terminal.id, + performedBy: user.id, + createdAt: now, + }); + } + + res.json({ success: true, newBalance }); + } catch (err) { + next(err); + } }); export default router; diff --git a/backend/src/routes/terminals.js b/backend/src/routes/terminals.js index 128b7da..06324c8 100644 --- a/backend/src/routes/terminals.js +++ b/backend/src/routes/terminals.js @@ -1,118 +1,114 @@ import { Router } from 'express'; import { v4 as uuidv4 } from 'uuid'; -import db from '../db.js'; +import { machines, terminals, logs } from '../dal.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; +import { createTerminalSchema, updateTerminalSchema } from '../validators/index.js'; const router = Router(); router.use(authenticateToken, requireAdmin); +function generateSlug(name) { + return name + .toLowerCase() + .replace(/[äöüß]/g, c => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' })[c] || c) + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); +} + // List all terminals (with machine info) -router.get('/', (req, res) => { - const terminals = db.data.terminals.map(t => { - const machine = db.data.machines.find(m => m.id === t.machineId); - return { ...t, machine: machine || null }; - }); - res.json(terminals); +router.get('/', (_req, res) => { + res.json(terminals.findAll()); }); // Get single terminal router.get('/:id', (req, res) => { - const terminal = db.data.terminals.find(t => t.id === req.params.id); + const terminal = terminals.findByIdWithMachine(req.params.id); if (!terminal) return res.status(404).json({ error: 'Terminal nicht gefunden' }); - const machine = db.data.machines.find(m => m.id === terminal.machineId); - res.json({ ...terminal, machine: machine || null }); + res.json(terminal); }); // Get terminal activity log router.get('/:id/log', (req, res) => { - const logs = db.data.logs.filter(l => l.terminalId === req.params.id); - res.json(logs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))); + res.json(logs.findByTerminalId(req.params.id)); }); // Create terminal -router.post('/', async (req, res) => { - const { name, machineId } = req.body; - if (!name || !machineId) { - return res.status(400).json({ error: 'Name und Maschine erforderlich' }); - } - const machine = db.data.machines.find(m => m.id === machineId); - if (!machine) return res.status(404).json({ error: 'Maschine nicht gefunden' }); +router.post('/', (req, res, next) => { + try { + const data = createTerminalSchema.parse(req.body); + const machine = machines.findById(data.machineId); + if (!machine) return res.status(404).json({ error: 'Maschine nicht gefunden' }); - const slug = name - .toLowerCase() - .replace(/[äöüß]/g, c => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' })[c] || c) - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)/g, ''); + const slug = generateSlug(data.name); + if (terminals.slugExists(slug)) { + return res.status(409).json({ error: 'Terminal-Name existiert bereits' }); + } - if (db.data.terminals.some(t => t.slug === slug)) { - return res.status(409).json({ error: 'Terminal-Name existiert bereits' }); + const now = new Date().toISOString(); + const created = terminals.create({ + id: uuidv4(), + name: data.name, + slug, + machineId: data.machineId, + type: 'web', + quickButtonsEnabled: 0, + quickButton1: 5, + quickButton2: 10, + alphabetFilterEnabled: 1, + createdAt: now, + updatedAt: now, + }); + res.status(201).json(created); + } catch (err) { + next(err); } - - const terminal = { - id: uuidv4(), - name, - slug, - machineId, - type: 'web', - quickButtons: { enabled: false, button1: 5, button2: 10 }, - alphabetFilter: { enabled: true }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - db.data.terminals.push(terminal); - await db.write(); - res.status(201).json({ ...terminal, machine }); }); // Update terminal -router.put('/:id', async (req, res) => { - const terminal = db.data.terminals.find(t => t.id === req.params.id); - if (!terminal) return res.status(404).json({ error: 'Terminal nicht gefunden' }); +router.put('/:id', (req, res, next) => { + try { + const data = updateTerminalSchema.parse(req.body); + const terminal = terminals.findById(req.params.id); + if (!terminal) return res.status(404).json({ error: 'Terminal nicht gefunden' }); - const { name, machineId, quickButtons, alphabetFilter } = req.body; - if (quickButtons !== undefined) { - terminal.quickButtons = { - enabled: !!quickButtons.enabled, - button1: parseFloat(quickButtons.button1) || 5, - button2: parseFloat(quickButtons.button2) || 10, - }; - } - if (alphabetFilter !== undefined) { - terminal.alphabetFilter = { - enabled: !!alphabetFilter.enabled, - }; - } - if (name !== undefined) { - const slug = name - .toLowerCase() - .replace(/[äöüß]/g, c => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' })[c] || c) - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)/g, ''); - if (db.data.terminals.some(t => t.slug === slug && t.id !== req.params.id)) { - return res.status(409).json({ error: 'Terminal-Name existiert bereits' }); + const updates = {}; + + if (data.quickButtons !== undefined) { + updates.quickButtonsEnabled = data.quickButtons.enabled ? 1 : 0; + updates.quickButton1 = data.quickButtons.button1 || 5; + updates.quickButton2 = data.quickButtons.button2 || 10; } - terminal.name = name; - terminal.slug = slug; - } - if (machineId !== undefined) { - const machine = db.data.machines.find(m => m.id === machineId); - if (!machine) return res.status(404).json({ error: 'Maschine nicht gefunden' }); - terminal.machineId = machineId; - } - terminal.updatedAt = new Date().toISOString(); + if (data.alphabetFilter !== undefined) { + updates.alphabetFilterEnabled = data.alphabetFilter.enabled ? 1 : 0; + } + if (data.name !== undefined) { + const slug = generateSlug(data.name); + if (terminals.slugExists(slug, req.params.id)) { + return res.status(409).json({ error: 'Terminal-Name existiert bereits' }); + } + updates.name = data.name; + updates.slug = slug; + } + if (data.machineId !== undefined) { + const machine = machines.findById(data.machineId); + if (!machine) return res.status(404).json({ error: 'Maschine nicht gefunden' }); + updates.machineId = data.machineId; + } + updates.updatedAt = new Date().toISOString(); - await db.write(); - const machine = db.data.machines.find(m => m.id === terminal.machineId); - res.json({ ...terminal, machine: machine || null }); + const updated = terminals.update(req.params.id, updates); + res.json(updated); + } catch (err) { + next(err); + } }); // Delete terminal -router.delete('/:id', async (req, res) => { - if (!db.data.terminals.some(t => t.id === req.params.id)) { +router.delete('/:id', (req, res) => { + if (!terminals.exists(req.params.id)) { return res.status(404).json({ error: 'Terminal nicht gefunden' }); } - db.data.terminals = db.data.terminals.filter(t => t.id !== req.params.id); - await db.write(); + terminals.delete(req.params.id); res.json({ success: true }); }); diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index aaafe1c..c72acc3 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -2,144 +2,140 @@ import { Router } from 'express'; import bcrypt from 'bcryptjs'; import { v4 as uuidv4 } from 'uuid'; import crypto from 'crypto'; -import db from '../db.js'; +import { users, logs } from '../dal.js'; import { authenticateToken, requireAdmin } from '../middleware/auth.js'; +import { createUserSchema, updateUserSchema } from '../validators/index.js'; const router = Router(); router.use(authenticateToken, requireAdmin); // List all users -router.get('/', (req, res) => { - const users = db.data.users.map(({ password, ...u }) => u); - res.json(users); +router.get('/', (_req, res) => { + res.json(users.findAll()); }); // Get single user router.get('/:id', (req, res) => { - const user = db.data.users.find(u => u.id === req.params.id); + const user = users.findByIdSafe(req.params.id); if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); - const { password, ...safe } = user; - res.json(safe); + res.json(user); }); // Get user activity log router.get('/:id/log', (req, res) => { - const logs = db.data.logs.filter(l => l.userId === req.params.id); - res.json(logs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))); + res.json(logs.findByUserId(req.params.id)); }); // Create user -router.post('/', async (req, res) => { - const { username, displayName, password, type } = req.body; - if (!username || !displayName || !type) { - return res.status(400).json({ error: 'username, displayName und type erforderlich' }); - } - if (!['admin', 'api', 'drinker'].includes(type)) { - return res.status(400).json({ error: 'Ungültiger Benutzertyp' }); - } - if (type !== 'api' && !password) { - return res.status(400).json({ error: 'Passwort erforderlich für diesen Benutzertyp' }); - } - if (db.data.users.some(u => u.username === username)) { - return res.status(409).json({ error: 'Benutzername existiert bereits' }); - } - - const newUser = { - id: uuidv4(), - username, - displayName, - password: type !== 'api' ? await bcrypt.hash(password, 10) : null, - type, - isRoot: false, - balance: 0, - identifiers: [], - apiKey: type === 'api' ? crypto.randomBytes(32).toString('hex') : null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - // Auto-generate a unique PIN for drinkers - if (type === 'drinker') { - let pin; - do { - pin = String(Math.floor(1000 + Math.random() * 9000)); - } while ( - db.data.users.some(u => - u.identifiers.some(i => i.type === 'pin' && i.value === pin) - ) - ); - newUser.identifiers.push({ id: uuidv4(), type: 'pin', value: pin }); - } - - db.data.users.push(newUser); - - db.data.logs.push({ - id: uuidv4(), - type: 'user_created', - userId: newUser.id, - machineId: null, - terminalId: null, - details: { createdBy: req.user.id, userType: type }, - createdAt: new Date().toISOString(), - }); - - await db.write(); - const { password: _, ...safe } = newUser; - res.status(201).json(safe); -}); +router.post('/', async (req, res, next) => { + try { + const { username, displayName, password, type } = createUserSchema.parse(req.body); -// Update user -router.put('/:id', async (req, res) => { - const idx = db.data.users.findIndex(u => u.id === req.params.id); - if (idx === -1) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); - - const user = db.data.users[idx]; + if (type !== 'api' && !password) { + return res.status(400).json({ error: 'Passwort erforderlich für diesen Benutzertyp' }); + } + if (users.usernameExists(username)) { + return res.status(409).json({ error: 'Benutzername existiert bereits' }); + } - if (user.isRoot) { - return res.status(403).json({ error: 'Root-Benutzer kann nicht über die UI bearbeitet werden' }); - } + const identifiers = []; - const { displayName, password, balance, identifiers } = req.body; + // Auto-generate a unique PIN for drinkers + if (type === 'drinker') { + let pin; + do { + pin = String(Math.floor(1000 + Math.random() * 9000)); + } while (users.pinExists(pin)); + identifiers.push({ id: uuidv4(), type: 'pin', value: pin }); + } - if (displayName !== undefined) user.displayName = displayName; - if (password) user.password = await bcrypt.hash(password, 10); - if (balance !== undefined && user.type === 'drinker') { - const oldBalance = user.balance; - user.balance = parseFloat(balance); - db.data.logs.push({ + const now = new Date().toISOString(); + const newUser = { id: uuidv4(), - type: 'balance', - userId: user.id, + username, + displayName, + password: type !== 'api' ? await bcrypt.hash(password, 10) : null, + type, + isRoot: false, + balance: 0, + apiKey: type === 'api' ? crypto.randomBytes(32).toString('hex') : null, + createdAt: now, + updatedAt: now, + identifiers, + }; + + const created = users.create(newUser); + + logs.create({ + id: uuidv4(), + type: 'user_created', + userId: created.id, machineId: null, terminalId: null, - details: { oldBalance, newBalance: user.balance, method: 'admin' }, - createdAt: new Date().toISOString(), + details: { createdBy: req.user.id, userType: type }, + createdAt: now, }); + + const { password: _, ...safe } = created; + res.status(201).json(safe); + } catch (err) { + next(err); } - if (identifiers !== undefined && user.type === 'drinker') { - const pinCount = identifiers.filter(i => i.type === 'pin').length; - if (pinCount > 1) { - return res.status(400).json({ error: 'Nur ein PIN-Identifier pro Benutzer erlaubt' }); +}); + +// Update user +router.put('/:id', async (req, res, next) => { + try { + const data = updateUserSchema.parse(req.body); + const user = users.findById(req.params.id); + if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + if (user.isRoot) { + return res.status(403).json({ error: 'Root-Benutzer kann nicht über die UI bearbeitet werden' }); } - user.identifiers = identifiers; - } - user.updatedAt = new Date().toISOString(); - await db.write(); - const { password: _, ...safe } = user; - res.json(safe); + const updates = {}; + if (data.displayName !== undefined) updates.displayName = data.displayName; + if (data.password) updates.password = await bcrypt.hash(data.password, 10); + if (data.balance !== undefined && user.type === 'drinker') { + const oldBalance = user.balance; + updates.balance = data.balance; + logs.create({ + id: uuidv4(), + type: 'balance', + userId: user.id, + machineId: null, + terminalId: null, + details: { oldBalance, newBalance: data.balance, method: 'admin' }, + createdAt: new Date().toISOString(), + }); + } + if (data.identifiers !== undefined && user.type === 'drinker') { + const pinCount = data.identifiers.filter(i => i.type === 'pin').length; + if (pinCount > 1) { + return res.status(400).json({ error: 'Nur ein PIN-Identifier pro Benutzer erlaubt' }); + } + users.replaceIdentifiers(user.id, data.identifiers); + } + updates.updatedAt = new Date().toISOString(); + + const updated = users.update(req.params.id, updates); + const { password: _, ...safe } = updated; + res.json(safe); + } catch (err) { + next(err); + } }); // Delete user -router.delete('/:id', async (req, res) => { - const user = db.data.users.find(u => u.id === req.params.id); +router.delete('/:id', (req, res) => { + const user = users.findById(req.params.id); if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); if (user.isRoot) return res.status(403).json({ error: 'Root-Benutzer kann nicht gelöscht werden' }); if (user.id === req.user.id) return res.status(403).json({ error: 'Eigenen Account kann man nicht löschen' }); - db.data.users = db.data.users.filter(u => u.id !== req.params.id); + users.delete(req.params.id); - db.data.logs.push({ + logs.create({ id: uuidv4(), type: 'user_deleted', userId: req.params.id, @@ -149,7 +145,6 @@ router.delete('/:id', async (req, res) => { createdAt: new Date().toISOString(), }); - await db.write(); res.json({ success: true }); }); diff --git a/backend/src/schema.sql b/backend/src/schema.sql new file mode 100644 index 0000000..d5998e7 --- /dev/null +++ b/backend/src/schema.sql @@ -0,0 +1,96 @@ +-- CupTrack SQLite Schema + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + displayName TEXT NOT NULL, + password TEXT, + type TEXT NOT NULL CHECK(type IN ('admin', 'api', 'drinker')), + isRoot INTEGER NOT NULL DEFAULT 0, + balance REAL NOT NULL DEFAULT 0, + apiKey TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS identifiers ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + value TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_identifiers_userId ON identifiers(userId); +CREATE INDEX IF NOT EXISTS idx_identifiers_type_value ON identifiers(type, value); + +CREATE TABLE IF NOT EXISTS machines ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + room TEXT NOT NULL DEFAULT '', + pricePerCoffee REAL NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS terminals ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + machineId TEXT NOT NULL REFERENCES machines(id), + type TEXT NOT NULL DEFAULT 'web', + quickButtonsEnabled INTEGER NOT NULL DEFAULT 0, + quickButton1 REAL NOT NULL DEFAULT 5, + quickButton2 REAL NOT NULL DEFAULT 10, + alphabetFilterEnabled INTEGER NOT NULL DEFAULT 1, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_terminals_slug ON terminals(slug); +CREATE INDEX IF NOT EXISTS idx_terminals_machineId ON terminals(machineId); + +CREATE TABLE IF NOT EXISTS logs ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + userId TEXT, + machineId TEXT, + terminalId TEXT, + details TEXT, + createdAt TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_logs_userId ON logs(userId); +CREATE INDEX IF NOT EXISTS idx_logs_machineId ON logs(machineId); +CREATE INDEX IF NOT EXISTS idx_logs_terminalId ON logs(terminalId); +CREATE INDEX IF NOT EXISTS idx_logs_type ON logs(type); +CREATE INDEX IF NOT EXISTS idx_logs_createdAt ON logs(createdAt); + +CREATE TABLE IF NOT EXISTS cash_book ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + amount REAL NOT NULL, + comment TEXT NOT NULL DEFAULT '', + machineId TEXT, + terminalId TEXT, + performedBy TEXT NOT NULL, + createdAt TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS archived_stats ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS log_cleanups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + deletedAt TEXT NOT NULL, + deletedBy TEXT NOT NULL, + deletedCount INTEGER NOT NULL, + periodFrom TEXT NOT NULL, + periodTo TEXT NOT NULL +); diff --git a/backend/src/validators/index.js b/backend/src/validators/index.js new file mode 100644 index 0000000..06084c7 --- /dev/null +++ b/backend/src/validators/index.js @@ -0,0 +1,96 @@ +import { z } from 'zod'; + +// ─── Auth ─────────────────────────────────────────────────── + +export const loginSchema = z.object({ + username: z.string().min(1, 'Benutzername erforderlich'), + password: z.string().min(1, 'Passwort erforderlich'), +}); + +// ─── Users ────────────────────────────────────────────────── + +export const createUserSchema = z.object({ + username: z.string().min(1, 'Benutzername erforderlich'), + displayName: z.string().min(1, 'Anzeigename erforderlich'), + password: z.string().min(1).optional(), + type: z.enum(['admin', 'api', 'drinker'], { message: 'Ungültiger Benutzertyp' }), +}); + +export const updateUserSchema = z.object({ + displayName: z.string().min(1).optional(), + password: z.string().min(1).optional(), + balance: z.preprocess(v => (v !== undefined ? parseFloat(v) : undefined), z.number().optional()), + identifiers: z.array(z.object({ + id: z.string(), + type: z.string(), + value: z.string(), + })).optional(), +}); + +// ─── Machines ─────────────────────────────────────────────── + +export const createMachineSchema = z.object({ + name: z.string().min(1, 'Name erforderlich'), + room: z.string().optional().default(''), + pricePerCoffee: z.preprocess(v => parseFloat(v), z.number().positive('Preis muss positiv sein')), +}); + +export const updateMachineSchema = z.object({ + name: z.string().min(1).optional(), + room: z.string().optional(), + pricePerCoffee: z.preprocess(v => (v !== undefined ? parseFloat(v) : undefined), z.number().positive().optional()), +}); + +// ─── Terminals ────────────────────────────────────────────── + +export const createTerminalSchema = z.object({ + name: z.string().min(1, 'Name erforderlich'), + machineId: z.string().min(1, 'Maschine erforderlich'), +}); + +export const updateTerminalSchema = z.object({ + name: z.string().min(1).optional(), + machineId: z.string().min(1).optional(), + quickButtons: z.object({ + enabled: z.boolean(), + button1: z.preprocess(v => parseFloat(v), z.number().positive()), + button2: z.preprocess(v => parseFloat(v), z.number().positive()), + }).optional(), + alphabetFilter: z.object({ + enabled: z.boolean(), + }).optional(), +}); + +// ─── Terminal Actions ─────────────────────────────────────── + +export const verifyPinSchema = z.object({ + userId: z.string().min(1), + pin: z.string().min(1), +}); + +export const verifyNfcSchema = z.object({ + serialNumber: z.string().min(1, 'Seriennummer erforderlich'), +}); + +export const sessionTokenSchema = z.object({ + sessionToken: z.string().min(1, 'Session-Token erforderlich'), +}); + +export const updateBalanceSchema = z.object({ + sessionToken: z.string().min(1), + amount: z.preprocess(v => (v !== undefined ? parseFloat(v) : undefined), z.number().positive().optional()), + mode: z.enum(['add', 'reset']).optional(), +}); + +// ─── Cash Book ────────────────────────────────────────────── + +export const cashBookEntrySchema = z.object({ + amount: z.preprocess(v => parseFloat(v), z.number().positive('Gültiger Betrag erforderlich')), + comment: z.string().min(1, 'Kommentar erforderlich').transform(s => s.trim()), +}); + +// ─── Settings ─────────────────────────────────────────────── + +export const settingsSchema = z.object({ + language: z.enum(['de', 'en'], { message: 'Ungültige Sprache' }), +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..12fd245 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + cuptrack: + build: . + container_name: cuptrack + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - ./backend/data:/app/backend/data + env_file: + - ./backend/.env + environment: + - NODE_ENV=production diff --git a/docs/deployment-docker.md b/docs/deployment-docker.md new file mode 100644 index 0000000..fee09e2 --- /dev/null +++ b/docs/deployment-docker.md @@ -0,0 +1,104 @@ +# CupTrack – Docker Deployment + +## Voraussetzungen + +- Docker ≥ 24 & Docker Compose ≥ 2 +- Funktioniert auf x86_64, ARM64 (Raspberry Pi 4/5 mit 64-bit OS) + +## 1. Umgebungsvariablen konfigurieren + +```bash +cp backend/.env.example backend/.env +nano backend/.env +``` + +Pflicht-Anpassungen: +- `JWT_SECRET` – langer zufälliger String (≥ 32 Zeichen): + ```bash + openssl rand -base64 48 + ``` +- `ROOT_PASSWORD` – sicheres Admin-Passwort +- `CORS_ORIGIN` – z.B. `https://cuptrack.example.com` + +## 2. Container bauen und starten + +```bash +docker compose up -d --build +``` + +Prüfen: + +```bash +docker compose ps +curl http://localhost:3000/api/health +``` + +## 3. Migration (nur bei Update von lowdb) + +Falls zuvor eine Version mit `db.json` genutzt wurde, die Datei nach `backend/data/db.json` kopieren und dann: + +```bash +docker compose exec cuptrack node backend/src/migrate-from-lowdb.js +``` + +## 4. nginx Reverse Proxy (HTTPS) + +Beispiel-Konfiguration für einen externen nginx: + +```nginx +server { + listen 80; + server_name cuptrack.example.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name cuptrack.example.com; + + ssl_certificate /etc/letsencrypt/live/cuptrack.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/cuptrack.example.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +## 5. Backup + +Die SQLite-Datenbank wird im Volume unter `./backend/data/` gespeichert. + +```bash +# Manuelles Backup +cp backend/data/cuptrack.db backend/data/backup-$(date +%Y%m%d).db + +# Oder per Cronjob (auf dem Host): +0 3 * * * cp /path/to/cuptrack/backend/data/cuptrack.db /path/to/cuptrack/backend/data/backup-$(date +\%Y\%m\%d).db +``` + +## 6. Updates + +```bash +git pull +docker compose up -d --build +``` + +## 7. Logs einsehen + +```bash +docker compose logs -f cuptrack +``` + +## 8. Container stoppen + +```bash +docker compose down +``` + +> **Hinweis:** Die Datenbank bleibt in `backend/data/` erhalten, da sie als Volume gemountet ist. diff --git a/docs/deployment-raspi.md b/docs/deployment-raspi.md new file mode 100644 index 0000000..a5427d0 --- /dev/null +++ b/docs/deployment-raspi.md @@ -0,0 +1,166 @@ +# CupTrack – Raspberry Pi Deployment + +Diese Anleitung beschreibt die Installation von CupTrack direkt auf einem Raspberry Pi (ohne Docker). + +## Voraussetzungen + +- Raspberry Pi 3B+ / 4 / 5 mit Raspberry Pi OS (64-bit empfohlen) +- Node.js 20 LTS +- Mindestens 512 MB RAM frei +- Zugang per SSH oder direkt + +## 1. Node.js 20 installieren + +```bash +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash - +sudo apt install -y nodejs +node -v # sollte v20.x sein +``` + +## 2. Repository klonen & bauen + +```bash +cd /opt +sudo git clone https://github.com//cuptrack.git +sudo chown -R $USER:$USER /opt/cuptrack +cd /opt/cuptrack + +# Frontend bauen +cd frontend +npm ci +npm run build +cd .. + +# Backend Dependencies installieren +cd backend +npm ci --omit=dev +cd .. +``` + +## 3. Umgebungsvariablen konfigurieren + +```bash +cp backend/.env.example backend/.env +nano backend/.env +``` + +Pflicht-Anpassungen: +- `JWT_SECRET` – langer zufälliger String (≥ 32 Zeichen): + ```bash + openssl rand -base64 48 + ``` +- `ROOT_PASSWORD` – sicheres Admin-Passwort +- `CORS_ORIGIN` – z.B. `http://192.168.1.100:3000` oder die Domain + +## 4. Migration (nur bei Update von lowdb) + +Falls zuvor eine Version mit `db.json` genutzt wurde: + +```bash +node backend/src/migrate-from-lowdb.js +``` + +Die SQLite-Datenbank wird unter `backend/data/cuptrack.db` erstellt. + +## 5. systemd Service einrichten + +```bash +sudo nano /etc/systemd/system/cuptrack.service +``` + +Inhalt: + +```ini +[Unit] +Description=CupTrack Kaffee-Tracking +After=network.target + +[Service] +Type=simple +User=pi +WorkingDirectory=/opt/cuptrack +ExecStart=/usr/bin/node backend/src/index.js +Restart=on-failure +RestartSec=5 +Environment=NODE_ENV=production + +[Install] +WantedBy=multi-user.target +``` + +> **Hinweis:** `User=pi` ggf. anpassen. Der Benutzer muss Leserechte auf `/opt/cuptrack` haben. + +```bash +sudo systemctl daemon-reload +sudo systemctl enable cuptrack +sudo systemctl start cuptrack +``` + +Status prüfen: + +```bash +sudo systemctl status cuptrack +curl http://localhost:3000/api/health +``` + +## 6. nginx Reverse Proxy (optional, für HTTPS) + +```bash +sudo apt install -y nginx certbot python3-certbot-nginx +``` + +```nginx +# /etc/nginx/sites-available/cuptrack +server { + listen 80; + server_name cuptrack.example.com; + + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +```bash +sudo ln -s /etc/nginx/sites-available/cuptrack /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx + +# HTTPS via Let's Encrypt +sudo certbot --nginx -d cuptrack.example.com +``` + +## 7. Backup (Cronjob) + +Die gesamte Datenbank liegt in einer einzigen Datei: + +```bash +# Tägliches Backup um 3:00 Uhr +crontab -e +``` + +Zeile hinzufügen: + +``` +0 3 * * * cp /opt/cuptrack/backend/data/cuptrack.db /opt/cuptrack/backend/data/backup-$(date +\%Y\%m\%d).db +``` + +Alte Backups aufräumen (älter als 30 Tage): + +``` +0 4 * * * find /opt/cuptrack/backend/data/ -name 'backup-*.db' -mtime +30 -delete +``` + +## 8. Updates einspielen + +```bash +cd /opt/cuptrack +git pull +cd frontend && npm ci && npm run build && cd .. +cd backend && npm ci --omit=dev && cd .. +sudo systemctl restart cuptrack +``` From 7885edd5516a12e0b38943dc6857a4a6447ea81b Mon Sep 17 00:00:00 2001 From: Punkerschaf <105040919+Punkerschaf@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:18:04 +0100 Subject: [PATCH 02/12] ignore local build docker image --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ec4bdc0..f4d40e5 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,7 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* # Local stuff -tasks.md \ No newline at end of file +tasks.md + +# Docker Images +*.tar.gz \ No newline at end of file From c7736ad869f43acf5798be83b36ed1bd5c43ea09 Mon Sep 17 00:00:00 2001 From: Punkerschaf <105040919+Punkerschaf@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:51:20 +0100 Subject: [PATCH 03/12] fix: No input validation when changing PIN Fixes #11 --- backend/src/validators/index.js | 5 ++++- frontend/src/locales/de.json | 1 + frontend/src/locales/en.json | 1 + frontend/src/pages/Users.tsx | 14 +++++++++++++- 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/backend/src/validators/index.js b/backend/src/validators/index.js index 06084c7..d501427 100644 --- a/backend/src/validators/index.js +++ b/backend/src/validators/index.js @@ -24,7 +24,10 @@ export const updateUserSchema = z.object({ id: z.string(), type: z.string(), value: z.string(), - })).optional(), + }).refine( + (ident) => ident.type !== 'pin' || /^[0-9]{4}$/.test(ident.value), + { message: 'PIN muss genau 4 Ziffern haben' } + )).optional(), }); // ─── Machines ─────────────────────────────────────────────── diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 3c2c5cd..6d43583 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -70,6 +70,7 @@ "newPassword": "Neues Passwort (leer lassen = unverändert)", "balanceLabel": "Guthaben (€)", "pinAutoGenerated": "Eine Terminal-PIN wird automatisch generiert.", + "pinMustBe4Digits": "PIN muss genau 4 Ziffern haben", "identifier": "Identifier", "apiKey": "API-Key", "currentBalance": "Aktuelles Guthaben" diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index c62dc20..abf60ae 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -70,6 +70,7 @@ "newPassword": "New password (leave empty = unchanged)", "balanceLabel": "Balance (€)", "pinAutoGenerated": "A terminal PIN will be automatically generated.", + "pinMustBe4Digits": "PIN must be exactly 4 digits", "identifier": "Identifier", "apiKey": "API Key", "currentBalance": "Current Balance" diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index 01e9723..805b084 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -548,9 +548,21 @@ function EditUserDialog({ updateIdentifier(idx, e.target.value)} + onChange={(e) => { + if (ident.type === 'pin') { + const v = e.target.value.replace(/[^0-9]/g, '').slice(0, 4); + updateIdentifier(idx, v); + } else { + updateIdentifier(idx, e.target.value); + } + }} placeholder={ident.type === 'kaba_nfc' ? '01:23:45:67:89:AB:CD' : ''} sx={{ flex: 1 }} + {...(ident.type === 'pin' && { + inputProps: { inputMode: 'numeric', pattern: '[0-9]{4}', maxLength: 4 }, + error: ident.value.length > 0 && ident.value.length < 4, + helperText: ident.value.length > 0 && ident.value.length < 4 ? t('users.pinMustBe4Digits') : '', + })} /> Date: Thu, 26 Mar 2026 10:00:39 +0100 Subject: [PATCH 04/12] fix: Modify PIN when creating new drinker Fixes #12 --- backend/src/routes/users.js | 17 ++++++++++++----- backend/src/validators/index.js | 1 + frontend/src/pages/Users.tsx | 31 +++++++++++++++++++++++++------ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index c72acc3..ff94fc2 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -29,7 +29,7 @@ router.get('/:id/log', (req, res) => { // Create user router.post('/', async (req, res, next) => { try { - const { username, displayName, password, type } = createUserSchema.parse(req.body); + const { username, displayName, password, type, pin: providedPin } = createUserSchema.parse(req.body); if (type !== 'api' && !password) { return res.status(400).json({ error: 'Passwort erforderlich für diesen Benutzertyp' }); @@ -40,12 +40,19 @@ router.post('/', async (req, res, next) => { const identifiers = []; - // Auto-generate a unique PIN for drinkers + // Use provided PIN or auto-generate a unique one for drinkers if (type === 'drinker') { let pin; - do { - pin = String(Math.floor(1000 + Math.random() * 9000)); - } while (users.pinExists(pin)); + if (providedPin) { + if (users.pinExists(providedPin)) { + return res.status(409).json({ error: 'PIN wird bereits verwendet' }); + } + pin = providedPin; + } else { + do { + pin = String(Math.floor(1000 + Math.random() * 9000)); + } while (users.pinExists(pin)); + } identifiers.push({ id: uuidv4(), type: 'pin', value: pin }); } diff --git a/backend/src/validators/index.js b/backend/src/validators/index.js index d501427..296ec12 100644 --- a/backend/src/validators/index.js +++ b/backend/src/validators/index.js @@ -14,6 +14,7 @@ export const createUserSchema = z.object({ displayName: z.string().min(1, 'Anzeigename erforderlich'), password: z.string().min(1).optional(), type: z.enum(['admin', 'api', 'drinker'], { message: 'Ungültiger Benutzertyp' }), + pin: z.string().regex(/^[0-9]{4}$/, 'PIN muss genau 4 Ziffern haben').optional(), }); export const updateUserSchema = z.object({ diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index 805b084..1db5f02 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -331,11 +331,14 @@ function CreateUserDialog({ onClose: () => void; onCreated: () => void; }) { + const generatePin = () => String(Math.floor(1000 + Math.random() * 9000)); + const [form, setForm] = useState({ username: '', displayName: '', password: '', type: 'drinker' as string, + pin: generatePin(), }); const [error, setError] = useState(''); const [saving, setSaving] = useState(false); @@ -345,10 +348,12 @@ function CreateUserDialog({ setError(''); setSaving(true); try { - await api.createUser(form); + const payload: Record = { ...form }; + if (form.type !== 'drinker') delete payload.pin; + await api.createUser(payload); onCreated(); onClose(); - setForm({ username: '', displayName: '', password: '', type: 'drinker' }); + setForm({ username: '', displayName: '', password: '', type: 'drinker', pin: generatePin() }); } catch (e: unknown) { setError(e instanceof Error ? e.message : t('common.error')); } finally { @@ -386,7 +391,10 @@ function CreateUserDialog({ select label={t('common.type')} value={form.type} - onChange={(e) => setForm({ ...form, type: e.target.value })} + onChange={(e) => { + const newType = e.target.value; + setForm({ ...form, type: newType, pin: newType === 'drinker' ? generatePin() : '' }); + }} margin="dense" > {t('users.typeAdmin')} @@ -405,9 +413,20 @@ function CreateUserDialog({ /> )} {form.type === 'drinker' && ( - - {t('users.pinAutoGenerated')} - + { + const v = e.target.value.replace(/[^0-9]/g, '').slice(0, 4); + setForm({ ...form, pin: v }); + }} + margin="dense" + required + inputProps={{ inputMode: 'numeric', pattern: '[0-9]{4}', maxLength: 4 }} + error={form.pin.length > 0 && form.pin.length < 4} + helperText={form.pin.length > 0 && form.pin.length < 4 ? t('users.pinMustBe4Digits') : t('users.pinAutoGenerated')} + /> )} From 9c27a0bb19367fec691a8fcf8c940fa7c0c8b033 Mon Sep 17 00:00:00 2001 From: Punkerschaf <105040919+Punkerschaf@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:03:48 +0100 Subject: [PATCH 05/12] full api documenation (german) --- docs/api.md | 1170 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1170 insertions(+) create mode 100644 docs/api.md diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..e1f234c --- /dev/null +++ b/docs/api.md @@ -0,0 +1,1170 @@ +# CupTrack API-Dokumentation + +Base-URL: `/api` + +--- + +## Inhaltsverzeichnis + +1. [Authentifizierung](#authentifizierung) +2. [Health Check](#health-check) +3. [Auth](#auth) +4. [Users](#users) +5. [Machines](#machines) +6. [Terminals](#terminals) +7. [Terminal Actions](#terminal-actions) +8. [Cash Book](#cash-book) +9. [Settings](#settings) +10. [Stats](#stats) +11. [Datenbank-Schema](#datenbank-schema) + +--- + +## Authentifizierung + +Die API verwendet **JWT Bearer Tokens** zur Authentifizierung. Nach einem erfolgreichen Login wird ein Token zurückgegeben, das im `Authorization`-Header mitgesendet werden muss: + +``` +Authorization: Bearer +``` + +Tokens sind **24 Stunden** gültig. + +### Rollen + +| Rolle | Beschreibung | +| --------- | ------------------------------------------------- | +| `admin` | Voller Dashboard-Zugang, kann alle Ressourcen verwalten | +| `api` | API-Zugang per API-Key (kein Dashboard-Login) | +| `drinker` | Endbenutzer, kann nur über Terminals interagieren | + +### Rate Limiting + +| Bereich | Fenster | Max. Anfragen | +| --------- | -------- | ------------- | +| Global | 15 Min. | 300 | +| Auth | 15 Min. | 15 | + +--- + +## Health Check + +### `GET /api/health` + +Gibt den Status und die Version der Anwendung zurück. Keine Authentifizierung erforderlich. + +**Response `200`:** +```json +{ + "status": "ok", + "version": "1.0.0", + "codeName": "string" +} +``` + +--- + +## Auth + +### `POST /api/auth/login` + +Authentifiziert einen Admin-Benutzer und gibt ein JWT-Token zurück. + +> Rate-Limited: max. 15 Anfragen pro 15 Minuten. + +**Request Body:** +```json +{ + "username": "string", // Pflicht, min. 1 Zeichen + "password": "string" // Pflicht, min. 1 Zeichen +} +``` + +**Response `200`:** +```json +{ + "token": "jwt-string", + "user": { + "id": "uuid", + "username": "string", + "displayName": "string", + "type": "admin", + "isRoot": 0, + "balance": 0, + "apiKey": null, + "createdAt": "ISO-8601", + "updatedAt": "ISO-8601" + } +} +``` + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `401` | Ungültige Anmeldedaten | +| `403` | Benutzer ist kein Admin | + +--- + +### `GET /api/auth/me` + +Gibt den aktuell authentifizierten Benutzer zurück. + +> Erfordert: `Bearer Token` + +**Response `200`:** +```json +{ + "id": "uuid", + "username": "string", + "displayName": "string", + "type": "admin", + "isRoot": 0, + "balance": 0, + "apiKey": null, + "createdAt": "ISO-8601", + "updatedAt": "ISO-8601" +} +``` + +--- + +## Users + +> Alle Endpunkte erfordern: `Bearer Token` + `admin`-Rolle. + +### `GET /api/users` + +Listet alle Benutzer auf. + +**Response `200`:** Array von User-Objekten (ohne `password`-Feld). + +```json +[ + { + "id": "uuid", + "username": "string", + "displayName": "string", + "type": "admin | api | drinker", + "isRoot": 0, + "balance": 0.0, + "apiKey": "string | null", + "createdAt": "ISO-8601", + "updatedAt": "ISO-8601", + "identifiers": [ + { "id": "uuid", "type": "pin | nfc", "value": "string" } + ] + } +] +``` + +--- + +### `GET /api/users/:id` + +Gibt einen einzelnen Benutzer zurück. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | User-ID (UUID) | + +**Response `200`:** User-Objekt (ohne `password`). + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `404` | Benutzer nicht gefunden | + +--- + +### `GET /api/users/:id/log` + +Gibt das Aktivitätsprotokoll eines Benutzers zurück. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | User-ID (UUID) | + +**Response `200`:** Array von Log-Einträgen. + +```json +[ + { + "id": "uuid", + "type": "string", + "userId": "uuid", + "machineId": "uuid | null", + "terminalId": "uuid | null", + "details": {}, + "createdAt": "ISO-8601" + } +] +``` + +--- + +### `POST /api/users` + +Erstellt einen neuen Benutzer. + +**Request Body:** +```json +{ + "username": "string", // Pflicht, min. 1 Zeichen + "displayName": "string", // Pflicht, min. 1 Zeichen + "password": "string", // Optional (Pflicht für admin/drinker, nicht für api) + "type": "admin | api | drinker", // Pflicht + "pin": "1234" // Optional, genau 4 Ziffern (nur für drinker) +} +``` + +**Verhalten:** +- Für `drinker`: Wird automatisch eine 4-stellige PIN generiert, wenn keine angegeben wird. +- Für `api`: Ein `apiKey` wird automatisch generiert; kein Passwort nötig. +- Für `admin`/`drinker`: Passwort ist Pflicht. + +**Response `201`:** Erstellter User (ohne `password`). + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `400` | Passwort erforderlich für diesen Benutzertyp | +| `409` | Benutzername existiert bereits | +| `409` | PIN wird bereits verwendet | + +--- + +### `PUT /api/users/:id` + +Aktualisiert einen bestehenden Benutzer. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | User-ID (UUID) | + +**Request Body:** +```json +{ + "displayName": "string", // Optional + "password": "string", // Optional + "balance": 10.5, // Optional (nur für drinker) + "identifiers": [ // Optional (nur für drinker) + { + "id": "uuid", + "type": "pin | nfc", + "value": "string" + } + ] +} +``` + +**Einschränkungen:** +- `balance` wird nur bei `drinker`-Benutzern aktualisiert. +- `identifiers` werden nur bei `drinker`-Benutzern aktualisiert. +- Max. 1 PIN-Identifier pro Benutzer. +- PIN-Werte müssen genau 4 Ziffern haben. +- Root-Benutzer können nicht über die UI bearbeitet werden. + +**Response `200`:** Aktualisierter User (ohne `password`). + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `400` | Nur ein PIN-Identifier pro Benutzer erlaubt | +| `403` | Root-Benutzer kann nicht über die UI bearbeitet werden | +| `404` | Benutzer nicht gefunden | + +--- + +### `DELETE /api/users/:id` + +Löscht einen Benutzer. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | User-ID (UUID) | + +**Response `200`:** +```json +{ "success": true } +``` + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `403` | Root-Benutzer kann nicht gelöscht werden | +| `403` | Eigenen Account kann man nicht löschen | +| `404` | Benutzer nicht gefunden | + +--- + +## Machines + +> Alle Endpunkte erfordern: `Bearer Token` + `admin`-Rolle. + +### `GET /api/machines` + +Listet alle Maschinen auf. + +**Response `200`:** +```json +[ + { + "id": "uuid", + "name": "string", + "room": "string", + "pricePerCoffee": 0.5, + "createdAt": "ISO-8601", + "updatedAt": "ISO-8601" + } +] +``` + +--- + +### `GET /api/machines/:id` + +Gibt eine einzelne Maschine zurück. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | Machine-ID (UUID) | + +**Response `200`:** Machine-Objekt. + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `404` | Maschine nicht gefunden | + +--- + +### `GET /api/machines/:id/log` + +Gibt das Aktivitätsprotokoll einer Maschine zurück. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | Machine-ID (UUID) | + +**Response `200`:** Array von Log-Einträgen. + +--- + +### `POST /api/machines` + +Erstellt eine neue Maschine. + +**Request Body:** +```json +{ + "name": "string", // Pflicht, min. 1 Zeichen + "room": "string", // Optional, Standard: "" + "pricePerCoffee": 0.5 // Pflicht, muss positiv sein +} +``` + +**Response `201`:** Erstellte Maschine. + +--- + +### `PUT /api/machines/:id` + +Aktualisiert eine bestehende Maschine. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | Machine-ID (UUID) | + +**Request Body:** +```json +{ + "name": "string", // Optional + "room": "string", // Optional + "pricePerCoffee": 0.5 // Optional, muss positiv sein +} +``` + +**Response `200`:** Aktualisierte Maschine. + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `404` | Maschine nicht gefunden | + +--- + +### `DELETE /api/machines/:id` + +Löscht eine Maschine. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | Machine-ID (UUID) | + +**Response `200`:** +```json +{ "success": true } +``` + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `404` | Maschine nicht gefunden | +| `409` | Maschine wird noch von einem Terminal verwendet | + +--- + +## Terminals + +> Alle Endpunkte erfordern: `Bearer Token` + `admin`-Rolle. + +### `GET /api/terminals` + +Listet alle Terminals mit zugehöriger Maschinen-Info auf. + +**Response `200`:** +```json +[ + { + "id": "uuid", + "name": "string", + "slug": "string", + "machineId": "uuid", + "type": "web", + "quickButtonsEnabled": 0, + "quickButton1": 5, + "quickButton2": 10, + "alphabetFilterEnabled": 1, + "createdAt": "ISO-8601", + "updatedAt": "ISO-8601" + } +] +``` + +--- + +### `GET /api/terminals/:id` + +Gibt ein einzelnes Terminal mit Maschinen-Info zurück. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | Terminal-ID (UUID) | + +**Response `200`:** Terminal-Objekt mit Maschinen-Info. + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `404` | Terminal nicht gefunden | + +--- + +### `GET /api/terminals/:id/log` + +Gibt das Aktivitätsprotokoll eines Terminals zurück. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | Terminal-ID (UUID) | + +**Response `200`:** Array von Log-Einträgen. + +--- + +### `POST /api/terminals` + +Erstellt ein neues Terminal. + +**Request Body:** +```json +{ + "name": "string", // Pflicht, min. 1 Zeichen + "machineId": "uuid" // Pflicht, muss existierende Maschine referenzieren +} +``` + +**Verhalten:** +- Der `slug` wird automatisch aus dem Namen generiert (Umlaute werden konvertiert, Sonderzeichen entfernt). +- Standard-Einstellungen: `quickButtonsEnabled: false`, `alphabetFilterEnabled: true`. + +**Response `201`:** Erstelltes Terminal. + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `404` | Maschine nicht gefunden | +| `409` | Terminal-Name existiert bereits (Slug-Kollision) | + +--- + +### `PUT /api/terminals/:id` + +Aktualisiert ein bestehendes Terminal. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | Terminal-ID (UUID) | + +**Request Body:** +```json +{ + "name": "string", // Optional + "machineId": "uuid", // Optional + "quickButtons": { // Optional + "enabled": true, + "button1": 5.0, // Muss positiv sein + "button2": 10.0 // Muss positiv sein + }, + "alphabetFilter": { // Optional + "enabled": true + } +} +``` + +**Response `200`:** Aktualisiertes Terminal. + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `404` | Terminal nicht gefunden | +| `404` | Maschine nicht gefunden (bei machineId-Änderung) | +| `409` | Terminal-Name existiert bereits (Slug-Kollision) | + +--- + +### `DELETE /api/terminals/:id` + +Löscht ein Terminal. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | Terminal-ID (UUID) | + +**Response `200`:** +```json +{ "success": true } +``` + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `404` | Terminal nicht gefunden | + +--- + +## Terminal Actions + +> Öffentliche Endpunkte — keine JWT-Authentifizierung erforderlich. +> Terminals werden über ihren `slug` (URL-freundlicher Name) identifiziert. + +### `GET /api/terminal-actions/:slug` + +Gibt Terminal-Informationen, Maschinen-Daten und die Liste der berechtigten Benutzer zurück. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `slug` | string | Terminal-Slug | + +**Response `200`:** +```json +{ + "terminal": { + "id": "uuid", + "name": "string", + "slug": "string", + "quickButtons": { "enabled": false, "button1": 5, "button2": 10 }, + "alphabetFilter": { "enabled": true } + }, + "machine": { + "id": "uuid", + "name": "string", + "room": "string", + "pricePerCoffee": 0.5 + }, + "users": [ + { "id": "uuid", "displayName": "string", "balance": 0.0 } + ] +} +``` + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `404` | Terminal nicht gefunden | + +--- + +### `POST /api/terminal-actions/:slug/verify-nfc` + +Verifiziert eine NFC-Seriennummer und gibt ein kurzlebiges Session-Token zurück. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `slug` | string | Terminal-Slug | + +**Request Body:** +```json +{ + "serialNumber": "string" // Pflicht, NFC-Seriennummer +} +``` + +**Response `200`:** +```json +{ + "success": true, + "sessionToken": "jwt-string", + "user": { + "id": "uuid", + "displayName": "string", + "balance": 0.0 + } +} +``` + +**Verhalten:** +- Das Session-Token ist **5 Minuten** gültig. +- Enthält `userId`, `terminalId` und `purpose: "terminal-session"`. + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `404` | Terminal nicht gefunden | +| `404` | Kein Benutzer mit dieser NFC-Seriennummer gefunden | + +--- + +### `POST /api/terminal-actions/:slug/verify-pin` + +Verifiziert eine Benutzer-PIN und gibt ein kurzlebiges Session-Token zurück. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `slug` | string | Terminal-Slug | + +**Request Body:** +```json +{ + "userId": "uuid", // Pflicht + "pin": "1234" // Pflicht +} +``` + +**Response `200`:** +```json +{ + "success": true, + "sessionToken": "jwt-string", + "user": { + "id": "uuid", + "displayName": "string", + "balance": 0.0 + } +} +``` + +**Verhalten:** +- Das Session-Token ist **5 Minuten** gültig. +- Nur `drinker`-Benutzer können sich per PIN verifizieren. + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `401` | Ungültige PIN | +| `404` | Terminal nicht gefunden | +| `404` | Benutzer nicht gefunden | + +--- + +### `POST /api/terminal-actions/:slug/anonymous-coffee` + +Registriert einen anonymen Kaffee (Gast, ohne Anmeldung). + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `slug` | string | Terminal-Slug | + +**Request Body:** Keiner. + +**Response `200`:** +```json +{ + "success": true, + "price": 0.5 +} +``` + +**Verhalten:** +- Erstellt einen `anonymous_coffee`-Eintrag im Kassenbuch. +- Loggt den anonymen Kaffee. + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `404` | Terminal nicht gefunden | +| `500` | Maschine nicht gefunden | + +--- + +### `POST /api/terminal-actions/:slug/count-coffee` + +Bucht einen Kaffee für einen authentifizierten Benutzer. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `slug` | string | Terminal-Slug | + +**Request Body:** +```json +{ + "sessionToken": "jwt-string" // Pflicht, gültiges Session-Token +} +``` + +**Response `200`:** +```json +{ + "success": true, + "newBalance": -0.5 +} +``` + +**Verhalten:** +- Zieht den Kaffeepreis der zugehörigen Maschine vom Guthaben ab. +- Das Guthaben kann negativ werden. + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `401` | Session-Token erforderlich | +| `403` | Session abgelaufen oder ungültig | +| `404` | Benutzer nicht gefunden | +| `500` | Maschine nicht gefunden | + +--- + +### `POST /api/terminal-actions/:slug/update-balance` + +Aktualisiert das Guthaben eines Benutzers über das Terminal. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `slug` | string | Terminal-Slug | + +**Request Body:** +```json +{ + "sessionToken": "jwt-string", // Pflicht + "amount": 5.0, // Optional (Pflicht bei mode "add"), muss positiv sein + "mode": "add | reset" // Optional, Standard: "add" +} +``` + +**Verhalten:** +- `mode: "add"` — Addiert `amount` zum aktuellen Guthaben. +- `mode: "reset"` — Setzt das Guthaben auf 0 zurück. +- Bei `reset` mit negativem Guthaben: Schuldenbetrag wird als Einzahlung ins Kassenbuch gebucht. +- Bei `add`: Der Betrag wird als Einzahlung ins Kassenbuch gebucht. + +**Response `200`:** +```json +{ + "success": true, + "newBalance": 5.0 +} +``` + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `400` | Gültiger Betrag erforderlich | +| `401` | Session-Token erforderlich | +| `403` | Session abgelaufen oder ungültig | +| `404` | Benutzer nicht gefunden | + +--- + +## Cash Book + +> Alle Endpunkte erfordern: `Bearer Token` + `admin`-Rolle. + +### `GET /api/cashbook` + +Gibt alle Kassenbuch-Einträge zurück (neueste zuerst). + +**Response `200`:** +```json +[ + { + "id": "uuid", + "type": "deposit | withdrawal | anonymous_coffee", + "amount": 5.0, + "comment": "string", + "machineId": "uuid | null", + "terminalId": "uuid | null", + "performedBy": "uuid | 'terminal'", + "createdAt": "ISO-8601" + } +] +``` + +--- + +### `GET /api/cashbook/balance` + +Gibt den berechneten Kassenstand zurück. + +**Response `200`:** +```json +{ + "balance": 42.50 +} +``` + +--- + +### `POST /api/cashbook/deposit` + +Erstellt eine Einzahlung. + +**Request Body:** +```json +{ + "amount": 10.0, // Pflicht, muss positiv sein + "comment": "string" // Pflicht, min. 1 Zeichen (wird getrimmt) +} +``` + +**Response `201`:** Erstellter Kassenbuch-Eintrag. + +--- + +### `POST /api/cashbook/withdrawal` + +Erstellt eine Auszahlung. + +**Request Body:** +```json +{ + "amount": 10.0, // Pflicht, muss positiv sein + "comment": "string" // Pflicht, min. 1 Zeichen (wird getrimmt) +} +``` + +**Response `201`:** Erstellter Kassenbuch-Eintrag. + +--- + +### `DELETE /api/cashbook/:id` + +Löscht einen Kassenbuch-Eintrag. + +**URL-Parameter:** + +| Parameter | Typ | Beschreibung | +| --------- | ------ | ------------ | +| `id` | string | Entry-ID (UUID) | + +**Einschränkungen:** +- `anonymous_coffee`-Einträge können nicht gelöscht werden. + +**Response `200`:** +```json +{ "success": true } +``` + +**Fehler:** + +| Status | Beschreibung | +| ------ | ------------ | +| `403` | Gast-Kaffee-Einträge können nicht gelöscht werden | +| `404` | Eintrag nicht gefunden | + +--- + +## Settings + +### `GET /api/settings` + +Gibt die aktuellen Einstellungen zurück. **Keine Authentifizierung erforderlich.** + +**Response `200`:** +```json +{ + "language": "de | en" +} +``` + +--- + +### `PUT /api/settings` + +Aktualisiert die Einstellungen. + +> Erfordert: `Bearer Token` + `admin`-Rolle. + +**Request Body:** +```json +{ + "language": "de | en" // Pflicht +} +``` + +**Response `200`:** Aktualisierte Einstellungen. + +--- + +### `DELETE /api/settings/cleanup-logs` + +Löscht alle Logs, die älter als 1 Jahr sind. Kaffee-Statistiken werden dabei archiviert. + +> Erfordert: `Bearer Token` + `admin`-Rolle. + +**Response `200`:** +```json +{ + "deletedCount": 150, + "periodFrom": "ISO-8601", + "periodTo": "ISO-8601" +} +``` + +Wenn keine alten Logs vorhanden: +```json +{ + "deletedCount": 0, + "message": "Keine Logs älter als ein Jahr gefunden." +} +``` + +--- + +### `GET /api/settings/log-cleanups` + +Gibt die Historie der Log-Bereinigungen zurück. + +> Erfordert: `Bearer Token` + `admin`-Rolle. + +**Response `200`:** +```json +[ + { + "id": 1, + "deletedAt": "ISO-8601", + "deletedBy": "string", + "deletedCount": 150, + "periodFrom": "ISO-8601", + "periodTo": "ISO-8601" + } +] +``` + +--- + +## Stats + +> Alle Endpunkte erfordern: `Bearer Token` + `admin`-Rolle. + +### `GET /api/stats/dashboard` + +Gibt aggregierte Statistiken für das Dashboard zurück. + +**Response `200`:** +```json +{ + "totalCoffees": 1234, + "coffeesToday": 12, + "coffeesPerDay": [ + { "date": "2026-03-25", "count": 15 }, + { "date": "2026-03-26", "count": 8 } + ], + "topDrinkers": [ + { "userId": "uuid", "displayName": "string", "count": 200 } + ], + "popularMachines": [ + { "machineId": "uuid", "name": "string", "count": 500 } + ], + "totalUsers": 42, + "totalMachines": 3, + "totalTerminals": 5 +} +``` + +**Details:** +- `coffeesPerDay`: Letzte 30 Tage, inkl. Tage ohne Kaffees (count: 0). +- `topDrinkers`: Top 5 Kaffeetrinker (aktuell + archiviert). +- `popularMachines`: Alle Maschinen nach Nutzung sortiert (aktuell + archiviert). +- Archivierte Statistiken (aus Log-Bereinigungen) werden mit eingerechnet. + +--- + +## Datenbank-Schema + +### `users` + +| Spalte | Typ | Beschreibung | +| ----------- | ------- | ----------------------------------------- | +| `id` | TEXT PK | UUID | +| `username` | TEXT | Einzigartig, Pflicht | +| `displayName` | TEXT | Anzeigename | +| `password` | TEXT | Bcrypt-Hash (null für `api`-Benutzer) | +| `type` | TEXT | `admin`, `api` oder `drinker` | +| `isRoot` | INTEGER | 1 = Root-Benutzer (nicht löschbar) | +| `balance` | REAL | Guthaben (Standard: 0) | +| `apiKey` | TEXT | API-Key (nur für `api`-Benutzer) | +| `createdAt` | TEXT | ISO-8601 Zeitstempel | +| `updatedAt` | TEXT | ISO-8601 Zeitstempel | + +### `identifiers` + +| Spalte | Typ | Beschreibung | +| -------- | ------- | -------------------------------------- | +| `id` | TEXT PK | UUID | +| `userId` | TEXT FK | Referenz auf `users.id` (CASCADE) | +| `type` | TEXT | z.B. `pin`, `nfc` | +| `value` | TEXT | Identifier-Wert (PIN oder NFC-Serial) | + +### `machines` + +| Spalte | Typ | Beschreibung | +| --------------- | ------- | -------------------------- | +| `id` | TEXT PK | UUID | +| `name` | TEXT | Name der Maschine | +| `room` | TEXT | Raum (Standard: "") | +| `pricePerCoffee`| REAL | Preis pro Kaffee | +| `createdAt` | TEXT | ISO-8601 Zeitstempel | +| `updatedAt` | TEXT | ISO-8601 Zeitstempel | + +### `terminals` + +| Spalte | Typ | Beschreibung | +| ---------------------- | ------- | ------------------------------------- | +| `id` | TEXT PK | UUID | +| `name` | TEXT | Terminal-Name | +| `slug` | TEXT | URL-freundlicher Name (einzigartig) | +| `machineId` | TEXT FK | Referenz auf `machines.id` | +| `type` | TEXT | Terminal-Typ (Standard: `web`) | +| `quickButtonsEnabled` | INTEGER | Schnelltasten aktiviert (0/1) | +| `quickButton1` | REAL | Betrag Schnelltaste 1 (Standard: 5) | +| `quickButton2` | REAL | Betrag Schnelltaste 2 (Standard: 10) | +| `alphabetFilterEnabled`| INTEGER | Alphabetfilter aktiviert (0/1) | +| `createdAt` | TEXT | ISO-8601 Zeitstempel | +| `updatedAt` | TEXT | ISO-8601 Zeitstempel | + +### `logs` + +| Spalte | Typ | Beschreibung | +| ------------ | ------- | ------------------------------------------- | +| `id` | TEXT PK | UUID | +| `type` | TEXT | Log-Typ (z.B. `coffee`, `login`, `balance`) | +| `userId` | TEXT | Betroffener Benutzer (nullable) | +| `machineId` | TEXT | Betroffene Maschine (nullable) | +| `terminalId` | TEXT | Betroffenes Terminal (nullable) | +| `details` | TEXT | JSON-Details | +| `createdAt` | TEXT | ISO-8601 Zeitstempel | + +**Log-Typen:** + +| Typ | Beschreibung | +| ---------------- | ------------------------------- | +| `login` | Dashboard-Login | +| `coffee` | Kaffee gebucht | +| `anonymous_coffee` | Anonymer Kaffee | +| `balance` | Guthaben geändert | +| `user_created` | Benutzer erstellt | +| `user_deleted` | Benutzer gelöscht | +| `cashbook` | Kassenbuch-Aktion | + +### `cash_book` + +| Spalte | Typ | Beschreibung | +| ------------ | ------- | -------------------------------------------------- | +| `id` | TEXT PK | UUID | +| `type` | TEXT | `deposit`, `withdrawal` oder `anonymous_coffee` | +| `amount` | REAL | Betrag (immer positiv) | +| `comment` | TEXT | Kommentar (Standard: "") | +| `machineId` | TEXT | Maschine (nullable) | +| `terminalId` | TEXT | Terminal (nullable) | +| `performedBy`| TEXT | User-ID oder `"terminal"` | +| `createdAt` | TEXT | ISO-8601 Zeitstempel | + +### `settings` + +| Spalte | Typ | Beschreibung | +| ------- | ------- | --------------------- | +| `key` | TEXT PK | Einstellungsschlüssel | +| `value` | TEXT | Einstellungswert | + +### `archived_stats` + +| Spalte | Typ | Beschreibung | +| ------- | ------- | -------------------------------------- | +| `key` | TEXT PK | Statistik-Schlüssel | +| `value` | TEXT | JSON-Wert (Kaffee-Zähler pro User/Maschine) | + +### `log_cleanups` + +| Spalte | Typ | Beschreibung | +| ------------- | ------------ | ------------------------------ | +| `id` | INTEGER PK | Auto-Increment | +| `deletedAt` | TEXT | Zeitpunkt der Bereinigung | +| `deletedBy` | TEXT | Username des Ausführenden | +| `deletedCount`| INTEGER | Anzahl gelöschter Logs | +| `periodFrom` | TEXT | Ältester gelöschter Log | +| `periodTo` | TEXT | Neuester gelöschter Log | \ No newline at end of file From c90b50fd418184ce67e9e0f649bd1c50559870f8 Mon Sep 17 00:00:00 2001 From: Punkerschaf <105040919+Punkerschaf@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:48:24 +0100 Subject: [PATCH 06/12] fix: display overflow in terminal --- frontend/src/pages/terminal/TerminalView.tsx | 93 +++++++++++--------- 1 file changed, 49 insertions(+), 44 deletions(-) diff --git a/frontend/src/pages/terminal/TerminalView.tsx b/frontend/src/pages/terminal/TerminalView.tsx index ae66e7c..3806c27 100644 --- a/frontend/src/pages/terminal/TerminalView.tsx +++ b/frontend/src/pages/terminal/TerminalView.tsx @@ -11,13 +11,11 @@ import { List, ListItemButton, ListItemText, - InputAdornment, } from '@mui/material'; import LocalCafeIcon from '@mui/icons-material/LocalCafe'; import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import BackspaceIcon from '@mui/icons-material/Backspace'; -import SearchIcon from '@mui/icons-material/Search'; import NfcIcon from '@mui/icons-material/Nfc'; import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; import { useTranslation } from 'react-i18next'; @@ -51,7 +49,6 @@ export default function TerminalView() { const [sessionToken, setSessionToken] = useState(''); const [balance, setBalance] = useState(0); const [newBalance, setNewBalance] = useState(''); - const [userSearch, setUserSearch] = useState(''); const [alphabetFilter, setAlphabetFilter] = useState(null); const [nfcScanning, setNfcScanning] = useState(false); const [nfcStatus, setNfcStatus] = useState<'idle' | 'scanning' | 'success' | 'error'>('idle'); @@ -101,7 +98,6 @@ export default function TerminalView() { setSessionToken(''); setBalance(0); setNewBalance(''); - setUserSearch(''); setAlphabetFilter(null); setNfcScanning(false); setNfcStatus('idle'); @@ -295,21 +291,21 @@ export default function TerminalView() { : []; const filteredUsers = info.users.filter((u) => { - const matchesSearch = u.displayName - .toLowerCase() - .includes(userSearch.toLowerCase()); - const matchesLetter = - !alphabetFilter || - u.displayName.charAt(0).toUpperCase() === alphabetFilter; - return matchesSearch && matchesLetter; + return !alphabetFilter || u.displayName.charAt(0).toUpperCase() === alphabetFilter; }); return ( - - + + {info.machine && ( - <> + {info.machine.name} @@ -318,16 +314,16 @@ export default function TerminalView() { {info.machine.room} )} - + )} - + {t('terminalView.selectName')} {nfcSupported && ( - + + ) : undefined + } + > + {t('migrations.bannerTitle')} + {t('migrations.bannerText', { count: pendingManual.length })} + + )} + + {/* Migration Dialog */} + !migrationRunning && setMigrationDialogOpen(false)} maxWidth="sm" fullWidth> + {t('migrations.dialogTitle')} + + {selectedMigration && ( + + + {t('migrations.version')} {selectedMigration.version}: {selectedMigration.name} + + + {selectedMigration.description} + + {selectedMigration.breaking && selectedMigration.breaking.length > 0 && ( + + + {t('migrations.breakingChanges')} + +
    + {selectedMigration.breaking.map((b, i) => ( +
  • {b}
  • + ))} +
+
+ )} + {selectedMigration.adminAction && ( + + {t('migrations.adminAction')} + {selectedMigration.adminAction} + + )} + {migrationError && ( + {migrationError} + )} +
+ )} +
+ + + + +
+ {versionInfo && ( diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 6d43583..9b19c2d 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -184,5 +184,18 @@ "confirmDeleteText": "Soll dieser Kassenbuch-Eintrag wirklich gelöscht werden? Dieser Vorgang kann nicht rückgängig gemacht werden.", "errorLoading": "Fehler beim Laden des Kassenbuchs", "machine": "Maschine" + }, + "migrations": { + "bannerTitle": "Datenbank-Migration erforderlich", + "bannerText": "{{count}} manuelle Migration(en) ausstehend. Einige Funktionen sind eingeschränkt, bis die Migration durchgeführt wird.", + "runMigration": "Migration durchführen", + "dialogTitle": "Datenbank-Migration", + "version": "Version", + "breakingChanges": "Inkompatible Änderungen", + "adminAction": "Aktion erforderlich", + "confirm": "Migration jetzt durchführen", + "running": "Migration läuft...", + "success": "Migration erfolgreich abgeschlossen.", + "error": "Migration fehlgeschlagen" } } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index abf60ae..6137ff8 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -184,5 +184,18 @@ "confirmDeleteText": "Do you really want to delete this cash book entry? This action cannot be undone.", "errorLoading": "Error loading cash book", "machine": "Machine" + }, + "migrations": { + "bannerTitle": "Database migration required", + "bannerText": "{{count}} manual migration(s) pending. Some features are restricted until the migration is completed.", + "runMigration": "Run migration", + "dialogTitle": "Database Migration", + "version": "Version", + "breakingChanges": "Breaking changes", + "adminAction": "Action required", + "confirm": "Run migration now", + "running": "Migration running...", + "success": "Migration completed successfully.", + "error": "Migration failed" } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8a2a551..3795c94 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -87,3 +87,28 @@ export interface CashBookEntry { performedBy: string; createdAt: string; } + +export interface PendingMigration { + version: number; + name: string; + type: 'auto' | 'manual'; + description: string; + breaking: string[] | null; + adminAction: string | null; + params: { key: string; label: string; type: string; default?: unknown }[] | null; +} + +export interface AppliedMigration { + version: number; + name: string; + type: 'auto' | 'manual'; + executedAt: string; + executedBy: string; +} + +export interface MigrationStatus { + currentVersion: number; + maintenanceMode: boolean; + applied: AppliedMigration[]; + pending: PendingMigration[]; +} diff --git a/version.json b/version.json index 55e85a9..4ed3f53 100644 --- a/version.json +++ b/version.json @@ -2,5 +2,6 @@ "major": 0, "minor": 3, "patch": 0, - "codeName": "Cold Coffee" + "codeName": "Cold Coffee", + "schemaVersion": 1 } From 96782c6d76bf076e1f20a06bf5982f319cdc582b Mon Sep 17 00:00:00 2001 From: Punkerschaf <105040919+Punkerschaf@users.noreply.github.com> Date: Fri, 1 May 2026 12:20:25 +0200 Subject: [PATCH 08/12] edit gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f4d40e5..01e4986 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,7 @@ vite.config.ts.timestamp-* # Local stuff tasks.md +todo.md # Docker Images *.tar.gz \ No newline at end of file From abb7450187082fa312a9d0c170e95e44d2716e8f Mon Sep 17 00:00:00 2001 From: Punkerschaf <105040919+Punkerschaf@users.noreply.github.com> Date: Fri, 1 May 2026 12:20:33 +0200 Subject: [PATCH 09/12] add changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..73d20b6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# [1.0.0] **Ristretto" + +- Production-ready release of CupTrack - digital coffe fund From c32989f9a343557603a4b9a22bf6cc34ae22f25f Mon Sep 17 00:00:00 2001 From: Punkerschaf <105040919+Punkerschaf@users.noreply.github.com> Date: Fri, 1 May 2026 12:53:56 +0200 Subject: [PATCH 10/12] user self service --- CHANGELOG.md | 2 + backend/src/dal.js | 15 + .../migrations/scripts/002-self-service.js | 35 ++ backend/src/routes/terminalActions.js | 105 +++- backend/src/routes/terminals.js | 6 + backend/src/validators/index.js | 16 + frontend/src/api.ts | 21 + frontend/src/locales/de.json | 27 +- frontend/src/locales/en.json | 27 +- frontend/src/pages/Terminals.tsx | 28 + frontend/src/pages/terminal/TerminalView.tsx | 519 +++++++++++++++++- frontend/src/types.ts | 4 + version.json | 2 +- 13 files changed, 794 insertions(+), 13 deletions(-) create mode 100644 backend/src/migrations/scripts/002-self-service.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d20b6..602049a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ # [1.0.0] **Ristretto" - Production-ready release of CupTrack - digital coffe fund +- PIN-change for user on terminal +- create user on terminal diff --git a/backend/src/dal.js b/backend/src/dal.js index 628fec9..e1c1dae 100644 --- a/backend/src/dal.js +++ b/backend/src/dal.js @@ -36,6 +36,8 @@ function toTerminal(row) { alphabetFilter: { enabled: !!row.alphabetFilterEnabled, }, + pinChangeEnabled: !!row.pinChangeEnabled, + selfRegistrationEnabled: !!row.selfRegistrationEnabled, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -90,6 +92,19 @@ export const users = { return !!db.prepare("SELECT 1 FROM identifiers WHERE type = 'pin' AND value = ?").get(pin); }, + displayNameExists(displayName) { + return !!db.prepare('SELECT 1 FROM users WHERE LOWER(displayName) = LOWER(?)').get(displayName); + }, + + updatePinIdentifier(userId, newPin) { + const existing = db.prepare("SELECT id FROM identifiers WHERE userId = ? AND type = 'pin'").get(userId); + if (existing) { + db.prepare("UPDATE identifiers SET value = ? WHERE id = ?").run(newPin, existing.id); + } else { + db.prepare('INSERT INTO identifiers (id, type, value, userId) VALUES (?, ?, ?, ?)').run(uuidv4(), 'pin', newPin, userId); + } + }, + create(userData) { const { identifiers: ids, ...user } = userData; db.prepare( diff --git a/backend/src/migrations/scripts/002-self-service.js b/backend/src/migrations/scripts/002-self-service.js new file mode 100644 index 0000000..8a3a634 --- /dev/null +++ b/backend/src/migrations/scripts/002-self-service.js @@ -0,0 +1,35 @@ +/** + * Migration 002 — Terminal Self Service Settings + * + * Adds two new columns to the terminals table to enable/disable + * self-service features per terminal: + * - pinChangeEnabled: allows authenticated users to change their PIN + * - selfRegistrationEnabled: allows new users to self-register + */ +export default { + version: 2, + name: 'self-service', + type: 'auto', + description: 'Adds pinChangeEnabled and selfRegistrationEnabled columns to terminals table.', + + validate(db) { + const table = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='terminals'", + ).get(); + if (!table) { + return { ok: false, message: 'Table "terminals" does not exist.' }; + } + return { ok: true, message: 'Terminals table exists, ready for migration.' }; + }, + + up(db) { + const cols = db.prepare("PRAGMA table_info(terminals)").all().map(c => c.name); + + if (!cols.includes('pinChangeEnabled')) { + db.prepare('ALTER TABLE terminals ADD COLUMN pinChangeEnabled INTEGER DEFAULT 0').run(); + } + if (!cols.includes('selfRegistrationEnabled')) { + db.prepare('ALTER TABLE terminals ADD COLUMN selfRegistrationEnabled INTEGER DEFAULT 0').run(); + } + }, +}; diff --git a/backend/src/routes/terminalActions.js b/backend/src/routes/terminalActions.js index 4cf816f..1f3723f 100644 --- a/backend/src/routes/terminalActions.js +++ b/backend/src/routes/terminalActions.js @@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import config from '../config.js'; import { users, machines, terminals, logs, cashBook } from '../dal.js'; -import { verifyPinSchema, verifyNfcSchema, sessionTokenSchema, updateBalanceSchema } from '../validators/index.js'; +import { verifyPinSchema, verifyNfcSchema, sessionTokenSchema, updateBalanceSchema, changePinSchema, registerUserSchema } from '../validators/index.js'; const router = Router(); @@ -22,6 +22,8 @@ router.get('/:slug', (req, res) => { slug: terminal.slug, quickButtons: terminal.quickButtons || { enabled: false, button1: 5, button2: 10 }, alphabetFilter: terminal.alphabetFilter || { enabled: true }, + pinChangeEnabled: terminal.pinChangeEnabled ?? false, + selfRegistrationEnabled: terminal.selfRegistrationEnabled ?? false, }, machine: machine ? { id: machine.id, name: machine.name, room: machine.room, pricePerCoffee: machine.pricePerCoffee } @@ -238,4 +240,105 @@ router.post('/:slug/update-balance', (req, res, next) => { } }); +// Check username/displayName availability (no auth required) +router.post('/:slug/check-user-availability', (req, res, next) => { + try { + const terminal = terminals.findBySlug(req.params.slug); + if (!terminal) return res.status(404).json({ error: 'Terminal nicht gefunden' }); + if (!terminal.selfRegistrationEnabled) { + return res.status(403).json({ error: 'Selbstregistrierung an diesem Terminal nicht aktiviert' }); + } + + const { username, displayName } = req.body; + const usernameAvailable = username ? !users.usernameExists(username.trim()) : true; + const displayNameAvailable = displayName ? !users.displayNameExists(displayName.trim()) : true; + + res.json({ usernameAvailable, displayNameAvailable }); + } catch (err) { + next(err); + } +}); + +// Change PIN (requires active session) +router.post('/:slug/change-pin', (req, res, next) => { + try { + const session = verifySession(req, res); + if (!session) return; + + const { terminal, user } = session; + + if (!terminal.pinChangeEnabled) { + return res.status(403).json({ error: 'PIN-Änderung an diesem Terminal nicht aktiviert' }); + } + + const { newPin } = changePinSchema.parse(req.body); + + users.updatePinIdentifier(user.id, newPin); + + logs.create({ + id: uuidv4(), + type: 'pin_change', + userId: user.id, + machineId: null, + terminalId: terminal.id, + details: {}, + createdAt: new Date().toISOString(), + }); + + res.json({ success: true }); + } catch (err) { + next(err); + } +}); + +// Self-register new user (no auth required) +router.post('/:slug/register-user', (req, res, next) => { + try { + const terminal = terminals.findBySlug(req.params.slug); + if (!terminal) return res.status(404).json({ error: 'Terminal nicht gefunden' }); + + if (!terminal.selfRegistrationEnabled) { + return res.status(403).json({ error: 'Selbstregistrierung an diesem Terminal nicht aktiviert' }); + } + + const { username, displayName, pin } = registerUserSchema.parse(req.body); + + if (users.usernameExists(username)) { + return res.status(409).json({ error: 'Benutzername bereits vergeben', field: 'username' }); + } + if (users.displayNameExists(displayName)) { + return res.status(409).json({ error: 'Anzeigename bereits vergeben', field: 'displayName' }); + } + + const now = new Date().toISOString(); + const newUser = users.create({ + id: uuidv4(), + username, + displayName, + password: '', + type: 'drinker', + isRoot: false, + balance: 0, + apiKey: null, + createdAt: now, + updatedAt: now, + identifiers: [{ id: uuidv4(), type: 'pin', value: pin }], + }); + + logs.create({ + id: uuidv4(), + type: 'user_registered', + userId: newUser.id, + machineId: null, + terminalId: terminal.id, + details: { username, displayName }, + createdAt: now, + }); + + res.json({ success: true, user: { id: newUser.id, displayName: newUser.displayName } }); + } catch (err) { + next(err); + } +}); + export default router; diff --git a/backend/src/routes/terminals.js b/backend/src/routes/terminals.js index 06324c8..37c25e8 100644 --- a/backend/src/routes/terminals.js +++ b/backend/src/routes/terminals.js @@ -81,6 +81,12 @@ router.put('/:id', (req, res, next) => { if (data.alphabetFilter !== undefined) { updates.alphabetFilterEnabled = data.alphabetFilter.enabled ? 1 : 0; } + if (data.pinChangeEnabled !== undefined) { + updates.pinChangeEnabled = data.pinChangeEnabled ? 1 : 0; + } + if (data.selfRegistrationEnabled !== undefined) { + updates.selfRegistrationEnabled = data.selfRegistrationEnabled ? 1 : 0; + } if (data.name !== undefined) { const slug = generateSlug(data.name); if (terminals.slugExists(slug, req.params.id)) { diff --git a/backend/src/validators/index.js b/backend/src/validators/index.js index 296ec12..7f4a482 100644 --- a/backend/src/validators/index.js +++ b/backend/src/validators/index.js @@ -63,10 +63,26 @@ export const updateTerminalSchema = z.object({ alphabetFilter: z.object({ enabled: z.boolean(), }).optional(), + pinChangeEnabled: z.boolean().optional(), + selfRegistrationEnabled: z.boolean().optional(), }); // ─── Terminal Actions ─────────────────────────────────────── +export const changePinSchema = z.object({ + sessionToken: z.string().min(1, 'Session-Token erforderlich'), + newPin: z.string().regex(/^[0-9]{4}$/, 'PIN muss genau 4 Ziffern haben'), +}); + +export const registerUserSchema = z.object({ + username: z.string() + .min(1, 'Benutzername erforderlich') + .max(30, 'Benutzername zu lang') + .regex(/^[a-z0-9_.\-]+$/, 'Benutzername darf nur Kleinbuchstaben, Zahlen, Punkte, Unterstriche und Bindestriche enthalten'), + displayName: z.string().min(1, 'Anzeigename erforderlich').max(50, 'Anzeigename zu lang'), + pin: z.string().regex(/^[0-9]{4}$/, 'PIN muss genau 4 Ziffern haben'), +}); + export const verifyPinSchema = z.object({ userId: z.string().min(1), pin: z.string().min(1), diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7c5fe5b..dba1b7e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -162,6 +162,27 @@ export const api = { { method: 'POST' }, ), + // Terminal: Change PIN + changePin: (slug: string, sessionToken: string, newPin: string) => + request<{ success: boolean }>( + `/terminal-actions/${encodeURIComponent(slug)}/change-pin`, + { method: 'POST', body: JSON.stringify({ sessionToken, newPin }) }, + ), + + // Terminal: Check username/displayName availability + checkUserAvailability: (slug: string, data: { username: string; displayName: string }) => + request<{ usernameAvailable: boolean; displayNameAvailable: boolean }>( + `/terminal-actions/${encodeURIComponent(slug)}/check-user-availability`, + { method: 'POST', body: JSON.stringify(data) }, + ), + + // Terminal: Register new user + registerUser: (slug: string, data: { username: string; displayName: string; pin: string }) => + request<{ success: boolean; user: { id: string; displayName: string } }>( + `/terminal-actions/${encodeURIComponent(slug)}/register-user`, + { method: 'POST', body: JSON.stringify(data) }, + ), + // Migrations getMigrationStatus: () => request('/admin/migrations'), runMigration: (version: number, confirm: boolean, params?: Record) => diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 9b19c2d..0907151 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -13,6 +13,7 @@ "confirmDelete": "Wirklich löschen?", "error": "Fehler", "back": "Zurück", + "next": "Weiter", "name": "Name", "type": "Typ", "actions": "Aktionen", @@ -104,7 +105,10 @@ "quickButton1": "Betrag Button 1 (€)", "quickButton2": "Betrag Button 2 (€)", "alphabetFilter": "Alphabet-Filter", - "alphabetFilterEnabled": "Alphabet-Filter in Benutzerliste aktivieren" + "alphabetFilterEnabled": "Alphabet-Filter in Benutzerliste aktivieren", + "selfService": "Self Service", + "pinChangeEnabled": "PIN-Änderung im Terminal aktivieren", + "selfRegistrationEnabled": "Selbstregistrierung im Terminal aktivieren" }, "terminalView": { "selectName": "Wähle deinen Namen:", @@ -132,7 +136,22 @@ "resetBalance": "Guthaben auf 0 setzen", "newBalanceLabel": "Neues Guthaben (€)", "confirm": "Bestätigen", - "terminalNotFound": "Terminal nicht gefunden" + "terminalNotFound": "Terminal nicht gefunden", + "changePinButton": "PIN ändern", + "newPinFor": "Neuer PIN für {{name}}", + "pinChanged": "PIN erfolgreich geändert", + "newUser": "Neuer Benutzer", + "newUserFormTitle": "Neuen Benutzer anlegen", + "usernameLabel": "Benutzername", + "usernameHint": "z.B. maxmustermann", + "displayNameLabel": "Anzeigename", + "displayNameHint": "z.B. Max", + "newUserPinFor": "PIN wählen für {{name}}", + "userCreated": "Benutzer {{name}} wurde angelegt", + "usernameTaken": "Benutzername bereits vergeben", + "displayNameTaken": "Anzeigename bereits vergeben", + "fieldRequired": "Dieses Feld ist erforderlich", + "usernameFormatError": "Nur Kleinbuchstaben, Zahlen, Punkte, _ und -" }, "logs": { "coffee": "Kaffee gezählt", @@ -142,7 +161,9 @@ "userDeleted": "Benutzer gelöscht", "cashbook": "Kassenbuch", "anonymousCoffee": "Gast-Kaffee", - "balanceTopUp": "Guthaben aufgeladen (Kassenbuch)" + "balanceTopUp": "Guthaben aufgeladen (Kassenbuch)", + "pin_change": "PIN geändert", + "user_registered": "Benutzer registriert" }, "settings": { "title": "Systemeinstellungen", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 6137ff8..8d3806f 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -13,6 +13,7 @@ "confirmDelete": "Really delete?", "error": "Error", "back": "Back", + "next": "Next", "name": "Name", "type": "Type", "actions": "Actions", @@ -104,7 +105,10 @@ "quickButton1": "Amount Button 1 (€)", "quickButton2": "Amount Button 2 (€)", "alphabetFilter": "Alphabet Filter", - "alphabetFilterEnabled": "Enable alphabet filter in user list" + "alphabetFilterEnabled": "Enable alphabet filter in user list", + "selfService": "Self Service", + "pinChangeEnabled": "Enable PIN change in terminal", + "selfRegistrationEnabled": "Enable self-registration in terminal" }, "terminalView": { "selectName": "Select your name:", @@ -132,7 +136,22 @@ "resetBalance": "Reset balance to 0", "newBalanceLabel": "New balance (€)", "confirm": "Confirm", - "terminalNotFound": "Terminal not found" + "terminalNotFound": "Terminal not found", + "changePinButton": "Change PIN", + "newPinFor": "New PIN for {{name}}", + "pinChanged": "PIN changed successfully", + "newUser": "New User", + "newUserFormTitle": "Register New User", + "usernameLabel": "Username", + "usernameHint": "e.g. johndoe", + "displayNameLabel": "Display Name", + "displayNameHint": "e.g. John", + "newUserPinFor": "Choose PIN for {{name}}", + "userCreated": "User {{name}} was registered", + "usernameTaken": "Username already taken", + "displayNameTaken": "Display name already taken", + "fieldRequired": "This field is required", + "usernameFormatError": "Only lowercase letters, numbers, dots, _ and -" }, "logs": { "coffee": "Coffee counted", @@ -142,7 +161,9 @@ "userDeleted": "User deleted", "cashbook": "Cash Book", "anonymousCoffee": "Guest Coffee", - "balanceTopUp": "Balance top-up (Cash Book)" + "balanceTopUp": "Balance top-up (Cash Book)", + "pin_change": "PIN changed", + "user_registered": "User registered" }, "settings": { "title": "System Settings", diff --git a/frontend/src/pages/Terminals.tsx b/frontend/src/pages/Terminals.tsx index 2459c3a..8af2855 100644 --- a/frontend/src/pages/Terminals.tsx +++ b/frontend/src/pages/Terminals.tsx @@ -373,6 +373,8 @@ function EditTerminalDialog({ machineId: terminal.machineId, quickButtons: terminal.quickButtons || { enabled: false, button1: 5, button2: 10 }, alphabetFilter: terminal.alphabetFilter || { enabled: true }, + pinChangeEnabled: terminal.pinChangeEnabled ?? false, + selfRegistrationEnabled: terminal.selfRegistrationEnabled ?? false, }); const [error, setError] = useState(''); const [saving, setSaving] = useState(false); @@ -512,6 +514,32 @@ function EditTerminalDialog({ label={t('terminals.alphabetFilterEnabled')} /> + + + + {t('terminals.selfService')} + + setForm({ ...form, pinChangeEnabled: e.target.checked })} + /> + } + label={t('terminals.pinChangeEnabled')} + /> + + setForm({ ...form, selfRegistrationEnabled: e.target.checked })} + /> + } + label={t('terminals.selfRegistrationEnabled')} + /> + + diff --git a/frontend/src/pages/terminal/TerminalView.tsx b/frontend/src/pages/terminal/TerminalView.tsx index 3806c27..230de98 100644 --- a/frontend/src/pages/terminal/TerminalView.tsx +++ b/frontend/src/pages/terminal/TerminalView.tsx @@ -18,6 +18,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import BackspaceIcon from '@mui/icons-material/Backspace'; import NfcIcon from '@mui/icons-material/Nfc'; import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'; +import LockIcon from '@mui/icons-material/Lock'; +import PersonAddIcon from '@mui/icons-material/PersonAdd'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import { useTranslation } from 'react-i18next'; import { api } from '../../api'; import type { TerminalInfo } from '../../types'; @@ -29,7 +32,12 @@ type Step = | 'counting' | 'editBalance' | 'balanceUpdated' - | 'guestCoffeeCounted'; + | 'guestCoffeeCounted' + | 'changePin' + | 'changePinSuccess' + | 'newUserForm' + | 'newUserPin' + | 'newUserSuccess'; export default function TerminalView() { const { terminalName } = useParams<{ terminalName: string }>(); @@ -58,6 +66,16 @@ export default function TerminalView() { const [connected, setConnected] = useState(true); const [guestCoffeePrice, setGuestCoffeePrice] = useState(0); + // Self-service state + const [newPin, setNewPin] = useState(''); + const [newPinStatus, setNewPinStatus] = useState<'idle' | 'success' | 'error'>('idle'); + const [newUserUsername, setNewUserUsername] = useState(''); + const [newUserDisplayName, setNewUserDisplayName] = useState(''); + const [newUserUsernameError, setNewUserUsernameError] = useState(''); + const [newUserDisplayNameError, setNewUserDisplayNameError] = useState(''); + const [newUserFormLoading, setNewUserFormLoading] = useState(false); + const [newUserActionError, setNewUserActionError] = useState(''); + useEffect(() => { api.getVersion().then((v) => setCodeName(v.codeName)).catch(() => {}); }, []); @@ -102,6 +120,14 @@ export default function TerminalView() { setNfcScanning(false); setNfcStatus('idle'); setNfcError(''); + setNewPin(''); + setNewPinStatus('idle'); + setNewUserUsername(''); + setNewUserDisplayName(''); + setNewUserUsernameError(''); + setNewUserDisplayNameError(''); + setNewUserFormLoading(false); + setNewUserActionError(''); loadInfo(); }, [loadInfo]); @@ -164,7 +190,7 @@ export default function TerminalView() { // Auto-redirect after confirmation screens useEffect(() => { - if (step === 'counting' || step === 'balanceUpdated' || step === 'guestCoffeeCounted') { + if (step === 'counting' || step === 'balanceUpdated' || step === 'guestCoffeeCounted' || step === 'changePinSuccess' || step === 'newUserSuccess') { const timer = setTimeout(resetToHome, 3000); return () => clearTimeout(timer); } @@ -259,6 +285,56 @@ export default function TerminalView() { } }; + const handleNewPinDigit = async (digit: string, mode: 'changePin' | 'newUserPin') => { + if (newPinStatus !== 'idle') return; + const next = newPin + digit; + setNewPin(next); + + if (next.length === 4) { + if (mode === 'changePin' && terminalName) { + try { + await api.changePin(terminalName, sessionToken, next); + setNewPinStatus('success'); + setTimeout(() => setStep('changePinSuccess'), 600); + } catch { + setNewPinStatus('error'); + setTimeout(() => { + setNewPin(''); + setNewPinStatus('idle'); + }, 1000); + } + } + // newUserPin: just store it, user confirms via button + } + }; + + const handleNewPinBackspace = () => { + if (newPinStatus !== 'idle') return; + setNewPin(newPin.slice(0, -1)); + }; + + const handleRegisterUser = async () => { + if (!terminalName || newPin.length !== 4) return; + setNewUserActionError(''); + try { + await api.registerUser(terminalName, { + username: newUserUsername.trim(), + displayName: newUserDisplayName.trim(), + pin: newPin, + }); + setStep('newUserSuccess'); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : t('common.error'); + if (msg.includes('Benutzername') || msg.includes('Username') || msg.includes('username')) { + setNewUserActionError(t('terminalView.usernameTaken')); + } else if (msg.includes('Anzeigename') || msg.includes('Display') || msg.includes('displayName')) { + setNewUserActionError(t('terminalView.displayNameTaken')); + } else { + setNewUserActionError(msg); + } + } + }; + if (loading) return ( @@ -449,8 +525,8 @@ export default function TerminalView() { - {/* Guest Coffee Button */} - + {/* Guest Coffee + New User Buttons */} + + + {info.terminal.selfRegistrationEnabled && ( + + )}
); @@ -661,6 +766,22 @@ export default function TerminalView() { {t('terminalView.editBalance')} + {info.terminal.pinChangeEnabled && ( + + )} + + + + {/* PIN dots */} + + {[0, 1, 2, 3].map((i) => ( + + ))} + + + {/* Numpad */} + + {['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'back'].map((key) => { + if (key === '') return ; + if (key === 'back') + return ( + + ); + return ( + + ); + })} + + + ); + } + + // ─── Change PIN Success ─── + if (step === 'changePinSuccess') { + return ( + + + + + {t('terminalView.pinChanged')} + + + {t('terminalView.backToStart')} + + + + ); + } + + // ─── New User Form ─── + if (step === 'newUserForm') { + const handleWeiter = async () => { + if (!terminalName) return; + const u = newUserUsername.trim(); + const d = newUserDisplayName.trim(); + + let hasError = false; + if (!u) { + setNewUserUsernameError(t('terminalView.fieldRequired')); + hasError = true; + } else if (!/^[a-z0-9_.\-]+$/.test(u)) { + setNewUserUsernameError(t('terminalView.usernameFormatError')); + hasError = true; + } else { + setNewUserUsernameError(''); + } + if (!d) { + setNewUserDisplayNameError(t('terminalView.fieldRequired')); + hasError = true; + } else { + setNewUserDisplayNameError(''); + } + if (hasError) return; + + setNewUserFormLoading(true); + try { + const result = await api.checkUserAvailability(terminalName, { username: u, displayName: d }); + let resultHasError = false; + if (!result.usernameAvailable) { + setNewUserUsernameError(t('terminalView.usernameTaken')); + resultHasError = true; + } + if (!result.displayNameAvailable) { + setNewUserDisplayNameError(t('terminalView.displayNameTaken')); + resultHasError = true; + } + if (!resultHasError) { + setNewPin(''); + setNewPinStatus('idle'); + setStep('newUserPin'); + } + } catch { + setNewUserUsernameError(t('common.error')); + } finally { + setNewUserFormLoading(false); + } + }; + + return ( + + + + {t('terminalView.newUserFormTitle')} + + + + { + setNewUserUsername(e.target.value); + if (newUserUsernameError) setNewUserUsernameError(''); + }} + error={!!newUserUsernameError} + helperText={newUserUsernameError || t('terminalView.usernameHint')} + inputProps={{ autoCapitalize: 'none', autoCorrect: 'off' }} + disabled={newUserFormLoading} + /> + { + setNewUserDisplayName(e.target.value); + if (newUserDisplayNameError) setNewUserDisplayNameError(''); + }} + error={!!newUserDisplayNameError} + helperText={newUserDisplayNameError || t('terminalView.displayNameHint')} + disabled={newUserFormLoading} + /> + + + + + + + ); + } + + // ─── New User PIN Entry ─── + if (step === 'newUserPin') { + const bgColor = + newPinStatus === 'success' + ? '#4CAF50' + : newPinStatus === 'error' + ? '#F44336' + : 'transparent'; + + return ( + + + + + {newUserActionError && ( + + {newUserActionError} + + )} + + {/* PIN dots */} + + {[0, 1, 2, 3].map((i) => ( + + ))} + + + {/* Numpad */} + + {['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'back'].map((key) => { + if (key === '') return ; + if (key === 'back') + return ( + + ); + return ( + + ); + })} + + + {/* Confirm button – active when 4 digits entered */} + + + + + ); + } + + // ─── New User Success ─── + if (step === 'newUserSuccess') { + return ( + + + + + {t('terminalView.userCreated', { name: newUserDisplayName })} + + + {t('terminalView.backToStart')} + + + + ); + } + return null; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 3795c94..3e7af6d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -35,6 +35,8 @@ export interface Terminal { machine?: Machine | null; quickButtons?: { enabled: boolean; button1: number; button2: number }; alphabetFilter?: { enabled: boolean }; + pinChangeEnabled?: boolean; + selfRegistrationEnabled?: boolean; createdAt: string; updatedAt: string; } @@ -67,6 +69,8 @@ export interface TerminalInfo { slug: string; quickButtons?: { enabled: boolean; button1: number; button2: number }; alphabetFilter?: { enabled: boolean }; + pinChangeEnabled?: boolean; + selfRegistrationEnabled?: boolean; }; machine: { id: string; diff --git a/version.json b/version.json index 4ed3f53..1f3bcab 100644 --- a/version.json +++ b/version.json @@ -3,5 +3,5 @@ "minor": 3, "patch": 0, "codeName": "Cold Coffee", - "schemaVersion": 1 + "schemaVersion": 2 } From da0a46de06957d28d994ef02fc20db20b2887e2a Mon Sep 17 00:00:00 2001 From: Punkerschaf <105040919+Punkerschaf@users.noreply.github.com> Date: Fri, 1 May 2026 13:27:52 +0200 Subject: [PATCH 11/12] several statistic bugs --- backend/src/dal.js | 12 ++++++---- backend/src/routes/stats.js | 12 +++++----- backend/src/routes/terminalActions.js | 8 +++++-- frontend/src/api.ts | 4 ++-- frontend/src/locales/de.json | 1 + frontend/src/locales/en.json | 1 + frontend/src/pages/terminal/TerminalView.tsx | 23 +++++++++++++++++++- 7 files changed, 46 insertions(+), 15 deletions(-) diff --git a/backend/src/dal.js b/backend/src/dal.js index e1c1dae..452a4d8 100644 --- a/backend/src/dal.js +++ b/backend/src/dal.js @@ -308,20 +308,24 @@ export const logs = { }, countCoffeesForDate(dateStr) { - return db.prepare("SELECT COUNT(*) as count FROM logs WHERE type = 'coffee' AND createdAt LIKE ?") - .get(dateStr + '%').count; + return db.prepare("SELECT COUNT(*) as count FROM logs WHERE type = 'coffee' AND DATE(createdAt, 'localtime') = ?") + .get(dateStr).count; }, coffeesPerDay(startDateStr) { return db.prepare( - "SELECT DATE(createdAt) as date, COUNT(*) as count FROM logs WHERE type = 'coffee' AND createdAt >= ? GROUP BY DATE(createdAt)", - ).all(startDateStr + 'T00:00:00.000Z'); + "SELECT DATE(createdAt, 'localtime') as date, COUNT(*) as count FROM logs WHERE type = 'coffee' AND DATE(createdAt, 'localtime') >= ? GROUP BY DATE(createdAt, 'localtime')", + ).all(startDateStr); }, coffeeCountsByUser() { return db.prepare("SELECT userId, COUNT(*) as count FROM logs WHERE type = 'coffee' GROUP BY userId").all(); }, + countCoffeesForUser(userId) { + return db.prepare("SELECT COUNT(*) as count FROM logs WHERE type = 'coffee' AND userId = ?").get(userId).count; + }, + coffeeCountsByMachine() { return db.prepare( "SELECT machineId, COUNT(*) as count FROM logs WHERE type = 'coffee' AND machineId IS NOT NULL GROUP BY machineId", diff --git a/backend/src/routes/stats.js b/backend/src/routes/stats.js index 9e11dc6..30f60dd 100644 --- a/backend/src/routes/stats.js +++ b/backend/src/routes/stats.js @@ -9,10 +9,10 @@ router.get('/dashboard', (_req, res) => { const now = new Date(); const archived = archivedStats.get(); - // Coffees today - const todayStr = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - .toISOString() - .split('T')[0]; + const localDate = new Intl.DateTimeFormat('en-CA'); + + // Coffees today (local date, consistent with SQLite DATE(..., 'localtime')) + const todayStr = localDate.format(now); const coffeesToday = logs.countCoffeesForDate(todayStr); // Total coffees (current + archived) — we need the count of current coffee logs @@ -23,7 +23,7 @@ router.get('/dashboard', (_req, res) => { // Coffees per day (last 30 days) const startDate = new Date(now); startDate.setDate(startDate.getDate() - 29); - const startDateStr = startDate.toISOString().split('T')[0]; + const startDateStr = localDate.format(startDate); const dbCoffeesPerDay = logs.coffeesPerDay(startDateStr); const dayMap = {}; @@ -33,7 +33,7 @@ router.get('/dashboard', (_req, res) => { for (let i = 29; i >= 0; i--) { const date = new Date(now); date.setDate(date.getDate() - i); - const dayStr = date.toISOString().split('T')[0]; + const dayStr = localDate.format(date); coffeesPerDay.push({ date: dayStr, count: dayMap[dayStr] || 0 }); } diff --git a/backend/src/routes/terminalActions.js b/backend/src/routes/terminalActions.js index 1f3723f..4faaa4c 100644 --- a/backend/src/routes/terminalActions.js +++ b/backend/src/routes/terminalActions.js @@ -49,10 +49,12 @@ router.post('/:slug/verify-nfc', (req, res, next) => { { expiresIn: '5m' }, ); + const totalCoffees = logs.countCoffeesForUser(user.id); + res.json({ success: true, sessionToken, - user: { id: user.id, displayName: user.displayName, balance: user.balance }, + user: { id: user.id, displayName: user.displayName, balance: user.balance, totalCoffees }, }); } catch (err) { next(err); @@ -79,10 +81,12 @@ router.post('/:slug/verify-pin', (req, res, next) => { { expiresIn: '5m' }, ); + const totalCoffees = logs.countCoffeesForUser(user.id); + res.json({ success: true, sessionToken, - user: { id: user.id, displayName: user.displayName, balance: user.balance }, + user: { id: user.id, displayName: user.displayName, balance: user.balance, totalCoffees }, }); } catch (err) { next(err); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index dba1b7e..20194a4 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -88,7 +88,7 @@ export const api = { request<{ success: boolean; sessionToken: string; - user: { id: string; displayName: string; balance: number }; + user: { id: string; displayName: string; balance: number; totalCoffees: number }; }>(`/terminal-actions/${encodeURIComponent(slug)}/verify-pin`, { method: 'POST', body: JSON.stringify({ userId, pin }), @@ -97,7 +97,7 @@ export const api = { request<{ success: boolean; sessionToken: string; - user: { id: string; displayName: string; balance: number }; + user: { id: string; displayName: string; balance: number; totalCoffees: number }; }>(`/terminal-actions/${encodeURIComponent(slug)}/verify-nfc`, { method: 'POST', body: JSON.stringify({ serialNumber }), diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 0907151..9941a6c 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -125,6 +125,7 @@ "countCoffee": "Kaffee zählen", "editBalance": "Guthaben bearbeiten", "coffeeCounted": "Kaffee gezählt!", + "totalCoffeesLabel": "Deine Kaffees gesamt", "newBalance": "Neues Guthaben:", "balanceUpdated": "Guthaben aktualisiert!", "backToStart": "Zurück zum Start in wenigen Sekunden...", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 8d3806f..7b2d112 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -125,6 +125,7 @@ "countCoffee": "Count coffee", "editBalance": "Edit balance", "coffeeCounted": "Coffee counted!", + "totalCoffeesLabel": "Your total coffees", "newBalance": "New balance:", "balanceUpdated": "Balance updated!", "backToStart": "Returning to start in a few seconds...", diff --git a/frontend/src/pages/terminal/TerminalView.tsx b/frontend/src/pages/terminal/TerminalView.tsx index 230de98..359e538 100644 --- a/frontend/src/pages/terminal/TerminalView.tsx +++ b/frontend/src/pages/terminal/TerminalView.tsx @@ -56,6 +56,7 @@ export default function TerminalView() { ); const [sessionToken, setSessionToken] = useState(''); const [balance, setBalance] = useState(0); + const [totalCoffees, setTotalCoffees] = useState(0); const [newBalance, setNewBalance] = useState(''); const [alphabetFilter, setAlphabetFilter] = useState(null); const [nfcScanning, setNfcScanning] = useState(false); @@ -115,6 +116,7 @@ export default function TerminalView() { setPinStatus('idle'); setSessionToken(''); setBalance(0); + setTotalCoffees(0); setNewBalance(''); setAlphabetFilter(null); setNfcScanning(false); @@ -161,6 +163,7 @@ export default function TerminalView() { setSelectedUserName(res.user.displayName); setSessionToken(res.sessionToken); setBalance(res.user.balance); + setTotalCoffees(res.user.totalCoffees); setNfcScanning(false); setTimeout(() => setStep('menu'), 600); } catch { @@ -215,6 +218,7 @@ export default function TerminalView() { setPinStatus('success'); setSessionToken(res.sessionToken); setBalance(res.user.balance); + setTotalCoffees(res.user.totalCoffees); setTimeout(() => setStep('menu'), 600); } catch { setPinStatus('error'); @@ -703,7 +707,7 @@ export default function TerminalView() { = 0 ? '#E8F5E9' : '#FFEBEE', @@ -721,6 +725,23 @@ export default function TerminalView() { + + + {t('terminalView.totalCoffeesLabel')} + + + {totalCoffees} + + + Date: Fri, 1 May 2026 13:29:24 +0200 Subject: [PATCH 12/12] update changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 602049a..42a5553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # [1.0.0] **Ristretto" -- Production-ready release of CupTrack - digital coffe fund +- Production-ready release of CupTrack - digital coffee fund - PIN-change for user on terminal - create user on terminal +- fixed: todays coffees not counted +- total users coffees shown on terminal