V0.1
This commit is contained in:
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, []),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user