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

6
server/lib/http.js Normal file
View 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
View 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`;

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

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