V0.1
This commit is contained in:
112
server/db.js
112
server/db.js
@@ -2,6 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { mergeLegacyAttachmentEventsByDay } from './lib/merge-attachment-events.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dbPath =
|
||||
@@ -16,6 +17,12 @@ 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 machineCols2 = db.prepare('PRAGMA table_info(machines)').all();
|
||||
if (!machineCols2.some((c) => c.name === 'list_status')) {
|
||||
db.exec(
|
||||
"ALTER TABLE machines ADD COLUMN list_status TEXT NOT NULL DEFAULT ''",
|
||||
);
|
||||
}
|
||||
|
||||
const hasCustomerId = machineCols.some((c) => c.name === 'customer_id');
|
||||
const tables = db
|
||||
@@ -30,6 +37,9 @@ 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');
|
||||
}
|
||||
if (eventCols.length > 0 && !eventCols.some((c) => c.name === 'teamviewer_notes')) {
|
||||
db.exec('ALTER TABLE events ADD COLUMN teamviewer_notes TEXT');
|
||||
}
|
||||
const hasEventExtras = eventCols.some((c) => c.name === 'callback_number');
|
||||
if (eventCols.length > 0 && !hasEventExtras) {
|
||||
db.exec('BEGIN');
|
||||
@@ -44,10 +54,11 @@ if (eventCols.length > 0 && !hasEventExtras) {
|
||||
"teamviewer_id" TEXT,
|
||||
"article_number" TEXT,
|
||||
"remote_duration_seconds" INTEGER,
|
||||
"teamviewer_notes" TEXT,
|
||||
"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)
|
||||
INSERT INTO events_new (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, teamviewer_notes, created_at)
|
||||
SELECT
|
||||
id,
|
||||
ticket_id,
|
||||
@@ -57,6 +68,7 @@ if (eventCols.length > 0 && !hasEventExtras) {
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
created_at
|
||||
FROM events;
|
||||
DROP TABLE events;
|
||||
@@ -85,12 +97,13 @@ if (hasCustomerId || hasCustomersTable) {
|
||||
"typ" TEXT NOT NULL,
|
||||
"seriennummer" TEXT NOT NULL,
|
||||
"standort" TEXT NOT NULL,
|
||||
"list_status" TEXT NOT NULL DEFAULT '',
|
||||
"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;
|
||||
INSERT INTO machines_new (id, name, typ, seriennummer, standort, list_status, extras, created_at, updated_at)
|
||||
SELECT id, name, typ, seriennummer, standort, COALESCE(list_status, ''), extras, created_at, updated_at FROM machines;
|
||||
DROP TABLE machines;
|
||||
ALTER TABLE machines_new RENAME TO machines;
|
||||
`);
|
||||
@@ -160,4 +173,97 @@ if (!ldapLogTbl) {
|
||||
`);
|
||||
}
|
||||
|
||||
const ticketAttachmentsTbl = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='ticket_attachments'",
|
||||
)
|
||||
.get();
|
||||
if (!ticketAttachmentsTbl) {
|
||||
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', 'ATTACHMENT')),
|
||||
"description" TEXT NOT NULL,
|
||||
"callback_number" TEXT,
|
||||
"teamviewer_id" TEXT,
|
||||
"article_number" TEXT,
|
||||
"remote_duration_seconds" INTEGER,
|
||||
"teamviewer_notes" TEXT,
|
||||
"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, teamviewer_notes, created_at
|
||||
)
|
||||
SELECT
|
||||
id, ticket_id, type, description, callback_number, teamviewer_id, article_number,
|
||||
remote_duration_seconds, teamviewer_notes, 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(`
|
||||
CREATE TABLE "ticket_attachments" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"event_id" TEXT NOT NULL,
|
||||
"original_name" TEXT NOT NULL,
|
||||
"stored_path" TEXT NOT NULL,
|
||||
"mime_type" TEXT,
|
||||
"size_bytes" INTEGER NOT NULL,
|
||||
"created_at" TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY ("event_id") REFERENCES "events" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ticket_attachments_event_idx ON "ticket_attachments" ("event_id");
|
||||
`);
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const hasTicketAttachments = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='ticket_attachments'",
|
||||
)
|
||||
.get();
|
||||
const attachmentMergeDone = db
|
||||
.prepare('SELECT 1 AS ok FROM app_settings WHERE key = ?')
|
||||
.get('attachment_events_merge_day_v1');
|
||||
if (hasTicketAttachments && !attachmentMergeDone) {
|
||||
try {
|
||||
mergeLegacyAttachmentEventsByDay(db);
|
||||
db.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run(
|
||||
'attachment_events_merge_day_v1',
|
||||
'1',
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('CRM: Zusammenführung Anhang-Events fehlgeschlagen:', e);
|
||||
}
|
||||
}
|
||||
|
||||
const ticketCols = db.prepare('PRAGMA table_info(tickets)').all();
|
||||
if (!ticketCols.some((c) => c.name === 'sla_days')) {
|
||||
db.exec('ALTER TABLE tickets ADD COLUMN sla_days INTEGER');
|
||||
}
|
||||
if (!ticketCols.some((c) => c.name === 'sla_anchor_at')) {
|
||||
db.exec('ALTER TABLE tickets ADD COLUMN sla_anchor_at TEXT');
|
||||
}
|
||||
const ticketCols2 = db.prepare('PRAGMA table_info(tickets)').all();
|
||||
if (ticketCols2.some((c) => c.name === 'sla_anchor_at')) {
|
||||
db.prepare(
|
||||
'UPDATE tickets SET sla_anchor_at = created_at WHERE sla_anchor_at IS NULL',
|
||||
).run();
|
||||
}
|
||||
|
||||
export default db;
|
||||
|
||||
741
server/index.js
741
server/index.js
@@ -1,17 +1,13 @@
|
||||
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';
|
||||
import { restartLdapSyncScheduler } from './integrations.js';
|
||||
import { createApiRouter } from './routes/api/index.js';
|
||||
import { createAdminRouter } from './routes/admin/index.js';
|
||||
import authRouter from './routes/auth.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -42,734 +38,21 @@ app.use(
|
||||
}),
|
||||
);
|
||||
|
||||
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;
|
||||
app.use('/auth', authRouter);
|
||||
app.use('/api', createApiRouter());
|
||||
app.use('/api', createAdminRouter());
|
||||
|
||||
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 */
|
||||
/** Unbekannte /api/*-Routen: JSON 404 */
|
||||
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.get('/', (_req, res) => {
|
||||
res.redirect(302, '/start.html');
|
||||
});
|
||||
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
app.listen(PORT, () => {
|
||||
restartLdapSyncScheduler();
|
||||
console.log(`CRM-Server http://localhost:${PORT}`);
|
||||
|
||||
69
server/integrations.js
Normal file
69
server/integrations.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import db from './db.js';
|
||||
import { performLdapSync } from './ldap-sync.js';
|
||||
|
||||
const DEFAULT_INTEGRATIONS = {
|
||||
ldap: {
|
||||
serverUrl: '',
|
||||
bindDn: '',
|
||||
bindPassword: '',
|
||||
searchBase: '',
|
||||
userSearchFilter: '',
|
||||
userFilter: '',
|
||||
usernameAttribute: 'sAMAccountName',
|
||||
firstNameAttribute: 'givenName',
|
||||
lastNameAttribute: 'sn',
|
||||
syncIntervalMinutes: 1440,
|
||||
syncEnabled: false,
|
||||
syncNotes: '',
|
||||
},
|
||||
teamviewer: {
|
||||
bearerToken: '',
|
||||
notes: '',
|
||||
},
|
||||
};
|
||||
|
||||
export 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;
|
||||
}
|
||||
}
|
||||
|
||||
export 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;
|
||||
|
||||
export 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);
|
||||
}
|
||||
6
server/lib/http.js
Normal file
6
server/lib/http.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export 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;
|
||||
|
||||
export function badRequest(res, msg) {
|
||||
return res.status(400).json({ message: msg });
|
||||
}
|
||||
138
server/lib/mappers.js
Normal file
138
server/lib/mappers.js
Normal file
@@ -0,0 +1,138 @@
|
||||
export function parseJsonField(v) {
|
||||
if (v == null) return undefined;
|
||||
if (typeof v === 'object') return v;
|
||||
return JSON.parse(v);
|
||||
}
|
||||
|
||||
export function mapMachine(r) {
|
||||
const o = {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
typ: r.typ,
|
||||
seriennummer: r.seriennummer,
|
||||
standort: r.standort,
|
||||
listStatus:
|
||||
r.list_status != null && String(r.list_status).trim() !== ''
|
||||
? String(r.list_status).trim()
|
||||
: '',
|
||||
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;
|
||||
}
|
||||
|
||||
export 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,
|
||||
slaDays: r.sla_days != null ? r.sla_days : null,
|
||||
slaAnchorAt: r.sla_anchor_at ?? null,
|
||||
dueAt: r.sla_due_at ?? null,
|
||||
isOverdue: Boolean(r.sla_is_overdue),
|
||||
createdAt: r.created_at,
|
||||
/** Letzte Änderung: neueres aus Ticket-Zeile oder letztem Event (für Anzeige „Aktualisiert“). */
|
||||
updatedAt: r.ticket_last_activity_at ?? r.updated_at,
|
||||
};
|
||||
if (machine_row) {
|
||||
t.machine = mapMachine(machine_row);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
function mapAttachmentRow(a, ticketId) {
|
||||
return {
|
||||
id: a.id,
|
||||
originalName: a.original_name,
|
||||
mimeType: a.mime_type ?? null,
|
||||
sizeBytes: a.size_bytes,
|
||||
createdAt: a.created_at,
|
||||
url: `/tickets/${ticketId}/attachments/${a.id}/file`,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapEvent(r, attachmentRows = []) {
|
||||
const list = Array.isArray(attachmentRows) ? attachmentRows : [];
|
||||
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,
|
||||
teamviewerNotes:
|
||||
r.teamviewer_notes != null && String(r.teamviewer_notes).trim() !== ''
|
||||
? String(r.teamviewer_notes).trim()
|
||||
: null,
|
||||
attachments: list.map((a) => mapAttachmentRow(a, r.ticket_id)),
|
||||
};
|
||||
}
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Spätester Start der Bearbeitungszeit: gespeicherter Anker oder letztes Nutzer-Event (ohne SYSTEM). */
|
||||
export const ticketSlaActivityAnchorExpr = `MAX(
|
||||
datetime(COALESCE(t.sla_anchor_at, t.created_at)),
|
||||
COALESCE(
|
||||
(SELECT MAX(datetime(e.created_at)) FROM events e
|
||||
WHERE e.ticket_id = t.id AND e.type IN ('NOTE','CALL','REMOTE','PART','ATTACHMENT')),
|
||||
datetime(COALESCE(t.sla_anchor_at, t.created_at))
|
||||
)
|
||||
)`;
|
||||
|
||||
export const ticketSlaDueExpr = `datetime((${ticketSlaActivityAnchorExpr}), '+' || CAST(COALESCE(t.sla_days, 2) AS TEXT) || ' days')`;
|
||||
|
||||
/** Spätester Zeitpunkt aus Ticket und Historie (alle Event-Typen). */
|
||||
export const ticketLastActivityExpr = `MAX(
|
||||
datetime(t.updated_at),
|
||||
COALESCE(
|
||||
(SELECT MAX(datetime(e.created_at)) FROM events e WHERE e.ticket_id = t.id),
|
||||
datetime(t.updated_at)
|
||||
)
|
||||
)`;
|
||||
|
||||
export const ticketJoinSelect = `
|
||||
SELECT t.*,
|
||||
${ticketLastActivityExpr} AS ticket_last_activity_at,
|
||||
${ticketSlaDueExpr} AS sla_due_at,
|
||||
(CASE WHEN t.status IN ('OPEN','WAITING') AND datetime('now') > ${ticketSlaDueExpr} THEN 1 ELSE 0 END) AS sla_is_overdue,
|
||||
json_object(
|
||||
'id', m.id,
|
||||
'name', m.name,
|
||||
'typ', m.typ,
|
||||
'seriennummer', m.seriennummer,
|
||||
'standort', m.standort,
|
||||
'list_status', m.list_status,
|
||||
'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`;
|
||||
51
server/lib/merge-attachment-events.js
Normal file
51
server/lib/merge-attachment-events.js
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Mehrere ATTACHMENT-Events pro Ticket und Kalendertag (lokal) zu einem Event zusammenführen.
|
||||
*/
|
||||
export function mergeLegacyAttachmentEventsByDay(db) {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, ticket_id, created_at, description,
|
||||
date(created_at, 'localtime') AS day_key
|
||||
FROM events
|
||||
WHERE type = 'ATTACHMENT'
|
||||
ORDER BY ticket_id, day_key, created_at ASC`,
|
||||
)
|
||||
.all();
|
||||
|
||||
/** @type {Map<string, Array<{id: string, description: string}>>} */
|
||||
const groups = new Map();
|
||||
for (const r of rows) {
|
||||
const k = `${r.ticket_id}|${r.day_key}`;
|
||||
if (!groups.has(k)) groups.set(k, []);
|
||||
groups.get(k).push({ id: r.id, description: r.description || '' });
|
||||
}
|
||||
|
||||
for (const [, evs] of groups) {
|
||||
if (evs.length <= 1) continue;
|
||||
const keeper = evs[0].id;
|
||||
const others = evs.slice(1);
|
||||
const descParts = evs
|
||||
.map((e) => String(e.description || '').trim())
|
||||
.filter(Boolean);
|
||||
const mergedDesc = [...new Set(descParts)].join('\n\n');
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
db.prepare('UPDATE events SET description = ? WHERE id = ?').run(
|
||||
mergedDesc,
|
||||
keeper,
|
||||
);
|
||||
for (const o of others) {
|
||||
db.prepare('UPDATE ticket_attachments SET event_id = ? WHERE event_id = ?').run(
|
||||
keeper,
|
||||
o.id,
|
||||
);
|
||||
db.prepare('DELETE FROM events WHERE id = ?').run(o.id);
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
server/lib/ticket-events-merge.js
Normal file
86
server/lib/ticket-events-merge.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { mapEvent } from './mappers.js';
|
||||
|
||||
/**
|
||||
* Mehrere ATTACHMENT-DB-Zeilen pro Kalendertag (lokal) zu einem Listeneintrag zusammenführen.
|
||||
* @param {Array<Record<string, unknown>>} rows – SELECT * FROM events (bereits sortiert)
|
||||
* @param {Map<string, Array<Record<string, unknown>>>} byEvent – event_id → ticket_attachments-Zeilen
|
||||
* @param {{ prepare: (sql: string) => { get: (a: unknown) => { d?: string } | undefined } }} db
|
||||
*/
|
||||
export function mergeAttachmentEventsForApi(rows, byEvent, db) {
|
||||
const dayKeyStmt = db.prepare(`SELECT date(?, 'localtime') AS d`);
|
||||
|
||||
const nonAtt = [];
|
||||
/** @type {Map<string, Array<Record<string, unknown>>>} */
|
||||
const attByDay = new Map();
|
||||
|
||||
for (const r of rows) {
|
||||
if (r.type !== 'ATTACHMENT') {
|
||||
nonAtt.push(r);
|
||||
continue;
|
||||
}
|
||||
const dk = dayKeyStmt.get(r.created_at)?.d;
|
||||
const key = dk || String(r.created_at || '').slice(0, 10);
|
||||
if (!attByDay.has(key)) attByDay.set(key, []);
|
||||
attByDay.get(key).push(r);
|
||||
}
|
||||
|
||||
/** @type {Array<{ row: Record<string, unknown>; attachments: Array<Record<string, unknown>> }>} */
|
||||
const mergedChunks = [];
|
||||
|
||||
for (const [, group] of attByDay) {
|
||||
group.sort((a, b) =>
|
||||
String(a.created_at).localeCompare(String(b.created_at)),
|
||||
);
|
||||
const maxCreated = group.reduce((best, x) =>
|
||||
String(x.created_at) > String(best) ? x.created_at : best,
|
||||
group[0].created_at,
|
||||
);
|
||||
const descs = [
|
||||
...new Set(
|
||||
group
|
||||
.map((g) => String(g.description || '').trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
const mergedRow = {
|
||||
...group[0],
|
||||
id: group[0].id,
|
||||
created_at: maxCreated,
|
||||
description: descs.join('\n\n'),
|
||||
};
|
||||
|
||||
const allAtt = [];
|
||||
for (const ev of group) {
|
||||
const list = byEvent.get(ev.id);
|
||||
if (list) allAtt.push(...list);
|
||||
}
|
||||
allAtt.sort((a, b) =>
|
||||
String(a.created_at).localeCompare(String(b.created_at)),
|
||||
);
|
||||
|
||||
mergedChunks.push({ row: mergedRow, attachments: allAtt });
|
||||
}
|
||||
|
||||
mergedChunks.sort((a, b) =>
|
||||
String(b.row.created_at).localeCompare(String(a.row.created_at)),
|
||||
);
|
||||
|
||||
nonAtt.sort((a, b) =>
|
||||
String(b.created_at).localeCompare(String(a.created_at)),
|
||||
);
|
||||
|
||||
const out = [
|
||||
...nonAtt,
|
||||
...mergedChunks.map((c) => c.row),
|
||||
];
|
||||
|
||||
const mergedByEventId = new Map(
|
||||
mergedChunks.map((c) => [c.row.id, c.attachments]),
|
||||
);
|
||||
|
||||
return out.map((r) =>
|
||||
r.type === 'ATTACHMENT'
|
||||
? mapEvent(r, mergedByEventId.get(r.id) || [])
|
||||
: mapEvent(r, []),
|
||||
);
|
||||
}
|
||||
22
server/middleware/auth.js
Normal file
22
server/middleware/auth.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import db from '../db.js';
|
||||
|
||||
export 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();
|
||||
}
|
||||
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (req.session?.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Administratorrechte erforderlich.' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
173
server/routes/admin/index.js
Normal file
173
server/routes/admin/index.js
Normal file
@@ -0,0 +1,173 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Router } from 'express';
|
||||
import db from '../../db.js';
|
||||
import { getSyncStatus, performLdapSync } from '../../ldap-sync.js';
|
||||
import { badRequest, UUID } from '../../lib/http.js';
|
||||
import { mapPublicUser } from '../../lib/mappers.js';
|
||||
import {
|
||||
loadIntegrations,
|
||||
restartLdapSyncScheduler,
|
||||
saveIntegrations,
|
||||
} from '../../integrations.js';
|
||||
import { hashPassword } from '../../password.js';
|
||||
import { requireAdmin, requireAuth } from '../../middleware/auth.js';
|
||||
|
||||
export function createAdminRouter() {
|
||||
const admin = 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);
|
||||
});
|
||||
|
||||
return admin;
|
||||
}
|
||||
304
server/routes/api/attachments.js
Normal file
304
server/routes/api/attachments.js
Normal file
@@ -0,0 +1,304 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { fileURLToPath } from 'url';
|
||||
import multer from 'multer';
|
||||
import db from '../../db.js';
|
||||
import { badRequest, UUID } from '../../lib/http.js';
|
||||
import { mapEvent } from '../../lib/mappers.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Beschreibungstexte beim Zusammenführen (ohne doppelte Blöcke). */
|
||||
function mergeDescriptions(existing, incoming) {
|
||||
const a = String(existing || '').trim();
|
||||
const b = String(incoming || '').trim();
|
||||
if (!b) return a;
|
||||
if (!a) return b;
|
||||
if (a.includes(b)) return a;
|
||||
return `${a}\n\n${b}`;
|
||||
}
|
||||
const uploadsRoot = path.resolve(
|
||||
process.env.UPLOAD_DIR || path.join(__dirname, '..', '..', 'data', 'uploads'),
|
||||
);
|
||||
|
||||
const maxFileSize = Number(process.env.ATTACHMENT_MAX_BYTES) || 20 * 1024 * 1024;
|
||||
const maxFiles = Number(process.env.ATTACHMENT_MAX_FILES) || 20;
|
||||
|
||||
function safeBasename(name) {
|
||||
let base = path.basename(name || 'file');
|
||||
base = base.replace(/[/\\?%*:|"<>]/g, '_');
|
||||
return base.slice(0, 200) || 'file';
|
||||
}
|
||||
|
||||
function handleMulterError(err, res) {
|
||||
if (err?.name === 'MulterError') {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ message: 'Datei zu groß.' });
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
return res.status(400).json({ message: 'Zu viele Dateien.' });
|
||||
}
|
||||
return res.status(400).json({ message: err.message || 'Upload fehlgeschlagen.' });
|
||||
}
|
||||
return res.status(400).json({ message: err.message || 'Upload fehlgeschlagen.' });
|
||||
}
|
||||
|
||||
/** Multer 2.x: kein diskStorage — Dateien liegen temporär vor, Zugriff über `stream`. */
|
||||
function uploadMiddleware(req, res, next) {
|
||||
const { ticketId } = req.params;
|
||||
if (!UUID.test(ticketId)) {
|
||||
return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
}
|
||||
const upload = multer({
|
||||
limits: { fileSize: maxFileSize, files: maxFiles },
|
||||
}).array('files', maxFiles);
|
||||
|
||||
upload(req, res, (err) => {
|
||||
if (err) return handleMulterError(err, res);
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/** Streams nach `data/uploads/tickets/<ticketId>/` schreiben (permanent). */
|
||||
async function persistUploadedFiles(files, ticketId) {
|
||||
const destDir = path.join(uploadsRoot, 'tickets', ticketId);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const saved = [];
|
||||
for (const f of files) {
|
||||
const filename = `${randomUUID()}_${safeBasename(f.originalName)}`;
|
||||
const absPath = path.join(destDir, filename);
|
||||
await pipeline(f.stream, fs.createWriteStream(absPath));
|
||||
const stat = fs.statSync(absPath);
|
||||
saved.push({
|
||||
path: absPath,
|
||||
originalname: f.originalName || 'Datei',
|
||||
mimetype: f.clientReportedMimeType,
|
||||
size: stat.size,
|
||||
});
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
function unlinkUploaded(files) {
|
||||
for (const f of files || []) {
|
||||
try {
|
||||
if (f?.path) fs.unlinkSync(f.path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Temporäre Multer-Dateien verwerfen, wenn kein persist erfolgt. */
|
||||
function discardIncomingFiles(files) {
|
||||
for (const f of files || []) {
|
||||
try {
|
||||
if (f.stream && typeof f.stream.destroy === 'function') f.stream.destroy();
|
||||
if (f.path && fs.existsSync(f.path)) fs.unlinkSync(f.path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAttachmentRoutes(api) {
|
||||
api.post(
|
||||
'/tickets/:ticketId/events/attachments',
|
||||
uploadMiddleware,
|
||||
async (req, res) => {
|
||||
const { ticketId } = req.params;
|
||||
const incoming = req.files || [];
|
||||
if (incoming.length === 0) {
|
||||
return badRequest(res, 'Mindestens eine Datei erforderlich.');
|
||||
}
|
||||
|
||||
const t = db
|
||||
.prepare('SELECT 1 AS ok FROM tickets WHERE id = ?')
|
||||
.get(ticketId);
|
||||
if (!t) {
|
||||
discardIncomingFiles(incoming);
|
||||
return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
}
|
||||
|
||||
const descRaw =
|
||||
req.body && req.body.description != null
|
||||
? String(req.body.description).trim()
|
||||
: '';
|
||||
const description = descRaw || '';
|
||||
|
||||
let saved;
|
||||
try {
|
||||
saved = await persistUploadedFiles(incoming, ticketId);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.status(500).json({ message: 'Dateien konnten nicht gespeichert werden.' });
|
||||
}
|
||||
|
||||
const existing = db
|
||||
.prepare(
|
||||
`SELECT id, description FROM events
|
||||
WHERE ticket_id = ? AND type = 'ATTACHMENT'
|
||||
AND date(created_at, 'localtime') = date('now', 'localtime')
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(ticketId);
|
||||
|
||||
let eid;
|
||||
let createdNewEvent = false;
|
||||
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
if (existing) {
|
||||
eid = existing.id;
|
||||
const mergedDesc = mergeDescriptions(existing.description, description);
|
||||
db.prepare('UPDATE events SET description = ? WHERE id = ?').run(
|
||||
mergedDesc,
|
||||
eid,
|
||||
);
|
||||
} else {
|
||||
eid = randomUUID();
|
||||
createdNewEvent = true;
|
||||
db.prepare(
|
||||
`INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, teamviewer_notes)
|
||||
VALUES (?, ?, 'ATTACHMENT', ?, NULL, NULL, NULL, NULL, NULL)`,
|
||||
).run(eid, ticketId, description);
|
||||
}
|
||||
|
||||
const insAtt = db.prepare(
|
||||
`INSERT INTO ticket_attachments (id, event_id, original_name, stored_path, mime_type, size_bytes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
for (const f of saved) {
|
||||
const aid = randomUUID();
|
||||
const rel = path
|
||||
.relative(uploadsRoot, f.path)
|
||||
.split(path.sep)
|
||||
.join('/');
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
||||
throw new Error('Ungültiger Speicherpfad');
|
||||
}
|
||||
const mime =
|
||||
f.mimetype && String(f.mimetype).trim()
|
||||
? String(f.mimetype).trim()
|
||||
: null;
|
||||
insAtt.run(
|
||||
aid,
|
||||
eid,
|
||||
f.originalname,
|
||||
rel,
|
||||
mime,
|
||||
f.size,
|
||||
);
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE tickets SET updated_at = datetime('now'),
|
||||
sla_anchor_at = CASE WHEN status IN ('OPEN', 'WAITING') THEN datetime('now') ELSE sla_anchor_at END
|
||||
WHERE id = ?`,
|
||||
).run(ticketId);
|
||||
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
|
||||
const row = db.prepare('SELECT * FROM events WHERE id = ?').get(eid);
|
||||
const attRows = db
|
||||
.prepare(
|
||||
'SELECT * FROM ticket_attachments WHERE event_id = ? ORDER BY created_at ASC',
|
||||
)
|
||||
.all(eid);
|
||||
res
|
||||
.status(createdNewEvent ? 201 : 200)
|
||||
.json(mapEvent(row, attRows));
|
||||
} catch (e) {
|
||||
unlinkUploaded(saved);
|
||||
console.error(e);
|
||||
return res.status(500).json({ message: 'Speichern fehlgeschlagen.' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
api.get('/tickets/:ticketId/attachments/:attachmentId/file', (req, res) => {
|
||||
const { ticketId, attachmentId } = req.params;
|
||||
if (!UUID.test(ticketId) || !UUID.test(attachmentId)) {
|
||||
return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
}
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT ta.id, ta.original_name, ta.stored_path, e.ticket_id AS ev_tid
|
||||
FROM ticket_attachments ta
|
||||
JOIN events e ON e.id = ta.event_id
|
||||
WHERE ta.id = ? AND e.ticket_id = ?`,
|
||||
)
|
||||
.get(attachmentId, ticketId);
|
||||
if (!row) {
|
||||
return res.status(404).json({ message: 'Nicht gefunden' });
|
||||
}
|
||||
if (String(row.stored_path).includes('..')) {
|
||||
return res.status(400).json({ message: 'Ungültiger Pfad' });
|
||||
}
|
||||
const abs = path.resolve(path.join(uploadsRoot, row.stored_path));
|
||||
const rootResolved = path.resolve(uploadsRoot);
|
||||
if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
|
||||
return res.status(400).json({ message: 'Ungültiger Pfad' });
|
||||
}
|
||||
if (!fs.existsSync(abs)) {
|
||||
return res.status(404).json({ message: 'Datei fehlt' });
|
||||
}
|
||||
|
||||
const inline = req.query.inline === '1' || req.query.inline === 'true';
|
||||
let mime =
|
||||
row.mime_type && String(row.mime_type).trim()
|
||||
? String(row.mime_type).trim()
|
||||
: null;
|
||||
if (!mime) {
|
||||
mime = guessMimeFromFilename(row.original_name);
|
||||
}
|
||||
|
||||
if (inline) {
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`inline; filename*=UTF-8''${encodeURIComponent(row.original_name)}`,
|
||||
);
|
||||
return res.sendFile(abs);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', mime);
|
||||
res.download(abs, row.original_name);
|
||||
});
|
||||
}
|
||||
|
||||
function guessMimeFromFilename(name) {
|
||||
const ext = path.extname(name || '').toLowerCase();
|
||||
const map = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.csv': 'text/csv; charset=utf-8',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.htm': 'text/html; charset=utf-8',
|
||||
'.md': 'text/markdown; charset=utf-8',
|
||||
'.webm': 'video/webm',
|
||||
'.mp4': 'video/mp4',
|
||||
'.ogg': 'video/ogg',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
};
|
||||
return map[ext] || 'application/octet-stream';
|
||||
}
|
||||
104
server/routes/api/events.js
Normal file
104
server/routes/api/events.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import db from '../../db.js';
|
||||
import { badRequest } from '../../lib/http.js';
|
||||
import { mapEvent } from '../../lib/mappers.js';
|
||||
import { computeRemoteDurationSeconds } from '../../teamviewer.js';
|
||||
|
||||
const EVENT_TYPES_USER = new Set(['NOTE', 'CALL', 'REMOTE', 'PART']);
|
||||
|
||||
export function registerEventRoutes(api) {
|
||||
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') {
|
||||
// Rückrufnummer optional; nur Beschreibung ist Pflicht.
|
||||
if (!description) return badRequest(res, 'Beschreibung 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;
|
||||
let teamviewerNotes = null;
|
||||
if (type === 'REMOTE') {
|
||||
remoteDurationSeconds = computeRemoteDurationSeconds(
|
||||
b.teamviewerStartDate,
|
||||
b.teamviewerEndDate,
|
||||
);
|
||||
const tn =
|
||||
b.teamviewerNotes != null ? String(b.teamviewerNotes).trim() : '';
|
||||
teamviewerNotes = tn || null;
|
||||
}
|
||||
|
||||
const eid = randomUUID();
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
const row = db
|
||||
.prepare(
|
||||
`INSERT INTO events (id, ticket_id, type, description, callback_number, teamviewer_id, article_number, remote_duration_seconds, teamviewer_notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
||||
)
|
||||
.get(
|
||||
eid,
|
||||
ticketId,
|
||||
type,
|
||||
description,
|
||||
cb,
|
||||
tv,
|
||||
art,
|
||||
remoteDurationSeconds,
|
||||
teamviewerNotes,
|
||||
);
|
||||
|
||||
db.prepare(
|
||||
`UPDATE tickets SET updated_at = datetime('now'),
|
||||
sla_anchor_at = CASE WHEN status IN ('OPEN', 'WAITING') THEN datetime('now') ELSE sla_anchor_at END
|
||||
WHERE id = ?`,
|
||||
).run(ticketId);
|
||||
|
||||
db.exec('COMMIT');
|
||||
res.status(201).json(mapEvent(row));
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
19
server/routes/api/index.js
Normal file
19
server/routes/api/index.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import { loadIntegrations } from '../../integrations.js';
|
||||
import { requireAuth } from '../../middleware/auth.js';
|
||||
import { registerTeamViewerRoutes } from '../../teamviewer.js';
|
||||
import { registerAttachmentRoutes } from './attachments.js';
|
||||
import { registerEventRoutes } from './events.js';
|
||||
import { registerMachineRoutes } from './machines.js';
|
||||
import { registerTicketRoutes } from './tickets.js';
|
||||
|
||||
export function createApiRouter() {
|
||||
const api = Router();
|
||||
api.use(requireAuth);
|
||||
registerMachineRoutes(api);
|
||||
registerTicketRoutes(api);
|
||||
registerAttachmentRoutes(api);
|
||||
registerTeamViewerRoutes(api, loadIntegrations);
|
||||
registerEventRoutes(api);
|
||||
return api;
|
||||
}
|
||||
144
server/routes/api/machines.js
Normal file
144
server/routes/api/machines.js
Normal file
@@ -0,0 +1,144 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import db from '../../db.js';
|
||||
import { badRequest, UUID } from '../../lib/http.js';
|
||||
import { mapMachine } from '../../lib/mappers.js';
|
||||
|
||||
const ALLOWED_LIST_STATUS = new Set([
|
||||
'',
|
||||
'PRUEFEN',
|
||||
'VERSCHROTTET',
|
||||
'SN_GEAENDERT',
|
||||
'IN_BEARBEITUNG',
|
||||
'UPDATE_RAUS',
|
||||
]);
|
||||
|
||||
function normalizeListStatus(v) {
|
||||
if (v == null || v === '') return '';
|
||||
const s = String(v).trim();
|
||||
return ALLOWED_LIST_STATUS.has(s) ? s : null;
|
||||
}
|
||||
|
||||
/** @returns {{ ok: true, json: string | null } | { ok: false, message: string }} */
|
||||
function parseExtrasField(extras) {
|
||||
if (extras === null || extras === '') {
|
||||
return { ok: true, json: null };
|
||||
}
|
||||
if (typeof extras === 'object' && extras !== null) {
|
||||
try {
|
||||
return { ok: true, json: JSON.stringify(extras) };
|
||||
} catch {
|
||||
return { ok: false, message: 'extras ist kein gültiges JSON-Objekt.' };
|
||||
}
|
||||
}
|
||||
if (typeof extras === 'string') {
|
||||
try {
|
||||
JSON.parse(extras);
|
||||
return { ok: true, json: extras };
|
||||
} catch {
|
||||
return { ok: false, message: 'extras ist kein gültiger JSON-String.' };
|
||||
}
|
||||
}
|
||||
return { ok: false, message: 'extras hat ein ungültiges Format.' };
|
||||
}
|
||||
|
||||
export function registerMachineRoutes(api) {
|
||||
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 b = req.body || {};
|
||||
const { name, typ, seriennummer, standort, listStatus } = b;
|
||||
if (!name || !typ || !seriennummer || !standort) {
|
||||
return badRequest(res, 'Pflichtfelder fehlen.');
|
||||
}
|
||||
const ls = normalizeListStatus(listStatus);
|
||||
if (ls === null) return badRequest(res, 'Ungültiger Listen-Status.');
|
||||
let extrasJson = null;
|
||||
if (Object.prototype.hasOwnProperty.call(b, 'extras')) {
|
||||
const p = parseExtrasField(b.extras);
|
||||
if (!p.ok) return badRequest(res, p.message);
|
||||
extrasJson = p.json;
|
||||
}
|
||||
const id = randomUUID();
|
||||
const row = db
|
||||
.prepare(
|
||||
`INSERT INTO machines (id, name, typ, seriennummer, standort, list_status, extras, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) RETURNING *`,
|
||||
)
|
||||
.get(id, name, typ, seriennummer, standort, ls, extrasJson);
|
||||
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 listStatusVal = cur.list_status ?? '';
|
||||
if (Object.prototype.hasOwnProperty.call(b, 'listStatus')) {
|
||||
const ls = normalizeListStatus(b.listStatus);
|
||||
if (ls === null) return badRequest(res, 'Ungültiger Listen-Status.');
|
||||
listStatusVal = ls;
|
||||
}
|
||||
let extrasJson = cur.extras;
|
||||
if (Object.prototype.hasOwnProperty.call(b, 'extras')) {
|
||||
const p = parseExtrasField(b.extras);
|
||||
if (!p.ok) return badRequest(res, p.message);
|
||||
extrasJson = p.json;
|
||||
}
|
||||
const row = db
|
||||
.prepare(
|
||||
`UPDATE machines SET name = ?, typ = ?, seriennummer = ?, standort = ?, list_status = ?, extras = ?, updated_at = datetime('now')
|
||||
WHERE id = ? RETURNING *`,
|
||||
)
|
||||
.get(
|
||||
next.name,
|
||||
next.typ,
|
||||
next.seriennummer,
|
||||
next.standort,
|
||||
listStatusVal,
|
||||
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));
|
||||
});
|
||||
}
|
||||
202
server/routes/api/tickets.js
Normal file
202
server/routes/api/tickets.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import db from '../../db.js';
|
||||
import { badRequest, UUID } from '../../lib/http.js';
|
||||
import { mergeAttachmentEventsForApi } from '../../lib/ticket-events-merge.js';
|
||||
import {
|
||||
mapTicket,
|
||||
ticketJoinSelect,
|
||||
ticketLastActivityExpr,
|
||||
ticketSlaDueExpr,
|
||||
} from '../../lib/mappers.js';
|
||||
|
||||
function parseSlaDaysForCreate(v) {
|
||||
if (v === undefined || v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 5) return undefined;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Nur wenn Key gesetzt: null = Standard (2 Tage), 1–5 = Tage. */
|
||||
function parseSlaDaysForUpdate(v) {
|
||||
if (v === null || v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < 1 || n > 5) return undefined;
|
||||
return n;
|
||||
}
|
||||
|
||||
function slaDaysEqual(a, b) {
|
||||
const na = a == null || a === '' ? null : Number(a);
|
||||
const nb = b == null || b === '' ? null : Number(b);
|
||||
return na === nb;
|
||||
}
|
||||
|
||||
const ticketListOrderBy = `
|
||||
ORDER BY
|
||||
CASE WHEN t.status IN ('OPEN','WAITING') AND datetime('now') > ${ticketSlaDueExpr} THEN 0 ELSE 1 END ASC,
|
||||
CASE WHEN t.status IN ('OPEN','WAITING') AND datetime('now') > ${ticketSlaDueExpr} THEN ${ticketSlaDueExpr} ELSE '9999-12-31' END ASC,
|
||||
${ticketLastActivityExpr} DESC`;
|
||||
|
||||
export function registerTicketRoutes(api) {
|
||||
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 ')} ${ticketListOrderBy}`;
|
||||
const rows = db.prepare(sql).all(...params);
|
||||
res.json(rows.map(mapTicket));
|
||||
});
|
||||
|
||||
api.post('/tickets', (req, res) => {
|
||||
const { machineId, title, description, status, priority, slaDays } =
|
||||
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 sd = parseSlaDaysForCreate(slaDays);
|
||||
if (sd === undefined) {
|
||||
return badRequest(res, 'slaDays ungültig (1–5 oder weglassen für Standard).');
|
||||
}
|
||||
const tid = randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO tickets (id, machine_id, title, description, status, priority, sla_days, sla_anchor_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`,
|
||||
).run(tid, machineId, title, description, st, pr, sd);
|
||||
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 CASE WHEN type = 'ATTACHMENT' THEN 1 ELSE 0 END ASC,
|
||||
created_at DESC`,
|
||||
)
|
||||
.all(id);
|
||||
const eventIds = rows.map((r) => r.id);
|
||||
const byEvent = new Map();
|
||||
if (eventIds.length > 0) {
|
||||
const ph = eventIds.map(() => '?').join(',');
|
||||
const attRows = db
|
||||
.prepare(
|
||||
`SELECT * FROM ticket_attachments WHERE event_id IN (${ph}) ORDER BY created_at ASC`,
|
||||
)
|
||||
.all(...eventIds);
|
||||
for (const a of attRows) {
|
||||
if (!byEvent.has(a.event_id)) byEvent.set(a.event_id, []);
|
||||
byEvent.get(a.event_id).push(a);
|
||||
}
|
||||
}
|
||||
res.json(mergeAttachmentEventsForApi(rows, byEvent, db));
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
let nextSlaDays = cur.sla_days != null ? cur.sla_days : null;
|
||||
let resetSlaAnchor = false;
|
||||
if (Object.prototype.hasOwnProperty.call(b, 'slaDays')) {
|
||||
const parsed = parseSlaDaysForUpdate(b.slaDays);
|
||||
if (parsed === undefined) {
|
||||
return badRequest(
|
||||
res,
|
||||
'slaDays ungültig (1–5 oder leer für Standard).',
|
||||
);
|
||||
}
|
||||
nextSlaDays = parsed;
|
||||
resetSlaAnchor = !slaDaysEqual(parsed, cur.sla_days);
|
||||
}
|
||||
|
||||
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 (
|
||||
Object.prototype.hasOwnProperty.call(b, 'slaDays') &&
|
||||
!slaDaysEqual(nextSlaDays, cur.sla_days)
|
||||
) {
|
||||
const label = (d) =>
|
||||
d == null ? 'Standard (2 Tage)' : `${d} Tag(e)`;
|
||||
lines.push(
|
||||
`Fälligkeit: ${label(cur.sla_days)} → ${label(nextSlaDays)}`,
|
||||
);
|
||||
}
|
||||
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, teamviewer_notes)
|
||||
VALUES (?, ?, 'SYSTEM', ?, NULL, NULL, NULL, NULL, NULL)`,
|
||||
).run(eid, id, lines.join('; '));
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE tickets SET title = ?, description = ?, status = ?, priority = ?, sla_days = ?,
|
||||
sla_anchor_at = CASE WHEN ? THEN datetime('now') ELSE sla_anchor_at END,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
next.title,
|
||||
next.description,
|
||||
next.status,
|
||||
next.priority,
|
||||
nextSlaDays,
|
||||
resetSlaAnchor ? 1 : 0,
|
||||
id,
|
||||
);
|
||||
|
||||
const row = db.prepare(`${ticketJoinSelect} WHERE t.id = ?`).get(id);
|
||||
res.json(mapTicket(row));
|
||||
});
|
||||
}
|
||||
92
server/routes/auth.js
Normal file
92
server/routes/auth.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Router } from 'express';
|
||||
import db from '../db.js';
|
||||
import { badRequest } from '../lib/http.js';
|
||||
import { hashPassword, verifyPassword } from '../password.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/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 },
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/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' },
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/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 },
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) return res.status(500).json({ message: 'Abmelden fehlgeschlagen.' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -36,6 +36,18 @@ export function computeRemoteDurationSeconds(startIso, endIso) {
|
||||
* jüngster Session je Gerät; Anzeigename Gerät = devicename oder sonst deviceid.
|
||||
*/
|
||||
/** TeamViewer liefert Feldnamen je nach Version leicht unterschiedlich (userid / user_id / …). */
|
||||
/** TeamViewer Connection Report: optionales Freitextfeld */
|
||||
function pickNotes(rec) {
|
||||
const raw =
|
||||
rec.notes ??
|
||||
rec.Notes ??
|
||||
rec.connection_notes ??
|
||||
rec.session_notes ??
|
||||
'';
|
||||
const s = String(raw ?? '').trim();
|
||||
return s || null;
|
||||
}
|
||||
|
||||
function pickUserIdentity(rec) {
|
||||
const uid = String(
|
||||
rec.userid ?? rec.user_id ?? rec.userId ?? rec.UserID ?? '',
|
||||
@@ -112,6 +124,7 @@ function buildSessionsByUser(allRecords) {
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null,
|
||||
durationSeconds: computeRemoteDurationSeconds(startDate, endDate),
|
||||
notes: pickNotes(rec),
|
||||
});
|
||||
}
|
||||
devices.sort((a, b) =>
|
||||
|
||||
Reference in New Issue
Block a user