diff --git a/backend/lib/mail.js b/backend/lib/mail.js new file mode 100644 index 0000000..f1d1521 --- /dev/null +++ b/backend/lib/mail.js @@ -0,0 +1,32 @@ +import { Resend } from 'resend'; + +const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null; +const FROM = process.env.MAIL_FROM || 'Pfandsystem Demo '; + +export async function sendDemoInviteMail({ to, name, slug, password, loginUrl, days }) { + if (!resend) { + console.log('[mail-dev] Demo-Invite an', to, 'Slug:', slug, 'Pw:', password); + return { dev: true }; + } + const html = ` +
+

Willkommen im Pfandsystem-Demo

+

Hallo ${name},

+

dein ${days}-Tage Demo-Zugang ist bereit. Du kannst alle Funktionen + in Ruhe testen und eigene Beispieldaten anlegen.

+
+

Login-URL: ${loginUrl}

+

E-Mail: ${to}

+

Passwort: ${password}

+

Tenant-Kennung: ${slug}

+
+

Bitte aendere dein Passwort nach dem ersten Login.

+

Diese Mail wurde automatisch versandt vom Pfandsystem-Demo-Portal.

+
`; + return resend.emails.send({ + from: FROM, + to, + subject: `Dein Pfandsystem-Demo-Zugang (${days} Tage)`, + html + }); +} diff --git a/backend/lib/seed.js b/backend/lib/seed.js new file mode 100644 index 0000000..d671904 --- /dev/null +++ b/backend/lib/seed.js @@ -0,0 +1,62 @@ +import crypto from 'crypto'; + +// Befuellt einen frischen Demo-Tenant mit Beispieldaten +// (5 Kunden, 3 Artikel, 6 Lieferungen, 2 Geraete). +export async function seedDemoTenant(conn, tenantId) { + // Artikel + const artikel = [ + { id: crypto.randomUUID(), name: 'Brotkorb klein', pfand: 2.50 }, + { id: crypto.randomUUID(), name: 'Brotkorb gross', pfand: 5.00 }, + { id: crypto.randomUUID(), name: 'Transportbox', pfand: 10.00 } + ]; + for (const a of artikel) { + await conn.query( + 'INSERT INTO artikel (id, tenant_id, bezeichnung, pfand_betrag) VALUES (?, ?, ?, ?)', + [a.id, tenantId, a.name, a.pfand] + ); + } + + // Kunden + const kunden = [ + { name: 'Cafe Sonnenschein', adresse: 'Bahnhofstr. 12, 10115 Berlin', zeit: '05:00', hinweis: 'Anlieferung Hintertuer. Code 1234#.' }, + { name: 'Restaurant Roma', adresse: 'Hauptstr. 88, 10117 Berlin', zeit: '06:30', hinweis: 'Schluesselkasten links neben Eingang.' }, + { name: 'Hotel Adlerhorst', adresse: 'Seestrasse 5, 14193 Berlin', zeit: '05:30', hinweis: 'Anlieferung Tiefgarage Ebene -1, Tor 3.' }, + { name: 'Kantine Werk Nord', adresse: 'Industriestr. 200, 13407 Berlin', zeit: '06:00', hinweis: 'Werkschutz Bescheid geben, Tor B.' }, + { name: 'Baeckerei Schmitt', adresse: 'Marktplatz 4, 10178 Berlin', zeit: '04:45', hinweis: 'Direkt in den Verkaufsraum stellen.' } + ]; + const kundenIds = []; + for (const k of kunden) { + const id = crypto.randomUUID(); + kundenIds.push(id); + await conn.query( + `INSERT INTO kunden (id, tenant_id, name, adresse, lieferzeit_bis, anlieferungshinweis) + VALUES (?, ?, ?, ?, ?, ?)`, + [id, tenantId, k.name, k.adresse, k.zeit, k.hinweis] + ); + } + + // Lieferungen (verteilt ueber die letzten 14 Tage) + for (let i = 0; i < 6; i++) { + const kid = kundenIds[i % kundenIds.length]; + const aid = artikel[i % artikel.length].id; + const anz = (i % 3) + 1; + await conn.query( + `INSERT INTO lieferungen (tenant_id, kunde_id, artikel_id, anzahl, erstellt_am) + VALUES (?, ?, ?, ?, DATE_SUB(NOW(), INTERVAL ? DAY))`, + [tenantId, kid, aid, anz, i * 2] + ); + } + + // Geraete + const geraete = [ + { sn: 'OF-2024-001', typ: 'Ofen', bez: 'Miwe Condo 3-Etagen', kid: kundenIds[0] }, + { sn: 'KS-2024-007', typ: 'Kuehlschrank', bez: 'Liebherr GKv 5710', kid: kundenIds[2] } + ]; + for (const g of geraete) { + await conn.query( + `INSERT INTO geraete (id, tenant_id, seriennummer, typ, bezeichnung, kunde_id, status) + VALUES (UUID(), ?, ?, ?, ?, ?, 'aktiv')`, + [tenantId, g.sn, g.typ, g.bez, g.kid] + ); + } +} diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 4a8b40b..636c299 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,8 +1,12 @@ import jwt from 'jsonwebtoken'; +import pool from '../config/database.js'; +// Verifiziert JWT und stellt req.user bereit +// req.user enthaelt: { id, email, name, rolle, tenant_id } +// tenant_id ist NULL fuer SuperAdmins. export const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; - const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + const token = authHeader && authHeader.split(' ')[1]; if (!token) { return res.status(401).json({ error: 'Kein Token bereitgestellt' }); @@ -10,23 +14,52 @@ export const authenticateToken = (req, res, next) => { jwt.verify(token, process.env.JWT_SECRET, (err, user) => { if (err) { - return res.status(403).json({ error: 'Ungültiger oder abgelaufener Token' }); + return res.status(403).json({ error: 'Ungueltiger oder abgelaufener Token' }); } req.user = user; next(); }); }; -export const requireRole = (roles) => { - return (req, res, next) => { - if (!req.user) { - return res.status(401).json({ error: 'Nicht authentifiziert' }); +// Stellt sicher, dass der User einen Tenant hat und der Tenant aktiv ist. +// SuperAdmins ohne Tenant werden mit 403 abgelehnt (sollen tenants-Route nutzen). +export const requireTenant = async (req, res, next) => { + if (!req.user) return res.status(401).json({ error: 'Nicht authentifiziert' }); + if (!req.user.tenant_id) { + return res.status(403).json({ error: 'Tenant-Kontext erforderlich (SuperAdmin nicht erlaubt)' }); + } + try { + const [rows] = await pool.query( + 'SELECT id, status, demo_expires_at, typ FROM tenants WHERE id = ?', + [req.user.tenant_id] + ); + if (rows.length === 0) return res.status(403).json({ error: 'Tenant nicht gefunden' }); + const t = rows[0]; + if (t.status !== 'aktiv') return res.status(403).json({ error: `Tenant-Status: ${t.status}` }); + if (t.typ === 'demo' && t.demo_expires_at && new Date(t.demo_expires_at) < new Date()) { + await pool.query("UPDATE tenants SET status='abgelaufen' WHERE id = ?", [t.id]); + return res.status(403).json({ error: 'Demo-Zugang abgelaufen' }); } - - if (!roles.includes(req.user.rolle)) { - return res.status(403).json({ error: 'Keine Berechtigung' }); - } - + req.tenant = t; next(); - }; + } catch (e) { + console.error('requireTenant-Fehler:', e); + res.status(500).json({ error: 'Serverfehler' }); + } +}; + +export const requireRole = (roles) => (req, res, next) => { + if (!req.user) return res.status(401).json({ error: 'Nicht authentifiziert' }); + if (!roles.includes(req.user.rolle)) { + return res.status(403).json({ error: 'Keine Berechtigung' }); + } + next(); +}; + +export const requireSuperAdmin = (req, res, next) => { + if (!req.user) return res.status(401).json({ error: 'Nicht authentifiziert' }); + if (req.user.rolle !== 'superadmin') { + return res.status(403).json({ error: 'SuperAdmin erforderlich' }); + } + next(); }; diff --git a/backend/routes/artikel.js b/backend/routes/artikel.js index 43b76bc..dac6c9b 100644 --- a/backend/routes/artikel.js +++ b/backend/routes/artikel.js @@ -1,116 +1,76 @@ import express from 'express'; import { body, validationResult } from 'express-validator'; import pool from '../config/database.js'; -import { authenticateToken } from '../middleware/auth.js'; +import { authenticateToken, requireTenant } from '../middleware/auth.js'; const router = express.Router(); +router.use(authenticateToken, requireTenant); -// Alle Routen erfordern Authentifizierung -router.use(authenticateToken); - -// GET alle Artikel router.get('/', async (req, res) => { try { const [rows] = await pool.query( - 'SELECT * FROM artikel ORDER BY bezeichnung ASC' + 'SELECT * FROM artikel WHERE tenant_id = ? ORDER BY bezeichnung ASC', + [req.user.tenant_id] ); res.json(rows); - } catch (error) { - console.error('Fehler beim Abrufen der Artikel:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// GET einzelner Artikel router.get('/:id', async (req, res) => { try { const [rows] = await pool.query( - 'SELECT * FROM artikel WHERE id = ?', - [req.params.id] + 'SELECT * FROM artikel WHERE id = ? AND tenant_id = ?', + [req.params.id, req.user.tenant_id] ); - - if (rows.length === 0) { - return res.status(404).json({ error: 'Artikel nicht gefunden' }); - } - + if (rows.length === 0) return res.status(404).json({ error: 'Artikel nicht gefunden' }); res.json(rows[0]); - } catch (error) { - console.error('Fehler beim Abrufen des Artikels:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// POST neuer Artikel router.post('/', [ body('bezeichnung').notEmpty().trim(), body('pfand_betrag').isFloat({ min: 0 }) ], async (req, res) => { const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { bezeichnung, pfand_betrag } = req.body; - + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); try { - const [result] = await pool.query( - 'INSERT INTO artikel (bezeichnung, pfand_betrag) VALUES (?, ?)', - [bezeichnung, pfand_betrag] + await pool.query( + 'INSERT INTO artikel (id, tenant_id, bezeichnung, pfand_betrag) VALUES (UUID(), ?, ?, ?)', + [req.user.tenant_id, req.body.bezeichnung, req.body.pfand_betrag] ); - - const [newArtikel] = await pool.query('SELECT * FROM artikel WHERE id = ?', [result.insertId]); - res.status(201).json(newArtikel[0]); - } catch (error) { - console.error('Fehler beim Anlegen des Artikels:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + const [rows] = await pool.query( + 'SELECT * FROM artikel WHERE tenant_id = ? ORDER BY erstellt_am DESC LIMIT 1', + [req.user.tenant_id] + ); + res.status(201).json(rows[0]); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// PUT Artikel aktualisieren router.put('/:id', [ body('bezeichnung').notEmpty().trim(), body('pfand_betrag').isFloat({ min: 0 }) ], async (req, res) => { const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { bezeichnung, pfand_betrag } = req.body; - + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); try { - await pool.query( - 'UPDATE artikel SET bezeichnung = ?, pfand_betrag = ? WHERE id = ?', - [bezeichnung, pfand_betrag, req.params.id] + const [r] = await pool.query( + 'UPDATE artikel SET bezeichnung=?, pfand_betrag=? WHERE id=? AND tenant_id=?', + [req.body.bezeichnung, req.body.pfand_betrag, req.params.id, req.user.tenant_id] ); - - const [updated] = await pool.query('SELECT * FROM artikel WHERE id = ?', [req.params.id]); - - if (updated.length === 0) { - return res.status(404).json({ error: 'Artikel nicht gefunden' }); - } - - res.json(updated[0]); - } catch (error) { - console.error('Fehler beim Aktualisieren des Artikels:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + if (r.affectedRows === 0) return res.status(404).json({ error: 'Artikel nicht gefunden' }); + const [rows] = await pool.query('SELECT * FROM artikel WHERE id=? AND tenant_id=?', + [req.params.id, req.user.tenant_id]); + res.json(rows[0]); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// DELETE Artikel router.delete('/:id', async (req, res) => { try { - const [result] = await pool.query('DELETE FROM artikel WHERE id = ?', [req.params.id]); - - if (result.affectedRows === 0) { - return res.status(404).json({ error: 'Artikel nicht gefunden' }); - } - - res.json({ message: 'Artikel erfolgreich gelöscht' }); - } catch (error) { - console.error('Fehler beim Löschen des Artikels:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + const [r] = await pool.query('DELETE FROM artikel WHERE id=? AND tenant_id=?', + [req.params.id, req.user.tenant_id]); + if (r.affectedRows === 0) return res.status(404).json({ error: 'Artikel nicht gefunden' }); + res.json({ message: 'Artikel geloescht' }); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); export default router; diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 5994501..8023e4b 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -7,171 +7,117 @@ import { authenticateToken } from '../middleware/auth.js'; const router = express.Router(); -// Login +const issueToken = (m) => jwt.sign( + { id: m.id, email: m.email, rolle: m.rolle, name: m.name, tenant_id: m.tenant_id }, + process.env.JWT_SECRET, + { expiresIn: '24h' } +); + +// POST /api/auth/login +// body: { email, password, tenant_slug? } +// - tenant_slug optional: falls mehrere Tenants mit gleicher Mail (sollte selten sein) +// - SuperAdmins logen sich ohne tenant_slug ein. router.post('/login', [ body('email').isEmail().normalizeEmail(), body('password').notEmpty() ], async (req, res) => { const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); - const { email, password } = req.body; + const { email, password, tenant_slug } = req.body; try { - const [rows] = await pool.query( - 'SELECT * FROM mitarbeiter WHERE email = ?', - [email] - ); - - if (rows.length === 0) { - return res.status(401).json({ error: 'Ungültige Anmeldedaten' }); + let rows; + if (tenant_slug) { + [rows] = await pool.query( + `SELECT m.* FROM mitarbeiter m + JOIN tenants t ON m.tenant_id = t.id + WHERE m.email = ? AND t.slug = ? AND m.aktiv = 1`, + [email, tenant_slug] + ); + } else { + // Versuche zuerst SuperAdmin (tenant_id NULL), dann ersten Tenant-Match + [rows] = await pool.query( + `SELECT * FROM mitarbeiter + WHERE email = ? AND aktiv = 1 + ORDER BY (tenant_id IS NULL) DESC + LIMIT 1`, + [email] + ); } - const mitarbeiter = rows[0]; - const validPassword = await bcrypt.compare(password, mitarbeiter.password_hash); + if (rows.length === 0) return res.status(401).json({ error: 'Ungueltige Anmeldedaten' }); + const m = rows[0]; - if (!validPassword) { - return res.status(401).json({ error: 'Ungültige Anmeldedaten' }); + const ok = await bcrypt.compare(password, m.password_hash); + if (!ok) return res.status(401).json({ error: 'Ungueltige Anmeldedaten' }); + + // Demo-Ablauf-Pruefung + if (m.tenant_id) { + const [tr] = await pool.query( + 'SELECT status, demo_expires_at, typ, name FROM tenants WHERE id = ?', + [m.tenant_id] + ); + if (tr.length === 0 || tr[0].status !== 'aktiv') { + return res.status(403).json({ error: 'Tenant nicht aktiv' }); + } + if (tr[0].typ === 'demo' && tr[0].demo_expires_at && new Date(tr[0].demo_expires_at) < new Date()) { + await pool.query("UPDATE tenants SET status='abgelaufen' WHERE id = ?", [m.tenant_id]); + return res.status(403).json({ error: 'Demo-Zugang abgelaufen' }); + } } - const token = jwt.sign( - { - id: mitarbeiter.id, - email: mitarbeiter.email, - rolle: mitarbeiter.rolle, - name: mitarbeiter.name - }, - process.env.JWT_SECRET, - { expiresIn: '24h' } - ); - res.json({ - token, + token: issueToken(m), user: { - id: mitarbeiter.id, - email: mitarbeiter.email, - name: mitarbeiter.name, - rolle: mitarbeiter.rolle + id: m.id, email: m.email, name: m.name, rolle: m.rolle, tenant_id: m.tenant_id } }); - } catch (error) { - console.error('Login-Fehler:', error); + } catch (e) { + console.error('Login-Fehler:', e); res.status(500).json({ error: 'Serverfehler beim Login' }); } }); -// Register (nur für Admin) -router.post('/register', authenticateToken, [ - body('email').isEmail().normalizeEmail(), - body('password').isLength({ min: 6 }), - body('name').notEmpty(), - body('rolle').isIn(['user', 'admin']) -], async (req, res) => { - // Nur Admins dürfen neue Benutzer anlegen - if (req.user.rolle !== 'admin') { - return res.status(403).json({ error: 'Keine Berechtigung' }); - } - - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { email, password, name, rolle } = req.body; - - try { - // Prüfe ob Email bereits existiert - const [existing] = await pool.query( - 'SELECT id FROM mitarbeiter WHERE email = ?', - [email] - ); - - if (existing.length > 0) { - return res.status(400).json({ error: 'Email bereits registriert' }); - } - - const passwordHash = await bcrypt.hash(password, 10); - - const [result] = await pool.query( - 'INSERT INTO mitarbeiter (email, password_hash, name, rolle) VALUES (?, ?, ?, ?)', - [email, passwordHash, name, rolle] - ); - - res.status(201).json({ - message: 'Mitarbeiter erfolgreich angelegt', - id: result.insertId - }); - } catch (error) { - console.error('Registrierungs-Fehler:', error); - res.status(500).json({ error: 'Serverfehler bei der Registrierung' }); - } -}); - -// Get current user +// GET /api/auth/me router.get('/me', authenticateToken, async (req, res) => { try { const [rows] = await pool.query( - 'SELECT id, email, name, rolle, erstellt_am FROM mitarbeiter WHERE id = ?', + `SELECT m.id, m.email, m.name, m.rolle, m.tenant_id, m.erstellt_am, + t.name AS tenant_name, t.slug AS tenant_slug, + t.typ AS tenant_typ, t.demo_expires_at, t.status AS tenant_status + FROM mitarbeiter m + LEFT JOIN tenants t ON m.tenant_id = t.id + WHERE m.id = ?`, [req.user.id] ); - - if (rows.length === 0) { - return res.status(404).json({ error: 'Benutzer nicht gefunden' }); - } - + if (rows.length === 0) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); res.json(rows[0]); - } catch (error) { - console.error('Fehler beim Abrufen des Benutzers:', error); + } catch (e) { + console.error('me-Fehler:', e); res.status(500).json({ error: 'Serverfehler' }); } }); -// Change password +// POST /api/auth/change-password router.post('/change-password', authenticateToken, [ body('currentPassword').notEmpty(), body('newPassword').isLength({ min: 6 }) ], async (req, res) => { const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { currentPassword, newPassword } = req.body; + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); try { - // Hole aktuellen Benutzer - const [rows] = await pool.query( - 'SELECT * FROM mitarbeiter WHERE id = ?', - [req.user.id] - ); - - if (rows.length === 0) { - return res.status(404).json({ error: 'Benutzer nicht gefunden' }); - } - - const mitarbeiter = rows[0]; - - // Prüfe aktuelles Passwort - const validPassword = await bcrypt.compare(currentPassword, mitarbeiter.password_hash); - if (!validPassword) { - return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' }); - } - - // Hash neues Passwort - const newPasswordHash = await bcrypt.hash(newPassword, 10); - - // Update Passwort - await pool.query( - 'UPDATE mitarbeiter SET password_hash = ? WHERE id = ?', - [newPasswordHash, req.user.id] - ); - - res.json({ message: 'Passwort erfolgreich geändert' }); - } catch (error) { - console.error('Fehler beim Ändern des Passworts:', error); - res.status(500).json({ error: 'Serverfehler beim Ändern des Passworts' }); + const [rows] = await pool.query('SELECT password_hash FROM mitarbeiter WHERE id = ?', [req.user.id]); + if (rows.length === 0) return res.status(404).json({ error: 'Benutzer nicht gefunden' }); + const ok = await bcrypt.compare(req.body.currentPassword, rows[0].password_hash); + if (!ok) return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' }); + const hash = await bcrypt.hash(req.body.newPassword, 10); + await pool.query('UPDATE mitarbeiter SET password_hash = ? WHERE id = ?', [hash, req.user.id]); + res.json({ message: 'Passwort erfolgreich geaendert' }); + } catch (e) { + console.error('change-password-Fehler:', e); + res.status(500).json({ error: 'Serverfehler' }); } }); diff --git a/backend/routes/geraete.js b/backend/routes/geraete.js new file mode 100644 index 0000000..d552f18 --- /dev/null +++ b/backend/routes/geraete.js @@ -0,0 +1,110 @@ +import express from 'express'; +import { body, validationResult } from 'express-validator'; +import pool from '../config/database.js'; +import { authenticateToken, requireTenant } from '../middleware/auth.js'; + +const router = express.Router(); +router.use(authenticateToken, requireTenant); + +router.get('/', async (req, res) => { + try { + const [rows] = await pool.query( + `SELECT g.*, k.name AS kunde_name + FROM geraete g + LEFT JOIN kunden k ON g.kunde_id = k.id + WHERE g.tenant_id = ? + ORDER BY g.typ, g.seriennummer`, + [req.user.tenant_id] + ); + res.json(rows); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } +}); + +router.get('/:id', async (req, res) => { + try { + const [rows] = await pool.query( + `SELECT g.*, k.name AS kunde_name FROM geraete g + LEFT JOIN kunden k ON g.kunde_id = k.id + WHERE g.id = ? AND g.tenant_id = ?`, + [req.params.id, req.user.tenant_id] + ); + if (rows.length === 0) return res.status(404).json({ error: 'Geraet nicht gefunden' }); + res.json(rows[0]); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } +}); + +router.post('/', [ + body('seriennummer').notEmpty().trim(), + body('typ').notEmpty().trim(), + body('bezeichnung').optional({ checkFalsy: true }).trim(), + body('kunde_id').optional({ checkFalsy: true }), + body('status').optional().isIn(['aktiv', 'in_reparatur', 'ausgemustert', 'lager']), + body('notiz').optional({ checkFalsy: true }).trim() +], async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { seriennummer, typ, bezeichnung, kunde_id, status, notiz } = req.body; + try { + if (kunde_id) { + const [[k]] = await pool.query('SELECT id FROM kunden WHERE id=? AND tenant_id=?', + [kunde_id, req.user.tenant_id]); + if (!k) return res.status(400).json({ error: 'Kunde nicht im Tenant' }); + } + await pool.query( + `INSERT INTO geraete (id, tenant_id, seriennummer, typ, bezeichnung, kunde_id, status, notiz) + VALUES (UUID(), ?, ?, ?, ?, ?, ?, ?)`, + [req.user.tenant_id, seriennummer, typ, bezeichnung || null, + kunde_id || null, status || 'aktiv', notiz || null] + ); + const [rows] = await pool.query( + 'SELECT * FROM geraete WHERE tenant_id = ? AND seriennummer = ?', + [req.user.tenant_id, seriennummer] + ); + res.status(201).json(rows[0]); + } catch (e) { + if (e.code === 'ER_DUP_ENTRY') return res.status(400).json({ error: 'Seriennummer bereits vergeben' }); + console.error('POST geraete:', e); + res.status(500).json({ error: 'Serverfehler' }); + } +}); + +router.put('/:id', [ + body('seriennummer').notEmpty().trim(), + body('typ').notEmpty().trim(), + body('status').optional().isIn(['aktiv', 'in_reparatur', 'ausgemustert', 'lager']) +], async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { seriennummer, typ, bezeichnung, kunde_id, status, notiz } = req.body; + try { + if (kunde_id) { + const [[k]] = await pool.query('SELECT id FROM kunden WHERE id=? AND tenant_id=?', + [kunde_id, req.user.tenant_id]); + if (!k) return res.status(400).json({ error: 'Kunde nicht im Tenant' }); + } + const [r] = await pool.query( + `UPDATE geraete SET seriennummer=?, typ=?, bezeichnung=?, kunde_id=?, status=?, notiz=? + WHERE id=? AND tenant_id=?`, + [seriennummer, typ, bezeichnung || null, kunde_id || null, + status || 'aktiv', notiz || null, req.params.id, req.user.tenant_id] + ); + if (r.affectedRows === 0) return res.status(404).json({ error: 'Geraet nicht gefunden' }); + const [rows] = await pool.query('SELECT * FROM geraete WHERE id=? AND tenant_id=?', + [req.params.id, req.user.tenant_id]); + res.json(rows[0]); + } catch (e) { + if (e.code === 'ER_DUP_ENTRY') return res.status(400).json({ error: 'Seriennummer bereits vergeben' }); + res.status(500).json({ error: 'Serverfehler' }); + } +}); + +router.delete('/:id', async (req, res) => { + try { + const [r] = await pool.query('DELETE FROM geraete WHERE id=? AND tenant_id=?', + [req.params.id, req.user.tenant_id]); + if (r.affectedRows === 0) return res.status(404).json({ error: 'Geraet nicht gefunden' }); + res.json({ message: 'Geraet geloescht' }); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } +}); + +export default router; diff --git a/backend/routes/kunden-bilder.js b/backend/routes/kunden-bilder.js new file mode 100644 index 0000000..ad55a76 --- /dev/null +++ b/backend/routes/kunden-bilder.js @@ -0,0 +1,84 @@ +import express from 'express'; +import fs from 'fs'; +import path from 'path'; +import pool from '../config/database.js'; +import { authenticateToken, requireTenant } from '../middleware/auth.js'; +import { upload } from '../middleware/upload.js'; + +const router = express.Router(); +router.use(authenticateToken, requireTenant); + +// POST /api/kunden-bilder/:kunde_id (Multipart, Feld: foto) +router.post('/:kunde_id', upload.single('foto'), async (req, res) => { + if (!req.file) return res.status(400).json({ error: 'Kein Bild hochgeladen' }); + try { + const [[k]] = await pool.query('SELECT id FROM kunden WHERE id=? AND tenant_id=?', + [req.params.kunde_id, req.user.tenant_id]); + if (!k) { + // Datei aufraeumen + try { fs.unlinkSync(req.file.path); } catch {} + return res.status(404).json({ error: 'Kunde nicht gefunden' }); + } + const [[c]] = await pool.query( + 'SELECT COUNT(*) AS n FROM kunden_bilder WHERE kunde_id = ? AND tenant_id = ?', + [req.params.kunde_id, req.user.tenant_id] + ); + if (c.n >= 5) { + try { fs.unlinkSync(req.file.path); } catch {} + return res.status(400).json({ error: 'Max. 5 Bilder pro Kunde' }); + } + await pool.query( + `INSERT INTO kunden_bilder (id, tenant_id, kunde_id, dateiname, beschreibung, sortierung) + VALUES (UUID(), ?, ?, ?, ?, ?)`, + [req.user.tenant_id, req.params.kunde_id, `/uploads/${req.file.filename}`, + req.body.beschreibung || null, parseInt(req.body.sortierung) || c.n] + ); + const [rows] = await pool.query( + `SELECT * FROM kunden_bilder WHERE kunde_id = ? AND tenant_id = ? + ORDER BY sortierung, erstellt_am`, + [req.params.kunde_id, req.user.tenant_id] + ); + res.status(201).json(rows); + } catch (e) { + console.error('POST kunden-bilder:', e); + res.status(500).json({ error: 'Serverfehler' }); + } +}); + +// PATCH /api/kunden-bilder/:id -> beschreibung / sortierung +router.patch('/bild/:id', async (req, res) => { + const updates = []; + const values = []; + if (req.body.beschreibung !== undefined) { updates.push('beschreibung = ?'); values.push(req.body.beschreibung); } + if (req.body.sortierung !== undefined) { updates.push('sortierung = ?'); values.push(parseInt(req.body.sortierung)); } + if (updates.length === 0) return res.status(400).json({ error: 'Keine Aenderungen' }); + values.push(req.params.id, req.user.tenant_id); + try { + const [r] = await pool.query( + `UPDATE kunden_bilder SET ${updates.join(', ')} WHERE id = ? AND tenant_id = ?`, + values + ); + if (r.affectedRows === 0) return res.status(404).json({ error: 'Bild nicht gefunden' }); + res.json({ message: 'Aktualisiert' }); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } +}); + +// DELETE /api/kunden-bilder/bild/:id +router.delete('/bild/:id', async (req, res) => { + try { + const [rows] = await pool.query( + 'SELECT dateiname FROM kunden_bilder WHERE id = ? AND tenant_id = ?', + [req.params.id, req.user.tenant_id] + ); + if (rows.length === 0) return res.status(404).json({ error: 'Bild nicht gefunden' }); + await pool.query('DELETE FROM kunden_bilder WHERE id = ? AND tenant_id = ?', + [req.params.id, req.user.tenant_id]); + // Datei loeschen (best effort) + const filename = rows[0].dateiname.replace(/^\/uploads\//, ''); + const fullPath = path.join(process.env.UPLOAD_DIR || '/app/uploads', filename); + try { fs.unlinkSync(fullPath); } catch {} + res.json({ message: 'Bild geloescht' }); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } +}); + +export default router; diff --git a/backend/routes/kunden.js b/backend/routes/kunden.js index 0329910..8a179c8 100644 --- a/backend/routes/kunden.js +++ b/backend/routes/kunden.js @@ -1,126 +1,100 @@ import express from 'express'; import { body, validationResult } from 'express-validator'; import pool from '../config/database.js'; -import { authenticateToken } from '../middleware/auth.js'; +import { authenticateToken, requireTenant } from '../middleware/auth.js'; const router = express.Router(); +router.use(authenticateToken, requireTenant); -// Alle Routen erfordern Authentifizierung -router.use(authenticateToken); - -// GET alle Kunden router.get('/', async (req, res) => { try { const [rows] = await pool.query( - 'SELECT * FROM kunden ORDER BY name ASC' + 'SELECT * FROM kunden WHERE tenant_id = ? ORDER BY name ASC', + [req.user.tenant_id] ); res.json(rows); - } catch (error) { - console.error('Fehler beim Abrufen der Kunden:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// GET einzelner Kunde router.get('/:id', async (req, res) => { try { const [rows] = await pool.query( - 'SELECT * FROM kunden WHERE id = ?', - [req.params.id] + 'SELECT * FROM kunden WHERE id = ? AND tenant_id = ?', + [req.params.id, req.user.tenant_id] ); - - if (rows.length === 0) { - return res.status(404).json({ error: 'Kunde nicht gefunden' }); - } - - res.json(rows[0]); - } catch (error) { - console.error('Fehler beim Abrufen des Kunden:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + if (rows.length === 0) return res.status(404).json({ error: 'Kunde nicht gefunden' }); + const [bilder] = await pool.query( + 'SELECT id, dateiname, beschreibung, sortierung FROM kunden_bilder WHERE kunde_id = ? AND tenant_id = ? ORDER BY sortierung, erstellt_am', + [req.params.id, req.user.tenant_id] + ); + res.json({ ...rows[0], bilder }); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// POST neuer Kunde router.post('/', [ body('name').notEmpty().trim(), - body('email').optional().isEmail().normalizeEmail(), - body('telefon').optional().trim(), - body('adresse').optional().trim(), - body('ansprechpartner').optional().trim(), - body('notiz').optional().trim(), - body('anrede').optional().isIn(['Herr', 'Frau', 'Firma', '']) + body('email').optional({ checkFalsy: true }).isEmail().normalizeEmail(), + body('lieferzeit_bis').optional({ checkFalsy: true }).matches(/^\d{1,2}:\d{2}$/), + body('anrede').optional({ checkFalsy: true }).isIn(['Herr', 'Frau', 'Firma']) ], async (req, res) => { const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { name, email, telefon, adresse, ansprechpartner, notiz, anrede } = req.body; - + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { name, email, telefon, adresse, ansprechpartner, notiz, anrede, + anlieferungshinweis, lieferzeit_bis } = req.body; try { const [result] = await pool.query( - 'INSERT INTO kunden (name, email, telefon, adresse, ansprechpartner, notiz, anrede) VALUES (?, ?, ?, ?, ?, ?, ?)', - [name, email || null, telefon || null, adresse || null, ansprechpartner || null, notiz || null, anrede || null] + `INSERT INTO kunden (id, tenant_id, name, email, telefon, adresse, ansprechpartner, notiz, anrede, + anlieferungshinweis, lieferzeit_bis) + VALUES (UUID(), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [req.user.tenant_id, name, email || null, telefon || null, adresse || null, + ansprechpartner || null, notiz || null, anrede || null, + anlieferungshinweis || null, lieferzeit_bis || null] ); - - const [newKunde] = await pool.query('SELECT * FROM kunden WHERE id = ?', [result.insertId]); - res.status(201).json(newKunde[0]); - } catch (error) { - console.error('Fehler beim Anlegen des Kunden:', error); + // Re-fetch via tenant + name (id ist UUID, neuer Wert kennen wir nicht direkt) + const [rows] = await pool.query( + 'SELECT * FROM kunden WHERE tenant_id = ? ORDER BY erstellt_am DESC LIMIT 1', + [req.user.tenant_id] + ); + res.status(201).json(rows[0]); + } catch (e) { + console.error('POST kunden:', e); res.status(500).json({ error: 'Serverfehler' }); } }); -// PUT Kunde aktualisieren router.put('/:id', [ body('name').notEmpty().trim(), - body('email').optional().isEmail().normalizeEmail(), - body('telefon').optional().trim(), - body('adresse').optional().trim(), - body('ansprechpartner').optional().trim(), - body('notiz').optional().trim(), - body('anrede').optional().isIn(['Herr', 'Frau', 'Firma', '']) + body('email').optional({ checkFalsy: true }).isEmail().normalizeEmail(), + body('lieferzeit_bis').optional({ checkFalsy: true }).matches(/^\d{1,2}:\d{2}$/), + body('anrede').optional({ checkFalsy: true }).isIn(['Herr', 'Frau', 'Firma']) ], async (req, res) => { const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { name, email, telefon, adresse, ansprechpartner, notiz, anrede } = req.body; - + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + const { name, email, telefon, adresse, ansprechpartner, notiz, anrede, + anlieferungshinweis, lieferzeit_bis } = req.body; try { - await pool.query( - 'UPDATE kunden SET name = ?, email = ?, telefon = ?, adresse = ?, ansprechpartner = ?, notiz = ?, anrede = ? WHERE id = ?', - [name, email || null, telefon || null, adresse || null, ansprechpartner || null, notiz || null, anrede || null, req.params.id] + const [r] = await pool.query( + `UPDATE kunden SET name=?, email=?, telefon=?, adresse=?, ansprechpartner=?, notiz=?, anrede=?, + anlieferungshinweis=?, lieferzeit_bis=? + WHERE id=? AND tenant_id=?`, + [name, email || null, telefon || null, adresse || null, ansprechpartner || null, + notiz || null, anrede || null, anlieferungshinweis || null, lieferzeit_bis || null, + req.params.id, req.user.tenant_id] ); - - const [updated] = await pool.query('SELECT * FROM kunden WHERE id = ?', [req.params.id]); - - if (updated.length === 0) { - return res.status(404).json({ error: 'Kunde nicht gefunden' }); - } - - res.json(updated[0]); - } catch (error) { - console.error('Fehler beim Aktualisieren des Kunden:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + if (r.affectedRows === 0) return res.status(404).json({ error: 'Kunde nicht gefunden' }); + const [rows] = await pool.query('SELECT * FROM kunden WHERE id = ? AND tenant_id = ?', + [req.params.id, req.user.tenant_id]); + res.json(rows[0]); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// DELETE Kunde router.delete('/:id', async (req, res) => { try { - const [result] = await pool.query('DELETE FROM kunden WHERE id = ?', [req.params.id]); - - if (result.affectedRows === 0) { - return res.status(404).json({ error: 'Kunde nicht gefunden' }); - } - - res.json({ message: 'Kunde erfolgreich gelöscht' }); - } catch (error) { - console.error('Fehler beim Löschen des Kunden:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + const [r] = await pool.query('DELETE FROM kunden WHERE id=? AND tenant_id=?', + [req.params.id, req.user.tenant_id]); + if (r.affectedRows === 0) return res.status(404).json({ error: 'Kunde nicht gefunden' }); + res.json({ message: 'Kunde geloescht' }); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); export default router; diff --git a/backend/routes/lieferungen.js b/backend/routes/lieferungen.js index 4b42ce3..1ed27f7 100644 --- a/backend/routes/lieferungen.js +++ b/backend/routes/lieferungen.js @@ -1,116 +1,77 @@ import express from 'express'; import { body, validationResult } from 'express-validator'; import pool from '../config/database.js'; -import { authenticateToken } from '../middleware/auth.js'; +import { authenticateToken, requireTenant } from '../middleware/auth.js'; import { upload } from '../middleware/upload.js'; const router = express.Router(); +router.use(authenticateToken, requireTenant); -// Alle Routen erfordern Authentifizierung -router.use(authenticateToken); +const baseSelect = ` + SELECT l.*, k.name AS kunde_name, a.bezeichnung AS artikel_bezeichnung, + a.pfand_betrag, m.name AS mitarbeiter_name + FROM lieferungen l + LEFT JOIN kunden k ON l.kunde_id = k.id + LEFT JOIN artikel a ON l.artikel_id = a.id + LEFT JOIN mitarbeiter m ON l.mitarbeiter_id = m.id`; -// GET alle Lieferungen router.get('/', async (req, res) => { try { - const [rows] = await pool.query(` - SELECT - l.*, - k.name as kunde_name, - a.bezeichnung as artikel_bezeichnung, - a.pfand_betrag, - m.name as mitarbeiter_name - FROM lieferungen l - LEFT JOIN kunden k ON l.kunde_id = k.id - LEFT JOIN artikel a ON l.artikel_id = a.id - LEFT JOIN mitarbeiter m ON l.mitarbeiter_id = m.id - ORDER BY l.erstellt_am DESC - `); + const [rows] = await pool.query( + `${baseSelect} WHERE l.tenant_id = ? ORDER BY l.erstellt_am DESC`, + [req.user.tenant_id] + ); res.json(rows); - } catch (error) { - console.error('Fehler beim Abrufen der Lieferungen:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// GET Lieferungen nach Kunde router.get('/kunde/:kunde_id', async (req, res) => { try { - const [rows] = await pool.query(` - SELECT - l.*, - k.name as kunde_name, - a.bezeichnung as artikel_bezeichnung, - a.pfand_betrag, - m.name as mitarbeiter_name - FROM lieferungen l - LEFT JOIN kunden k ON l.kunde_id = k.id - LEFT JOIN artikel a ON l.artikel_id = a.id - LEFT JOIN mitarbeiter m ON l.mitarbeiter_id = m.id - WHERE l.kunde_id = ? - ORDER BY l.erstellt_am DESC - `, [req.params.kunde_id]); + const [rows] = await pool.query( + `${baseSelect} WHERE l.tenant_id = ? AND l.kunde_id = ? ORDER BY l.erstellt_am DESC`, + [req.user.tenant_id, req.params.kunde_id] + ); res.json(rows); - } catch (error) { - console.error('Fehler beim Abrufen der Lieferungen:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// POST neue Lieferung (mit optionalem Foto) router.post('/', upload.single('foto'), [ body('kunde_id').notEmpty(), body('artikel_id').notEmpty(), body('anzahl').isInt({ min: 1 }) ], async (req, res) => { const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { kunde_id, artikel_id, anzahl } = req.body; + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); const foto_url = req.file ? `/uploads/${req.file.filename}` : null; - try { + // Sicherstellen: kunde_id + artikel_id gehoeren dem Tenant + const [[k]] = await pool.query('SELECT id FROM kunden WHERE id=? AND tenant_id=?', + [req.body.kunde_id, req.user.tenant_id]); + const [[a]] = await pool.query('SELECT id FROM artikel WHERE id=? AND tenant_id=?', + [req.body.artikel_id, req.user.tenant_id]); + if (!k || !a) return res.status(400).json({ error: 'Kunde oder Artikel nicht im Tenant' }); + const [result] = await pool.query( - 'INSERT INTO lieferungen (kunde_id, artikel_id, anzahl, foto_url, mitarbeiter_id) VALUES (?, ?, ?, ?, ?)', - [kunde_id, artikel_id, anzahl, foto_url, req.user.id] + `INSERT INTO lieferungen (tenant_id, kunde_id, artikel_id, anzahl, foto_url, mitarbeiter_id) + VALUES (?, ?, ?, ?, ?, ?)`, + [req.user.tenant_id, req.body.kunde_id, req.body.artikel_id, req.body.anzahl, + foto_url, req.user.id] ); - - const [newLieferung] = await pool.query(` - SELECT - l.*, - k.name as kunde_name, - a.bezeichnung as artikel_bezeichnung, - a.pfand_betrag, - m.name as mitarbeiter_name - FROM lieferungen l - LEFT JOIN kunden k ON l.kunde_id = k.id - LEFT JOIN artikel a ON l.artikel_id = a.id - LEFT JOIN mitarbeiter m ON l.mitarbeiter_id = m.id - WHERE l.id = ? - `, [result.insertId]); - - res.status(201).json(newLieferung[0]); - } catch (error) { - console.error('Fehler beim Anlegen der Lieferung:', error); + const [rows] = await pool.query(`${baseSelect} WHERE l.id = ?`, [result.insertId]); + res.status(201).json(rows[0]); + } catch (e) { + console.error('POST lieferungen:', e); res.status(500).json({ error: 'Serverfehler' }); } }); -// DELETE Lieferung router.delete('/:id', async (req, res) => { try { - const [result] = await pool.query('DELETE FROM lieferungen WHERE id = ?', [req.params.id]); - - if (result.affectedRows === 0) { - return res.status(404).json({ error: 'Lieferung nicht gefunden' }); - } - - res.json({ message: 'Lieferung erfolgreich gelöscht' }); - } catch (error) { - console.error('Fehler beim Löschen der Lieferung:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + const [r] = await pool.query('DELETE FROM lieferungen WHERE id=? AND tenant_id=?', + [req.params.id, req.user.tenant_id]); + if (r.affectedRows === 0) return res.status(404).json({ error: 'Lieferung nicht gefunden' }); + res.json({ message: 'Lieferung geloescht' }); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); export default router; diff --git a/backend/routes/mitarbeiter.js b/backend/routes/mitarbeiter.js index df08fba..b08808a 100644 --- a/backend/routes/mitarbeiter.js +++ b/backend/routes/mitarbeiter.js @@ -1,126 +1,102 @@ import express from 'express'; +import bcrypt from 'bcrypt'; import { body, validationResult } from 'express-validator'; import pool from '../config/database.js'; -import { authenticateToken, requireRole } from '../middleware/auth.js'; +import { authenticateToken, requireTenant, requireRole } from '../middleware/auth.js'; const router = express.Router(); +router.use(authenticateToken, requireTenant); -// Alle Routen erfordern Authentifizierung -router.use(authenticateToken); - -// GET alle Mitarbeiter (nur Admin) -router.get('/', requireRole(['admin']), async (req, res) => { +router.get('/', requireRole(['admin', 'superadmin']), async (req, res) => { try { const [rows] = await pool.query( - 'SELECT id, email, name, rolle, erstellt_am FROM mitarbeiter ORDER BY name ASC' + `SELECT id, email, name, rolle, aktiv, erstellt_am FROM mitarbeiter + WHERE tenant_id = ? ORDER BY name ASC`, + [req.user.tenant_id] ); res.json(rows); - } catch (error) { - console.error('Fehler beim Abrufen der Mitarbeiter:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// GET einzelner Mitarbeiter -router.get('/:id', async (req, res) => { - try { - const [rows] = await pool.query( - 'SELECT id, email, name, rolle, erstellt_am FROM mitarbeiter WHERE id = ?', - [req.params.id] - ); - - if (rows.length === 0) { - return res.status(404).json({ error: 'Mitarbeiter nicht gefunden' }); - } - - res.json(rows[0]); - } catch (error) { - console.error('Fehler beim Abrufen des Mitarbeiters:', error); - res.status(500).json({ error: 'Serverfehler' }); - } -}); - -// PUT Mitarbeiter aktualisieren (nur Admin oder eigener Account) -router.put('/:id', [ - body('name').optional().trim(), - body('rolle').optional().isIn(['user', 'admin']) +router.post('/', requireRole(['admin']), [ + body('email').isEmail().normalizeEmail(), + body('password').isLength({ min: 6 }), + body('name').notEmpty().trim(), + body('rolle').isIn(['user', 'admin', 'fahrer']) ], async (req, res) => { const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + try { + // Pruefe Limit + const [[t]] = await pool.query('SELECT max_users FROM tenants WHERE id = ?', [req.user.tenant_id]); + const [[c]] = await pool.query('SELECT COUNT(*) AS n FROM mitarbeiter WHERE tenant_id = ?', + [req.user.tenant_id]); + if (c.n >= t.max_users) return res.status(403).json({ error: `Limit erreicht (${t.max_users} User)` }); + + const [[ex]] = await pool.query( + 'SELECT id FROM mitarbeiter WHERE email = ? AND tenant_id = ?', + [req.body.email, req.user.tenant_id] + ); + if (ex) return res.status(400).json({ error: 'Email bereits vergeben' }); + + const hash = await bcrypt.hash(req.body.password, 10); + await pool.query( + `INSERT INTO mitarbeiter (id, tenant_id, email, password_hash, name, rolle, aktiv) + VALUES (UUID(), ?, ?, ?, ?, ?, 1)`, + [req.user.tenant_id, req.body.email, hash, req.body.name, req.body.rolle] + ); + res.status(201).json({ message: 'Mitarbeiter angelegt' }); + } catch (e) { + console.error('POST mitarbeiter:', e); + res.status(500).json({ error: 'Serverfehler' }); + } +}); + +router.put('/:id', [ + body('name').optional().trim(), + body('rolle').optional().isIn(['user', 'admin', 'fahrer']), + body('aktiv').optional().isBoolean() +], async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); - // Nur Admin darf andere Mitarbeiter bearbeiten oder Rollen ändern if (req.params.id !== req.user.id && req.user.rolle !== 'admin') { return res.status(403).json({ error: 'Keine Berechtigung' }); } - - // Nur Admin darf Rollen ändern - if (req.body.rolle && req.user.rolle !== 'admin') { - return res.status(403).json({ error: 'Keine Berechtigung zum Ändern der Rolle' }); + if ((req.body.rolle || req.body.aktiv !== undefined) && req.user.rolle !== 'admin') { + return res.status(403).json({ error: 'Nur Admin darf Rolle/Aktiv aendern' }); } - const { name, rolle } = req.body; const updates = []; const values = []; - - if (name) { - updates.push('name = ?'); - values.push(name); - } - - if (rolle && req.user.rolle === 'admin') { - updates.push('rolle = ?'); - values.push(rolle); - } - - if (updates.length === 0) { - return res.status(400).json({ error: 'Keine Änderungen angegeben' }); - } - - values.push(req.params.id); + if (req.body.name) { updates.push('name = ?'); values.push(req.body.name); } + if (req.body.rolle) { updates.push('rolle = ?'); values.push(req.body.rolle); } + if (req.body.aktiv !== undefined) { updates.push('aktiv = ?'); values.push(req.body.aktiv ? 1 : 0); } + if (updates.length === 0) return res.status(400).json({ error: 'Keine Aenderungen' }); + values.push(req.params.id, req.user.tenant_id); try { - await pool.query( - `UPDATE mitarbeiter SET ${updates.join(', ')} WHERE id = ?`, + const [r] = await pool.query( + `UPDATE mitarbeiter SET ${updates.join(', ')} WHERE id = ? AND tenant_id = ?`, values ); - - const [updated] = await pool.query( - 'SELECT id, email, name, rolle, erstellt_am FROM mitarbeiter WHERE id = ?', - [req.params.id] + if (r.affectedRows === 0) return res.status(404).json({ error: 'Mitarbeiter nicht gefunden' }); + const [rows] = await pool.query( + 'SELECT id, email, name, rolle, aktiv FROM mitarbeiter WHERE id = ? AND tenant_id = ?', + [req.params.id, req.user.tenant_id] ); - - if (updated.length === 0) { - return res.status(404).json({ error: 'Mitarbeiter nicht gefunden' }); - } - - res.json(updated[0]); - } catch (error) { - console.error('Fehler beim Aktualisieren des Mitarbeiters:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + res.json(rows[0]); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// DELETE Mitarbeiter (nur Admin) router.delete('/:id', requireRole(['admin']), async (req, res) => { - // Verhindere Selbstlöschung - if (req.params.id === req.user.id) { - return res.status(400).json({ error: 'Sie können sich nicht selbst löschen' }); - } - + if (req.params.id === req.user.id) return res.status(400).json({ error: 'Sie koennen sich nicht selbst loeschen' }); try { - const [result] = await pool.query('DELETE FROM mitarbeiter WHERE id = ?', [req.params.id]); - - if (result.affectedRows === 0) { - return res.status(404).json({ error: 'Mitarbeiter nicht gefunden' }); - } - - res.json({ message: 'Mitarbeiter erfolgreich gelöscht' }); - } catch (error) { - console.error('Fehler beim Löschen des Mitarbeiters:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + const [r] = await pool.query('DELETE FROM mitarbeiter WHERE id=? AND tenant_id=?', + [req.params.id, req.user.tenant_id]); + if (r.affectedRows === 0) return res.status(404).json({ error: 'Mitarbeiter nicht gefunden' }); + res.json({ message: 'Mitarbeiter geloescht' }); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); export default router; diff --git a/backend/routes/ruecknahmen.js b/backend/routes/ruecknahmen.js index 7d76484..d4b2bab 100644 --- a/backend/routes/ruecknahmen.js +++ b/backend/routes/ruecknahmen.js @@ -1,116 +1,76 @@ import express from 'express'; import { body, validationResult } from 'express-validator'; import pool from '../config/database.js'; -import { authenticateToken } from '../middleware/auth.js'; +import { authenticateToken, requireTenant } from '../middleware/auth.js'; import { upload } from '../middleware/upload.js'; const router = express.Router(); +router.use(authenticateToken, requireTenant); -// Alle Routen erfordern Authentifizierung -router.use(authenticateToken); +const baseSelect = ` + SELECT r.*, k.name AS kunde_name, a.bezeichnung AS artikel_bezeichnung, + a.pfand_betrag, m.name AS mitarbeiter_name + FROM ruecknahmen r + LEFT JOIN kunden k ON r.kunde_id = k.id + LEFT JOIN artikel a ON r.artikel_id = a.id + LEFT JOIN mitarbeiter m ON r.mitarbeiter_id = m.id`; -// GET alle Rücknahmen router.get('/', async (req, res) => { try { - const [rows] = await pool.query(` - SELECT - r.*, - k.name as kunde_name, - a.bezeichnung as artikel_bezeichnung, - a.pfand_betrag, - m.name as mitarbeiter_name - FROM ruecknahmen r - LEFT JOIN kunden k ON r.kunde_id = k.id - LEFT JOIN artikel a ON r.artikel_id = a.id - LEFT JOIN mitarbeiter m ON r.mitarbeiter_id = m.id - ORDER BY r.erstellt_am DESC - `); + const [rows] = await pool.query( + `${baseSelect} WHERE r.tenant_id = ? ORDER BY r.erstellt_am DESC`, + [req.user.tenant_id] + ); res.json(rows); - } catch (error) { - console.error('Fehler beim Abrufen der Rücknahmen:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// GET Rücknahmen nach Kunde router.get('/kunde/:kunde_id', async (req, res) => { try { - const [rows] = await pool.query(` - SELECT - r.*, - k.name as kunde_name, - a.bezeichnung as artikel_bezeichnung, - a.pfand_betrag, - m.name as mitarbeiter_name - FROM ruecknahmen r - LEFT JOIN kunden k ON r.kunde_id = k.id - LEFT JOIN artikel a ON r.artikel_id = a.id - LEFT JOIN mitarbeiter m ON r.mitarbeiter_id = m.id - WHERE r.kunde_id = ? - ORDER BY r.erstellt_am DESC - `, [req.params.kunde_id]); + const [rows] = await pool.query( + `${baseSelect} WHERE r.tenant_id = ? AND r.kunde_id = ? ORDER BY r.erstellt_am DESC`, + [req.user.tenant_id, req.params.kunde_id] + ); res.json(rows); - } catch (error) { - console.error('Fehler beim Abrufen der Rücknahmen:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); -// POST neue Rücknahme (mit optionalem Foto) router.post('/', upload.single('foto'), [ body('kunde_id').notEmpty(), body('artikel_id').notEmpty(), body('anzahl').isInt({ min: 1 }) ], async (req, res) => { const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } - - const { kunde_id, artikel_id, anzahl } = req.body; + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); const foto_url = req.file ? `/uploads/${req.file.filename}` : null; - try { + const [[k]] = await pool.query('SELECT id FROM kunden WHERE id=? AND tenant_id=?', + [req.body.kunde_id, req.user.tenant_id]); + const [[a]] = await pool.query('SELECT id FROM artikel WHERE id=? AND tenant_id=?', + [req.body.artikel_id, req.user.tenant_id]); + if (!k || !a) return res.status(400).json({ error: 'Kunde oder Artikel nicht im Tenant' }); + const [result] = await pool.query( - 'INSERT INTO ruecknahmen (kunde_id, artikel_id, anzahl, foto_url, mitarbeiter_id) VALUES (?, ?, ?, ?, ?)', - [kunde_id, artikel_id, anzahl, foto_url, req.user.id] + `INSERT INTO ruecknahmen (tenant_id, kunde_id, artikel_id, anzahl, foto_url, mitarbeiter_id) + VALUES (?, ?, ?, ?, ?, ?)`, + [req.user.tenant_id, req.body.kunde_id, req.body.artikel_id, req.body.anzahl, + foto_url, req.user.id] ); - - const [newRuecknahme] = await pool.query(` - SELECT - r.*, - k.name as kunde_name, - a.bezeichnung as artikel_bezeichnung, - a.pfand_betrag, - m.name as mitarbeiter_name - FROM ruecknahmen r - LEFT JOIN kunden k ON r.kunde_id = k.id - LEFT JOIN artikel a ON r.artikel_id = a.id - LEFT JOIN mitarbeiter m ON r.mitarbeiter_id = m.id - WHERE r.id = ? - `, [result.insertId]); - - res.status(201).json(newRuecknahme[0]); - } catch (error) { - console.error('Fehler beim Anlegen der Rücknahme:', error); + const [rows] = await pool.query(`${baseSelect} WHERE r.id = ?`, [result.insertId]); + res.status(201).json(rows[0]); + } catch (e) { + console.error('POST ruecknahmen:', e); res.status(500).json({ error: 'Serverfehler' }); } }); -// DELETE Rücknahme router.delete('/:id', async (req, res) => { try { - const [result] = await pool.query('DELETE FROM ruecknahmen WHERE id = ?', [req.params.id]); - - if (result.affectedRows === 0) { - return res.status(404).json({ error: 'Rücknahme nicht gefunden' }); - } - - res.json({ message: 'Rücknahme erfolgreich gelöscht' }); - } catch (error) { - console.error('Fehler beim Löschen der Rücknahme:', error); - res.status(500).json({ error: 'Serverfehler' }); - } + const [r] = await pool.query('DELETE FROM ruecknahmen WHERE id=? AND tenant_id=?', + [req.params.id, req.user.tenant_id]); + if (r.affectedRows === 0) return res.status(404).json({ error: 'Ruecknahme nicht gefunden' }); + res.json({ message: 'Ruecknahme geloescht' }); + } catch (e) { res.status(500).json({ error: 'Serverfehler' }); } }); export default router; diff --git a/backend/routes/tenants.js b/backend/routes/tenants.js new file mode 100644 index 0000000..ef1e32f --- /dev/null +++ b/backend/routes/tenants.js @@ -0,0 +1,140 @@ +import express from 'express'; +import bcrypt from 'bcrypt'; +import { body, validationResult } from 'express-validator'; +import crypto from 'crypto'; +import pool from '../config/database.js'; +import { authenticateToken, requireSuperAdmin } from '../middleware/auth.js'; +import { sendDemoInviteMail } from '../lib/mail.js'; +import { seedDemoTenant } from '../lib/seed.js'; + +const router = express.Router(); +router.use(authenticateToken, requireSuperAdmin); + +// GET /api/tenants +router.get('/', async (req, res) => { + try { + const [rows] = await pool.query( + `SELECT t.*, + (SELECT COUNT(*) FROM mitarbeiter WHERE tenant_id = t.id) AS user_count, + (SELECT COUNT(*) FROM kunden WHERE tenant_id = t.id) AS kunden_count + FROM tenants t + ORDER BY t.erstellt_am DESC` + ); + res.json(rows); + } catch (e) { + console.error('GET tenants:', e); + res.status(500).json({ error: 'Serverfehler' }); + } +}); + +// GET /api/tenants/:id +router.get('/:id', async (req, res) => { + try { + const [rows] = await pool.query('SELECT * FROM tenants WHERE id = ?', [req.params.id]); + if (rows.length === 0) return res.status(404).json({ error: 'Tenant nicht gefunden' }); + res.json(rows[0]); + } catch (e) { + res.status(500).json({ error: 'Serverfehler' }); + } +}); + +// POST /api/tenants -> legt Demo-Tenant + Admin-User + Sample-Daten an, schickt Invite-Mail +// body: { name, kontakt_email, firma?, days? (default 30), seed? (default true) } +router.post('/', [ + body('name').notEmpty().trim(), + body('kontakt_email').isEmail().normalizeEmail(), + body('days').optional().isInt({ min: 1, max: 365 }) +], async (req, res) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); + + const { name, kontakt_email, firma, days = 30, seed = true } = req.body; + const slug = (firma || name).toLowerCase() + .replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 50) + + '-' + crypto.randomBytes(2).toString('hex'); + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + + // tenant + const tenantId = crypto.randomUUID(); + await conn.query( + `INSERT INTO tenants (id, name, slug, kontakt_email, firma, typ, status, demo_expires_at, max_users) + VALUES (?, ?, ?, ?, ?, 'demo', 'aktiv', DATE_ADD(NOW(), INTERVAL ? DAY), 5)`, + [tenantId, name, slug, kontakt_email, firma || null, days] + ); + + // admin user + const adminId = crypto.randomUUID(); + const plainPw = crypto.randomBytes(8).toString('base64url'); + const hash = await bcrypt.hash(plainPw, 10); + await conn.query( + `INSERT INTO mitarbeiter (id, tenant_id, email, password_hash, name, rolle, aktiv) + VALUES (?, ?, ?, ?, ?, 'admin', 1)`, + [adminId, tenantId, kontakt_email, hash, name] + ); + + if (seed) await seedDemoTenant(conn, tenantId); + + await conn.commit(); + + // Mail asynchron - Fehler nicht blockend + sendDemoInviteMail({ + to: kontakt_email, name, slug, password: plainPw, + loginUrl: `https://pfandsystem.dockly.de/login?tenant=${slug}`, + days + }).catch(err => console.error('Mail-Fehler:', err.message)); + + res.status(201).json({ + tenant_id: tenantId, slug, + admin_email: kontakt_email, + // Passwort wird ausnahmsweise im Response zurueckgegeben, damit der SuperAdmin + // es ggf. manuell weitergeben kann, falls die Mail nicht ankommt. + admin_password_oneshot: plainPw, + demo_expires_in_days: days + }); + } catch (e) { + await conn.rollback(); + console.error('Tenant-Anlage-Fehler:', e); + res.status(500).json({ error: 'Fehler beim Anlegen des Tenants', detail: e.message }); + } finally { + conn.release(); + } +}); + +// PATCH /api/tenants/:id -> Demo verlaengern, Status aendern, Limits aendern +router.patch('/:id', async (req, res) => { + const updates = []; + const values = []; + if (req.body.extend_days) { + updates.push('demo_expires_at = DATE_ADD(COALESCE(demo_expires_at, NOW()), INTERVAL ? DAY)'); + values.push(parseInt(req.body.extend_days)); + updates.push("status = 'aktiv'"); + } + if (req.body.status) { updates.push('status = ?'); values.push(req.body.status); } + if (req.body.max_users) { updates.push('max_users = ?'); values.push(parseInt(req.body.max_users)); } + if (req.body.typ) { updates.push('typ = ?'); values.push(req.body.typ); } + if (updates.length === 0) return res.status(400).json({ error: 'Keine Aenderungen' }); + values.push(req.params.id); + try { + await pool.query(`UPDATE tenants SET ${updates.join(', ')} WHERE id = ?`, values); + const [rows] = await pool.query('SELECT * FROM tenants WHERE id = ?', [req.params.id]); + res.json(rows[0]); + } catch (e) { + console.error('Tenant-Update:', e); + res.status(500).json({ error: 'Serverfehler' }); + } +}); + +// DELETE /api/tenants/:id -> Tenant inkl. aller Daten loeschen (CASCADE) +router.delete('/:id', async (req, res) => { + try { + await pool.query('DELETE FROM tenants WHERE id = ?', [req.params.id]); + res.json({ message: 'Tenant geloescht' }); + } catch (e) { + res.status(500).json({ error: 'Serverfehler' }); + } +}); + +export default router; diff --git a/backend/scripts/bootstrap.js b/backend/scripts/bootstrap.js new file mode 100644 index 0000000..e525834 --- /dev/null +++ b/backend/scripts/bootstrap.js @@ -0,0 +1,38 @@ +import bcrypt from 'bcrypt'; +import pool from '../config/database.js'; + +// Stellt sicher dass mindestens ein SuperAdmin existiert. +// Nutzt env SUPERADMIN_EMAIL + SUPERADMIN_PASSWORD beim ersten Start. +export async function bootstrapSuperAdmin() { + const email = process.env.SUPERADMIN_EMAIL; + const password = process.env.SUPERADMIN_PASSWORD; + if (!email || !password) { + console.warn('[bootstrap] SUPERADMIN_EMAIL/PASSWORD nicht gesetzt - skip'); + return; + } + // Auf Datenbank-Bereitschaft warten (max 30s) + for (let i = 0; i < 30; i++) { + try { + await pool.query('SELECT 1'); + break; + } catch { + await new Promise(r => setTimeout(r, 1000)); + } + } + + const [rows] = await pool.query( + "SELECT id FROM mitarbeiter WHERE email = ? AND tenant_id IS NULL AND rolle = 'superadmin'", + [email] + ); + if (rows.length > 0) { + console.log('[bootstrap] SuperAdmin existiert:', email); + return; + } + const hash = await bcrypt.hash(password, 10); + await pool.query( + `INSERT INTO mitarbeiter (id, tenant_id, email, password_hash, name, rolle, aktiv) + VALUES (UUID(), NULL, ?, ?, 'SuperAdmin', 'superadmin', 1)`, + [email, hash] + ); + console.log('[bootstrap] SuperAdmin angelegt:', email); +} diff --git a/backend/server.js b/backend/server.js index e506fb9..2e7a050 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,13 +4,17 @@ import dotenv from 'dotenv'; import path from 'path'; import { fileURLToPath } from 'url'; -// Routes import authRoutes from './routes/auth.js'; +import tenantRoutes from './routes/tenants.js'; import kundenRoutes from './routes/kunden.js'; +import kundenBilderRoutes from './routes/kunden-bilder.js'; import artikelRoutes from './routes/artikel.js'; import lieferungenRoutes from './routes/lieferungen.js'; import ruecknahmenRoutes from './routes/ruecknahmen.js'; import mitarbeiterRoutes from './routes/mitarbeiter.js'; +import geraeteRoutes from './routes/geraete.js'; + +import { bootstrapSuperAdmin } from './scripts/bootstrap.js'; dotenv.config(); @@ -20,53 +24,41 @@ const __dirname = path.dirname(__filename); const app = express(); const PORT = process.env.PORT || 3001; -// Middleware app.use(cors()); app.use(express.json()); app.use(express.urlencoded({ extended: true })); +app.use('/uploads', express.static(process.env.UPLOAD_DIR || path.join(__dirname, 'uploads'))); -// Statische Dateien (Uploads) -app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); +app.get('/health', (req, res) => res.json({ status: 'OK', timestamp: new Date().toISOString() })); -// Health Check -app.get('/health', (req, res) => { - res.json({ status: 'OK', timestamp: new Date().toISOString() }); -}); - -// API Routes app.use('/api/auth', authRoutes); +app.use('/api/tenants', tenantRoutes); app.use('/api/kunden', kundenRoutes); +app.use('/api/kunden-bilder', kundenBilderRoutes); app.use('/api/artikel', artikelRoutes); app.use('/api/lieferungen', lieferungenRoutes); app.use('/api/ruecknahmen', ruecknahmenRoutes); app.use('/api/mitarbeiter', mitarbeiterRoutes); +app.use('/api/geraete', geraeteRoutes); -// Error Handler app.use((err, req, res, next) => { console.error('Fehler:', err); - if (err.name === 'MulterError') { - if (err.code === 'LIMIT_FILE_SIZE') { - return res.status(400).json({ error: 'Datei zu groß (max. 10MB)' }); - } + if (err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error: 'Datei zu gross (max. 10MB)' }); return res.status(400).json({ error: err.message }); } - - res.status(err.status || 500).json({ - error: err.message || 'Interner Serverfehler' + res.status(err.status || 500).json({ error: err.message || 'Interner Serverfehler' }); +}); + +app.use((req, res) => res.status(404).json({ error: 'Route nicht gefunden' })); + +// Bootstrap SuperAdmin beim Start, dann Server starten +bootstrapSuperAdmin() + .catch(err => console.error('Bootstrap-Fehler:', err)) + .finally(() => { + app.listen(PORT, '0.0.0.0', () => { + console.log(`Backend laeuft auf Port ${PORT}`); + }); }); -}); - -// 404 Handler -app.use((req, res) => { - res.status(404).json({ error: 'Route nicht gefunden' }); -}); - -// Server starten -app.listen(PORT, '0.0.0.0', () => { - console.log(`🚀 Backend-Server läuft auf Port ${PORT}`); - console.log(`📊 Health Check: http://localhost:${PORT}/health`); - console.log(`🔐 API Base URL: http://localhost:${PORT}/api`); -}); export default app;