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>
290 lines
12 KiB
JavaScript
290 lines
12 KiB
JavaScript
const sqlite3 = require('sqlite3').verbose();
|
|
const path = require('path');
|
|
|
|
const dbPath = path.join(__dirname, 'provision.db');
|
|
|
|
let dbInstance = null;
|
|
|
|
function getDatabase() {
|
|
if (!dbInstance) {
|
|
dbInstance = new sqlite3.Database(dbPath, (err) => {
|
|
if (err) {
|
|
console.error('Error opening database:', err.message);
|
|
dbInstance = null;
|
|
throw err;
|
|
}
|
|
console.log('Connected to SQLite database');
|
|
});
|
|
|
|
dbInstance.configure('busyTimeout', 3000);
|
|
}
|
|
return dbInstance;
|
|
}
|
|
|
|
function enableForeignKeys(db) {
|
|
db.run('PRAGMA foreign_keys = ON', (err) => {
|
|
if (err) {
|
|
console.error('Error enabling foreign keys:', err.message);
|
|
}
|
|
});
|
|
}
|
|
|
|
function closeDatabase() {
|
|
if (dbInstance) {
|
|
dbInstance.close((err) => {
|
|
if (err) {
|
|
console.error('Error closing database:', err.message);
|
|
} else {
|
|
console.log('Database connection closed');
|
|
}
|
|
dbInstance = null;
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
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
|
|
db.run(`CREATE TABLE IF NOT EXISTS mitarbeiter_prozentsaetze (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
anbieter_id INTEGER NOT NULL,
|
|
prozentsatz DECIMAL(5,2) NOT NULL DEFAULT 0,
|
|
gueltig_ab DATE DEFAULT CURRENT_DATE,
|
|
beschreibung TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
|
|
UNIQUE(anbieter_id, gueltig_ab)
|
|
)`);
|
|
|
|
// Tarife
|
|
db.run(`CREATE TABLE IF NOT EXISTS tarife (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
anbieter_id INTEGER,
|
|
provision_euro DECIMAL(10,2) DEFAULT 0,
|
|
mitarbeiter_provision DECIMAL(10,2) DEFAULT 0,
|
|
beschreibung TEXT,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id),
|
|
UNIQUE(name, anbieter_id)
|
|
)`);
|
|
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),
|
|
verkaeufer_provision_beschreibung TEXT,
|
|
gueltig_ab DATE DEFAULT CURRENT_DATE,
|
|
aktiv BOOLEAN DEFAULT 1,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (tarif_id) REFERENCES tarife (id)
|
|
)`);
|
|
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),
|
|
verkaeufer_provision_beschreibung TEXT,
|
|
gueltig_ab DATE DEFAULT CURRENT_DATE,
|
|
aktiv BOOLEAN DEFAULT 1,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (anbieter_id) REFERENCES anbieter (id)
|
|
)`);
|
|
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,
|
|
eingabe_datum DATE NOT NULL,
|
|
schaltungs_datum DATE NOT NULL,
|
|
vorname TEXT NOT NULL,
|
|
nachname TEXT NOT NULL,
|
|
firma TEXT,
|
|
rufnummer TEXT NOT NULL,
|
|
anbieter_id INTEGER NOT NULL,
|
|
tarif_id INTEGER NOT NULL,
|
|
hardware TEXT,
|
|
hardware_ek DECIMAL(10,2),
|
|
kundenverkaufspreis_netto DECIMAL(10,2),
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
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)');
|
|
|
|
// 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,
|
|
filename TEXT NOT NULL,
|
|
original_filename TEXT NOT NULL,
|
|
filepath TEXT NOT NULL,
|
|
filesize INTEGER,
|
|
mimetype TEXT,
|
|
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (schaltung_id) REFERENCES schaltungen (id) ON DELETE CASCADE
|
|
)`);
|
|
|
|
// Soft-deleted schaltungen
|
|
db.run(`CREATE TABLE IF NOT EXISTS geloeschte_schaltungen (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
original_id INTEGER NOT NULL,
|
|
schaltungs_id TEXT NOT NULL,
|
|
eingabe_datum DATE NOT NULL,
|
|
schaltungs_datum DATE NOT NULL,
|
|
vorname TEXT NOT NULL,
|
|
nachname TEXT NOT NULL,
|
|
firma TEXT,
|
|
rufnummer TEXT NOT NULL,
|
|
anbieter_id INTEGER NOT NULL,
|
|
tarif_id INTEGER NOT NULL,
|
|
hardware TEXT,
|
|
anbieter_name TEXT NOT NULL,
|
|
tarif_name TEXT NOT NULL,
|
|
loeschgrund TEXT NOT NULL,
|
|
geloescht_am DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
geloescht_von TEXT DEFAULT 'System'
|
|
)`);
|
|
|
|
// 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,
|
|
code TEXT,
|
|
provision_euro DECIMAL(10,2) DEFAULT 0,
|
|
beschreibung TEXT,
|
|
aktiv BOOLEAN DEFAULT 1,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
)`);
|
|
|
|
db.run(`CREATE TABLE IF NOT EXISTS tarifoptionen_tarife (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
tarifoption_id INTEGER NOT NULL,
|
|
tarif_id INTEGER NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (tarifoption_id) REFERENCES tarifoptionen_new (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (tarif_id) REFERENCES tarife (id) ON DELETE CASCADE,
|
|
UNIQUE(tarifoption_id, tarif_id)
|
|
)`);
|
|
|
|
// 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,
|
|
tarifoption_id INTEGER NOT NULL,
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
FOREIGN KEY (schaltung_id) REFERENCES schaltungen (id) ON DELETE CASCADE,
|
|
FOREIGN KEY (tarifoption_id) REFERENCES tarifoptionen (id),
|
|
UNIQUE(schaltung_id, tarifoption_id)
|
|
)`);
|
|
|
|
// 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
|
|
)`);
|
|
|
|
// Seed data (idempotent)
|
|
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')`);
|
|
|
|
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')`);
|
|
|
|
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')`);
|
|
|
|
// 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')`);
|
|
|
|
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')`);
|
|
});
|
|
|
|
enableForeignKeys(db);
|
|
}
|
|
|
|
module.exports = { initDatabase, getDatabase, closeDatabase };
|