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

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
DATABASE_URL=postgresql://crm:crm@localhost:5432/crm
PORT=3000

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
data/
.env
*.log
.DS_Store

BIN
Anlagenliste ITT.xlsx Normal file

Binary file not shown.

75
database/init.sql Normal file
View File

@@ -0,0 +1,75 @@
-- SQLite-Schema (CRM) — nur Maschinen, Tickets, Events (keine Kunden-Tabelle)
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS "machines" (
"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'))
);
CREATE TABLE IF NOT EXISTS "tickets" (
"id" TEXT NOT NULL PRIMARY KEY,
"machine_id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'OPEN' CHECK ("status" IN ('OPEN', 'WAITING', 'DONE')),
"priority" TEXT NOT NULL DEFAULT 'MEDIUM' CHECK ("priority" IN ('LOW', 'MEDIUM', 'HIGH')),
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
"updated_at" TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY ("machine_id") REFERENCES "machines" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS "events" (
"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
);
CREATE INDEX IF NOT EXISTS "tickets_machine_id_idx" ON "tickets" ("machine_id");
CREATE INDEX IF NOT EXISTS "tickets_status_idx" ON "tickets" ("status");
CREATE INDEX IF NOT EXISTS "tickets_priority_idx" ON "tickets" ("priority");
CREATE INDEX IF NOT EXISTS "events_ticket_id_idx" ON "events" ("ticket_id");
CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at");
CREATE TABLE IF NOT EXISTS "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");
CREATE TABLE IF NOT EXISTS "app_settings" (
"key" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "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 IF NOT EXISTS "ldap_sync_log_finished_idx" ON "ldap_sync_log" ("finished_at" DESC);

1293
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "sds-crm",
"private": true,
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=22.5.0"
},
"scripts": {
"start": "node server/index.js",
"dev": "node --watch server/index.js",
"db:init": "node server/init-db.js",
"import:anlagen": "node scripts/import-anlagen-itt.mjs",
"import:anlagen:neu": "node scripts/import-anlagen-itt.mjs --replace"
},
"dependencies": {
"bcrypt": "^6.0.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-session": "^1.19.0",
"ldapjs": "^3.0.7",
"xlsx": "^0.18.5"
}
}

866
public/css/style.css Normal file
View File

@@ -0,0 +1,866 @@
:root {
--bg: #0d1117;
--bg-card: #161b22;
--bg-raised: #1c2330;
--bg-hover: #212836;
--border: #30363d;
--border-hi: #444c56;
--text: #e6edf3;
--text-sub: #8b949e;
--text-muted:#6e7681;
--accent: #2f81f7;
--accent-hi: #58a6ff;
--green: #238636;
--green-fg: #3fb950;
--amber: #9e6a03;
--amber-fg: #d29922;
--red: #b91c1c;
--red-fg: #f85149;
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
line-height: 1.55;
color: var(--text);
background: var(--bg);
}
* { box-sizing: border-box; }
body { margin: 0; }
a {
color: var(--accent-hi);
text-decoration: none;
}
a:hover { text-decoration: underline; }
/* ── Header ──────────────────────────────────────── */
.header {
background: #010409;
border-bottom: 1px solid var(--border);
padding: 0 1.5rem;
height: 52px;
display: flex;
align-items: center;
gap: 2rem;
position: sticky;
top: 0;
z-index: 100;
}
.header h1 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text);
letter-spacing: 0.02em;
white-space: nowrap;
}
.header nav { display: flex; gap: 0.25rem; }
.header nav a {
color: var(--text-sub);
padding: 0.35rem 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
transition: color 0.1s, background 0.1s;
}
.header nav a:hover {
color: var(--text);
background: var(--bg-raised);
text-decoration: none;
}
.header h1 a {
color: inherit;
}
#main-nav {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
}
.nav-user {
font-size: 0.85rem;
}
.btn-nav-logout {
padding: 0.3rem 0.65rem;
font-size: 0.85rem;
}
.auth-panel {
max-width: 28rem;
}
.row-inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.users-table .users-actions {
white-space: nowrap;
}
.users-table .users-actions button {
margin-right: 0.35rem;
margin-bottom: 0.25rem;
}
.options-page .options-section-title {
margin: 0 0 0.5rem;
font-size: 1.05rem;
font-weight: 600;
color: var(--text);
text-transform: none;
letter-spacing: normal;
}
.options-page .options-section .muted code {
font-size: 0.85em;
}
/* LDAP-Synchronisation (Referenz-Layout) */
.ldap-section {
padding: 0;
overflow: hidden;
}
.ldap-section-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1.25rem;
margin: 0;
border: none;
border-bottom: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text);
font: inherit;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.ldap-section-toggle:hover {
background: var(--bg-hover);
}
.ldap-section-heading {
flex: 1;
}
.ldap-chevron {
font-size: 0.75rem;
color: var(--text-muted);
flex-shrink: 0;
}
.ldap-section-body {
padding: 1rem 1.25rem 1.25rem;
}
.ldap-section-body[hidden] {
display: none !important;
}
.ldap-subtitle {
margin: 0 0 1rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-sub);
}
.ldap-sync-check {
margin-bottom: 1rem;
}
.form-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
@media (max-width: 720px) {
.form-grid-2 {
grid-template-columns: 1fr;
}
}
.form-grid-ldap-attr {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
@media (max-width: 900px) {
.form-grid-ldap-attr {
grid-template-columns: 1fr;
}
}
.options-page label.full-width {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 0.75rem;
}
.ldap-filter-ta {
font-family: ui-monospace, monospace;
font-size: 0.82rem;
line-height: 1.45;
min-height: 5rem;
}
.ldap-hint {
margin: -0.35rem 0 0.75rem;
font-size: 0.82rem;
}
.options-actions {
display: flex;
justify-content: flex-start;
}
.btn-config-save {
padding: 0.5rem 1.25rem;
font-weight: 600;
}
/* LDAP Sync-Panel (Optionen) */
.sync-panel .sync-actions {
margin-bottom: 0.75rem;
}
.btn-ldap-sync-now {
padding: 0.5rem 1.1rem;
font-weight: 600;
}
.sync-last-line {
margin: 0 0 1rem;
font-size: 0.92rem;
}
.sync-log-title {
margin: 0 0 0.65rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-sub);
text-transform: none;
letter-spacing: normal;
}
.sync-log-table-wrap {
overflow-x: auto;
}
table.sync-log-table {
font-size: 0.88rem;
}
table.sync-log-table th,
table.sync-log-table td {
padding: 0.45rem 0.65rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
table.sync-log-table th {
font-weight: 600;
color: var(--text-sub);
text-align: left;
}
table.sync-log-table td.num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.sync-status-badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 600;
}
.sync-status-badge.sync-status-ok {
background: rgba(46, 160, 67, 0.25);
color: #3fb950;
border: 1px solid rgba(63, 185, 80, 0.45);
}
.sync-status-badge.sync-status-err {
background: rgba(248, 81, 73, 0.15);
color: var(--red, #f85149);
border: 1px solid rgba(248, 81, 73, 0.35);
}
.tv-device-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 0.75rem;
}
.tv-device-row label {
flex: 1;
min-width: 12rem;
}
.tv-conn-hint {
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
/* ── Layout ──────────────────────────────────────── */
.main {
padding: 1.5rem 2rem;
max-width: 100%;
}
/* ── Card ────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
/* ── Stack / Row ─────────────────────────────────── */
.stack {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: flex-end;
}
/* ── Typography ─────────────────────────────────── */
h2 { margin: 0 0 0.5rem; font-size: 1.25rem; }
h3, h4 { margin: 0 0 0.5rem; font-size: 0.95rem; color: var(--text-sub); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.muted { color: var(--text-muted); font-size: 0.9rem; }
.error { color: var(--red-fg); font-size: 0.9rem; }
/* ── Forms ───────────────────────────────────────── */
label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-sub);
}
input, select, textarea, button { font: inherit; }
input, select, textarea {
padding: 0.45rem 0.65rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
min-width: 180px;
transition: border-color 0.1s;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent);
}
textarea { min-height: 72px; resize: vertical; }
button {
padding: 0.45rem 0.9rem;
border-radius: 6px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
cursor: pointer;
font-weight: 500;
transition: opacity 0.12s;
}
button:hover { opacity: 0.85; }
button.secondary {
background: transparent;
color: var(--accent-hi);
border-color: var(--border-hi);
}
button.secondary:hover { background: var(--bg-hover); opacity: 1; }
button.danger { border-color: var(--red); background: var(--red); color: #fff; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Table (global) ──────────────────────────────── */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
}
th, td {
text-align: left;
padding: 0.55rem 0.6rem;
border-bottom: 1px solid var(--border);
}
th {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-sub);
font-weight: 600;
}
/* ── Badge ───────────────────────────────────────── */
.badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.03em;
background: var(--bg-raised);
color: var(--text-sub);
border: 1px solid var(--border-hi);
}
/* ── Timeline ────────────────────────────────────── */
.timeline {
border-left: 2px solid var(--border);
margin-left: 0.35rem;
padding-left: 1rem;
}
.timeline-item { margin-bottom: 1rem; }
.timeline-item time { font-size: 0.78rem; color: var(--text-muted); }
/* ── Table-Wrap ──────────────────────────────────── */
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* ── Extras / Anlagen ────────────────────────────── */
.extras-table { font-size: 0.88rem; }
.extras-table th {
vertical-align: top;
font-weight: 600;
text-transform: none;
letter-spacing: normal;
color: var(--text-sub);
border-right: 1px solid var(--border);
}
/* Nur Key-Value-Extras: feste Schlüsselspalte (nicht Anlagenliste mit 3 Spalten) */
.extras-table:not(.anlagen-voll) th {
width: 11rem;
}
.extras-cell-input {
width: 100%;
min-width: 0;
box-sizing: border-box;
font: inherit;
font-size: 0.88rem;
padding: 0.35rem 0.45rem;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
}
.extras-table th .extras-cell-input {
font-weight: 500;
text-transform: none;
letter-spacing: normal;
}
.machine-detail-card-title {
margin-top: 0;
font-size: 1rem;
font-weight: 600;
}
.machine-detail-actions {
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
/* Anlagenliste: Gruppe + Beschreibung schmal aber lesbar, „Wert“ der Rest.
Kein width:100% auf der letzten Zelle — das drückt Spalte 2 auf Mindestbreite. */
.anlagen-voll {
width: 100%;
table-layout: fixed;
}
/* Spaltenbreiten über colgroup: 1+2 kompakt, 3 bekommt den Rest (kein width:100% auf Zellen) */
.anlagen-voll col.anlagen-col-gruppe {
width: 10rem;
}
.anlagen-voll col.anlagen-col-beschr {
width: 22%;
min-width: 11rem;
}
.anlagen-voll col.anlagen-col-wert {
width: auto;
}
.anlagen-voll:not(.gruppiert) thead th {
text-transform: none;
letter-spacing: normal;
font-size: 0.75rem;
vertical-align: bottom;
}
.anlagen-voll:not(.gruppiert) thead th:nth-child(1),
.anlagen-voll:not(.gruppiert) tbody td:nth-child(1) {
vertical-align: top;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.85rem;
}
.anlagen-voll:not(.gruppiert) thead th:nth-child(2),
.anlagen-voll:not(.gruppiert) tbody th:nth-child(2) {
vertical-align: top;
max-width: 28rem;
white-space: normal;
word-break: break-word;
hyphens: auto;
}
.anlagen-voll:not(.gruppiert) thead th:nth-child(3),
.anlagen-voll:not(.gruppiert) tbody td:nth-child(3) {
vertical-align: top;
word-break: break-word;
}
/* Gruppierte Anlagenliste: Box pro Gruppe, Kopfzeile = Gruppenname */
.anlagen-voll.gruppiert col.anlagen-col-beschr {
width: 22%;
min-width: 11rem;
}
.anlagen-voll.gruppiert col.anlagen-col-wert {
width: auto;
}
.anlagen-voll.gruppiert thead th {
text-transform: none;
letter-spacing: normal;
font-size: 0.75rem;
vertical-align: bottom;
}
.anlagen-voll.gruppiert thead th:first-child,
.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) th {
vertical-align: top;
max-width: 28rem;
white-space: normal;
word-break: break-word;
hyphens: auto;
}
.anlagen-voll.gruppiert thead th:last-child,
.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) td {
vertical-align: top;
word-break: break-word;
}
.anlagen-voll.gruppiert tbody.anlagen-gruppe-spacer td {
height: 0.85rem;
padding: 0 !important;
border: none !important;
background: transparent !important;
}
.anlagen-voll.gruppiert tr.anlagen-gruppe-kopf td {
font-weight: 600;
font-size: 0.8rem;
text-transform: none;
letter-spacing: 0.02em;
color: var(--text);
background: var(--bg-raised);
border: 1px solid var(--border-hi);
border-bottom: 1px solid var(--border);
padding: 0.5rem 0.65rem;
}
.anlagen-voll.gruppiert tbody.anlagen-gruppe tr.anlagen-gruppe-kopf td:first-child {
border-radius: 8px 8px 0 0;
}
.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) th,
.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) td {
border-left: 1px solid var(--border-hi);
border-right: 1px solid var(--border-hi);
}
.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf) th {
border-right: 1px solid var(--border);
}
.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf):last-child th:first-child {
border-bottom: 1px solid var(--border-hi);
border-bottom-left-radius: 8px;
}
.anlagen-voll.gruppiert tbody.anlagen-gruppe tr:not(.anlagen-gruppe-kopf):last-child td:last-child {
border-bottom: 1px solid var(--border-hi);
border-bottom-right-radius: 8px;
}
.main:has(.machines-overview) { max-width: 100%; }
code {
font-size: 0.85em;
background: var(--bg-raised);
color: var(--text-sub);
padding: 0.1em 0.35em;
border-radius: 4px;
}
/* ═══════════════════════════════════════════════════
Startseite — Offene Tickets
════════════════════════════════════════════════════ */
.home-open-tickets { gap: 1rem; }
.home-kpi-bar {
display: flex;
align-items: center;
gap: 1.25rem;
flex-wrap: wrap;
}
.home-kpi-pills {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.kpi-pill .badge {
font-size: 0.8rem;
padding: 0.2rem 0.65rem;
}
.home-open-tickets h2 {
font-size: 1.1rem;
color: var(--text);
font-weight: 600;
letter-spacing: 0;
text-transform: none;
margin: 0;
}
.home-ticket-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.home-ticket-card {
padding: 0;
overflow: hidden;
}
.home-ticket-top {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem 1rem;
padding: 0.85rem 1.1rem;
border-bottom: 1px solid var(--border);
background: var(--bg-raised);
}
.home-ticket-top-inner {
flex: 1;
min-width: 12rem;
}
.home-ticket-titleline {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
font-size: 0.95rem;
margin-bottom: 0.35rem;
}
.home-ticket-titleline > a {
font-weight: 600;
}
.home-ticket-meta-row {
font-size: 0.8rem;
color: var(--text-muted);
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1rem;
}
.home-ticket-open {
display: inline-block;
padding: 0.35rem 0.75rem;
border: 1px solid var(--accent);
border-radius: 6px;
font-size: 0.82rem;
font-weight: 500;
color: var(--accent-hi);
transition: background 0.1s;
flex-shrink: 0;
text-decoration: none;
}
.home-ticket-open:hover {
background: rgba(47, 129, 247, 0.12);
text-decoration: none;
}
.home-ticket-context {
padding: 0.85rem 1.1rem;
border-bottom: 1px solid var(--border);
}
.home-ticket-context-h {
margin: 0 0 0.4rem;
font-size: 0.82rem;
}
.home-ticket-desc {
white-space: pre-wrap;
word-break: break-word;
color: var(--text-sub);
font-size: 0.88rem;
line-height: 1.55;
margin: 0;
}
.home-ticket-machine {
margin: 0.65rem 0 0;
font-size: 0.85rem;
color: var(--text-sub);
}
.home-ticket-events {
padding: 0.75rem 1.1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.home-ticket-no-events {
margin: 0;
font-size: 0.88rem;
}
.home-event-box {
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.65rem 0.85rem;
background: var(--bg);
font-size: 0.88rem;
}
.home-event-box-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
margin-bottom: 0.45rem;
}
.home-event-box-time {
font-size: 0.78rem;
color: var(--text-muted);
}
.home-event-box-body :last-child {
margin-bottom: 0;
}
/* Status badge variants */
.badge-open { background: rgba(35, 134, 54, 0.15); color: var(--green-fg); border-color: rgba(63, 185, 80, 0.3); }
.badge-waiting { background: rgba(158, 106, 3, 0.15); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.3); }
.badge-done { background: rgba(110, 118, 129, 0.12); color: var(--text-muted); border-color: var(--border); }
/* Priority badge variants */
.badge-high { background: rgba(185, 28, 28, 0.18); color: var(--red-fg); border-color: rgba(248, 81, 73, 0.35); }
.badge-medium { background: rgba(158, 106, 3, 0.15); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.3); }
.badge-low { background: rgba(35, 134, 54, 0.1); color: var(--green-fg); border-color: rgba(63, 185, 80, 0.2); }
/* Ticket-Detail: Ereignisse als Tabelle (neueste zuerst) */
.events-table {
font-size: 0.9rem;
}
.events-table thead th {
background: var(--bg-raised);
white-space: nowrap;
}
.events-table-time {
color: var(--text-sub);
font-variant-numeric: tabular-nums;
white-space: nowrap;
width: 1%;
vertical-align: top;
}
.events-table-desc {
color: var(--text-sub);
vertical-align: top;
line-height: 1.5;
}
.event-inhalt-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.event-inhalt-label {
margin: 0;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
font-weight: 600;
}
.event-inhalt-text {
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}
.event-inhalt-meta {
margin: 0;
font-size: 0.88rem;
}
.event-artnr {
font-size: 0.95em;
}
.event-tv-placeholder {
font-size: 0.85em;
}
.ev-field-group[hidden] {
display: none !important;
}
.event-type-badge {
white-space: nowrap;
}
.event-type-note { background: rgba(88, 166, 255, 0.12); color: var(--accent-hi); border-color: rgba(88, 166, 255, 0.35); }
.event-type-call { background: rgba(163, 113, 247, 0.14); color: #d2a8ff; border-color: rgba(163, 113, 247, 0.35); }
.event-type-remote { background: rgba(35, 134, 54, 0.15); color: var(--green-fg); border-color: rgba(63, 185, 80, 0.3); }
.event-type-part { background: rgba(210, 153, 34, 0.14); color: var(--amber-fg); border-color: rgba(210, 153, 34, 0.35); }
.event-type-system { background: rgba(110, 118, 129, 0.18); color: var(--text-muted); border-color: var(--border-hi); }

17
public/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SDS CRM</title>
<link rel="stylesheet" href="/css/style.css" />
</head>
<body>
<header class="header">
<h1><a href="#/home">SDS CRM</a></h1>
<nav id="main-nav" aria-label="Hauptnavigation"></nav>
</header>
<main id="app" class="main"></main>
<script type="module" src="/js/app.js"></script>
</body>
</html>

114
public/js/api.js Normal file
View File

@@ -0,0 +1,114 @@
async function parseError(res, text) {
try {
const j = JSON.parse(text);
if (j.message) {
const base = Array.isArray(j.message) ? j.message.join(', ') : j.message;
const parts = [base];
if (j.detail && String(j.detail).trim()) {
parts.push(String(j.detail).trim());
}
if (j.hint && String(j.hint).trim()) {
parts.push(String(j.hint).trim());
}
return parts.join(' — ');
}
} catch {
/* ignore */
}
return text || res.statusText;
}
function redirectToLogin() {
if (
!location.hash.startsWith('#/login') &&
!location.hash.startsWith('#/bootstrap')
) {
location.hash = '#/login';
}
}
/** Geschützte REST-API liegt unter /api (Root-URLs bleiben für die SPA frei). */
function apiUrl(path) {
if (path.startsWith('/auth/')) return path;
return `/api${path.startsWith('/') ? path : `/${path}`}`;
}
function onUnauthorized(path) {
if (path.startsWith('/auth/')) return;
redirectToLogin();
}
/** Wird bei 401 geworfen: Aufrufer sollen keine Fehlerseite rendern. */
export function isAuthRedirectError(e) {
return Boolean(e && (e.authRedirect === true || e.name === 'AuthRedirect'));
}
function authRedirectError() {
const err = new Error('SESSION');
err.name = 'AuthRedirect';
err.authRedirect = true;
return err;
}
function parseJsonBody(text) {
if (!text) return null;
const trimmed = text.trim();
if (trimmed.startsWith('<')) {
throw new Error(
'Server lieferte HTML statt JSON (API-URL/Proxy prüfen oder Server neu starten).',
);
}
try {
return JSON.parse(text);
} catch (e) {
throw new Error(
e && String(e.message || e).includes('JSON')
? 'Ungültige Server-Antwort (kein JSON).'
: e.message || 'Ungültige Server-Antwort',
);
}
}
async function apiRequest(method, path, body) {
const url = apiUrl(path);
const opt = { method, credentials: 'include', headers: {} };
if (body !== undefined) {
opt.headers['Content-Type'] = 'application/json';
opt.body = JSON.stringify(body);
}
const res = await fetch(url, opt);
const text = await res.text();
if (res.status === 401) {
if (path.startsWith('/auth/')) {
throw new Error((await parseError(res, text)) || 'Anmeldung fehlgeschlagen');
}
onUnauthorized(path);
throw authRedirectError();
}
if (!res.ok) throw new Error(await parseError(res, text));
return parseJsonBody(text);
}
export async function apiGet(path) {
return apiRequest('GET', path);
}
export async function apiPost(path, body) {
return apiRequest('POST', path, body);
}
export async function apiPut(path, body) {
return apiRequest('PUT', path, body);
}
export async function apiDelete(path) {
return apiRequest('DELETE', path);
}
/** Öffentlich: keine Session nötig, kein Redirect bei 401 */
export async function authFetchStatus() {
const res = await fetch('/auth/status', { credentials: 'include' });
const text = await res.text();
if (!res.ok) throw new Error(await parseError(res, text));
return parseJsonBody(text);
}

1502
public/js/app.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,156 @@
import { randomUUID } from 'crypto';
import fs from 'fs';
import path from 'path';
import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath } from 'url';
import XLSX from 'xlsx';
import dotenv from 'dotenv';
dotenv.config();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, '..');
const xlsxPath = path.join(root, 'Anlagenliste ITT.xlsx');
const dbPath = process.env.SQLITE_PATH || path.join(root, 'data', 'crm.db');
/** Zeile 9 (1-basiert): Spaltenbeschriftung / Feldnamen */
const ZEILE_BESCHREIBUNG = 9;
/** Zeile 7: Gruppierung / übergeordnete Rubrik pro Spalte (z. B. „Kunde“) */
const ZEILE_GRUPPE = 7;
/** Nur Spalten 199 importieren; Spalten 100180 entfallen */
const MAX_SPALTEN = 99;
if (!fs.existsSync(xlsxPath)) {
console.error('Datei nicht gefunden:', xlsxPath);
process.exit(1);
}
function dedupeHeaders(raw) {
const count = {};
return raw.map((h) => {
const base = String(h ?? '').trim() || 'Spalte';
count[base] = (count[base] || 0) + 1;
return count[base] === 1 ? base : `${base}_${count[base]}`;
});
}
function padRow(arr, width) {
const a = Array.isArray(arr) ? [...arr] : [];
while (a.length < width) a.push('');
return a.slice(0, width);
}
const wb = XLSX.readFile(xlsxPath);
const sheet =
wb.Sheets.Anlagen ||
wb.Sheets[wb.SheetNames.find((n) => /anlagen/i.test(n))] ||
wb.Sheets[wb.SheetNames[0]];
const aoa = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' });
const beschreibIdx = ZEILE_BESCHREIBUNG - 1;
const gruppeIdx = ZEILE_GRUPPE - 1;
let width = 0;
for (const r of [beschreibIdx, gruppeIdx]) {
if (aoa[r] && aoa[r].length > width) width = aoa[r].length;
}
if (sheet['!ref']) {
const range = XLSX.utils.decode_range(sheet['!ref']);
width = Math.max(width, range.e.c + 1);
}
width = Math.min(width, MAX_SPALTEN);
const rawRow9 = padRow(aoa[beschreibIdx] || [], width).map((h) =>
String(h ?? '').trim(),
);
const rawRow7 = padRow(aoa[gruppeIdx] || [], width).map((h) =>
String(h ?? '').trim(),
);
const headers = dedupeHeaders(rawRow9);
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const db = new DatabaseSync(dbPath);
db.exec('PRAGMA foreign_keys = ON');
const cols = db.prepare('PRAGMA table_info(machines)').all();
if (!cols.some((c) => c.name === 'extras')) {
db.exec('ALTER TABLE machines ADD COLUMN extras TEXT');
}
const replaceAll = process.argv.includes('--replace');
if (replaceAll) {
db.exec('DELETE FROM events');
db.exec('DELETE FROM tickets');
db.exec('DELETE FROM machines');
console.log('Bestehende Maschinen/Tickets/Events gelöscht.');
}
const insertMachine = db.prepare(
`INSERT INTO machines (id, name, typ, seriennummer, standort, extras, updated_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
);
let imported = 0;
let skipped = 0;
db.exec('BEGIN');
try {
for (let i = beschreibIdx + 1; i < aoa.length; i++) {
const row = aoa[i];
if (!row || !row.length) continue;
const padded = padRow(row, width);
const sn = String(padded[0] ?? '').trim();
if (!/^ITT#/i.test(sn)) continue;
const dup = db
.prepare('SELECT id FROM machines WHERE seriennummer = ?')
.get(sn);
if (dup) {
skipped += 1;
continue;
}
const rowObj = {};
headers.forEach((h, j) => {
const v = padded[j];
rowObj[h] =
v === '' || v === undefined || v === null ? '' : String(v).trim();
});
const typ = rowObj.Typ || '—';
const standort =
[rowObj.Stadt, rowObj.Land].filter(Boolean).join(', ') || '—';
const werteAlsListe = padded.map((v) =>
v === '' || v === undefined || v === null ? '' : String(v).trim(),
);
const extrasObj = {
_beschriftungZeile9: rawRow9,
_gruppeZeile7: rawRow7,
_werteAlsListe: werteAlsListe,
...rowObj,
};
const extrasJson = JSON.stringify(extrasObj);
const id = randomUUID();
insertMachine.run(id, sn, typ, sn, standort, extrasJson);
imported += 1;
}
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
db.close();
console.log(
`Anlagenliste: ${imported} Maschinen importiert, ${skipped} übersprungen (Seriennr. schon vorhanden).`,
);
if (!replaceAll && skipped > 0 && imported === 0) {
console.log(
'Hinweis: Für Neuimport: npm run import:anlagen -- --replace',
);
}

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: [],
});
}
});
}