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

+81
View File
@@ -0,0 +1,81 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
router.get('/', asyncHandler(async (req, res) => {
const rows = await db.all('SELECT * FROM anbieter ORDER BY name');
res.json(rows);
}));
router.post('/', asyncHandler(async (req, res) => {
const { name } = req.body || {};
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Name ist erforderlich' });
}
try {
const result = await db.run('INSERT INTO anbieter (name) VALUES (?)', [name.trim()]);
res.json({ id: result.lastID, name: name.trim() });
} catch (err) {
if (/UNIQUE/.test(err.message)) {
return res.status(400).json({ error: 'Anbieter mit diesem Namen existiert bereits' });
}
throw err;
}
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const [tariffeRow, schaltungenRow] = await Promise.all([
db.get('SELECT COUNT(*) AS n FROM tarife WHERE anbieter_id = ?', [id]),
db.get('SELECT COUNT(*) AS n FROM schaltungen WHERE anbieter_id = ?', [id]),
]);
if (tariffeRow.n + schaltungenRow.n > 0) {
const reasons = [];
if (tariffeRow.n > 0) reasons.push(`${tariffeRow.n} Tarif(e)`);
if (schaltungenRow.n > 0) reasons.push(`${schaltungenRow.n} Schaltung(en)`);
return res.status(400).json({
error: `Anbieter kann nicht gelöscht werden: ${reasons.join(', ')} verwenden diesen Anbieter`,
});
}
const deletedRules = await db.run('DELETE FROM anbieter_provisionsregeln WHERE anbieter_id = ?', [id]);
const result = await db.run('DELETE FROM anbieter WHERE id = ?', [id]);
if (result.changes === 0) {
return res.status(404).json({ error: 'Anbieter nicht gefunden' });
}
let message = 'Anbieter erfolgreich gelöscht';
if (deletedRules.changes > 0) {
message += ` (${deletedRules.changes} Provisionsregel(n) wurden mit gelöscht)`;
}
res.json({ message });
}));
// Global commission rules for this provider
router.get('/:id/provisionsregeln', asyncHandler(async (req, res) => {
const { id } = req.params;
const rows = await db.all(
`SELECT apr.*, a.name AS anbieter_name
FROM anbieter_provisionsregeln apr
JOIN anbieter a ON apr.anbieter_id = a.id
WHERE apr.anbieter_id = ? AND apr.aktiv = 1
ORDER BY apr.gueltig_ab DESC`,
[id]
);
res.json(rows);
}));
// Mitarbeiter-Prozentsätze for this provider
router.get('/:id/mitarbeiter-prozentsaetze', asyncHandler(async (req, res) => {
const { id } = req.params;
const rows = await db.all(
'SELECT * FROM mitarbeiter_prozentsaetze WHERE anbieter_id = ? ORDER BY gueltig_ab DESC',
[id]
);
res.json(rows);
}));
module.exports = router;
+120
View File
@@ -0,0 +1,120 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const { requireLogin } = require('../middleware/auth');
const router = express.Router();
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 20,
message: { error: 'Zu viele Anmeldeversuche, bitte später erneut versuchen.' },
standardHeaders: true,
legacyHeaders: false,
});
// Whether the system needs initial setup (no users yet).
router.get('/setup-required', asyncHandler(async (req, res) => {
const row = await db.get('SELECT COUNT(*) AS n FROM users');
res.json({ setupRequired: row.n === 0 });
}));
// One-shot setup: create the first admin user. Only works when users table is empty.
router.post('/setup', asyncHandler(async (req, res) => {
const existing = await db.get('SELECT COUNT(*) AS n FROM users');
if (existing.n > 0) {
return res.status(403).json({ error: 'Setup bereits abgeschlossen' });
}
const { username, password, displayName } = req.body || {};
if (!username || !password) {
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen lang sein' });
}
const hash = await bcrypt.hash(password, 10);
const result = await db.run(
'INSERT INTO users (username, password_hash, display_name, is_admin) VALUES (?, ?, ?, 1)',
[username, hash, displayName || username]
);
req.session.userId = result.lastID;
req.session.username = username;
req.session.isAdmin = true;
res.json({ id: result.lastID, username, isAdmin: true });
}));
router.post('/login', loginLimiter, asyncHandler(async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
}
const user = await db.get(
'SELECT id, username, password_hash, display_name, is_admin FROM users WHERE username = ?',
[username]
);
if (!user) {
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
}
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) {
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
}
req.session.regenerate((err) => {
if (err) return res.status(500).json({ error: 'Session-Fehler' });
req.session.userId = user.id;
req.session.username = user.username;
req.session.isAdmin = !!user.is_admin;
res.json({
id: user.id,
username: user.username,
displayName: user.display_name,
isAdmin: !!user.is_admin,
});
});
}));
router.post('/logout', (req, res) => {
if (!req.session) return res.json({ ok: true });
req.session.destroy(() => {
res.clearCookie('connect.sid');
res.json({ ok: true });
});
});
router.get('/me', requireLogin, asyncHandler(async (req, res) => {
const user = await db.get(
'SELECT id, username, display_name, is_admin FROM users WHERE id = ?',
[req.session.userId]
);
if (!user) {
req.session.destroy(() => {});
return res.status(401).json({ error: 'Benutzer nicht gefunden' });
}
res.json({
id: user.id,
username: user.username,
displayName: user.display_name,
isAdmin: !!user.is_admin,
});
}));
router.post('/change-password', requireLogin, asyncHandler(async (req, res) => {
const { oldPassword, newPassword } = req.body || {};
if (!oldPassword || !newPassword) {
return res.status(400).json({ error: 'Altes und neues Passwort erforderlich' });
}
if (newPassword.length < 8) {
return res.status(400).json({ error: 'Neues Passwort muss mindestens 8 Zeichen lang sein' });
}
const user = await db.get('SELECT password_hash FROM users WHERE id = ?', [req.session.userId]);
if (!user) return res.status(401).json({ error: 'Benutzer nicht gefunden' });
const ok = await bcrypt.compare(oldPassword, user.password_hash);
if (!ok) return res.status(401).json({ error: 'Altes Passwort falsch' });
const newHash = await bcrypt.hash(newPassword, 10);
await db.run('UPDATE users SET password_hash = ? WHERE id = ?', [newHash, req.session.userId]);
res.json({ ok: true });
}));
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const multer = require('multer');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
const ALLOWED_FILE_TYPES = {
'application/pdf': ['.pdf'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
};
function fileFilter(req, file, cb) {
const allowedExtensions = Object.values(ALLOWED_FILE_TYPES).flat();
const ext = path.extname(file.originalname).toLowerCase();
if (allowedExtensions.includes(ext) && ALLOWED_FILE_TYPES[file.mimetype]) {
cb(null, true);
} else {
cb(new Error(`Dateityp nicht erlaubt: ${ext}. Erlaubt sind: PDF, JPG, PNG, DOC, DOCX`), false);
}
}
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, 'uploads/'),
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
},
});
const upload = multer({ storage, fileFilter, limits: { fileSize: 10 * 1024 * 1024 } });
// Upload document for a schaltung (mounted as /api/schaltungen/:id/dokumente by server.js)
router.post('/schaltungen/:id/dokumente', upload.single('document'), asyncHandler(async (req, res) => {
const schaltung_id = req.params.id;
const file = req.file;
if (!file) return res.status(400).json({ error: 'Keine Datei hochgeladen' });
const cleanup = () => {
fs.unlink(file.path, (e) => {
if (e) console.error('Cleanup error:', e.message);
});
};
const schaltung = await db.get('SELECT id FROM schaltungen WHERE id = ?', [schaltung_id]);
if (!schaltung) {
cleanup();
return res.status(404).json({ error: 'Schaltung nicht gefunden' });
}
try {
const result = await db.run(
`INSERT INTO dokumente (schaltung_id, filename, original_filename, filepath, filesize, mimetype)
VALUES (?, ?, ?, ?, ?, ?)`,
[schaltung_id, file.filename, file.originalname, file.path, file.size, file.mimetype]
);
res.json({ id: result.lastID, message: 'Dokument erfolgreich hochgeladen', filename: file.originalname });
} catch (err) {
cleanup();
throw err;
}
}));
// List documents for a schaltung
router.get('/schaltungen/:id/dokumente', asyncHandler(async (req, res) => {
const rows = await db.all('SELECT * FROM dokumente WHERE schaltung_id = ?', [req.params.id]);
res.json(rows);
}));
// Download single document
router.get('/dokumente/:id/download', asyncHandler(async (req, res) => {
const row = await db.get('SELECT * FROM dokumente WHERE id = ?', [req.params.id]);
if (!row) return res.status(404).json({ error: 'Dokument nicht gefunden' });
const filepath = req.app.locals.safeUploadPath(row.filepath);
if (!filepath) {
console.error(`Path traversal attempt or invalid path for document ${row.id}: ${row.filepath}`);
return res.status(400).json({ error: 'Ungültiger Dateipfad' });
}
res.download(filepath, row.original_filename);
}));
// Delete document
router.delete('/dokumente/:id', asyncHandler(async (req, res) => {
const row = await db.get('SELECT * FROM dokumente WHERE id = ?', [req.params.id]);
if (!row) return res.status(404).json({ error: 'Dokument nicht gefunden' });
await db.run('DELETE FROM dokumente WHERE id = ?', [req.params.id]);
const filepath = req.app.locals.safeUploadPath(row.filepath);
if (filepath) {
fs.unlink(filepath, (err) => {
if (err) console.error('Fehler beim Löschen der Datei:', err.message);
});
} else {
console.error(`Skipping file delete - invalid path: ${row.filepath}`);
}
res.json({ message: 'Dokument erfolgreich gelöscht' });
}));
module.exports = router;
+34
View File
@@ -0,0 +1,34 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
router.post('/', asyncHandler(async (req, res) => {
const { anbieter_id, prozentsatz, gueltig_ab, beschreibung } = req.body || {};
if (!anbieter_id) return res.status(400).json({ error: 'Anbieter ist erforderlich' });
const p = parseFloat(prozentsatz);
if (!Number.isFinite(p) || p < 0 || p > 100) {
return res.status(400).json({ error: 'Prozentsatz muss zwischen 0 und 100 liegen' });
}
try {
const result = await db.run(
'INSERT INTO mitarbeiter_prozentsaetze (anbieter_id, prozentsatz, gueltig_ab, beschreibung) VALUES (?, ?, ?, ?)',
[anbieter_id, p, gueltig_ab || new Date().toISOString().split('T')[0], beschreibung || null]
);
res.json({ id: result.lastID, message: 'Mitarbeiter-Prozentsatz erfolgreich erstellt' });
} catch (err) {
if (/UNIQUE/.test(err.message)) {
return res.status(400).json({ error: 'Ein Prozentsatz für dieses Datum existiert bereits für diesen Anbieter' });
}
throw err;
}
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const result = await db.run('DELETE FROM mitarbeiter_prozentsaetze WHERE id = ?', [req.params.id]);
if (result.changes === 0) return res.status(404).json({ error: 'Mitarbeiter-Prozentsatz nicht gefunden' });
res.json({ message: 'Mitarbeiter-Prozentsatz erfolgreich gelöscht' });
}));
module.exports = router;
+151
View File
@@ -0,0 +1,151 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const provisionService = require('../provisionService');
const router = express.Router();
// Load all inputs needed by provisionService.calculate for one schaltung.
async function loadProvisionContext(schaltungId) {
const schaltung = await db.get(
`SELECT s.*,
t.name AS tarif_name, t.provision_euro AS tarif_provision_euro,
t.mitarbeiter_provision AS tarif_mitarbeiter_provision,
t.beschreibung AS tarif_beschreibung,
a.name AS anbieter_name
FROM schaltungen s
JOIN tarife t ON s.tarif_id = t.id
JOIN anbieter a ON s.anbieter_id = a.id
WHERE s.id = ?`,
[schaltungId]
);
if (!schaltung) return null;
const tarif = {
id: schaltung.tarif_id,
name: schaltung.tarif_name,
provision_euro: schaltung.tarif_provision_euro,
mitarbeiter_provision: schaltung.tarif_mitarbeiter_provision,
beschreibung: schaltung.tarif_beschreibung,
};
const tarifRule = await db.get(
`SELECT * FROM provisionsregeln
WHERE tarif_id = ? AND aktiv = 1 AND gueltig_ab <= ?
ORDER BY gueltig_ab DESC LIMIT 1`,
[schaltung.tarif_id, schaltung.schaltungs_datum]
);
const anbieterRule = await db.get(
`SELECT * FROM anbieter_provisionsregeln
WHERE anbieter_id = ? AND aktiv = 1 AND gueltig_ab <= ?
ORDER BY gueltig_ab DESC LIMIT 1`,
[schaltung.anbieter_id, schaltung.schaltungs_datum]
);
const tarifoptionen = await db.all(
`SELECT t.*
FROM tarifoptionen t
JOIN schaltungen_tarifoptionen st ON t.id = st.tarifoption_id
WHERE st.schaltung_id = ?`,
[schaltungId]
);
const mp = await db.get(
`SELECT prozentsatz FROM mitarbeiter_prozentsaetze
WHERE anbieter_id = ? AND gueltig_ab <= ?
ORDER BY gueltig_ab DESC LIMIT 1`,
[schaltung.anbieter_id, schaltung.schaltungs_datum]
);
return {
schaltung,
tarif,
tarifRule: tarifRule || null,
anbieterRule: anbieterRule || null,
tarifoptionen,
mitarbeiterProzentsatz: mp ? mp.prozentsatz : 0,
};
}
// Provision for a single schaltung
router.get('/schaltungen/:id/provision', asyncHandler(async (req, res) => {
const ctx = await loadProvisionContext(req.params.id);
if (!ctx) return res.status(404).json({ error: 'Schaltung nicht gefunden' });
const result = provisionService.calculate(ctx);
res.json({
id: ctx.schaltung.id,
schaltungs_id: ctx.schaltung.schaltungs_id,
schaltungs_datum: ctx.schaltung.schaltungs_datum,
vorname: ctx.schaltung.vorname,
nachname: ctx.schaltung.nachname,
firma: ctx.schaltung.firma,
tarif_name: ctx.tarif.name,
anbieter_name: ctx.schaltung.anbieter_name,
shop_provision: result.shop,
mitarbeiter_provision: result.mitarbeiter,
tarifoptionen: result.tarifoptionen,
hardware_gewinn: result.hardware_gewinn,
// Legacy flat fields for older frontend code
berechnete_shop_provision: result.shop.betrag.toFixed(2),
berechnete_mitarbeiter_provision: result.mitarbeiter.betrag.toFixed(2),
shop_quelle: result.shop.quelle,
mitarbeiter_quelle: result.mitarbeiter.quelle,
});
}));
// Aggregated commission overview across all schaltungen
router.get('/provisionen', asyncHandler(async (req, res) => {
const schaltungen = await db.all('SELECT id FROM schaltungen ORDER BY schaltungs_datum DESC');
const processed = [];
let totalShop = 0;
let totalMitarbeiter = 0;
for (const { id } of schaltungen) {
const ctx = await loadProvisionContext(id);
if (!ctx) continue;
const result = provisionService.calculate(ctx);
totalShop += result.shop.betrag;
totalMitarbeiter += result.mitarbeiter.betrag;
processed.push({
id: ctx.schaltung.id,
schaltungs_id: ctx.schaltung.schaltungs_id,
eingabe_datum: ctx.schaltung.eingabe_datum,
schaltungs_datum: ctx.schaltung.schaltungs_datum,
vorname: ctx.schaltung.vorname,
nachname: ctx.schaltung.nachname,
firma: ctx.schaltung.firma,
hardware: ctx.schaltung.hardware,
hardware_ek: ctx.schaltung.hardware_ek,
kundenverkaufspreis_netto: ctx.schaltung.kundenverkaufspreis_netto,
tarif_name: ctx.tarif.name,
anbieter_name: ctx.schaltung.anbieter_name,
shop_provision_euro: result.shop.betrag,
berechnete_shop_provision: result.shop.betrag.toFixed(2),
mitarbeiter_prozentsatz: result.mitarbeiter.prozentsatz,
berechnete_mitarbeiter_provision: result.mitarbeiter.betrag.toFixed(2),
shop_quelle: result.shop.quelle,
mitarbeiter_quelle: result.mitarbeiter.quelle,
tarifoptionen_provision: result.tarifoptionen.betrag,
hardware_gewinn: result.hardware_gewinn,
});
}
res.json({
schaltungen: processed,
total_provision: totalShop.toFixed(2),
total_shop_provision: totalShop.toFixed(2),
total_mitarbeiter_provision: totalMitarbeiter.toFixed(2),
total_gesamt_provision: (totalShop + totalMitarbeiter).toFixed(2),
});
}));
// Deleted schaltungen list
router.get('/geloeschte-schaltungen', asyncHandler(async (req, res) => {
const rows = await db.all('SELECT * FROM geloeschte_schaltungen ORDER BY geloescht_am DESC');
res.json(rows);
}));
module.exports = router;
+172
View File
@@ -0,0 +1,172 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
// Validates that a percent rule has a basis_euro >= 0. Returns error string or null.
function validateRule({ provision_typ, betrag, basis_euro, verkaeufer_provision_typ, verkaeufer_provision_betrag }) {
if (!['fix', 'prozent'].includes(provision_typ)) {
return 'Ungültiger Provisionstyp';
}
const b = parseFloat(betrag);
if (!Number.isFinite(b)) return 'Betrag muss eine Zahl sein';
if (provision_typ === 'prozent' && (b < 0 || b > 100)) {
return 'Prozentual muss zwischen 0 und 100 sein';
}
if (provision_typ === 'fix' && b < 0) {
return 'Fixer Betrag kann nicht negativ sein';
}
if (provision_typ === 'prozent') {
if (basis_euro === undefined || basis_euro === null || basis_euro === '') {
return 'Prozent-Provision benötigt einen Basis-Betrag (basis_euro)';
}
const bb = parseFloat(basis_euro);
if (!Number.isFinite(bb) || bb < 0) {
return 'Basis-Betrag muss eine positive Zahl sein';
}
}
if (verkaeufer_provision_typ) {
if (!['fix', 'prozent'].includes(verkaeufer_provision_typ)) {
return 'Ungültiger Verkäufer-Provisionstyp';
}
const vb = parseFloat(verkaeufer_provision_betrag);
if (!Number.isFinite(vb)) return 'Verkäufer-Betrag muss eine Zahl sein';
if (verkaeufer_provision_typ === 'prozent' && (vb < 0 || vb > 100)) {
return 'Verkäufer-Prozentwert muss zwischen 0 und 100 sein';
}
}
return null;
}
// --- Tariff-specific rules (provisionsregeln) ---
router.post('/provisionsregeln', asyncHandler(async (req, res) => {
const { tarif_id, provision_typ, betrag, basis_euro, beschreibung, gueltig_ab,
verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung } = req.body || {};
if (!tarif_id) return res.status(400).json({ error: 'Tarif ist erforderlich' });
const err = validateRule({ provision_typ, betrag, basis_euro, verkaeufer_provision_typ, verkaeufer_provision_betrag });
if (err) return res.status(400).json({ error: err });
const result = await db.run(
`INSERT INTO provisionsregeln
(tarif_id, provision_typ, betrag, basis_euro, beschreibung, gueltig_ab,
verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
tarif_id, provision_typ, betrag,
provision_typ === 'prozent' ? parseFloat(basis_euro) : null,
beschreibung || null,
gueltig_ab || new Date().toISOString().split('T')[0],
verkaeufer_provision_typ || null,
verkaeufer_provision_betrag != null ? parseFloat(verkaeufer_provision_betrag) : null,
verkaeufer_provision_beschreibung || null,
]
);
res.json({ id: result.lastID, message: 'Provisionsregel erfolgreich erstellt' });
}));
router.put('/provisionsregeln/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const current = await db.get('SELECT * FROM provisionsregeln WHERE id = ?', [id]);
if (!current) return res.status(404).json({ error: 'Provisionsregel nicht gefunden' });
const merged = {
provision_typ: req.body.provision_typ !== undefined ? req.body.provision_typ : current.provision_typ,
betrag: req.body.betrag !== undefined ? req.body.betrag : current.betrag,
basis_euro: req.body.basis_euro !== undefined ? req.body.basis_euro : current.basis_euro,
beschreibung: req.body.beschreibung !== undefined ? req.body.beschreibung : current.beschreibung,
gueltig_ab: req.body.gueltig_ab !== undefined ? req.body.gueltig_ab : current.gueltig_ab,
aktiv: req.body.aktiv !== undefined ? req.body.aktiv : current.aktiv,
verkaeufer_provision_typ: req.body.verkaeufer_provision_typ !== undefined ? req.body.verkaeufer_provision_typ : current.verkaeufer_provision_typ,
verkaeufer_provision_betrag: req.body.verkaeufer_provision_betrag !== undefined ? req.body.verkaeufer_provision_betrag : current.verkaeufer_provision_betrag,
verkaeufer_provision_beschreibung: req.body.verkaeufer_provision_beschreibung !== undefined ? req.body.verkaeufer_provision_beschreibung : current.verkaeufer_provision_beschreibung,
};
const verr = validateRule(merged);
if (verr) return res.status(400).json({ error: verr });
await db.run(
`UPDATE provisionsregeln
SET provision_typ = ?, betrag = ?, basis_euro = ?, beschreibung = ?, gueltig_ab = ?, aktiv = ?,
verkaeufer_provision_typ = ?, verkaeufer_provision_betrag = ?, verkaeufer_provision_beschreibung = ?
WHERE id = ?`,
[
merged.provision_typ, merged.betrag,
merged.provision_typ === 'prozent' ? merged.basis_euro : null,
merged.beschreibung, merged.gueltig_ab, merged.aktiv,
merged.verkaeufer_provision_typ,
merged.verkaeufer_provision_betrag,
merged.verkaeufer_provision_beschreibung,
id,
]
);
res.json({ message: 'Provisionsregel erfolgreich aktualisiert' });
}));
// --- Global provider rules (anbieter_provisionsregeln) ---
router.post('/anbieter-provisionsregeln', asyncHandler(async (req, res) => {
const { anbieter_id, provision_typ, betrag, basis_euro, beschreibung, gueltig_ab,
verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung } = req.body || {};
if (!anbieter_id) return res.status(400).json({ error: 'Anbieter ist erforderlich' });
const err = validateRule({ provision_typ, betrag, basis_euro, verkaeufer_provision_typ, verkaeufer_provision_betrag });
if (err) return res.status(400).json({ error: err });
const result = await db.run(
`INSERT INTO anbieter_provisionsregeln
(anbieter_id, provision_typ, betrag, basis_euro, beschreibung, gueltig_ab,
verkaeufer_provision_typ, verkaeufer_provision_betrag, verkaeufer_provision_beschreibung)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
anbieter_id, provision_typ, betrag,
provision_typ === 'prozent' ? parseFloat(basis_euro) : null,
beschreibung || null,
gueltig_ab || new Date().toISOString().split('T')[0],
verkaeufer_provision_typ || null,
verkaeufer_provision_betrag != null ? parseFloat(verkaeufer_provision_betrag) : null,
verkaeufer_provision_beschreibung || null,
]
);
res.json({ id: result.lastID, message: 'Globale Provisionsregel erfolgreich erstellt' });
}));
router.put('/anbieter-provisionsregeln/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const current = await db.get('SELECT * FROM anbieter_provisionsregeln WHERE id = ?', [id]);
if (!current) return res.status(404).json({ error: 'Globale Provisionsregel nicht gefunden' });
const merged = {
provision_typ: req.body.provision_typ !== undefined ? req.body.provision_typ : current.provision_typ,
betrag: req.body.betrag !== undefined ? req.body.betrag : current.betrag,
basis_euro: req.body.basis_euro !== undefined ? req.body.basis_euro : current.basis_euro,
beschreibung: req.body.beschreibung !== undefined ? req.body.beschreibung : current.beschreibung,
gueltig_ab: req.body.gueltig_ab !== undefined ? req.body.gueltig_ab : current.gueltig_ab,
aktiv: req.body.aktiv !== undefined ? req.body.aktiv : current.aktiv,
verkaeufer_provision_typ: req.body.verkaeufer_provision_typ !== undefined ? req.body.verkaeufer_provision_typ : current.verkaeufer_provision_typ,
verkaeufer_provision_betrag: req.body.verkaeufer_provision_betrag !== undefined ? req.body.verkaeufer_provision_betrag : current.verkaeufer_provision_betrag,
verkaeufer_provision_beschreibung: req.body.verkaeufer_provision_beschreibung !== undefined ? req.body.verkaeufer_provision_beschreibung : current.verkaeufer_provision_beschreibung,
};
const verr = validateRule(merged);
if (verr) return res.status(400).json({ error: verr });
await db.run(
`UPDATE anbieter_provisionsregeln
SET provision_typ = ?, betrag = ?, basis_euro = ?, beschreibung = ?, gueltig_ab = ?, aktiv = ?,
verkaeufer_provision_typ = ?, verkaeufer_provision_betrag = ?, verkaeufer_provision_beschreibung = ?
WHERE id = ?`,
[
merged.provision_typ, merged.betrag,
merged.provision_typ === 'prozent' ? merged.basis_euro : null,
merged.beschreibung, merged.gueltig_ab, merged.aktiv,
merged.verkaeufer_provision_typ,
merged.verkaeufer_provision_betrag,
merged.verkaeufer_provision_beschreibung,
id,
]
);
res.json({ message: 'Globale Provisionsregel erfolgreich aktualisiert' });
}));
module.exports = router;
+217
View File
@@ -0,0 +1,217 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
function validateDate(s) {
return typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s) && !isNaN(new Date(s).getTime());
}
function validateHardware(h) {
return !h || String(h).length === 15;
}
function validateAmount(a) {
return a === null || a === undefined || (Number.isFinite(parseFloat(a)) && parseFloat(a) >= 0);
}
function validateSchaltungBody(body) {
const { schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname,
rufnummer, anbieter_id, tarif_id, hardware, hardware_ek, kundenverkaufspreis_netto } = body || {};
if (!schaltungs_id || !schaltungs_id.trim()) return 'Schaltungs-ID ist erforderlich';
if (!validateDate(eingabe_datum)) return 'Ungültiges Eingabe-Datum (Format: YYYY-MM-DD)';
if (!validateDate(schaltungs_datum)) return 'Ungültiges Schaltungs-Datum (Format: YYYY-MM-DD)';
if (!vorname || !vorname.trim()) return 'Vorname ist erforderlich';
if (!nachname || !nachname.trim()) return 'Nachname ist erforderlich';
if (!rufnummer || !rufnummer.trim()) return 'Rufnummer ist erforderlich';
if (!anbieter_id) return 'Anbieter ist erforderlich';
if (!tarif_id) return 'Tarif ist erforderlich';
if (!validateHardware(hardware)) return 'Hardware muss genau 15 Zeichen lang sein';
if (hardware && hardware.trim() !== '') {
if (!validateAmount(hardware_ek)) return 'Hardware-EK muss eine positive Zahl sein';
if (!validateAmount(kundenverkaufspreis_netto)) return 'Kundenverkaufspreis muss eine positive Zahl sein';
}
return null;
}
router.get('/', asyncHandler(async (req, res) => {
const rows = await db.all(
`SELECT s.*, a.name AS anbieter_name, t.name AS tarif_name
FROM schaltungen s
JOIN anbieter a ON s.anbieter_id = a.id
JOIN tarife t ON s.tarif_id = t.id
ORDER BY s.created_at DESC`
);
res.json(rows);
}));
router.post('/', asyncHandler(async (req, res) => {
const err = validateSchaltungBody(req.body);
if (err) return res.status(400).json({ error: err });
const b = req.body;
try {
const result = await db.run(
`INSERT INTO schaltungen
(schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname, firma, rufnummer,
anbieter_id, tarif_id, hardware, hardware_ek, kundenverkaufspreis_netto)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
b.schaltungs_id, b.eingabe_datum, b.schaltungs_datum, b.vorname, b.nachname,
b.firma || null, b.rufnummer, b.anbieter_id, b.tarif_id, b.hardware || null,
b.hardware_ek || null, b.kundenverkaufspreis_netto || null,
]
);
res.json({ id: result.lastID, message: 'Schaltung erfolgreich erstellt' });
} catch (e) {
if (/UNIQUE/.test(e.message)) {
return res.status(400).json({ error: 'Schaltungs-ID existiert bereits' });
}
throw e;
}
}));
router.put('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const err = validateSchaltungBody(req.body);
if (err) return res.status(400).json({ error: err });
const b = req.body;
const tarifoptionen = Array.isArray(b.tarifoptionen) ? b.tarifoptionen : [];
await db.transaction(async () => {
const upd = await db.run(
`UPDATE schaltungen
SET schaltungs_id = ?, eingabe_datum = ?, schaltungs_datum = ?, vorname = ?, nachname = ?,
firma = ?, rufnummer = ?, anbieter_id = ?, tarif_id = ?, hardware = ?,
hardware_ek = ?, kundenverkaufspreis_netto = ?
WHERE id = ?`,
[
b.schaltungs_id, b.eingabe_datum, b.schaltungs_datum, b.vorname, b.nachname,
b.firma || null, b.rufnummer, b.anbieter_id, b.tarif_id, b.hardware || null,
b.hardware_ek || null, b.kundenverkaufspreis_netto || null, id,
]
);
if (upd.changes === 0) {
const e = new Error('Schaltung nicht gefunden');
e.status = 404;
throw e;
}
await db.run('DELETE FROM schaltungen_tarifoptionen WHERE schaltung_id = ?', [id]);
for (const tarifoptionId of tarifoptionen) {
await db.run(
'INSERT INTO schaltungen_tarifoptionen (schaltung_id, tarifoption_id) VALUES (?, ?)',
[id, tarifoptionId]
);
}
});
res.json({ message: 'Schaltung erfolgreich aktualisiert' });
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const { loeschgrund } = req.body || {};
if (!loeschgrund || !loeschgrund.trim()) {
return res.status(400).json({ error: 'Löschgrund ist erforderlich' });
}
const schaltung = await db.get(
`SELECT s.*, a.name AS anbieter_name, t.name AS tarif_name
FROM schaltungen s
JOIN anbieter a ON s.anbieter_id = a.id
JOIN tarife t ON s.tarif_id = t.id
WHERE s.id = ?`,
[id]
);
if (!schaltung) return res.status(404).json({ error: 'Schaltung nicht gefunden' });
const documents = await db.all('SELECT filepath FROM dokumente WHERE schaltung_id = ?', [id]);
const { safeUploadPath } = req.app.locals;
const fs = require('fs');
await db.transaction(async () => {
await db.run(
`INSERT INTO geloeschte_schaltungen (
original_id, schaltungs_id, eingabe_datum, schaltungs_datum, vorname, nachname,
firma, rufnummer, anbieter_id, tarif_id, hardware, anbieter_name, tarif_name, loeschgrund, geloescht_von
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
schaltung.id, schaltung.schaltungs_id, schaltung.eingabe_datum, schaltung.schaltungs_datum,
schaltung.vorname, schaltung.nachname, schaltung.firma, schaltung.rufnummer,
schaltung.anbieter_id, schaltung.tarif_id, schaltung.hardware,
schaltung.anbieter_name, schaltung.tarif_name, loeschgrund.trim(),
req.session && req.session.username ? req.session.username : 'System',
]
);
// Existing DBs may lack ON DELETE CASCADE on dokumente — delete explicitly.
await db.run('DELETE FROM dokumente WHERE schaltung_id = ?', [id]);
await db.run('DELETE FROM schaltungen WHERE id = ?', [id]);
});
// Best-effort file cleanup, outside the transaction.
let deletedFiles = 0;
for (const doc of documents) {
const filePath = safeUploadPath(doc.filepath);
if (!filePath) {
console.error(`Skipping file delete - invalid path: ${doc.filepath}`);
continue;
}
try {
fs.unlinkSync(filePath);
deletedFiles++;
} catch (e) {
console.error(`Error deleting ${filePath}:`, e.message);
}
}
res.json({
message: 'Schaltung erfolgreich gelöscht und archiviert',
loeschgrund: loeschgrund.trim(),
deleted_files: deletedFiles,
});
}));
// Tarifoptionen linked to a specific schaltung
router.get('/:id/tarifoptionen', asyncHandler(async (req, res) => {
const { id } = req.params;
const rows = await db.all(
`SELECT t.*, st.id AS schaltung_tarifoption_id
FROM tarifoptionen t
JOIN schaltungen_tarifoptionen st ON t.id = st.tarifoption_id
WHERE st.schaltung_id = ?
ORDER BY t.name`,
[id]
);
res.json(rows);
}));
router.post('/:id/tarifoptionen', asyncHandler(async (req, res) => {
const schaltung_id = req.params.id;
const { tarifoption_id } = req.body || {};
if (!tarifoption_id) return res.status(400).json({ error: 'tarifoption_id ist erforderlich' });
try {
const result = await db.run(
'INSERT INTO schaltungen_tarifoptionen (schaltung_id, tarifoption_id) VALUES (?, ?)',
[schaltung_id, tarifoption_id]
);
res.json({ id: result.lastID, message: 'Tarifoption erfolgreich hinzugefügt' });
} catch (err) {
if (/UNIQUE/.test(err.message)) {
return res.status(400).json({ error: 'Tarifoption bereits hinzugefügt' });
}
throw err;
}
}));
router.delete('/:schaltung_id/tarifoptionen/:tarifoption_id', asyncHandler(async (req, res) => {
const { schaltung_id, tarifoption_id } = req.params;
const result = await db.run(
'DELETE FROM schaltungen_tarifoptionen WHERE schaltung_id = ? AND tarifoption_id = ?',
[schaltung_id, tarifoption_id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Verknüpfung nicht gefunden' });
res.json({ message: 'Tarifoption erfolgreich entfernt' });
}));
module.exports = router;
+123
View File
@@ -0,0 +1,123 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
function validateAmount(amount) {
return amount === null || amount === undefined || (Number.isFinite(parseFloat(amount)) && parseFloat(amount) >= 0);
}
router.get('/', asyncHandler(async (req, res) => {
const rows = await db.all(
`SELECT t.*, a.name AS anbieter_name
FROM tarife t
JOIN anbieter a ON t.anbieter_id = a.id
ORDER BY a.name, t.name`
);
res.json(rows);
}));
router.get('/:anbieter_id', asyncHandler(async (req, res) => {
const { anbieter_id } = req.params;
const rows = await db.all('SELECT * FROM tarife WHERE anbieter_id = ? ORDER BY name', [anbieter_id]);
res.json(rows);
}));
router.post('/', asyncHandler(async (req, res) => {
const { name, anbieter_id, provision_euro, beschreibung } = req.body || {};
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Tarifname ist erforderlich' });
}
if (!anbieter_id) {
return res.status(400).json({ error: 'Anbieter ist erforderlich' });
}
if (!validateAmount(provision_euro)) {
return res.status(400).json({ error: 'Provision muss eine positive Zahl sein' });
}
try {
const result = await db.run(
`INSERT INTO tarife (name, anbieter_id, provision_euro, beschreibung) VALUES (?, ?, ?, ?)`,
[name.trim(), anbieter_id, provision_euro || 0, beschreibung || null]
);
res.json({
id: result.lastID,
name: name.trim(),
anbieter_id,
provision_euro: provision_euro || 0,
beschreibung: beschreibung || null,
});
} catch (err) {
if (/UNIQUE/.test(err.message)) {
return res.status(400).json({ error: 'Tarif mit diesem Namen existiert bereits für diesen Anbieter' });
}
throw err;
}
}));
router.put('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const { name, provision_euro, beschreibung } = req.body || {};
if (!name) return res.status(400).json({ error: 'Tarif-Name ist erforderlich' });
if (!validateAmount(provision_euro)) {
return res.status(400).json({ error: 'Provision muss eine positive Zahl sein' });
}
const result = await db.run(
`UPDATE tarife SET name = ?, provision_euro = ?, beschreibung = ? WHERE id = ?`,
[name, provision_euro || 0, beschreibung || null, id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Tarif nicht gefunden' });
res.json({ message: 'Tarif erfolgreich aktualisiert' });
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const used = await db.get(
'SELECT EXISTS(SELECT 1 FROM schaltungen WHERE tarif_id = ?) AS has_schaltungen',
[id]
);
if (used.has_schaltungen) {
return res.status(400).json({ error: 'Tarif kann nicht gelöscht werden: Tarif wird in Schaltungen verwendet' });
}
const result = await db.run('DELETE FROM tarife WHERE id = ?', [id]);
if (result.changes === 0) return res.status(404).json({ error: 'Tarif nicht gefunden' });
res.json({ message: 'Tarif erfolgreich gelöscht' });
}));
router.get('/:tarif_id/tarifoptionen', asyncHandler(async (req, res) => {
const { tarif_id } = req.params;
const rows = await db.all(
'SELECT * FROM tarifoptionen WHERE tarif_id = ? AND aktiv = 1 ORDER BY name',
[tarif_id]
);
res.json(rows);
}));
router.get('/:id/provisionsregeln', asyncHandler(async (req, res) => {
const { id } = req.params;
const rows = await db.all(
`SELECT pr.*, t.name AS tarif_name, a.name AS anbieter_name
FROM provisionsregeln pr
JOIN tarife t ON pr.tarif_id = t.id
JOIN anbieter a ON t.anbieter_id = a.id
WHERE pr.tarif_id = ? AND pr.aktiv = 1
ORDER BY pr.gueltig_ab DESC`,
[id]
);
res.json(rows);
}));
router.put('/:id/mitarbeiter-provision', asyncHandler(async (req, res) => {
const { id } = req.params;
const { mitarbeiter_provision } = req.body || {};
const result = await db.run(
'UPDATE tarife SET mitarbeiter_provision = ? WHERE id = ?',
[mitarbeiter_provision || 0, id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Tarif nicht gefunden' });
res.json({ message: 'Mitarbeiter-Provision erfolgreich aktualisiert' });
}));
module.exports = router;
+205
View File
@@ -0,0 +1,205 @@
const express = require('express');
const db = require('../db');
const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
// Helper: find or insert a tarifoptionen row matching the new structure's option.
// Used by the /multi endpoints to keep the old tarifoptionen table in sync.
async function syncLegacyOption(anbieter_id, tarif_id, name, code, provision_euro, beschreibung) {
try {
await db.run(
`INSERT OR IGNORE INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung)
VALUES (?, ?, ?, ?, ?, ?)`,
[anbieter_id, tarif_id, name, code || null, provision_euro || 0, beschreibung || null]
);
} catch (err) {
console.error('Legacy tarifoption sync failed:', err.message);
}
}
// --- Legacy single-tariff API (per-(anbieter,tarif) options) ---
router.get('/', asyncHandler(async (req, res) => {
const rows = await db.all(
`SELECT t.*, a.name AS anbieter_name, ta.name AS tarif_name
FROM tarifoptionen t
JOIN anbieter a ON t.anbieter_id = a.id
LEFT JOIN tarife ta ON t.tarif_id = ta.id
WHERE t.aktiv = 1
ORDER BY a.name, t.tarif_id IS NULL DESC, COALESCE(ta.name, ''), t.name`
);
res.json(rows);
}));
router.post('/', asyncHandler(async (req, res) => {
const { anbieter_id, tarif_id, name, code, provision_euro, beschreibung } = req.body || {};
if (!anbieter_id && !tarif_id) {
return res.status(400).json({ error: 'Entweder Anbieter oder Tarif muss angegeben werden' });
}
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Name ist erforderlich' });
}
let resolvedAnbieterId = anbieter_id;
if (tarif_id && !resolvedAnbieterId) {
const tarif = await db.get('SELECT anbieter_id FROM tarife WHERE id = ?', [tarif_id]);
if (!tarif) return res.status(404).json({ error: 'Tarif nicht gefunden' });
resolvedAnbieterId = tarif.anbieter_id;
}
try {
const result = await db.run(
`INSERT INTO tarifoptionen (anbieter_id, tarif_id, name, code, provision_euro, beschreibung)
VALUES (?, ?, ?, ?, ?, ?)`,
[resolvedAnbieterId, tarif_id || null, name.trim(), code || null, provision_euro || 0, beschreibung || null]
);
res.json({ id: result.lastID, message: 'Tarifoption erfolgreich erstellt' });
} catch (err) {
if (/UNIQUE/.test(err.message)) {
const scope = tarif_id ? 'für diesen Tarif' : 'für diesen Anbieter';
return res.status(400).json({ error: `Eine Tarifoption mit diesem Namen existiert bereits ${scope}` });
}
throw err;
}
}));
router.put('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const { name, provision_euro } = req.body || {};
if (!name) return res.status(400).json({ error: 'Tarifoption-Name ist erforderlich' });
const result = await db.run(
'UPDATE tarifoptionen SET name = ?, provision_euro = ? WHERE id = ?',
[name, provision_euro || 0, id]
);
if (result.changes === 0) return res.status(404).json({ error: 'Tarifoption nicht gefunden' });
res.json({ message: 'Tarifoption erfolgreich aktualisiert' });
}));
router.delete('/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const result = await db.run('DELETE FROM tarifoptionen WHERE id = ?', [id]);
if (result.changes === 0) return res.status(404).json({ error: 'Tarifoption nicht gefunden' });
res.json({ message: 'Tarifoption erfolgreich gelöscht' });
}));
// --- Multi-tariff API (tarifoptionen_new + tarifoptionen_tarife) ---
router.get('/multi', asyncHandler(async (req, res) => {
const rows = await db.all(
`SELECT tn.id, tn.name, tn.code, tn.provision_euro, tn.beschreibung, tn.aktiv, tn.created_at,
GROUP_CONCAT(t.name || ' (' || a.name || ')') AS tarif_names,
GROUP_CONCAT(t.id) AS tarif_ids
FROM tarifoptionen_new tn
LEFT JOIN tarifoptionen_tarife tt ON tn.id = tt.tarifoption_id
LEFT JOIN tarife t ON tt.tarif_id = t.id
LEFT JOIN anbieter a ON t.anbieter_id = a.id
WHERE tn.aktiv = 1
GROUP BY tn.id, tn.name, tn.code, tn.provision_euro, tn.beschreibung, tn.aktiv, tn.created_at
ORDER BY tn.created_at DESC`
);
res.json(rows);
}));
router.post('/multi', asyncHandler(async (req, res) => {
const { anbieter_id, tarif_ids = [], is_anbieter_weit = false, name, code, provision_euro, beschreibung } = req.body || {};
if (!anbieter_id || !name) return res.status(400).json({ error: 'Anbieter und Name sind erforderlich' });
if (!is_anbieter_weit && (!tarif_ids || tarif_ids.length === 0)) {
return res.status(400).json({ error: 'Bei tarif-spezifischen Optionen müssen Tarife ausgewählt werden' });
}
const newId = await db.transaction(async () => {
const inserted = await db.run(
'INSERT INTO tarifoptionen_new (name, code, provision_euro, beschreibung) VALUES (?, ?, ?, ?)',
[name, code || null, provision_euro || 0, beschreibung || null]
);
const tarifoptionId = inserted.lastID;
let targetTarifIds = tarif_ids;
if (is_anbieter_weit) {
const tarifs = await db.all('SELECT id FROM tarife WHERE anbieter_id = ?', [anbieter_id]);
targetTarifIds = tarifs.map(t => t.id);
}
for (const tarifId of targetTarifIds) {
await db.run(
'INSERT INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)',
[tarifoptionId, tarifId]
);
await syncLegacyOption(anbieter_id, tarifId, name, code, provision_euro, beschreibung);
}
return { id: tarifoptionId, count: targetTarifIds.length };
});
res.json({ id: newId.id, message: 'Tarifoption erfolgreich erstellt', assignments: newId.count });
}));
router.put('/multi/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const { name, code, provision_euro, beschreibung, anbieter_id, is_anbieter_weit, tarif_ids } = req.body || {};
if (!name) return res.status(400).json({ error: 'Tarifoption-Name ist erforderlich' });
await db.transaction(async () => {
const upd = await db.run(
'UPDATE tarifoptionen_new SET name = ?, code = ?, provision_euro = ?, beschreibung = ? WHERE id = ?',
[name, code || null, provision_euro || 0, beschreibung || null, id]
);
if (upd.changes === 0) {
const e = new Error('Tarifoption nicht gefunden');
e.status = 404;
throw e;
}
await db.run('DELETE FROM tarifoptionen_tarife WHERE tarifoption_id = ?', [id]);
// Best-effort cleanup of legacy rows that may have been synced under this name/code.
await db.run(
'DELETE FROM tarifoptionen WHERE name = ? AND (code = ? OR (code IS NULL AND ? IS NULL))',
[name, code || null, code || null]
);
let targetTarifIds = tarif_ids || [];
if (is_anbieter_weit && anbieter_id) {
const tarifs = await db.all('SELECT id FROM tarife WHERE anbieter_id = ?', [anbieter_id]);
targetTarifIds = tarifs.map(t => t.id);
}
for (const tarifId of targetTarifIds) {
await db.run(
'INSERT INTO tarifoptionen_tarife (tarifoption_id, tarif_id) VALUES (?, ?)',
[id, tarifId]
);
const tarif = await db.get('SELECT anbieter_id FROM tarife WHERE id = ?', [tarifId]);
if (tarif) {
await syncLegacyOption(tarif.anbieter_id, tarifId, name, code, provision_euro, beschreibung);
}
}
});
res.json({ message: 'Tarifoption erfolgreich aktualisiert' });
}));
router.delete('/multi/:id', asyncHandler(async (req, res) => {
const { id } = req.params;
const option = await db.get('SELECT name, code FROM tarifoptionen_new WHERE id = ?', [id]);
if (!option) return res.status(404).json({ error: 'Tarifoption nicht gefunden' });
await db.transaction(async () => {
await db.run('DELETE FROM tarifoptionen_tarife WHERE tarifoption_id = ?', [id]);
await db.run(
'DELETE FROM tarifoptionen WHERE name = ? AND (code = ? OR (code IS NULL AND ? IS NULL))',
[option.name, option.code, option.code]
);
await db.run('DELETE FROM tarifoptionen_new WHERE id = ?', [id]);
});
res.json({ message: 'Tarifoption erfolgreich gelöscht' });
}));
// Tariff-specific tarifoption list (used by frontend when adding options to a schaltung)
router.get('/tarif/:tarif_id', asyncHandler(async (req, res) => {
const { tarif_id } = req.params;
const rows = await db.all(
'SELECT * FROM tarifoptionen WHERE tarif_id = ? AND aktiv = 1 ORDER BY name',
[tarif_id]
);
res.json(rows);
}));
module.exports = router;