Inital Commit

This commit is contained in:
2026-03-22 19:26:35 +01:00
commit 705329d3c2
17 changed files with 5538 additions and 0 deletions

163
server/db.js Normal file
View File

@@ -0,0 +1,163 @@
import fs from 'fs';
import path from 'path';
import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dbPath =
process.env.SQLITE_PATH || path.join(__dirname, '..', 'data', 'crm.db');
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new DatabaseSync(dbPath);
db.exec('PRAGMA foreign_keys = ON');
const machineCols = db.prepare('PRAGMA table_info(machines)').all();
if (!machineCols.some((c) => c.name === 'extras')) {
db.exec('ALTER TABLE machines ADD COLUMN extras TEXT');
}
const hasCustomerId = machineCols.some((c) => c.name === 'customer_id');
const tables = db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
)
.all()
.map((r) => r.name);
const hasCustomersTable = tables.includes('customers');
const eventCols = db.prepare('PRAGMA table_info(events)').all();
if (eventCols.length > 0 && !eventCols.some((c) => c.name === 'remote_duration_seconds')) {
db.exec('ALTER TABLE events ADD COLUMN remote_duration_seconds INTEGER');
}
const hasEventExtras = eventCols.some((c) => c.name === 'callback_number');
if (eventCols.length > 0 && !hasEventExtras) {
db.exec('BEGIN');
try {
db.exec(`
CREATE TABLE events_new (
"id" TEXT NOT NULL PRIMARY KEY,
"ticket_id" TEXT NOT NULL,
"type" TEXT NOT NULL CHECK ("type" IN ('NOTE', 'CALL', 'REMOTE', 'PART', 'SYSTEM')),
"description" TEXT NOT NULL,
"callback_number" TEXT,
"teamviewer_id" TEXT,
"article_number" TEXT,
"remote_duration_seconds" INTEGER,
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY ("ticket_id") REFERENCES "tickets" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO events_new (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, created_at)
SELECT
id,
ticket_id,
CASE WHEN type = 'WORK' THEN 'REMOTE' ELSE type END,
description,
NULL,
NULL,
NULL,
NULL,
created_at
FROM events;
DROP TABLE events;
ALTER TABLE events_new RENAME TO events;
`);
db.exec(
'CREATE INDEX IF NOT EXISTS events_ticket_id_idx ON "events" ("ticket_id")',
);
db.exec(
'CREATE INDEX IF NOT EXISTS events_created_at_idx ON "events" ("created_at")',
);
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
}
if (hasCustomerId || hasCustomersTable) {
db.exec('BEGIN');
try {
db.exec(`
CREATE TABLE machines_new (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"typ" TEXT NOT NULL,
"seriennummer" TEXT NOT NULL,
"standort" TEXT NOT NULL,
"extras" TEXT,
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
"updated_at" TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO machines_new (id, name, typ, seriennummer, standort, extras, created_at, updated_at)
SELECT id, name, typ, seriennummer, standort, extras, created_at, updated_at FROM machines;
DROP TABLE machines;
ALTER TABLE machines_new RENAME TO machines;
`);
if (hasCustomersTable) {
db.exec('DROP TABLE customers');
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
}
const tbl = db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'",
)
.get();
if (!tbl) {
db.exec(`
CREATE TABLE "users" (
"id" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL UNIQUE,
"password_hash" TEXT,
"role" TEXT NOT NULL DEFAULT 'user' CHECK ("role" IN ('admin', 'user')),
"source" TEXT NOT NULL DEFAULT 'local' CHECK ("source" IN ('local', 'ldap')),
"ldap_dn" TEXT,
"active" INTEGER NOT NULL DEFAULT 1 CHECK ("active" IN (0, 1)),
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
"updated_at" TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS users_username_idx ON "users" ("username");
`);
}
const tblSet = db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'",
)
.get();
if (!tblSet) {
db.exec(`
CREATE TABLE "app_settings" (
"key" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL
);
`);
}
const ldapLogTbl = db
.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name='ldap_sync_log'",
)
.get();
if (!ldapLogTbl) {
db.exec(`
CREATE TABLE "ldap_sync_log" (
"id" TEXT NOT NULL PRIMARY KEY,
"started_at" TEXT NOT NULL,
"finished_at" TEXT NOT NULL,
"trigger_type" TEXT NOT NULL CHECK ("trigger_type" IN ('manual', 'automatic')),
"status" TEXT NOT NULL CHECK ("status" IN ('success', 'error')),
"users_synced" INTEGER NOT NULL DEFAULT 0,
"error_message" TEXT
);
CREATE INDEX ldap_sync_log_finished_idx ON "ldap_sync_log" ("finished_at" DESC);
`);
}
export default db;

776
server/index.js Normal file
View File

@@ -0,0 +1,776 @@
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 <token> */
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}`);
});

29
server/init-db.js Normal file
View File

@@ -0,0 +1,29 @@
import fs from 'fs';
import path from 'path';
import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
dotenv.config();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const sqlPath = path.join(__dirname, '..', 'database', 'init.sql');
const dbPath =
process.env.SQLITE_PATH || path.join(__dirname, '..', 'data', 'crm.db');
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const sql = fs.readFileSync(sqlPath, 'utf8');
const db = new DatabaseSync(dbPath);
db.exec('PRAGMA foreign_keys = ON');
try {
db.exec(sql);
console.log('Datenbank-Schema ist bereit.');
} catch (e) {
console.error(e);
process.exit(1);
} finally {
db.close();
}

209
server/ldap-sync.js Normal file
View File

@@ -0,0 +1,209 @@
import { randomUUID } from 'crypto';
import ldap from 'ldapjs';
let syncRunning = false;
function getAttr(entry, name) {
const want = String(name || '').toLowerCase();
for (const attr of entry.attributes) {
if (attr.type.toLowerCase() === want) {
const v = attr.values[0];
return v != null ? String(v).trim() : '';
}
}
return '';
}
function searchAsync(client, base, options) {
return new Promise((resolve, reject) => {
const results = [];
client.search(base, options, (err, res) => {
if (err) return reject(err);
res.on('searchEntry', (entry) => results.push(entry));
res.on('error', reject);
res.on('end', () => resolve(results));
});
});
}
function bindAsync(client, dn, password) {
return new Promise((resolve, reject) => {
client.bind(dn || '', password ?? '', (err) => (err ? reject(err) : resolve()));
});
}
function unbindAsync(client) {
return new Promise((resolve) => {
try {
client.unbind(() => resolve());
} catch {
resolve();
}
});
}
function insertLog(db, row) {
db.prepare(
`INSERT INTO ldap_sync_log (id, started_at, finished_at, trigger_type, status, users_synced, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
row.id,
row.startedAt,
row.finishedAt,
row.triggerType,
row.status,
row.usersSynced,
row.errorMessage,
);
}
/**
* @param {import('node:sqlite').DatabaseSync} db
* @param {() => object} loadIntegrations
* @param {'manual' | 'automatic'} trigger
*/
export async function performLdapSync(db, loadIntegrations, trigger) {
if (syncRunning) {
return { skipped: true, message: 'Synchronisation läuft bereits.' };
}
syncRunning = true;
const logId = randomUUID();
const startedAt = new Date().toISOString();
let usersSynced = 0;
let errorMessage = null;
let client = null;
try {
const config = loadIntegrations().ldap || {};
const serverUrl = String(config.serverUrl || '').trim();
const searchBase = String(config.searchBase || '').trim();
const filter = String(
config.userSearchFilter || config.userFilter || '',
).trim();
const usernameAttr = String(
config.usernameAttribute || 'sAMAccountName',
).trim();
if (!serverUrl) throw new Error('LDAP-Server URL fehlt.');
if (!searchBase) throw new Error('Base DN fehlt.');
if (!filter) throw new Error('User Search Filter fehlt.');
client = ldap.createClient({
url: serverUrl,
timeout: 120000,
connectTimeout: 20000,
reconnect: false,
});
client.on('error', () => {});
const bindDn = String(config.bindDn || '').trim();
const bindPassword =
config.bindPassword != null ? String(config.bindPassword) : '';
await bindAsync(client, bindDn, bindPassword);
const entries = await searchAsync(client, searchBase, {
scope: 'sub',
filter,
attributes: [usernameAttr],
});
await unbindAsync(client);
client = null;
db.exec('BEGIN');
try {
let n = 0;
for (const entry of entries) {
let dn;
try {
dn = entry.objectName.toString();
} catch {
continue;
}
const un = getAttr(entry, usernameAttr).toLowerCase().trim();
if (!un) continue;
const existing = db
.prepare('SELECT id, source FROM users WHERE username = ?')
.get(un);
if (existing) {
if (existing.source === 'local') continue;
db.prepare(
`UPDATE users SET ldap_dn = ?, updated_at = datetime('now') WHERE id = ?`,
).run(dn, existing.id);
n += 1;
} else {
const id = randomUUID();
db.prepare(
`INSERT INTO users (id, username, password_hash, role, source, ldap_dn, active, updated_at)
VALUES (?, ?, NULL, 'user', 'ldap', ?, 1, datetime('now'))`,
).run(id, un, dn);
n += 1;
}
}
db.exec('COMMIT');
usersSynced = n;
} catch (e) {
try {
db.exec('ROLLBACK');
} catch {
/* ignore */
}
throw e;
}
} catch (e) {
errorMessage = e && e.message ? String(e.message) : String(e);
if (client) {
try {
await unbindAsync(client);
} catch {
/* ignore */
}
client = null;
}
} finally {
try {
const finishedAt = new Date().toISOString();
const status = errorMessage ? 'error' : 'success';
insertLog(db, {
id: logId,
startedAt,
finishedAt,
triggerType: trigger,
status,
usersSynced: errorMessage ? 0 : usersSynced,
errorMessage,
});
} finally {
syncRunning = false;
}
}
if (errorMessage) {
return { ok: false, usersSynced: 0, error: errorMessage };
}
return { ok: true, usersSynced };
}
/**
* @param {import('node:sqlite').DatabaseSync} db
*/
export function getSyncStatus(db) {
const last = db
.prepare(
`SELECT finished_at FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 1`,
)
.get();
const entries = db
.prepare(
`SELECT finished_at AS finishedAt, trigger_type AS triggerType, status, users_synced AS usersSynced, error_message AS errorMessage
FROM ldap_sync_log ORDER BY finished_at DESC LIMIT 10`,
)
.all();
return {
lastSyncAt: last?.finished_at ?? null,
entries,
};
}

12
server/password.js Normal file
View File

@@ -0,0 +1,12 @@
import bcrypt from 'bcrypt';
const ROUNDS = 10;
export async function hashPassword(plain) {
return bcrypt.hash(plain, ROUNDS);
}
export async function verifyPassword(plain, hash) {
if (!hash || typeof hash !== 'string') return false;
return bcrypt.compare(plain, hash);
}

294
server/teamviewer.js Normal file
View File

@@ -0,0 +1,294 @@
/**
* TeamViewer Web API: Connection Reports (/api/v1/reports/connections)
*/
const CONNECTIONS_URL =
'https://webapi.teamviewer.com/api/v1/reports/connections';
const FETCH_HEADERS_BASE = {
Accept: 'application/json',
'User-Agent': 'SDS-CRM/1.0 (Node.js; TeamViewer reports/connections)',
};
/**
* TeamViewer akzeptiert ISO-8601-UTC ohne Millisekunden, z. B. 2019-01-31T19:20:30Z.
* JavaScripts toISOString() liefert .sssZ — das führt zu HTTP 400 „from_date is not valid“.
*/
export function formatTeamViewerDateParam(date) {
return new Date(date).toISOString().replace(/\.\d{3}Z$/, 'Z');
}
/** Dauer aus TeamViewer-Feldern `start_date` / `end_date` (ISO-Strings, ggf. leer). */
export function computeRemoteDurationSeconds(startIso, endIso) {
const ts = startIso != null ? String(startIso).trim() : '';
const te = endIso != null ? String(endIso).trim() : '';
if (!ts || !te) return null;
const s = new Date(ts);
const e = new Date(te);
if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime()) || e < s) {
return null;
}
return Math.max(0, Math.round((e - s) / 1000));
}
/**
* Gruppiert nach Benutzer (userid, sonst username), pro Benutzer Geräte mit
* jüngster Session je Gerät; Anzeigename Gerät = devicename oder sonst deviceid.
*/
/** TeamViewer liefert Feldnamen je nach Version leicht unterschiedlich (userid / user_id / …). */
function pickUserIdentity(rec) {
const uid = String(
rec.userid ?? rec.user_id ?? rec.userId ?? rec.UserID ?? '',
).trim();
const un = String(
rec.username ??
rec.user_name ??
rec.userName ??
rec.UserName ??
'',
).trim();
return { uid, un };
}
function buildSessionsByUser(allRecords) {
const byUser = new Map();
for (const rec of allRecords) {
const { uid, un } = pickUserIdentity(rec);
const userKey = uid || un || '_unbekannt';
if (!byUser.has(userKey)) {
byUser.set(userKey, {
userid: uid || null,
username: un || userKey,
records: [],
});
}
byUser.get(userKey).records.push(rec);
}
const users = [];
for (const [userKey, { userid, username, records }] of byUser) {
const byDev = new Map();
for (const rec of records) {
const did = String(
rec.deviceid ?? rec.device_id ?? rec.deviceId ?? '',
).trim();
const dn = String(
rec.devicename ?? rec.device_name ?? rec.deviceName ?? '',
).trim();
const dkey = did || dn;
if (!dkey) continue;
if (!byDev.has(dkey)) byDev.set(dkey, []);
byDev.get(dkey).push(rec);
}
const devices = [];
for (const [, recs] of byDev) {
recs.sort((a, b) => {
const eb =
(b.end_date ?? b.endDate ?? b.EndDate)
? new Date(b.end_date ?? b.endDate ?? b.EndDate).getTime()
: 0;
const ea =
(a.end_date ?? a.endDate ?? a.EndDate)
? new Date(a.end_date ?? a.endDate ?? a.EndDate).getTime()
: 0;
return eb - ea;
});
const rec = recs[0];
const did = String(
rec.deviceid ?? rec.device_id ?? rec.deviceId ?? '',
).trim();
const dn = String(
rec.devicename ?? rec.device_name ?? rec.deviceName ?? '',
).trim();
const label = dn || did;
const idForCrm = did || dn;
const startRaw = rec.start_date ?? rec.startDate ?? rec.StartDate;
const endRaw = rec.end_date ?? rec.endDate ?? rec.EndDate;
const startDate = startRaw ? String(startRaw).trim() : '';
const endDate = endRaw ? String(endRaw).trim() : '';
devices.push({
deviceid: idForCrm,
label,
startDate: startDate || null,
endDate: endDate || null,
durationSeconds: computeRemoteDurationSeconds(startDate, endDate),
});
}
devices.sort((a, b) =>
a.label.localeCompare(b.label, 'de', { sensitivity: 'base' }),
);
users.push({
userKey,
userid,
username,
devices,
});
}
users.sort((a, b) =>
a.username.localeCompare(b.username, 'de', { sensitivity: 'base' }),
);
return users;
}
function upstreamError(res, httpStatus, raw) {
const text = String(raw ?? '').trim();
if (text.startsWith('<')) {
return res.status(502).json({
message:
'TeamViewer-Web-API lieferte HTML statt JSON. Typisch: Script-Token ungültig/abgelaufen, falsche Berechtigungen (Verbindungsberichte), oder Dienst kurzzeitig nicht erreichbar.',
hint: 'TeamViewer Management Console → Apps → Script-Token: Zugriff auf Reporting / Connection Reports prüfen.',
devices: [],
users: [],
});
}
let detail = text.slice(0, 600);
try {
const j = JSON.parse(text);
const msg =
(typeof j.error_description === 'string' && j.error_description) ||
(typeof j.error === 'string' && j.error) ||
(typeof j.message === 'string' && j.message);
if (msg) detail = String(msg);
} catch {
/* Rohtext */
}
return res.status(502).json({
message: `TeamViewer-API meldet HTTP ${httpStatus}.`,
detail,
devices: [],
users: [],
});
}
function bodyNotJson(res, raw) {
const text = String(raw ?? '').trim();
if (text.startsWith('<')) {
return upstreamError(res, 200, raw);
}
return res.status(502).json({
message: 'TeamViewer: Antwort ist kein gültiges JSON.',
detail: text.slice(0, 400),
devices: [],
users: [],
});
}
/** TeamViewer-Antworten: `records` (Reports) oder andere Schlüssel / verschachtelt. */
function extractReportRecords(data) {
if (!data || typeof data !== 'object') return [];
if (Array.isArray(data)) return data;
const candidates = [
data.records,
data.Records,
data.connections,
data.Connections,
data.items,
data.Items,
data.values,
data.data?.records,
data.data?.connections,
data.report?.records,
];
let fallbackEmpty = null;
for (const c of candidates) {
if (Array.isArray(c)) {
if (c.length > 0) return c;
if (fallbackEmpty === null) fallbackEmpty = c;
}
}
return fallbackEmpty ?? [];
}
function hasNextPage(data) {
const next = data?.next_offset ?? data?.nextOffset ?? data?.NextOffset;
if (next == null) return false;
const s = String(next).trim();
return s.length > 0;
}
/** @param {import('express').Router} api @param {() => object} loadIntegrations */
export function registerTeamViewerRoutes(api, loadIntegrations) {
api.get('/integrations/teamviewer/connections', async (_req, res) => {
const integ = loadIntegrations();
const token = String(
integ.teamviewer?.bearerToken || integ.teamviewer?.apiToken || '',
).trim();
if (!token) {
return res.status(400).json({
message:
'TeamViewer Bearer-Token in den Optionen hinterlegen (Abschnitt TeamViewer).',
devices: [],
users: [],
});
}
const now = new Date();
const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const fromStr = formatTeamViewerDateParam(from);
const toStr = formatTeamViewerDateParam(now);
try {
const allRecords = [];
let offset;
let pagesFetched = 0;
for (let page = 0; page < 25; page++) {
pagesFetched += 1;
const u = new URL(CONNECTIONS_URL);
u.searchParams.set('from_date', fromStr);
u.searchParams.set('to_date', toStr);
if (offset != null && offset !== '') {
u.searchParams.set('offset', String(offset));
}
const r = await fetch(u, {
headers: {
...FETCH_HEADERS_BASE,
Authorization: `Bearer ${token}`,
},
});
const raw = await r.text();
if (!r.ok) {
console.error(
'[TeamViewer API] upstream HTTP',
r.status,
String(raw ?? '').slice(0, 500),
);
return upstreamError(res, r.status, raw);
}
let data;
try {
data = JSON.parse(raw);
} catch {
return bodyNotJson(res, raw);
}
const recs = extractReportRecords(data);
for (const rec of recs) {
allRecords.push(rec);
}
/*
* Wichtig: nicht abbrechen, nur weil eine Seite 0 Zeilen hat — TeamViewer liefert
* manchmal leere erste Seiten, setzt aber weiter next_offset (vgl. records_remaining).
* Früher: if (!next || recs.length === 0) break → alle Daten verloren.
*/
if (!hasNextPage(data)) break;
offset = data.next_offset ?? data.nextOffset ?? data.NextOffset;
}
const users = buildSessionsByUser(allRecords);
res.json({
users,
devices: [],
meta: {
recordCount: allRecords.length,
pagesFetched,
source: 'reports/connections',
},
});
} catch (e) {
console.error('[TeamViewer API] interner Fehler', e);
res.status(500).json({
message: 'TeamViewer-Verbindungen konnten nicht geladen werden.',
devices: [],
users: [],
});
}
});
}