This commit is contained in:
2026-03-23 02:09:14 +01:00
parent 705329d3c2
commit d8d46ed8e9
61 changed files with 6054 additions and 3116 deletions

View 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
View 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;
}
});
}

View 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;
}

View 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));
});
}

View 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), 15 = 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 (15 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 (15 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));
});
}