import { randomUUID } from 'crypto'; import cors from 'cors'; import dotenv from 'dotenv'; import express from 'express'; import session from 'express-session'; import path from 'path'; import { fileURLToPath } from 'url'; import db from './db.js'; import { getSyncStatus, performLdapSync } from './ldap-sync.js'; import { hashPassword, verifyPassword } from './password.js'; import { computeRemoteDurationSeconds, registerTeamViewerRoutes, } from './teamviewer.js'; dotenv.config(); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); const PORT = process.env.PORT || 3000; app.set('trust proxy', 1); app.use( cors({ origin: true, credentials: true, }), ); app.use(express.json()); app.use( session({ name: 'crm.sid', secret: process.env.SESSION_SECRET || 'crm-dev-secret-change-in-production', resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000, }, }), ); const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; function badRequest(res, msg) { return res.status(400).json({ message: msg }); } function parseJsonField(v) { if (v == null) return undefined; if (typeof v === 'object') return v; return JSON.parse(v); } function mapMachine(r) { const o = { id: r.id, name: r.name, typ: r.typ, seriennummer: r.seriennummer, standort: r.standort, createdAt: r.created_at, updatedAt: r.updated_at, }; if (r.extras != null && String(r.extras).trim() !== '') { try { o.extras = typeof r.extras === 'string' ? JSON.parse(r.extras) : r.extras; } catch { o.extras = null; } } return o; } function mapTicket(r) { const machine_row = parseJsonField(r.machine_row); const t = { id: r.id, machineId: r.machine_id, title: r.title, description: r.description, status: r.status, priority: r.priority, createdAt: r.created_at, updatedAt: r.updated_at, }; if (machine_row) { t.machine = mapMachine(machine_row); } return t; } function mapEvent(r) { return { id: r.id, ticketId: r.ticket_id, type: r.type, description: r.description, createdAt: r.created_at, callbackNumber: r.callback_number ?? null, teamviewerId: r.teamviewer_id ?? null, articleNumber: r.article_number ?? null, remoteDurationSeconds: r.remote_duration_seconds != null ? r.remote_duration_seconds : null, }; } function mapPublicUser(r) { return { id: r.id, username: r.username, role: r.role, active: Boolean(r.active), source: r.source, ldapDn: r.ldap_dn || null, createdAt: r.created_at, updatedAt: r.updated_at, }; } const ticketJoinSelect = ` SELECT t.*, json_object( 'id', m.id, 'name', m.name, 'typ', m.typ, 'seriennummer', m.seriennummer, 'standort', m.standort, 'extras', m.extras, 'created_at', m.created_at, 'updated_at', m.updated_at ) AS machine_row FROM tickets t JOIN machines m ON m.id = t.machine_id`; const DEFAULT_INTEGRATIONS = { ldap: { serverUrl: '', bindDn: '', bindPassword: '', searchBase: '', userSearchFilter: '', userFilter: '', usernameAttribute: 'sAMAccountName', firstNameAttribute: 'givenName', lastNameAttribute: 'sn', syncIntervalMinutes: 1440, syncEnabled: false, syncNotes: '', }, /* TeamViewer: Authorization: Bearer */ teamviewer: { bearerToken: '', notes: '', }, }; function loadIntegrations() { const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('integrations'); const base = structuredClone(DEFAULT_INTEGRATIONS); if (!row?.value) return base; try { const s = JSON.parse(row.value); if (s.ldap && typeof s.ldap === 'object') Object.assign(base.ldap, s.ldap); const ld = base.ldap; if (!ld.userSearchFilter && ld.userFilter) ld.userSearchFilter = ld.userFilter; if (s.teamviewer && typeof s.teamviewer === 'object') Object.assign(base.teamviewer, s.teamviewer); const tv = base.teamviewer; if (!tv.bearerToken && tv.apiToken) tv.bearerToken = tv.apiToken; if (tv.notes == null && tv.apiNotes) tv.notes = tv.apiNotes; return base; } catch { return base; } } function saveIntegrations(obj) { const json = JSON.stringify(obj); db.prepare( `INSERT INTO app_settings (key, value) VALUES ('integrations', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, ).run(json); } let ldapSyncTimer = null; function restartLdapSyncScheduler() { if (ldapSyncTimer) { clearInterval(ldapSyncTimer); ldapSyncTimer = null; } const cfg = loadIntegrations().ldap; if (!cfg.syncEnabled) return; const m = Math.max(0, Number(cfg.syncIntervalMinutes) || 0); if (m <= 0) return; ldapSyncTimer = setInterval(() => { performLdapSync(db, loadIntegrations, 'automatic').catch((err) => console.error('LDAP auto-sync:', err), ); }, m * 60 * 1000); } function requireAuth(req, res, next) { if (!req.session?.userId) { return res.status(401).json({ message: 'Nicht angemeldet' }); } const u = db .prepare( 'SELECT id, active FROM users WHERE id = ?', ) .get(req.session.userId); if (!u || !u.active) { req.session.destroy(() => {}); return res.status(401).json({ message: 'Nicht angemeldet' }); } next(); } function requireAdmin(req, res, next) { if (req.session?.role !== 'admin') { return res.status(403).json({ message: 'Administratorrechte erforderlich.' }); } next(); } /** ——— Öffentlich: Auth ——— */ app.get('/auth/status', (req, res) => { const count = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; const needsBootstrap = count === 0; if (needsBootstrap) { return res.json({ needsBootstrap: true, loggedIn: false, user: null }); } if (!req.session?.userId) { return res.json({ needsBootstrap: false, loggedIn: false, user: null }); } const u = db .prepare( 'SELECT id, username, role, active FROM users WHERE id = ?', ) .get(req.session.userId); if (!u || !u.active) { req.session.destroy(() => {}); return res.json({ needsBootstrap: false, loggedIn: false, user: null }); } res.json({ needsBootstrap: false, loggedIn: true, user: { id: u.id, username: u.username, role: u.role }, }); }); app.post('/auth/bootstrap', async (req, res) => { const count = db.prepare('SELECT COUNT(*) AS c FROM users').get().c; if (count > 0) { return res.status(403).json({ message: 'Initialisierung nicht mehr möglich.' }); } const { username, password } = req.body || {}; const un = String(username || '') .trim() .toLowerCase(); if (!un || !password || password.length < 8) { return badRequest(res, 'Benutzername und Passwort (min. 8 Zeichen) erforderlich.'); } const id = randomUUID(); const ph = await hashPassword(password); db.prepare( `INSERT INTO users (id, username, password_hash, role, source, active, updated_at) VALUES (?, ?, ?, 'admin', 'local', 1, datetime('now'))`, ).run(id, un, ph); req.session.userId = id; req.session.role = 'admin'; req.session.username = un; res.status(201).json({ user: { id, username: un, role: 'admin' }, }); }); app.post('/auth/login', async (req, res) => { const { username, password } = req.body || {}; const un = String(username || '') .trim() .toLowerCase(); if (!un || !password) { return badRequest(res, 'Benutzername und Passwort erforderlich.'); } const u = db.prepare('SELECT * FROM users WHERE username = ?').get(un); if (!u || !u.active) { return res.status(401).json({ message: 'Ungültige Zugangsdaten.' }); } if (!u.password_hash) { return res.status(401).json({ message: 'Kein lokales Passwort (LDAP). Anmeldung folgt mit Verzeichnis-Sync.', }); } const ok = await verifyPassword(password, u.password_hash); if (!ok) return res.status(401).json({ message: 'Ungültige Zugangsdaten.' }); req.session.userId = u.id; req.session.role = u.role; req.session.username = u.username; res.json({ user: { id: u.id, username: u.username, role: u.role }, }); }); app.post('/auth/logout', (req, res) => { req.session.destroy((err) => { if (err) return res.status(500).json({ message: 'Abmelden fehlgeschlagen.' }); res.json({ ok: true }); }); }); /** ——— Geschützte API ——— */ const api = express.Router(); api.use(requireAuth); api.get('/machines', (_req, res) => { const rows = db .prepare('SELECT * FROM machines ORDER BY seriennummer ASC') .all(); res.json(rows.map(mapMachine)); }); api.post('/machines', (req, res) => { const { name, typ, seriennummer, standort } = req.body || {}; if (!name || !typ || !seriennummer || !standort) { return badRequest(res, 'Pflichtfelder fehlen.'); } const id = randomUUID(); const row = db .prepare( `INSERT INTO machines (id, name, typ, seriennummer, standort, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now')) RETURNING *`, ) .get(id, name, typ, seriennummer, standort); res.status(201).json(mapMachine(row)); }); api.get('/machines/:id', (req, res) => { const { id } = req.params; if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); const row = db.prepare('SELECT * FROM machines WHERE id = ?').get(id); if (!row) return res.status(404).json({ message: 'Nicht gefunden' }); res.json(mapMachine(row)); }); api.put('/machines/:id', (req, res) => { const { id } = req.params; if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id); if (!cur) return res.status(404).json({ message: 'Nicht gefunden' }); const b = req.body || {}; const next = { name: b.name ?? cur.name, typ: b.typ ?? cur.typ, seriennummer: b.seriennummer ?? cur.seriennummer, standort: b.standort ?? cur.standort, }; let extrasJson = cur.extras; if (Object.prototype.hasOwnProperty.call(b, 'extras')) { if (b.extras === null || b.extras === '') { extrasJson = null; } else if (typeof b.extras === 'object' && b.extras !== null) { try { extrasJson = JSON.stringify(b.extras); } catch { return badRequest(res, 'extras ist kein gültiges JSON-Objekt.'); } } else if (typeof b.extras === 'string') { try { JSON.parse(b.extras); extrasJson = b.extras; } catch { return badRequest(res, 'extras ist kein gültiger JSON-String.'); } } else { return badRequest(res, 'extras hat ein ungültiges Format.'); } } const row = db .prepare( `UPDATE machines SET name = ?, typ = ?, seriennummer = ?, standort = ?, extras = ?, updated_at = datetime('now') WHERE id = ? RETURNING *`, ) .get(next.name, next.typ, next.seriennummer, next.standort, extrasJson, id); res.json(mapMachine(row)); }); api.delete('/machines/:id', (req, res) => { const { id } = req.params; if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); const cur = db.prepare('SELECT * FROM machines WHERE id = ?').get(id); if (!cur) return res.status(404).json({ message: 'Nicht gefunden' }); const tc = db .prepare('SELECT COUNT(*) AS c FROM tickets WHERE machine_id = ?') .get(id); if (tc.c > 0) { return res.status(409).json({ message: 'Maschine kann nicht gelöscht werden: Es existieren noch zugeordnete Tickets.', }); } const row = db .prepare('DELETE FROM machines WHERE id = ? RETURNING *') .get(id); res.json(mapMachine(row)); }); api.get('/tickets', (req, res) => { const { status, priority, machineId, open } = req.query; const cond = ['1=1']; const params = []; const openFilter = open === '1' || open === 'true'; if (openFilter) { cond.push("t.status IN ('OPEN', 'WAITING')"); } else if (status) { cond.push('t.status = ?'); params.push(status); } if (priority) { cond.push('t.priority = ?'); params.push(priority); } if (machineId) { cond.push('t.machine_id = ?'); params.push(machineId); } const sql = `${ticketJoinSelect} WHERE ${cond.join(' AND ')} ORDER BY t.updated_at DESC`; const rows = db.prepare(sql).all(...params); res.json(rows.map(mapTicket)); }); api.post('/tickets', (req, res) => { const { machineId, title, description, status, priority } = req.body || {}; if (!machineId || !title || !description) { return badRequest(res, 'Pflichtfelder fehlen.'); } const m = db .prepare('SELECT 1 AS ok FROM machines WHERE id = ?') .get(machineId); if (!m) return res.status(404).json({ message: 'Nicht gefunden' }); const st = status || 'OPEN'; const pr = priority || 'MEDIUM'; const tid = randomUUID(); db.prepare( `INSERT INTO tickets (id, machine_id, title, description, status, priority, updated_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`, ).run(tid, machineId, title, description, st, pr); const full = db .prepare(`${ticketJoinSelect} WHERE t.id = ?`) .get(tid); res.status(201).json(mapTicket(full)); }); api.get('/tickets/:id/events', (req, res) => { const { id } = req.params; if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); const ex = db.prepare('SELECT 1 AS ok FROM tickets WHERE id = ?').get(id); if (!ex) return res.status(404).json({ message: 'Nicht gefunden' }); const rows = db .prepare( 'SELECT * FROM events WHERE ticket_id = ? ORDER BY created_at DESC', ) .all(id); res.json(rows.map(mapEvent)); }); api.get('/tickets/:id', (req, res) => { const { id } = req.params; if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); const row = db.prepare(`${ticketJoinSelect} WHERE t.id = ?`).get(id); if (!row) return res.status(404).json({ message: 'Nicht gefunden' }); res.json(mapTicket(row)); }); api.put('/tickets/:id', (req, res) => { const { id } = req.params; if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); const cur = db.prepare('SELECT * FROM tickets WHERE id = ?').get(id); if (!cur) return res.status(404).json({ message: 'Nicht gefunden' }); const b = req.body || {}; const next = { title: b.title ?? cur.title, description: b.description ?? cur.description, status: b.status ?? cur.status, priority: b.priority ?? cur.priority, }; const lines = []; if (b.status !== undefined && b.status !== cur.status) { lines.push(`Status: ${cur.status} → ${b.status}`); } if (b.priority !== undefined && b.priority !== cur.priority) { lines.push(`Priorität: ${cur.priority} → ${b.priority}`); } if (b.title !== undefined && b.title !== cur.title) lines.push('Titel geändert'); if (b.description !== undefined && b.description !== cur.description) { lines.push('Beschreibung geändert'); } if (lines.length > 0) { const eid = randomUUID(); db.prepare( `INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds) VALUES (?, ?, 'SYSTEM', ?, NULL, NULL, NULL, NULL)`, ).run(eid, id, lines.join('; ')); } db.prepare( `UPDATE tickets SET title = ?, description = ?, status = ?, priority = ?, updated_at = datetime('now') WHERE id = ?`, ).run(next.title, next.description, next.status, next.priority, id); const row = db.prepare(`${ticketJoinSelect} WHERE t.id = ?`).get(id); res.json(mapTicket(row)); }); const EVENT_TYPES_USER = new Set(['NOTE', 'CALL', 'REMOTE', 'PART']); registerTeamViewerRoutes(api, loadIntegrations); api.post('/events', (req, res) => { const b = req.body || {}; const ticketId = b.ticketId; const type = b.type; if (!ticketId || !type || !EVENT_TYPES_USER.has(type)) { return badRequest(res, 'Pflichtfelder fehlen oder ungültiger Typ.'); } const t = db .prepare('SELECT 1 AS ok FROM tickets WHERE id = ?') .get(ticketId); if (!t) return res.status(404).json({ message: 'Nicht gefunden' }); const desc = b.description != null ? String(b.description).trim() : ''; const callbackNumber = b.callbackNumber != null ? String(b.callbackNumber).trim() : ''; const teamviewerId = b.teamviewerId != null ? String(b.teamviewerId).trim() : ''; const articleNumber = b.articleNumber != null ? String(b.articleNumber).trim() : ''; let description = desc; let cb = callbackNumber || null; let tv = teamviewerId || null; let art = articleNumber || null; if (type === 'NOTE') { if (!description) return badRequest(res, 'Beschreibung fehlt.'); cb = null; tv = null; art = null; } else if (type === 'CALL') { if (!description) return badRequest(res, 'Beschreibung fehlt.'); if (!callbackNumber) return badRequest(res, 'Rückrufnummer fehlt.'); tv = null; art = null; } else if (type === 'REMOTE') { if (!description?.trim() && !tv) { return badRequest(res, 'Beschreibung oder TeamViewer-Gerät erforderlich.'); } cb = null; art = null; } else if (type === 'PART') { if (!articleNumber) return badRequest(res, 'Artikelnummer fehlt.'); description = desc; cb = null; tv = null; } let remoteDurationSeconds = null; if (type === 'REMOTE') { remoteDurationSeconds = computeRemoteDurationSeconds( b.teamviewerStartDate, b.teamviewerEndDate, ); } const eid = randomUUID(); const row = db .prepare( `INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`, ) .get( eid, ticketId, type, description, cb, tv, art, remoteDurationSeconds, ); res.status(201).json(mapEvent(row)); }); app.use('/api', api); /** ——— Admin ——— */ const admin = express.Router(); admin.use(requireAuth, requireAdmin); admin.get('/users', (_req, res) => { const rows = db .prepare( 'SELECT id, username, role, source, active, ldap_dn, created_at, updated_at FROM users ORDER BY username ASC', ) .all(); res.json(rows.map(mapPublicUser)); }); admin.post('/users', async (req, res) => { const { username, password, role } = req.body || {}; const un = String(username || '') .trim() .toLowerCase(); if (!un || !password) return badRequest(res, 'Benutzername und Passwort erforderlich.'); const r = role === 'admin' ? 'admin' : 'user'; const id = randomUUID(); const ph = await hashPassword(password); try { db.prepare( `INSERT INTO users (id, username, password_hash, role, source, active, updated_at) VALUES (?, ?, ?, ?, 'local', 1, datetime('now'))`, ).run(id, un, ph, r); } catch (e) { if (String(e.message || e).includes('UNIQUE')) { return res.status(409).json({ message: 'Benutzername bereits vergeben.' }); } throw e; } const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id); res.status(201).json(mapPublicUser(row)); }); admin.put('/users/:id', async (req, res) => { const { id } = req.params; if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); const cur = db.prepare('SELECT * FROM users WHERE id = ?').get(id); if (!cur) return res.status(404).json({ message: 'Nicht gefunden' }); const b = req.body || {}; if (b.password != null && String(b.password).length > 0) { if (cur.source !== 'local') { return badRequest(res, 'Passwort nur für lokale Benutzer änderbar.'); } const ph = await hashPassword(b.password); db.prepare( 'UPDATE users SET password_hash = ?, updated_at = datetime(\'now\') WHERE id = ?', ).run(ph, id); } if (b.role !== undefined) { if (b.role !== 'admin' && b.role !== 'user') { return badRequest(res, 'Ungültige Rolle.'); } const admins = db .prepare( "SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1", ) .get().c; if (cur.role === 'admin' && b.role === 'user' && admins <= 1) { return res.status(400).json({ message: 'Letzter Administrator kann nicht herabgestuft werden.' }); } db.prepare('UPDATE users SET role = ?, updated_at = datetime(\'now\') WHERE id = ?').run( b.role, id, ); } if (b.active !== undefined) { const active = b.active ? 1 : 0; const admins = db .prepare( "SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1", ) .get().c; if (cur.role === 'admin' && cur.active && !active && admins <= 1) { return res.status(400).json({ message: 'Letzter Administrator kann nicht deaktiviert werden.' }); } db.prepare('UPDATE users SET active = ?, updated_at = datetime(\'now\') WHERE id = ?').run( active, id, ); } const row = db.prepare('SELECT * FROM users WHERE id = ?').get(id); res.json(mapPublicUser(row)); }); admin.delete('/users/:id', (req, res) => { const { id } = req.params; if (!UUID.test(id)) return res.status(404).json({ message: 'Nicht gefunden' }); if (id === req.session.userId) { return res.status(400).json({ message: 'Eigenes Konto kann nicht gelöscht werden.' }); } const cur = db.prepare('SELECT * FROM users WHERE id = ?').get(id); if (!cur) return res.status(404).json({ message: 'Nicht gefunden' }); if (cur.role === 'admin') { const admins = db .prepare( "SELECT COUNT(*) AS c FROM users WHERE role = 'admin' AND active = 1", ) .get().c; if (admins <= 1) { return res.status(400).json({ message: 'Letzter Administrator kann nicht gelöscht werden.' }); } } db.prepare('DELETE FROM users WHERE id = ?').run(id); res.json({ ok: true }); }); admin.get('/settings/integrations', (_req, res) => { res.json(loadIntegrations()); }); admin.put('/settings/integrations', (req, res) => { const b = req.body || {}; const cur = loadIntegrations(); if (b.ldap && typeof b.ldap === 'object') { const incoming = { ...b.ldap }; if (incoming.bindPassword === '' || incoming.bindPassword == null) { delete incoming.bindPassword; } if (incoming.syncIntervalMinutes != null) { const n = Number(incoming.syncIntervalMinutes); incoming.syncIntervalMinutes = Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 1440; } Object.assign(cur.ldap, incoming); if (b.ldap.userSearchFilter != null) { cur.ldap.userFilter = String(b.ldap.userSearchFilter); } } if (b.teamviewer && typeof b.teamviewer === 'object') { const inc = { ...b.teamviewer }; if (inc.bearerToken === '' || inc.bearerToken == null) { delete inc.bearerToken; } Object.assign(cur.teamviewer, inc); } saveIntegrations(cur); restartLdapSyncScheduler(); res.json(loadIntegrations()); }); admin.get('/ldap/sync-status', (_req, res) => { res.json(getSyncStatus(db)); }); admin.post('/ldap/sync', async (_req, res) => { const r = await performLdapSync(db, loadIntegrations, 'manual'); if (r.skipped) { return res.status(409).json({ message: r.message }); } res.json(r); }); app.use('/api', admin); /** Unbekannte /api/*-Routen: JSON 404 — verhindert SPA index.html (HTML) mit 200 bei falscher Route/alter Serverversion */ app.use('/api', (req, res) => { res.status(404).json({ message: 'API nicht gefunden' }); }); app.use(express.static(path.join(__dirname, '..', 'public'))); /** SPA: Direktaufrufe wie /tickets ohne Hash liefern index.html (Client-Routing) */ app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '..', 'public', 'index.html')); }); app.listen(PORT, () => { restartLdapSyncScheduler(); console.log(`CRM-Server http://localhost:${PORT}`); });