Inital Commit
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
DATABASE_URL=postgresql://crm:crm@localhost:5432/crm
|
||||
PORT=3000
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
data/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
BIN
Anlagenliste ITT.xlsx
Normal file
BIN
Anlagenliste ITT.xlsx
Normal file
Binary file not shown.
75
database/init.sql
Normal file
75
database/init.sql
Normal 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
1293
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal 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
866
public/css/style.css
Normal 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
17
public/index.html
Normal 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
114
public/js/api.js
Normal 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
1502
public/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
156
scripts/import-anlagen-itt.mjs
Normal file
156
scripts/import-anlagen-itt.mjs
Normal 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 1–99 importieren; Spalten 100–180 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
163
server/db.js
Normal 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
776
server/index.js
Normal 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
29
server/init-db.js
Normal 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
209
server/ldap-sync.js
Normal 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
12
server/password.js
Normal 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
294
server/teamviewer.js
Normal 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: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user