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/.gitignore b/.gitignore index ec4bdc0..01e4986 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,8 @@ vite.config.js.timestamp-* vite.config.ts.timestamp-* # Local stuff -tasks.md \ No newline at end of file +tasks.md +todo.md + +# Docker Images +*.tar.gz \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..42a5553 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# [1.0.0] **Ristretto" + +- 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 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/architecture.md b/architecture.md index fda0450..7145be6 100644 --- a/architecture.md +++ b/architecture.md @@ -6,7 +6,7 @@ CupTrack is a digital coffee fund for shared office coffee machines. It tracks which user has consumed how many coffees and deducts the corresponding price from their prepaid balance. CupTrack does **not** control the machines — it relies on user honesty, like a physical coffee fund. -**Version**: 0.2.0 "Cold Coffee" (defined in `/version.json`) +**Version**: 0.3.0 "Cold Coffee" (defined in `/version.json`) --- @@ -15,25 +15,43 @@ CupTrack is a digital coffee fund for shared office coffee machines. It tracks w ``` cuptrack/ ├── package.json # Root: concurrently runs backend + frontend -├── version.json # App version (major/minor/patch/codeName) +├── version.json # App version (major/minor/patch/codeName/schemaVersion) ├── backend/ │ ├── package.json -│ ├── data/db.json # Runtime JSON database (lowdb, gitignored) +│ ├── data/cuptrack.db # SQLite database (gitignored) │ └── src/ -│ ├── index.js # Express entry point, route mounting +│ ├── index.js # Express entry point, route mounting, server start │ ├── config.js # Env-based configuration -│ ├── db.js # lowdb setup + root user bootstrap +│ ├── db.js # SQLite setup, schema init, root user bootstrap, migration init +│ ├── dal.js # Data Access Layer (prepared statements for all CRUD) +│ ├── schema.sql # Full database schema (CREATE TABLE IF NOT EXISTS) │ ├── middleware/ -│ │ └── auth.js # JWT verification + admin guard -│ └── routes/ -│ ├── auth.js # Login, /me -│ ├── users.js # CRUD users, identifiers, balance -│ ├── machines.js # CRUD coffee machines -│ ├── terminals.js # CRUD terminals (admin) -│ ├── terminalActions.js # Public terminal interactions -│ ├── cashBook.js # Cash book (deposits, withdrawals) -│ ├── stats.js # Dashboard analytics -│ └── settings.js # Global settings (language) +│ │ ├── auth.js # JWT verification + admin guard +│ │ ├── maintenance.js # Maintenance mode guard (blocks non-admin during migrations) +│ │ ├── error-handler.js # Central error handler +│ │ └── request-logger.js # Pino request logging +│ ├── routes/ +│ │ ├── auth.js # Login, /me +│ │ ├── users.js # CRUD users, identifiers, balance +│ │ ├── machines.js # CRUD coffee machines +│ │ ├── terminals.js # CRUD terminals (admin) +│ │ ├── terminalActions.js # Public terminal interactions +│ │ ├── cashBook.js # Cash book (deposits, withdrawals) +│ │ ├── stats.js # Dashboard analytics +│ │ ├── settings.js # Global settings (language, log cleanup) +│ │ └── migrations.js # Admin migration API +│ ├── validators/ +│ │ └── index.js # Zod validation schemas for all request bodies +│ └── migrations/ +│ ├── runner.js # Migration engine (auto + manual migrations) +│ ├── cli.js # CLI tool for migration status/run/backup +│ └── scripts/ +│ └── 001-baseline.js # Baseline migration (version 1) +├── docs/ +│ ├── api.md +│ ├── migration.md # Migration system documentation +│ ├── deployment-docker.md +│ └── deployment-raspi.md └── frontend/ ├── package.json ├── vite.config.ts @@ -69,16 +87,18 @@ cuptrack/ ## Tech Stack -| Layer | Technology | -|----------|-----------------------------------------------------| -| Backend | Node.js, Express, ESM | -| Database | lowdb v7 (JSON file at `backend/data/db.json`) | -| Auth | JWT (jsonwebtoken), bcryptjs for password hashing | -| Frontend | React 18, TypeScript, Vite 6 | -| UI | Material-UI (MUI) v5, Emotion | -| Charts | Recharts | -| i18n | i18next + react-i18next (German default, English) | -| Dev | concurrently (runs backend + frontend in parallel) | +| Layer | Technology | +|----------|-----------------------------------------------------------| +| Backend | Node.js, Express, ESM | +| Database | SQLite via better-sqlite3 (WAL mode, foreign keys enabled)| +| Auth | JWT (jsonwebtoken), bcryptjs for password hashing | +| Frontend | React 18, TypeScript, Vite 6 | +| UI | Material-UI (MUI) v5, Emotion | +| Charts | Recharts | +| i18n | i18next + react-i18next (German default, English) | +| Validation | Zod (backend request body validation) | +| Logging | Pino (structured JSON logging) | +| Dev | concurrently (runs backend + frontend in parallel) | --- @@ -211,24 +231,47 @@ The running cash balance is computed as `SUM(deposits + balance_topups + anonymo --- -## Database Schema (lowdb) +## Database Schema (SQLite) -The JSON database (`backend/data/db.json`) has these top-level collections: +The database file is `backend/data/cuptrack.db` (SQLite, WAL mode, foreign keys enabled). The full schema is defined in `backend/src/schema.sql` using `CREATE TABLE IF NOT EXISTS`. -```json -{ - "users": [], - "machines": [], - "terminals": [], - "logs": [], - "settings": { "language": "de" }, - "archivedStats": { "totalCoffees": 0, "coffeesByUser": {}, "coffeesByMachine": {} }, - "logCleanups": [], - "cashBook": [] -} -``` +### Tables -On first start, the backend bootstraps a root admin user from env vars (`ROOT_USERNAME` / `ROOT_PASSWORD`, defaults: `root` / `Coffee`). +| Table | Purpose | +|---------------------|------------------------------------------------------------| +| `users` | Admin, API and drinker accounts (UUID PK) | +| `identifiers` | PIN/RFID/NFC/QR/Kaba credentials linked to users (FK) | +| `machines` | Coffee machines with room + price | +| `terminals` | Web/API terminals linked to a machine (FK), with settings | +| `logs` | Audit log (coffee, balance, login, cashbook, etc.) | +| `cash_book` | Cash flow tracking (deposits, withdrawals, auto entries) | +| `settings` | Global settings (key/value, e.g. `language`) | +| `archived_stats` | Preserved statistics after log cleanup | +| `log_cleanups` | History of admin-triggered log cleanup operations | +| `schema_migrations` | Migration tracking (version, name, type, executedAt) | + +### Key Constraints +- Foreign keys: `identifiers.userId → users.id`, `terminals.machineId → machines.id`, etc. +- `users.type` CHECK: `'admin'`, `'api'`, `'drinker'` +- `identifiers.type` CHECK: `'pin'`, `'rfid'`, `'nfc'`, `'qr'`, `'kaba_nfc'` +- Terminal settings (`quickButtons*`, `alphabetFilter`) stored as columns with defaults + +On first start, the backend executes `schema.sql`, then bootstraps a root admin user from env vars (`ROOT_USERNAME` / `ROOT_PASSWORD`, defaults: `root` / `Coffee`). + +--- + +## Database Migrations + +CupTrack uses a custom migration system (no external package). See [`docs/migration.md`](docs/migration.md) for full details. + +### Key Concepts +- **Migration scripts** live in `backend/src/migrations/scripts/NNN-name.js` (linear integer versioning) +- **Auto migrations**: Backward-compatible changes, run automatically on server start +- **Manual migrations**: Breaking changes requiring admin confirmation via the dashboard +- **Maintenance mode**: When manual migrations are pending, only auth and migration API routes are accessible +- **Backups**: Created automatically before each migration via `better-sqlite3`'s native `backup()` API (max 5 kept) +- **Schema version**: Tracked in `version.json` (`schemaVersion` field) and in the `schema_migrations` table +- **CLI**: `node backend/src/migrations/cli.js status|run|backup` for headless deployments (e.g. Raspberry Pi) --- @@ -309,6 +352,12 @@ All routes are mounted under `/api`. |--------|------|------|------------------------------| | GET | `/` | None | Returns version info | +### Migrations — `/api/admin/migrations` (JWT + Admin) +| Method | Path | Auth | Purpose | +|--------|--------|------------|------------------------------------------------| +| GET | `/` | JWT+Admin | Get migration status (current version, pending)| +| POST | `/run` | JWT+Root | Execute a single manual migration | + --- ## Authentication Flows @@ -351,7 +400,7 @@ All routes are mounted under `/api`. ## Key Patterns & Conventions - **ESM throughout** — Backend uses ES modules (`import/export`), configured via `"type": "module"` in backend/package.json. -- **No ORM** — Data access is direct via `db.data.users`, `db.data.machines`, etc. (lowdb). Mutations are followed by `await db.write()`. +- **Data Access Layer** — All database access goes through `backend/src/dal.js` using prepared statements (better-sqlite3). No raw SQL in route handlers. - **UUID for IDs** — All entities use `uuid` v4 for primary keys. - **Slug-based terminal access** — Terminals are accessed publicly by auto-generated slug (from name), not by ID. - **Audit logging** — Coffee consumption, balance changes, and logins are logged to the `logs` collection. @@ -361,7 +410,8 @@ All routes are mounted under `/api`. - **Root user protection** — The bootstrapped root admin cannot be edited or deleted via the API. - **Cash book** — Tracks physical cash in the office coffee fund. Manual deposits/withdrawals are created by admins. Automatic entries are created when users top up their balance at a terminal (`balance_topup`) or when a guest uses the anonymous coffee button (`anonymous_coffee`). Admin balance edits via the dashboard do **not** create cash book entries (they are considered error corrections). Only manual entries can be deleted. - **Guest coffee** — Terminals offer a "Guest Coffee" button on the home screen for anonymous, account-less coffee purchases. This creates a cash book deposit entry with the machine's coffee price. -- **Log cleanup** — Admins can manually delete logs older than one year via the Settings page. Before deletion, coffee statistics are aggregated into `archivedStats` so dashboard totals (total coffees, top drinkers, popular machines) remain accurate. Each cleanup is recorded in `logCleanups` with timestamp, admin name, count, and affected time range. +- **Log cleanup** — Admins can manually delete logs older than one year via the Settings page. Before deletion, coffee statistics are aggregated into `archived_stats` so dashboard totals (total coffees, top drinkers, popular machines) remain accurate. Each cleanup is recorded in `log_cleanups` with timestamp, admin name, count, and affected time range. +- **Maintenance mode** — When manual database migrations are pending, the `maintenance` middleware blocks all non-essential routes (only auth, migrations, and health remain accessible). The admin sees a banner in the dashboard with migration details and a confirmation dialog. --- @@ -373,3 +423,6 @@ All routes are mounted under `/api`. | `JWT_SECRET` | `cuptrack-dev-secret-change-in-production` | JWT signing key | | `ROOT_USERNAME` | `root` | Initial admin user | | `ROOT_PASSWORD` | `Coffee` | Initial admin pass | +| `NODE_ENV` | (unset) | `production` in Docker | +| `CORS_ORIGIN` | (unset) | Allowed CORS origin | +| `LOG_LEVEL` | `info` | Pino log level | 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..452a4d8 --- /dev/null +++ b/backend/src/dal.js @@ -0,0 +1,445 @@ +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, + }, + pinChangeEnabled: !!row.pinChangeEnabled, + selfRegistrationEnabled: !!row.selfRegistrationEnabled, + 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); + }, + + 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( + `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 DATE(createdAt, 'localtime') = ?") + .get(dateStr).count; + }, + + coffeesPerDay(startDateStr) { + return db.prepare( + "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", + ).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..a383f2b 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1,51 +1,95 @@ +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'; +import { runMigrations, getStatus } from './migrations/runner.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.'); } +// Migration state — set after initMigrations() is called +let maintenanceMode = false; +let pendingManualMigrations = null; + +/** + * Run pending migrations. Must be called before starting the server. + * Sets maintenanceMode if manual migrations are pending. + */ +export async function initMigrations(logger = console) { + const result = await runMigrations(db, logger); + + if (!result.success) { + const msg = `FATAL: Database migration failed: ${result.error}`; + if (result.backupPath) { + logger.error(`${msg}\nBackup available at: ${result.backupPath}`); + } else { + logger.error(msg); + } + process.exit(1); + } + + if (result.pendingManual && result.pendingManual.length > 0) { + maintenanceMode = true; + pendingManualMigrations = result.pendingManual; + logger.warn(`Maintenance mode: ${result.pendingManual.length} manual migration(s) pending.`); + } + + return result; +} + +export function isMaintenanceMode() { + return maintenanceMode; +} + +export function getPendingManualMigrations() { + return pendingManualMigrations; +} + +export function exitMaintenanceMode() { + maintenanceMode = false; + pendingManualMigrations = null; +} + +// Graceful shutdown +process.on('exit', () => db.close()); + export default db; diff --git a/backend/src/index.js b/backend/src/index.js index d441721..574b8e8 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,10 +1,16 @@ 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 { initMigrations } from './db.js'; +import { requestLogger } from './middleware/request-logger.js'; +import { errorHandler } from './middleware/error-handler.js'; +import { maintenanceGuard } from './middleware/maintenance.js'; import authRoutes from './routes/auth.js'; import userRoutes from './routes/users.js'; import machineRoutes from './routes/machines.js'; @@ -13,6 +19,9 @@ import terminalActionRoutes from './routes/terminalActions.js'; import statsRoutes from './routes/stats.js'; import settingsRoutes from './routes/settings.js'; import cashBookRoutes from './routes/cashBook.js'; +import migrationsRoutes from './routes/migrations.js'; + +const logger = pino({ level: config.logLevel }); const __dirname = dirname(fileURLToPath(import.meta.url)); const version = JSON.parse( @@ -20,9 +29,41 @@ const version = JSON.parse( ); 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' }, +}); + +// Maintenance mode guard (must be before routes, after middleware) +app.use(maintenanceGuard); + // Health check app.get('/api/health', (_req, res) => { res.json({ @@ -33,7 +74,8 @@ app.get('/api/health', (_req, res) => { }); // API routes -app.use('/api/auth', authRoutes); +app.use('/api/admin/migrations', migrationsRoutes); +app.use('/api/auth', authLimiter, authRoutes); app.use('/api/users', userRoutes); app.use('/api/machines', machineRoutes); app.use('/api/terminals', terminalRoutes); @@ -51,8 +93,28 @@ app.get('*', (_req, res, next) => { }); }); -app.listen(config.port, '0.0.0.0', () => { - console.log( - `CupTrack v${version.major}.${version.minor}.${version.patch} "${version.codeName}" auf Port ${config.port}`, - ); +// 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); }); + +// Run migrations, then start server +async function start() { + await initMigrations(logger); + + app.listen(config.port, '0.0.0.0', () => { + logger.info( + `CupTrack v${version.major}.${version.minor}.${version.patch} "${version.codeName}" auf Port ${config.port}`, + ); + }); +} + +start(); 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/maintenance.js b/backend/src/middleware/maintenance.js new file mode 100644 index 0000000..2c9de65 --- /dev/null +++ b/backend/src/middleware/maintenance.js @@ -0,0 +1,34 @@ +import { isMaintenanceMode, getPendingManualMigrations } from '../db.js'; + +/** + * Maintenance mode middleware. + * When manual migrations are pending, only allow auth and migration endpoints. + * Everything else gets HTTP 503. + */ +export function maintenanceGuard(req, res, next) { + if (!isMaintenanceMode()) { + return next(); + } + + // Always allow these paths in maintenance mode + const allowedPaths = [ + '/api/health', + '/api/auth/login', + '/api/auth/me', + '/api/admin/migrations', + ]; + + const isAllowed = allowedPaths.some( + p => req.path === p || req.path.startsWith(p + '/'), + ); + + if (isAllowed) { + return next(); + } + + return res.status(503).json({ + error: 'maintenance', + message: 'Database migration required. Please contact an administrator.', + pendingMigrations: getPendingManualMigrations(), + }); +} 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/migrations/cli.js b/backend/src/migrations/cli.js new file mode 100644 index 0000000..5c58a8b --- /dev/null +++ b/backend/src/migrations/cli.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +/** + * CupTrack Migration CLI + * + * Usage: + * node backend/src/migrations/cli.js status Show current migration status + * node backend/src/migrations/cli.js run Run all pending migrations (auto + manual) + * node backend/src/migrations/cli.js run --version N Run up to version N + * node backend/src/migrations/cli.js backup Create a manual database backup + * + * Docker: + * docker compose exec cuptrack node backend/src/migrations/cli.js status + */ + +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { readdirSync, statSync } from 'fs'; +import Database from 'better-sqlite3'; +import { getStatus, runMigrations, runManualMigration } from './runner.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const dataDir = join(__dirname, '..', '..', 'data'); +const dbPath = join(dataDir, 'cuptrack.db'); + +function openDb() { + const db = new Database(dbPath); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + return db; +} + +function printTable(rows, columns) { + if (rows.length === 0) { + console.log(' (none)'); + return; + } + const widths = columns.map(col => + Math.max(col.label.length, ...rows.map(r => String(r[col.key] ?? '').length)), + ); + const header = columns.map((col, i) => col.label.padEnd(widths[i])).join(' '); + const separator = widths.map(w => '-'.repeat(w)).join(' '); + console.log(` ${header}`); + console.log(` ${separator}`); + for (const row of rows) { + const line = columns.map((col, i) => String(row[col.key] ?? '').padEnd(widths[i])).join(' '); + console.log(` ${line}`); + } +} + +async function cmdStatus() { + const db = openDb(); + try { + const status = await getStatus(db); + console.log(`\nCupTrack Database Migration Status`); + console.log(`==================================`); + console.log(`Current schema version: ${status.currentVersion}`); + console.log(`Database: ${dbPath}\n`); + + console.log('Applied migrations:'); + printTable(status.applied, [ + { key: 'version', label: 'Version' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'executedAt', label: 'Executed At' }, + { key: 'executedBy', label: 'By' }, + ]); + + console.log('\nPending migrations:'); + printTable(status.pending, [ + { key: 'version', label: 'Version' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'description', label: 'Description' }, + ]); + + const manualPending = status.pending.filter(m => m.type === 'manual'); + if (manualPending.length > 0) { + console.log('\n⚠ Manual migrations require admin confirmation.'); + for (const m of manualPending) { + console.log(`\n Migration ${m.version}: ${m.name}`); + console.log(` ${m.description}`); + if (m.breaking) { + console.log(' Breaking changes:'); + for (const b of m.breaking) { + console.log(` - ${b}`); + } + } + if (m.adminAction) { + console.log(` Admin action required: ${m.adminAction}`); + } + } + } + console.log(''); + } finally { + db.close(); + } +} + +async function cmdRun(maxVersion) { + const db = openDb(); + try { + // First run auto migrations + const result = await runMigrations(db); + + if (!result.success) { + console.error(`\nMigration failed: ${result.error}`); + if (result.backupPath) { + console.error(`Backup available at: ${result.backupPath}`); + } + process.exit(1); + } + + // Then run manual migrations if any + if (result.pendingManual && result.pendingManual.length > 0) { + for (const migration of result.pendingManual) { + if (maxVersion && migration.version > maxVersion) { + console.log(`Stopping at version ${maxVersion} as requested.`); + break; + } + console.log(`\nRunning manual migration ${migration.version}: ${migration.name}`); + console.log(` ${migration.description}`); + if (migration.breaking) { + console.log(' Breaking changes:'); + for (const b of migration.breaking) { + console.log(` - ${b}`); + } + } + + const manualResult = await runManualMigration(db, migration.version, {}, 'cli'); + if (!manualResult.success) { + console.error(`\nManual migration failed: ${manualResult.error}`); + if (manualResult.backupPath) { + console.error(`Backup available at: ${manualResult.backupPath}`); + } + process.exit(1); + } + console.log(` ✓ Migration ${migration.version} applied.`); + } + } + + const status = await getStatus(db); + console.log(`\nAll migrations applied. Current version: ${status.currentVersion}`); + } finally { + db.close(); + } +} + +async function cmdBackup() { + const db = openDb(); + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = join(dataDir, `cuptrack.db.backup-manual-${timestamp}`); + await db.backup(backupPath); + console.log(`Backup created: ${backupPath}`); + } finally { + db.close(); + } +} + +// Parse CLI arguments +const args = process.argv.slice(2); +const command = args[0]; + +switch (command) { + case 'status': + await cmdStatus(); + break; + case 'run': { + const versionIdx = args.indexOf('--version'); + const maxVersion = versionIdx !== -1 ? parseInt(args[versionIdx + 1], 10) : null; + await cmdRun(maxVersion); + break; + } + case 'backup': + await cmdBackup(); + break; + default: + console.log(`CupTrack Migration CLI\n`); + console.log('Usage:'); + console.log(' node backend/src/migrations/cli.js status Show migration status'); + console.log(' node backend/src/migrations/cli.js run Run all pending migrations'); + console.log(' node backend/src/migrations/cli.js run --version N Run up to version N'); + console.log(' node backend/src/migrations/cli.js backup Create database backup'); + process.exit(1); +} diff --git a/backend/src/migrations/runner.js b/backend/src/migrations/runner.js new file mode 100644 index 0000000..b4e47fb --- /dev/null +++ b/backend/src/migrations/runner.js @@ -0,0 +1,285 @@ +import { readdirSync, unlinkSync, statSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SCRIPTS_DIR = join(__dirname, 'scripts'); +const MAX_BACKUPS = 5; + +/** + * Ensure the schema_migrations table exists (idempotent). + */ +function ensureMigrationsTable(db) { + db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('auto','manual')), + executedAt TEXT NOT NULL, + executedBy TEXT + )`); +} + +/** + * Load all migration scripts from the scripts/ directory, sorted by version. + */ +function loadMigrationScripts() { + let files; + try { + files = readdirSync(SCRIPTS_DIR).filter(f => /^\d{3}-.+\.js$/.test(f)).sort(); + } catch { + return []; + } + + const migrations = []; + for (const file of files) { + // Dynamic import is async, so we use a sync approach: + // Migration files must use module.exports (CJS-compatible) or default export + // We'll load them via dynamic import in the async wrapper + migrations.push({ file, path: join(SCRIPTS_DIR, file) }); + } + return migrations; +} + +/** + * Get the current schema version from the database. + */ +function getCurrentVersion(db) { + const row = db.prepare('SELECT MAX(version) as version FROM schema_migrations').get(); + return row?.version || 0; +} + +/** + * Get all applied migrations. + */ +function getAppliedMigrations(db) { + return db.prepare('SELECT * FROM schema_migrations ORDER BY version ASC').all(); +} + +/** + * Create a backup of the database before running migrations. + * Returns the backup file path. + */ +async function createBackup(db, version) { + const dataDir = dirname(db.name); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = join(dataDir, `cuptrack.db.backup-v${version}-${timestamp}`); + + await db.backup(backupPath); + + // Clean up old backups (keep MAX_BACKUPS most recent) + try { + const backups = readdirSync(dataDir) + .filter(f => f.startsWith('cuptrack.db.backup-')) + .map(f => ({ name: f, path: join(dataDir, f), mtime: statSync(join(dataDir, f)).mtimeMs })) + .sort((a, b) => b.mtime - a.mtime); + + for (const old of backups.slice(MAX_BACKUPS)) { + unlinkSync(old.path); + } + } catch { + // Cleanup failure is non-critical + } + + return backupPath; +} + +/** + * Get migration status — current version, applied, and pending migrations. + */ +export async function getStatus(db) { + ensureMigrationsTable(db); + const currentVersion = getCurrentVersion(db); + const applied = getAppliedMigrations(db); + const scriptFiles = loadMigrationScripts(); + + const pending = []; + for (const { file, path } of scriptFiles) { + const mod = await import(path); + const migration = mod.default || mod; + if (migration.version > currentVersion) { + pending.push({ + version: migration.version, + name: migration.name, + type: migration.type, + description: migration.description, + breaking: migration.breaking || null, + adminAction: migration.adminAction || null, + params: migration.params || null, + }); + } + } + + return { currentVersion, applied, pending }; +} + +/** + * Run all pending auto migrations. Stops before the first manual migration. + * Returns { success, currentVersion, pendingManual, error, backupPath }. + */ +export async function runMigrations(db, logger = console) { + ensureMigrationsTable(db); + const currentVersion = getCurrentVersion(db); + const scriptFiles = loadMigrationScripts(); + + const pending = []; + for (const { file, path } of scriptFiles) { + const mod = await import(path); + const migration = mod.default || mod; + if (migration.version > currentVersion) { + pending.push(migration); + } + } + + if (pending.length === 0) { + return { success: true, currentVersion, pendingManual: null, error: null }; + } + + // Sort by version + pending.sort((a, b) => a.version - b.version); + + let lastVersion = currentVersion; + + for (const migration of pending) { + // Stop at first manual migration + if (migration.type === 'manual') { + const remainingManual = pending.filter(m => m.version >= migration.version && m.type === 'manual'); + return { + success: true, + currentVersion: lastVersion, + pendingManual: remainingManual.map(m => ({ + version: m.version, + name: m.name, + type: m.type, + description: m.description, + breaking: m.breaking || null, + adminAction: m.adminAction || null, + params: m.params || null, + })), + error: null, + }; + } + + // Validate before running + if (migration.validate) { + const validation = migration.validate(db); + if (!validation.ok) { + return { + success: false, + currentVersion: lastVersion, + pendingManual: null, + error: `Migration ${migration.version} (${migration.name}) validation failed: ${validation.message}`, + }; + } + } + + // Create backup before first migration + let backupPath; + if (lastVersion === currentVersion) { + try { + backupPath = await createBackup(db, migration.version); + logger.info(`Backup created: ${backupPath}`); + } catch (err) { + return { + success: false, + currentVersion: lastVersion, + pendingManual: null, + error: `Backup failed before migration ${migration.version}: ${err.message}`, + }; + } + } + + // Run migration in transaction + try { + const runInTransaction = db.transaction(() => { + migration.up(db); + db.prepare( + 'INSERT INTO schema_migrations (version, name, type, executedAt, executedBy) VALUES (?, ?, ?, ?, ?)', + ).run(migration.version, migration.name, migration.type, new Date().toISOString(), 'system'); + }); + runInTransaction(); + lastVersion = migration.version; + logger.info(`Migration ${migration.version} (${migration.name}) applied successfully.`); + } catch (err) { + return { + success: false, + currentVersion: lastVersion, + pendingManual: null, + error: `Migration ${migration.version} (${migration.name}) failed: ${err.message}`, + backupPath, + }; + } + } + + return { success: true, currentVersion: lastVersion, pendingManual: null, error: null }; +} + +/** + * Run a specific manual migration by version number. + * Used by the admin API and CLI tool. + */ +export async function runManualMigration(db, version, params = {}, executedBy = 'admin', logger = console) { + ensureMigrationsTable(db); + const scriptFiles = loadMigrationScripts(); + + let migration; + for (const { file, path } of scriptFiles) { + const mod = await import(path); + const m = mod.default || mod; + if (m.version === version) { + migration = m; + break; + } + } + + if (!migration) { + return { success: false, error: `Migration version ${version} not found.` }; + } + + if (migration.type !== 'manual') { + return { success: false, error: `Migration ${version} is not a manual migration.` }; + } + + // Check it hasn't been applied already + const existing = db.prepare('SELECT 1 FROM schema_migrations WHERE version = ?').get(version); + if (existing) { + return { success: false, error: `Migration ${version} has already been applied.` }; + } + + // Check all prior migrations are applied + const currentVersion = getCurrentVersion(db); + if (migration.version !== currentVersion + 1) { + return { success: false, error: `Migration ${version} cannot run. Current version is ${currentVersion}, expected ${migration.version - 1}.` }; + } + + // Validate + if (migration.validate) { + const validation = migration.validate(db); + if (!validation.ok) { + return { success: false, error: `Validation failed: ${validation.message}` }; + } + } + + // Backup + let backupPath; + try { + backupPath = await createBackup(db, version); + logger.info(`Backup created: ${backupPath}`); + } catch (err) { + return { success: false, error: `Backup failed: ${err.message}` }; + } + + // Run in transaction + try { + const runInTransaction = db.transaction(() => { + migration.up(db, params); + db.prepare( + 'INSERT INTO schema_migrations (version, name, type, executedAt, executedBy) VALUES (?, ?, ?, ?, ?)', + ).run(migration.version, migration.name, migration.type, new Date().toISOString(), executedBy); + }); + runInTransaction(); + logger.info(`Manual migration ${migration.version} (${migration.name}) applied by ${executedBy}.`); + return { success: true, version: migration.version, name: migration.name, backupPath }; + } catch (err) { + return { success: false, error: `Migration ${version} failed: ${err.message}`, backupPath }; + } +} diff --git a/backend/src/migrations/scripts/001-baseline.js b/backend/src/migrations/scripts/001-baseline.js new file mode 100644 index 0000000..282abb7 --- /dev/null +++ b/backend/src/migrations/scripts/001-baseline.js @@ -0,0 +1,26 @@ +/** + * Migration 001 — Baseline + * + * Marks existing databases as version 1. No schema changes. + * This is the anchor point for all future migrations. + */ +export default { + version: 1, + name: 'baseline', + type: 'auto', + description: 'Baseline migration — marks the initial schema as version 1.', + + validate(db) { + const usersTable = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='users'", + ).get(); + if (!usersTable) { + return { ok: false, message: 'Table "users" does not exist. Database not initialized.' }; + } + return { ok: true, message: 'Database initialized, ready for baseline.' }; + }, + + up(/* db */) { + // No schema changes — this migration only records that version 1 is established. + }, +}; 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/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/migrations.js b/backend/src/routes/migrations.js new file mode 100644 index 0000000..ef111d3 --- /dev/null +++ b/backend/src/routes/migrations.js @@ -0,0 +1,81 @@ +import { Router } from 'express'; +import { authenticateToken, requireAdmin } from '../middleware/auth.js'; +import db, { isMaintenanceMode, getPendingManualMigrations, exitMaintenanceMode } from '../db.js'; +import { getStatus, runManualMigration } from '../migrations/runner.js'; + +const router = Router(); + +// All migration routes require admin auth +router.use(authenticateToken, requireAdmin); + +/** + * GET /api/admin/migrations + * Returns current migration status including pending migrations. + */ +router.get('/', async (_req, res, next) => { + try { + const status = await getStatus(db); + res.json({ + currentVersion: status.currentVersion, + maintenanceMode: isMaintenanceMode(), + applied: status.applied, + pending: status.pending, + }); + } catch (err) { + next(err); + } +}); + +/** + * POST /api/admin/migrations/run + * Run a specific manual migration. Only root users can trigger this. + * Body: { version: number, confirm: true, params?: object } + */ +router.post('/run', async (req, res, next) => { + try { + if (!req.user.isRoot) { + return res.status(403).json({ error: 'Only the root user can run migrations.' }); + } + + const { version, confirm, params } = req.body; + + if (!version || !confirm) { + return res.status(400).json({ error: 'Fields "version" and "confirm: true" are required.' }); + } + + const result = await runManualMigration( + db, + version, + params || {}, + req.user.username, + ); + + if (!result.success) { + return res.status(400).json({ + error: result.error, + backupPath: result.backupPath || null, + }); + } + + // Check if there are still pending manual migrations + const status = await getStatus(db); + const stillPendingManual = status.pending.filter(m => m.type === 'manual'); + + if (stillPendingManual.length === 0) { + exitMaintenanceMode(); + } + + res.json({ + success: true, + version: result.version, + name: result.name, + backupPath: result.backupPath, + maintenanceMode: isMaintenanceMode(), + remainingPending: status.pending, + }); + } catch (err) { + next(err); + } +}); + +export default router; 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..30f60dd 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: {} }; + const archived = archivedStats.get(); - // Total coffees (current + archived) - const totalCoffees = coffeeLogs.length + archived.totalCoffees; + const localDate = new Intl.DateTimeFormat('en-CA'); - // 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; + // 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 + 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 = localDate.format(startDate); + + 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 }); + const dayStr = localDate.format(date); + 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..4faaa4c 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, changePinSchema, registerUserSchema } 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: { @@ -24,82 +22,89 @@ 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 } : 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' }); - } +router.post('/:slug/verify-nfc', (req, res, next) => { + try { + const { serialNumber } = verifyNfcSchema.parse(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' }); + const terminal = terminals.findBySlug(req.params.slug); + if (!terminal) return res.status(404).json({ error: 'Terminal nicht gefunden' }); - // Normalize: lowercase, trimmed - const normalized = serialNumber.toLowerCase().trim(); + const user = users.findByNfcSerial(serialNumber); + if (!user) return res.status(404).json({ error: 'Kein Benutzer mit dieser NFC-Seriennummer 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 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' }, - ); + const totalCoffees = logs.countCoffeesForUser(user.id); - 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, totalCoffees }, + }); + } 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' }, + ); + + const totalCoffees = logs.countCoffeesForUser(user.id); + + res.json({ + success: true, + sessionToken, + user: { id: user.id, displayName: user.displayName, balance: user.balance, totalCoffees }, + }); + } 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 +112,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 +139,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 +157,192 @@ 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(); - db.data.logs.push({ + users.update(user.id, { balance: newBalance, updatedAt: now }); + + 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; +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); + } + + const now = new Date().toISOString(); + users.update(user.id, { balance: newBalance, updatedAt: now }); + + logs.create({ + id: uuidv4(), + type: 'balance', + userId: user.id, + machineId: null, + terminalId: terminal.id, + details: { oldBalance, newBalance, 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) { + cashBookAmount = Math.round(Math.abs(oldBalance) * 100) / 100; + } else if (mode !== 'reset') { + cashBookAmount = Math.round(parseFloat(amount) * 100) / 100; + } - const { user, terminal } = session; - const { amount, mode } = req.body; + if (cashBookAmount > 0) { + cashBook.create({ + id: uuidv4(), + type: 'deposit', + amount: cashBookAmount, + comment: '', + machineId: null, + terminalId: terminal.id, + performedBy: user.id, + createdAt: now, + }); + } - const oldBalance = user.balance; + res.json({ success: true, newBalance }); + } catch (err) { + next(err); + } +}); - 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' }); +// 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' }); } - user.balance += parseFloat(amount); + + 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); } +}); - user.updatedAt = new Date().toISOString(); +// Change PIN (requires active session) +router.post('/:slug/change-pin', (req, res, next) => { + try { + const session = verifySession(req, res); + if (!session) return; - const now = new Date().toISOString(); + const { terminal, user } = session; - 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, - }); + if (!terminal.pinChangeEnabled) { + return res.status(403).json({ error: 'PIN-Änderung an diesem Terminal nicht aktiviert' }); + } - // 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; + 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 (cashBookAmount > 0) { - if (!db.data.cashBook) db.data.cashBook = []; - db.data.cashBook.push({ + 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(), - type: 'deposit', - amount: cashBookAmount, - comment: '', + 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, - performedBy: user.id, + details: { username, displayName }, createdAt: now, }); - } - await db.write(); - res.json({ success: true, newBalance: user.balance }); + 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 128b7da..37c25e8 100644 --- a/backend/src/routes/terminals.js +++ b/backend/src/routes/terminals.js @@ -1,118 +1,120 @@ 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.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)) { + 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..ff94fc2 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -2,144 +2,147 @@ 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, pin: providedPin } = 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 (user.isRoot) { - return res.status(403).json({ error: 'Root-Benutzer kann nicht über die UI bearbeitet werden' }); - } + 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' }); + } - const { displayName, password, balance, identifiers } = req.body; + const identifiers = []; + + // Use provided PIN or auto-generate a unique one for drinkers + if (type === 'drinker') { + let 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 }); + } - 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 +152,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..ead6081 --- /dev/null +++ b/backend/src/schema.sql @@ -0,0 +1,104 @@ +-- 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 +); + +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('auto', 'manual')), + executedAt TEXT NOT NULL, + executedBy TEXT NOT NULL DEFAULT 'system' +); diff --git a/backend/src/validators/index.js b/backend/src/validators/index.js new file mode 100644 index 0000000..7f4a482 --- /dev/null +++ b/backend/src/validators/index.js @@ -0,0 +1,116 @@ +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' }), + pin: z.string().regex(/^[0-9]{4}$/, 'PIN muss genau 4 Ziffern haben').optional(), +}); + +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(), + }).refine( + (ident) => ident.type !== 'pin' || /^[0-9]{4}$/.test(ident.value), + { message: 'PIN muss genau 4 Ziffern haben' } + )).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(), + 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), +}); + +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/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 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 +``` diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..5a8d222 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,336 @@ +# CupTrack — Database Migrations + +This document describes the database migration system for CupTrack. It covers how migrations work, how to update a production system, how to create new migrations as a developer, and how to troubleshoot problems. + +--- + +## Overview + +CupTrack uses SQLite (via `better-sqlite3`) as its database. The migration system ensures that database schema changes are applied automatically or with admin confirmation when updating to a new version. + +**Two types of migrations:** + +| Type | Runs when | Admin action required | Use case | +|------|-----------|----------------------|----------| +| `auto` | Automatically on server start | No | Additive changes: new tables, new columns, new indexes | +| `manual` | Admin must confirm via Dashboard or CLI | Yes | Breaking changes: restructured tables, removed fields, incompatible data transformations | + +--- + +## For Administrators + +### Updating CupTrack (Docker) + +#### Normal update (automatic migrations only) + +```bash +git pull # or pull new image +docker compose build +docker compose up -d +``` + +The server runs all pending `auto` migrations on startup. No further action needed. + +#### Update with manual migrations (breaking changes) + +```bash +git pull +docker compose build +docker compose up -d +``` + +The server starts in **Maintenance Mode**: +- Auto migrations are applied automatically +- Terminals receive HTTP 503 (service unavailable) +- Only admin login and migration endpoints are accessible + +**Via Dashboard:** +1. Log in as root admin +2. A warning banner shows pending migrations with details +3. Review what changes and what breaks +4. Click "Run migration" and confirm +5. After completion, the server exits maintenance mode automatically + +**Via CLI (useful for headless deployments like Raspberry Pi):** + +```bash +# Check status +docker compose exec cuptrack node backend/src/migrations/cli.js status + +# Run all pending migrations (auto + manual) +docker compose exec cuptrack node backend/src/migrations/cli.js run + +# Run up to a specific version +docker compose exec cuptrack node backend/src/migrations/cli.js run --version 3 +``` + +### Rollback + +If a migration fails or causes issues: + +1. Stop the server: `docker compose down` +2. Restore from backup: + ```bash + # List available backups + ls backend/data/cuptrack.db.backup-* + + # Restore (pick the most recent one before the failed migration) + cp backend/data/cuptrack.db.backup-v2-2026-03-26T10-00-00-000Z backend/data/cuptrack.db + ``` +3. Revert to previous code version: `git checkout v0.x.x` +4. Rebuild and start: `docker compose build && docker compose up -d` + +### Backups + +The migration system creates automatic backups before each migration run: +- Location: `backend/data/cuptrack.db.backup-v{VERSION}-{TIMESTAMP}` +- Up to 5 backups are kept; older ones are deleted automatically + +To create a manual backup: +```bash +docker compose exec cuptrack node backend/src/migrations/cli.js backup +``` + +### CLI Reference + +```bash +# All commands (inside container or local dev) +node backend/src/migrations/cli.js status # Show schema version and pending migrations +node backend/src/migrations/cli.js run # Run all pending migrations +node backend/src/migrations/cli.js run --version N # Run migrations up to version N +node backend/src/migrations/cli.js backup # Create manual backup +``` + +--- + +## For Developers + +### Migration File Location + +``` +backend/src/migrations/ +├── runner.js # Migration engine (do not modify) +├── cli.js # CLI tool (do not modify) +└── scripts/ + ├── 001-baseline.js # Baseline (version 1) + ├── 002-add-feature-x.js # Example auto migration + └── 003-restructure-y.js # Example manual migration +``` + +### Creating a New Migration + +1. Create a new file in `backend/src/migrations/scripts/` with the next version number: + - Format: `NNN-descriptive-name.js` (e.g., `002-add-machine-status.js`) + - Version numbers must be sequential and unique + +2. Use the migration interface: + +```javascript +export default { + // Required fields + version: 2, // Sequential integer, must match filename prefix + name: 'add-machine-status', // Technical name (kebab-case) + type: 'auto', // 'auto' or 'manual' + description: 'Adds status field to machines table for maintenance tracking.', + + // Required for type: 'manual' only + breaking: [ // Array of strings describing what breaks + 'API field machines.status is now required in responses', + 'Terminal firmware < v2.0 cannot read status field', + ], + adminAction: 'Update all terminal firmware to v2.0+ after migration.', + + // Optional: Parameters the admin must provide before running + params: [ + { + key: 'defaultStatus', + label: 'Default status for existing machines', + type: 'string', + default: 'active', + }, + ], + + // Required: Pre-migration validation + validate(db) { + const table = db.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='machines'" + ).get(); + if (!table) { + return { ok: false, message: 'Table "machines" does not exist.' }; + } + return { ok: true, message: 'Ready.' }; + }, + + // Required: The migration logic + up(db, params) { + // params is an object with values from the params array above (manual only) + db.exec(`ALTER TABLE machines ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`); + }, +}; +``` + +### Migration Interface Reference + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `version` | `number` | Yes | Sequential integer, matches filename prefix | +| `name` | `string` | Yes | Technical name (kebab-case) | +| `type` | `'auto' \| 'manual'` | Yes | Auto = runs on startup; Manual = needs admin confirmation | +| `description` | `string` | Yes | Human-readable description of what the migration does | +| `breaking` | `string[] \| null` | Manual only | Changes that break existing functionality | +| `adminAction` | `string \| null` | Manual only | Actions the admin must take outside the database | +| `params` | `array \| null` | Optional | Input fields the admin fills before running | +| `validate(db)` | `function` | Yes | Returns `{ ok: boolean, message: string }` | +| `up(db, params?)` | `function` | Yes | Performs the actual schema/data changes | + +### When to Use `type: 'manual'` + +Use manual migrations when: +- Removing or renaming columns/tables +- Changing data types of existing columns +- Restructuring data that external systems depend on +- Any change that requires the admin to update external systems (API clients, terminal firmware, etc.) + +Use auto migrations when: +- Adding new tables +- Adding new columns with defaults +- Adding indexes +- Data backfills that don't change existing behavior + +### SQLite-Specific Considerations + +**Adding a column:** +```javascript +up(db) { + db.exec(`ALTER TABLE machines ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`); +} +``` + +**Renaming/restructuring a table (12-step ALTER TABLE):** +```javascript +up(db) { + // SQLite cannot alter column types or constraints directly. + // Use the 12-step process: create new → copy → drop old → rename new + db.pragma('foreign_keys = OFF'); + + db.exec(` + CREATE TABLE machines_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + + INSERT INTO machines_new (id, name, createdAt, updatedAt) + SELECT id, name, createdAt, updatedAt FROM machines; + + DROP TABLE machines; + + ALTER TABLE machines_new RENAME TO machines; + `); + + db.pragma('foreign_keys = ON'); + + // Verify foreign key integrity after re-enabling + const fkCheck = db.pragma('foreign_key_check'); + if (fkCheck.length > 0) { + throw new Error('Foreign key integrity violated after table rebuild'); + } +} +``` + +**Important:** When using `PRAGMA foreign_keys = OFF`, always re-enable and verify with `PRAGMA foreign_key_check` at the end. + +### Version Number Convention + +- Version numbers are sequential integers starting at 1 +- The baseline migration is always version 1 +- When working on feature branches, use a placeholder version number +- **Assign the final version number when merging into `main`** to avoid conflicts + +### Testing Migrations + +1. Start with a fresh database: delete `backend/data/cuptrack.db` +2. Run the server — all migrations should apply from baseline +3. Check the CLI status: `node backend/src/migrations/cli.js status` +4. Test with an existing database: verify only new migrations are applied +5. Test validation: intentionally create a bad state and verify `validate()` catches it + +--- + +## API Endpoints (Admin) + +### `GET /api/admin/migrations` + +Returns the current migration status. Requires JWT + admin role. + +**Response:** +```json +{ + "currentVersion": 2, + "maintenanceMode": false, + "applied": [ + { "version": 1, "name": "baseline", "type": "auto", "executedAt": "2026-03-26T10:00:00.000Z", "executedBy": "system" }, + { "version": 2, "name": "add-machine-status", "type": "auto", "executedAt": "2026-03-26T10:00:01.000Z", "executedBy": "system" } + ], + "pending": [ + { "version": 3, "name": "restructure-identifiers", "type": "manual", "description": "...", "breaking": ["..."], "adminAction": "...", "params": null } + ] +} +``` + +### `POST /api/admin/migrations/run` + +Runs a specific manual migration. Requires JWT + root user. + +**Request:** +```json +{ + "version": 3, + "confirm": true, + "params": {} +} +``` + +**Response:** +```json +{ + "success": true, + "version": 3, + "name": "restructure-identifiers", + "backupPath": "backend/data/cuptrack.db.backup-v3-2026-03-26T10-00-00-000Z", + "maintenanceMode": false, + "remainingPending": [] +} +``` + +--- + +## Troubleshooting + +### Server won't start after update + +**Symptom:** Server exits with `FATAL: Database migration failed: ...` + +**Solution:** +1. Check the error message — it tells you which migration failed and why +2. If a backup path is mentioned, you can restore it (see Rollback section) +3. Check the migration's `validate()` function — it may have failed a precondition + +### Maintenance mode won't end + +**Symptom:** Dashboard shows migration banner but no "Run migration" button + +**Solution:** Only the root user can run migrations. Log in with the root account. + +### Migration applied but data seems wrong + +**Solution:** +1. Stop the server +2. Restore the backup (see Rollback section above) +3. Report the issue — the migration may have a bug + +### CLI shows "no pending migrations" but server is in maintenance mode + +**Solution:** Restart the server. The maintenance mode flag is in-memory and is re-evaluated on startup. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index f3bed35..20194a4 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { User, Machine, Terminal, LogEntry, DashboardStats, TerminalInfo, CashBookEntry } from './types'; +import type { User, Machine, Terminal, LogEntry, DashboardStats, TerminalInfo, CashBookEntry, MigrationStatus } from './types'; const BASE = '/api'; @@ -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 }), @@ -161,4 +161,40 @@ export const api = { `/terminal-actions/${encodeURIComponent(slug)}/anonymous-coffee`, { 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) => + request<{ + success: boolean; + version: number; + name: string; + backupPath: string; + maintenanceMode: boolean; + remainingPending: MigrationStatus['pending']; + }>('/admin/migrations/run', { + method: 'POST', + body: JSON.stringify({ version, confirm, params }), + }), }; diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index ed12f53..977fae5 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -14,6 +14,14 @@ import { Divider, useMediaQuery, useTheme, + Alert, + AlertTitle, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + CircularProgress, } from '@mui/material'; import MenuIcon from '@mui/icons-material/Menu'; import DashboardIcon from '@mui/icons-material/Dashboard'; @@ -24,9 +32,11 @@ import LogoutIcon from '@mui/icons-material/Logout'; import SettingsIcon from '@mui/icons-material/Settings'; import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import { useTranslation } from 'react-i18next'; import { useAuth } from '../contexts/AuthContext'; import { api } from '../api'; +import type { MigrationStatus, PendingMigration } from '../types'; const DRAWER_WIDTH = 240; const DRAWER_WIDTH_COLLAPSED = 64; @@ -38,14 +48,45 @@ export default function DashboardLayout() { const [open, setOpen] = useState(!isMobile); const [mobileOpen, setMobileOpen] = useState(false); const [versionInfo, setVersionInfo] = useState<{ version: string; codeName: string } | null>(null); + const [migrationStatus, setMigrationStatus] = useState(null); + const [migrationDialogOpen, setMigrationDialogOpen] = useState(false); + const [selectedMigration, setSelectedMigration] = useState(null); + const [migrationRunning, setMigrationRunning] = useState(false); + const [migrationError, setMigrationError] = useState(null); const navigate = useNavigate(); const location = useLocation(); const { user, logout } = useAuth(); useEffect(() => { api.getVersion().then((v) => setVersionInfo({ version: v.version, codeName: v.codeName })).catch(() => {}); + api.getMigrationStatus().then(setMigrationStatus).catch(() => {}); }, []); + const pendingManual = migrationStatus?.pending.filter(m => m.type === 'manual') ?? []; + + const handleRunMigration = async () => { + if (!selectedMigration) return; + setMigrationRunning(true); + setMigrationError(null); + try { + const result = await api.runMigration(selectedMigration.version, true); + if (result.success) { + setMigrationDialogOpen(false); + setSelectedMigration(null); + // Refresh status + const status = await api.getMigrationStatus(); + setMigrationStatus(status); + if (!status.maintenanceMode) { + window.location.reload(); + } + } + } catch (err) { + setMigrationError(err instanceof Error ? err.message : String(err)); + } finally { + setMigrationRunning(false); + } + }; + const menuItems = [ { text: t('nav.dashboard'), icon: , path: '/dashboard' }, { text: t('nav.users'), icon: , path: '/dashboard/users' }, @@ -227,7 +268,81 @@ export default function DashboardLayout() { /> )} + {pendingManual.length > 0 && ( + } + sx={{ mb: 2 }} + action={ + user?.isRoot ? ( + + ) : 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 3c2c5cd..9941a6c 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", @@ -70,6 +71,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" @@ -103,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:", @@ -120,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...", @@ -131,7 +137,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", @@ -141,7 +162,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", @@ -183,5 +206,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 c62dc20..7b2d112 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", @@ -70,6 +71,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" @@ -103,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:", @@ -120,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...", @@ -131,7 +137,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", @@ -141,7 +162,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", @@ -183,5 +206,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/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/Users.tsx b/frontend/src/pages/Users.tsx index 01e9723..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')} + /> )} @@ -548,9 +567,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') : '', + })} /> (); @@ -50,8 +56,8 @@ export default function TerminalView() { ); const [sessionToken, setSessionToken] = useState(''); const [balance, setBalance] = useState(0); + const [totalCoffees, setTotalCoffees] = 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'); @@ -61,6 +67,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(() => {}); }, []); @@ -100,12 +116,20 @@ export default function TerminalView() { setPinStatus('idle'); setSessionToken(''); setBalance(0); + setTotalCoffees(0); setNewBalance(''); - setUserSearch(''); setAlphabetFilter(null); setNfcScanning(false); setNfcStatus('idle'); setNfcError(''); + setNewPin(''); + setNewPinStatus('idle'); + setNewUserUsername(''); + setNewUserDisplayName(''); + setNewUserUsernameError(''); + setNewUserDisplayNameError(''); + setNewUserFormLoading(false); + setNewUserActionError(''); loadInfo(); }, [loadInfo]); @@ -139,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 { @@ -168,7 +193,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); } @@ -193,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'); @@ -263,6 +289,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 ( @@ -295,21 +371,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 +394,16 @@ export default function TerminalView() { {info.machine.room} )} - + )} - + {t('terminalView.selectName')} {nfcSupported && ( - + + + {info.terminal.selfRegistrationEnabled && ( + + )} ); @@ -565,8 +654,8 @@ export default function TerminalView() { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 1.5, - maxWidth: 300, mx: 'auto', + width: '100%', }} > {['1', '2', '3', '4', '5', '6', '7', '8', '9', '', '0', 'back'].map( @@ -618,7 +707,7 @@ export default function TerminalView() { = 0 ? '#E8F5E9' : '#FFEBEE', @@ -636,6 +725,23 @@ export default function TerminalView() { + + + {t('terminalView.totalCoffeesLabel')} + + + {totalCoffees} + + + + {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; } @@ -942,15 +1452,16 @@ function TerminalWrapper({ children, codeName, terminalName, connected = true }: return ( {children} - + {terminalName && ( - + {terminalName} )} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8a2a551..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; @@ -87,3 +91,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..1f3bcab 100644 --- a/version.json +++ b/version.json @@ -2,5 +2,6 @@ "major": 0, "minor": 3, "patch": 0, - "codeName": "Cold Coffee" + "codeName": "Cold Coffee", + "schemaVersion": 2 }