Major refactor: auth, bug fixes, security hardening, modular routes

Bug fixes
- Sync database.js schema with actual production DB (tarifoptionen
  shape was wrong; new installs would have broken).
- Fix SQL syntax error `SELECT to.*` (reserved keyword) in single-
  schaltung provision endpoint, plus the dead fallback hierarchy
  that selected columns that were never in the query.
- Fix PUT /api/tarifoptionen/:id writing to non-existent column
  provision_betrag (should be provision_euro).
- Fix race condition in PUT /api/schaltungen/:id where forEach with
  async db.run inside a transaction could send duplicate responses.
- Delete dokumente rows explicitly before deleting schaltung
  (existing DBs lack ON DELETE CASCADE on dokumente FK).
- Replace hardcoded basisBetrag=100 for percent rules; basis is now
  configurable per rule via the new basis_euro column.

Security
- Multi-user authentication: users table, bcryptjs, express-session
  with SQLite store, login/logout/me/setup/change-password endpoints.
- Auth gate on all /api/* except /api/auth/*.
- CLI to create users: npm run create-user -- <user> <pass> [--admin].
- helmet for security headers.
- CORS restricted to ALLOWED_ORIGINS env (default same-origin).
- Rate limits: 300/min global, 20/15min on login.
- Path-traversal validation on document download/delete.
- XSS hardening in frontend: escapeHtml helper applied to all
  DB-derived innerHTML interpolations; inline onclick handlers that
  embedded user-controlled strings replaced with data-* attrs +
  addEventListener.

Architecture
- Provision calculation centralised in provisionService.js as the
  single source of truth (was duplicated and inconsistent).
- server.js cut from 2138 to ~140 lines; routes split into per-
  resource modules under routes/.
- Promise wrapper db.js for sqlite3 callbacks; async/await in routes.
- middleware/ for auth and async error handling.

Tests
- 14 unit tests for provisionService covering fallback hierarchy,
  percent calculation, tarifoptionen, hardware logic, employee
  commission. All green.

Config
- dotenv loads .env.local at startup (gitignored).
- .gitignore extended for sessions.db and .env*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kerimatilganandClaude Opus 4.7 committed 2026-05-14 21:43:19 +02:00
1 parent 55ec916f35
commit 4303b00b9b
23 files changed
+2427 -2575

No files matched your search

+94 -138
View File
@@ -3,10 +3,8 @@ const path = require('path');
const dbPath = path.join(__dirname, 'provision.db');
// Singleton database instance
let dbInstance = null;
// Get or create database instance (Singleton pattern)
function getDatabase() {
if (!dbInstance) {
dbInstance = new sqlite3.Database(dbPath, (err) => {
@@ -18,13 +16,11 @@ function getDatabase() {
console.log('Connected to SQLite database');
});
// Set busy timeout to handle concurrent access
dbInstance.configure('busyTimeout', 3000);
}
return dbInstance;
}
// Enable foreign keys for a database connection
function enableForeignKeys(db) {
db.run('PRAGMA foreign_keys = ON', (err) => {
if (err) {
@@ -33,7 +29,6 @@ function enableForeignKeys(db) {
});
}
// Close database connection (for graceful shutdown)
function closeDatabase() {
if (dbInstance) {
dbInstance.close((err) => {
@@ -47,18 +42,27 @@ function closeDatabase() {
}
}
// Best-effort ALTER TABLE: ignores "duplicate column" errors but logs others
function addColumnIfMissing(db, table, columnDef) {
db.run(`ALTER TABLE ${table} ADD COLUMN ${columnDef}`, (err) => {
if (err && !/duplicate column name/i.test(err.message)) {
console.error(`ALTER TABLE ${table} ADD ${columnDef}:`, err.message);
}
});
}
function initDatabase() {
const db = getDatabase();
db.serialize(() => {
// Anbieter (Providers) table
// Anbieter (Providers)
db.run(`CREATE TABLE IF NOT EXISTS anbieter (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Mitarbeiter-Prozentsätze pro Anbieter (can be changed retrospectively)
// Mitarbeiter-Prozentsätze pro Anbieter
db.run(`CREATE TABLE IF NOT EXISTS mitarbeiter_prozentsaetze (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anbieter_id INTEGER NOT NULL,
@@ -69,8 +73,8 @@ function initDatabase() {
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
UNIQUE(anbieter_id, gueltig_ab)
)`);
// Tarife (Tariffs) table - simplified with fixed euro amounts only
// Tarife
db.run(`CREATE TABLE IF NOT EXISTS tarife (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
@@ -82,18 +86,15 @@ function initDatabase() {
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
UNIQUE(name, anbieter_id)
)`);
// Add mitarbeiter_provision column if it doesn't exist (for existing databases)
db.run(`ALTER TABLE tarife ADD COLUMN mitarbeiter_provision DECIMAL(10,2) DEFAULT 0`, (err) => {
// Ignore error if column already exists
});
// Provision rules table (tariff-specific)
addColumnIfMissing(db, 'tarife', 'mitarbeiter_provision DECIMAL(10,2) DEFAULT 0');
// Tariff-specific commission rules. basis_euro: base amount for percent calculation.
db.run(`CREATE TABLE IF NOT EXISTS provisionsregeln (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tarif_id INTEGER NOT NULL,
provision_typ TEXT NOT NULL CHECK(provision_typ IN ('fix', 'prozent')),
betrag DECIMAL(10,2) NOT NULL,
basis_euro DECIMAL(10,2),
beschreibung TEXT,
verkaeufer_provision_typ TEXT CHECK(verkaeufer_provision_typ IN ('fix', 'prozent')),
verkaeufer_provision_betrag DECIMAL(10,2),
@@ -103,13 +104,15 @@ function initDatabase() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tarif_id) REFERENCES tarife (id)
)`);
// Global provider commission rules table
addColumnIfMissing(db, 'provisionsregeln', 'basis_euro DECIMAL(10,2)');
// Global provider commission rules
db.run(`CREATE TABLE IF NOT EXISTS anbieter_provisionsregeln (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anbieter_id INTEGER NOT NULL,
provision_typ TEXT NOT NULL CHECK(provision_typ IN ('fix', 'prozent')),
betrag DECIMAL(10,2) NOT NULL,
basis_euro DECIMAL(10,2),
beschreibung TEXT,
verkaeufer_provision_typ TEXT CHECK(verkaeufer_provision_typ IN ('fix', 'prozent')),
verkaeufer_provision_betrag DECIMAL(10,2),
@@ -119,8 +122,9 @@ function initDatabase() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id)
)`);
// Schaltungen (Connections/Commissions) table
addColumnIfMissing(db, 'anbieter_provisionsregeln', 'basis_euro DECIMAL(10,2)');
// Schaltungen
db.run(`CREATE TABLE IF NOT EXISTS schaltungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltungs_id TEXT NOT NULL UNIQUE,
@@ -139,16 +143,11 @@ function initDatabase() {
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
FOREIGN KEY (tarif_id) REFERENCES tarife (id)
)`);
addColumnIfMissing(db, 'schaltungen', 'hardware_ek DECIMAL(10,2)');
addColumnIfMissing(db, 'schaltungen', 'kundenverkaufspreis_netto DECIMAL(10,2)');
// Add new hardware pricing columns if they don't exist (for existing databases)
db.run(`ALTER TABLE schaltungen ADD COLUMN hardware_ek DECIMAL(10,2)`, (err) => {
// Ignore error if column already exists
});
db.run(`ALTER TABLE schaltungen ADD COLUMN kundenverkaufspreis_netto DECIMAL(10,2)`, (err) => {
// Ignore error if column already exists
});
// Dokumente (Documents) table
// Dokumente (note: ON DELETE CASCADE only applied to new DBs - existing DBs
// handled via explicit DELETE in server.js)
db.run(`CREATE TABLE IF NOT EXISTS dokumente (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltung_id INTEGER NOT NULL,
@@ -160,8 +159,8 @@ function initDatabase() {
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schaltung_id) REFERENCES schaltungen (id) ON DELETE CASCADE
)`);
// Deleted Schaltungen tracking table
// Soft-deleted schaltungen
db.run(`CREATE TABLE IF NOT EXISTS geloeschte_schaltungen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
original_id INTEGER NOT NULL,
@@ -181,39 +180,25 @@ function initDatabase() {
geloescht_am DATETIME DEFAULT CURRENT_TIMESTAMP,
geloescht_von TEXT DEFAULT 'System'
)`);
// Insert some default providers and tariffs for testing
db.run(`INSERT OR IGNORE INTO anbieter (name) VALUES
('Telekom'),
('Vodafone'),
('O2'),
('1&1')`);
db.run(`INSERT OR IGNORE INTO tarife (name, anbieter_id, provision_euro, beschreibung) VALUES
('MagentaMobil S', 1, 150.00, 'Telekom Basis-Tarif'),
('MagentaMobil M', 1, 200.00, 'Telekom Standard-Tarif'),
('MagentaMobil L', 1, 250.00, 'Telekom Premium-Tarif'),
('VF green LTE 10GB mit Smartphone 5', 2, 190.00, 'Vodafone Standard-Paket'),
('Red S', 2, 120.00, 'Vodafone Einsteiger-Tarif'),
('Red M', 2, 160.00, 'Vodafone Standard-Tarif'),
('Red L', 2, 200.00, 'Vodafone Premium-Tarif'),
('o2 Free S', 3, 100.00, 'O2 Basis-Tarif'),
('o2 Free M', 3, 140.00, 'O2 Standard-Tarif'),
('o2 Free L', 3, 180.00, 'O2 Premium-Tarif')`);
// Insert mitarbeiter prozentsätze for each provider
db.run(`INSERT OR IGNORE INTO mitarbeiter_prozentsaetze (anbieter_id, prozentsatz, beschreibung) VALUES
(1, 8.00, 'Telekom Mitarbeiter-Provision'),
(2, 10.00, 'Vodafone/Freenet Mitarbeiter-Provision'),
(3, 7.50, 'O2 Mitarbeiter-Provision'),
(4, 9.00, '1&1 Mitarbeiter-Provision')`);
// Insert some example global provider commission rules
db.run(`INSERT OR IGNORE INTO anbieter_provisionsregeln (anbieter_id, provision_typ, betrag, beschreibung) VALUES
(3, 'fix', 20.00, 'Standard O2 Provision'),
(4, 'prozent', 12.00, '12% Standard 1&1 Provision')`);
// Tarifoptionen - updated structure for multiple tarif support
// Tarifoptionen - schema matches production: anbieter_id required, tarif_id optional
// (NULL = anbieter-wide), unique per (anbieter_id, tarif_id, name).
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
anbieter_id INTEGER NOT NULL,
tarif_id INTEGER,
name TEXT NOT NULL,
code TEXT,
provision_euro DECIMAL(10,2) DEFAULT 0,
beschreibung TEXT,
aktiv BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
FOREIGN KEY (tarif_id) REFERENCES tarife (id),
UNIQUE(anbieter_id, tarif_id, name)
)`);
// M:N variant kept for the /multi endpoints
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
@@ -224,7 +209,6 @@ function initDatabase() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
// Many-to-many relationship between tarifoptionen and tarife
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_tarife (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tarifoption_id INTEGER NOT NULL,
@@ -235,21 +219,7 @@ function initDatabase() {
UNIQUE(tarifoption_id, tarif_id)
)`);
// Keep old table for migration compatibility
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tarif_id INTEGER NOT NULL,
name TEXT NOT NULL,
code TEXT,
provision_euro DECIMAL(10,2) DEFAULT 0,
beschreibung TEXT,
aktiv BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tarif_id) REFERENCES tarife (id),
UNIQUE(tarif_id, name)
)`);
// Schaltungen_Tarifoptionen (Many-to-Many relationship)
// Link table: which tarifoptionen are assigned to which schaltung
db.run(`CREATE TABLE IF NOT EXISTS schaltungen_tarifoptionen (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schaltung_id INTEGER NOT NULL,
@@ -259,75 +229,61 @@ function initDatabase() {
FOREIGN KEY (tarifoption_id) REFERENCES tarifoptionen (id),
UNIQUE(schaltung_id, tarifoption_id)
)`);
// Insert example tarifoptionen with simplified euro amounts (old format)
db.run(`INSERT OR IGNORE INTO tarifoptionen (tarif_id, name, code, provision_euro, beschreibung) VALUES
(1, 'Magenta Premium Option', 'MAG-PREM', 50.00, 'Premium-Zusatz für Telekom'),
(1, 'Aktionscode Neukunde', 'NEU2024', 30.00, 'Neukunden-Bonus'),
(4, '24 x 17€ MGP Rabatt GK A3958457', 'A3958457', -120.00, 'Vodafone Rabatt-Aktion'),
(5, 'Red M Aktion Q1', 'RED-M-Q1', 25.00, 'Quartals-Aktion'),
(6, 'Red L Premium Paket', 'RED-L-PREM', 35.00, 'Premium-Paket')`);
// Migration function to move data from old to new structure
function migrateToNewTarifoptionen() {
db.serialize(() => {
// Check if migration is needed
db.get(`SELECT COUNT(*) as count FROM tarifoptionen_new`, (err, row) => {
if (err || row.count > 0) return; // Already migrated or error
// Users for authentication
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
console.log('Migrating tarifoptionen to new structure...');
// Seed data (idempotent)
db.run(`INSERT OR IGNORE INTO anbieter (name) VALUES
('Telekom'),
('Vodafone'),
('O2'),
('1&1')`);
// Get all unique tarifoptionen from old table
db.all(`SELECT DISTINCT name, code, provision_euro, beschreibung, aktiv, created_at FROM tarifoptionen`, (err, oldOptions) => {
if (err || !oldOptions.length) return;
db.run(`INSERT OR IGNORE INTO tarife (name, anbieter_id, provision_euro, beschreibung) VALUES
('MagentaMobil S', 1, 150.00, 'Telekom Basis-Tarif'),
('MagentaMobil M', 1, 200.00, 'Telekom Standard-Tarif'),
('MagentaMobil L', 1, 250.00, 'Telekom Premium-Tarif'),
('VF green LTE 10GB mit Smartphone 5', 2, 190.00, 'Vodafone Standard-Paket'),
('Red S', 2, 120.00, 'Vodafone Einsteiger-Tarif'),
('Red M', 2, 160.00, 'Vodafone Standard-Tarif'),
('Red L', 2, 200.00, 'Vodafone Premium-Tarif'),
('o2 Free S', 3, 100.00, 'O2 Basis-Tarif'),
('o2 Free M', 3, 140.00, 'O2 Standard-Tarif'),
('o2 Free L', 3, 180.00, 'O2 Premium-Tarif')`);
oldOptions.forEach(option => {
// Insert unique tarifoption into new table
db.run(`INSERT OR IGNORE INTO tarifoptionen_new (name, code, provision_euro, beschreibung, aktiv, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[option.name, option.code, option.provision_euro, option.beschreibung, option.aktiv, option.created_at],
function(err) {
if (err) return;
db.run(`INSERT OR IGNORE INTO mitarbeiter_prozentsaetze (anbieter_id, prozentsatz, beschreibung) VALUES
(1, 8.00, 'Telekom Mitarbeiter-Provision'),
(2, 10.00, 'Vodafone/Freenet Mitarbeiter-Provision'),
(3, 7.50, 'O2 Mitarbeiter-Provision'),
(4, 9.00, '1&1 Mitarbeiter-Provision')`);
const newTarifoptionId = this.lastID;
db.run(`INSERT OR IGNORE INTO anbieter_provisionsregeln (anbieter_id, provision_typ, betrag, beschreibung) VALUES
(3, 'fix', 20.00, 'Standard O2 Provision'),
(4, 'prozent', 12.00, '12% Standard 1&1 Provision')`);
// Link this tarifoption to all tarifs it was originally assigned to
db.all(`SELECT tarif_id FROM tarifoptionen WHERE name = ? AND code = ?`,
[option.name, option.code],
(err, tarifs) => {
if (err) return;
// Seed tarifoptionen (correct schema: anbieter_id required)
db.run(`INSERT OR IGNORE INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung) VALUES
(1, 1, 'Magenta Premium Option', 'MAG-PREM', 50.00, 'Premium-Zusatz für Telekom'),
(1, 1, 'Aktionscode Neukunde', 'NEU2024', 30.00, 'Neukunden-Bonus'),
(2, 4, '24 x 17€ MGP Rabatt GK A3958457', 'A3958457', -120.00, 'Vodafone Rabatt-Aktion'),
(2, 5, 'Red M Aktion Q1', 'RED-M-Q1', 25.00, 'Quartals-Aktion'),
(2, 6, 'Red L Premium Paket', 'RED-L-PREM', 35.00, 'Premium-Paket')`);
tarifs.forEach(tarif => {
db.run(`INSERT OR IGNORE INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)`,
[newTarifoptionId, tarif.tarif_id]);
});
});
});
});
});
});
});
}
// Run migration only if not already done
db.get(`SELECT COUNT(*) as count FROM tarifoptionen_new`, (err, row) => {
if (!err && row && row.count === 0) {
migrateToNewTarifoptionen();
}
});
// Keep existing individual tariff rules for backward compatibility
db.run(`INSERT OR IGNORE INTO provisionsregeln (tarif_id, provision_typ, betrag, beschreibung) VALUES
(1, 'fix', 25.00, 'Feste Provision für MagentaMobil S'),
(2, 'fix', 35.00, 'Feste Provision für MagentaMobil M'),
(3, 'fix', 45.00, 'Feste Provision für MagentaMobil L'),
(4, 'prozent', 15.00, '15% Provision für Red S'),
(5, 'prozent', 18.00, '18% Provision für Red M'),
(6, 'prozent', 20.00, '20% Provision für Red L')`);
(3, 'fix', 45.00, 'Feste Provision für MagentaMobil L')`);
});
// Enable foreign keys AFTER all tables and initial data are created
enableForeignKeys(db);
}
module.exports = { initDatabase, getDatabase, closeDatabase };
module.exports = { initDatabase, getDatabase, closeDatabase };