Upload via GUI (340 Dateien)

This commit is contained in:
2026-10-26 21:19:07 +00:00
parent 0df18c6747
commit ecabba060c
6 changed files with 545 additions and 19 deletions
+1
View File
@@ -27,6 +27,7 @@ require_once __DIR__ . '/i18n.php';
require_once __DIR__ . '/csat.php';
require_once __DIR__ . '/status_page.php';
require_once __DIR__ . '/ticket_templates.php';
require_once __DIR__ . '/problems.php';
require_once __DIR__ . '/plugins.php';
require_once __DIR__ . '/app_downloads.php';
require_once __DIR__ . '/theme_catalog.php';
+237 -3
View File
@@ -222,6 +222,201 @@ function calc_sla_dates(array $priority, ?string $from = null, ?int $departmentI
];
}
function ticket_sla_is_paused_status(string $status): bool
{
return in_array($status, ['pending', 'on_hold'], true);
}
function ticket_sla_history_add(PDO $pdo, int $ticketId, string $event, ?string $deadlineType, string $details, ?int $staffId = null, ?string $at = null): void
{
try {
$pdo->prepare('INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,staff_id,created_at) VALUES (?,?,?,?,?,?)')
->execute([$ticketId, $event, $deadlineType, $details, $staffId, $at ?? db_now()]);
} catch (Throwable $e) {
error_log('DeskHub SLA-Verlauf konnte nicht gespeichert werden: ' . $e->getMessage());
}
}
/** Seconds that count against an SLA during the interval, honoring business hours and holidays. */
function ticket_sla_working_seconds_between(string $from, string $to): int
{
$start = strtotime($from); $end = strtotime($to);
if ($start === false || $end === false || $end <= $start) return 0;
if (get_setting('business_hours_enabled', '0') !== '1') return $end - $start;
$pdo = get_db(); $hours = [];
try {
foreach ($pdo->query('SELECT weekday,is_open,open_time,close_time FROM business_hours')->fetchAll() as $row) $hours[(int) $row['weekday']] = $row;
$holidays = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
} catch (Throwable $e) { error_log('DeskHub SLA-Geschäftszeiten nicht lesbar; verwende Kalenderzeit: ' . $e->getMessage()); return $end - $start; }
$zone = new DateTimeZone(date_default_timezone_get());
$day = (new DateTimeImmutable('@' . $start))->setTimezone($zone)->setTime(0, 0);
$lastDay = (new DateTimeImmutable('@' . $end))->setTimezone($zone)->setTime(0, 0);
$seconds = 0; $iterations = 0;
while ($day <= $lastDay && $iterations++ < 36600) {
$w = (int) $day->format('w'); $cfg = $hours[$w] ?? null; $date = $day->format('Y-m-d');
if ($cfg && !empty($cfg['is_open']) && !isset($holidays[$date])) {
$open = (new DateTimeImmutable($date . ' ' . $cfg['open_time'], $zone))->getTimestamp();
$close = (new DateTimeImmutable($date . ' ' . $cfg['close_time'], $zone))->getTimestamp();
$seconds += max(0, min($end, $close) - max($start, $open));
}
$day = $day->modify('+1 day');
}
return $seconds;
}
function ticket_sla_add_business_seconds(string $from, int $seconds): string
{
if ($seconds <= 0) return $from;
$pdo = get_db(); $hours = [];
try {
foreach ($pdo->query('SELECT weekday,is_open,open_time,close_time FROM business_hours')->fetchAll() as $row) $hours[(int) $row['weekday']] = $row;
$holidays = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
} catch (Throwable $e) {
error_log('DeskHub SLA-Geschäftszeiten nicht lesbar; verwende Kalenderzeit: ' . $e->getMessage());
return (new DateTimeImmutable($from))->modify('+' . $seconds . ' seconds')->format('Y-m-d H:i:s');
}
$zone = new DateTimeZone(date_default_timezone_get());
$cursor = new DateTimeImmutable($from, $zone); $iterations = 0;
while ($seconds > 0 && $iterations++ < 36600) {
$date = $cursor->format('Y-m-d'); $cfg = $hours[(int) $cursor->format('w')] ?? null;
if (!$cfg || empty($cfg['is_open']) || isset($holidays[$date])) {
$cursor = $cursor->modify('+1 day')->setTime(0, 0); continue;
}
$open = new DateTimeImmutable($date . ' ' . $cfg['open_time'], $zone);
$close = new DateTimeImmutable($date . ' ' . $cfg['close_time'], $zone);
if ($cursor < $open) $cursor = $open;
if ($cursor >= $close) { $cursor = $cursor->modify('+1 day')->setTime(0, 0); continue; }
$available = $close->getTimestamp() - $cursor->getTimestamp();
if ($seconds <= $available) { $cursor = $cursor->modify('+' . $seconds . ' seconds'); $seconds = 0; }
else { $seconds -= $available; $cursor = $cursor->modify('+1 day')->setTime(0, 0); }
}
return $cursor->format('Y-m-d H:i:s');
}
function ticket_sla_shift_deadline(string $deadline, int $pauseSeconds): string
{
if ($pauseSeconds <= 0) return $deadline;
if (get_setting('business_hours_enabled', '0') === '1') {
return ticket_sla_add_business_seconds($deadline, $pauseSeconds);
}
return (new DateTimeImmutable($deadline))->modify('+' . $pauseSeconds . ' seconds')->format('Y-m-d H:i:s');
}
function ticket_sla_completed_pause_seconds(PDO $pdo, int $ticketId): int
{
try {
$stmt = $pdo->prepare("SELECT event_type,created_at FROM ticket_sla_history WHERE ticket_id = ? AND event_type IN ('paused','resumed') ORDER BY created_at,id");
$stmt->execute([$ticketId]);
$started = null; $total = 0;
foreach ($stmt->fetchAll() as $event) {
if ($event['event_type'] === 'paused') $started = (string) $event['created_at'];
elseif ($started !== null) { $total += ticket_sla_working_seconds_between($started, (string) $event['created_at']); $started = null; }
}
return $total;
} catch (Throwable $e) { error_log('DeskHub SLA-Pausenzeiten nicht lesbar: ' . $e->getMessage()); return 0; }
}
/** Call after the ticket status has been persisted. pending/on_hold pause both SLA clocks. */
function ticket_sla_status_transition_impl(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
{
if ($oldStatus === $newStatus) return;
$wasPaused = ticket_sla_is_paused_status($oldStatus); $willPause = ticket_sla_is_paused_status($newStatus);
if (!$wasPaused && $willPause) {
$now = db_now();
$pdo->prepare('UPDATE tickets SET sla_paused_at = ? WHERE id = ?')->execute([$now, $ticketId]);
ticket_sla_history_add($pdo, $ticketId, 'paused', null, 'Pausiert: ' . status_label($newStatus)[0], $staffId, $now);
$q = $pdo->prepare('SELECT first_response_due_at,resolution_due_at,first_responded_at,resolved_at FROM tickets WHERE id = ?');
$q->execute([$ticketId]); $deadlines = $q->fetch() ?: [];
if (empty($deadlines['first_responded_at']) && !empty($deadlines['first_response_due_at']) && $deadlines['first_response_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'first_response', (string) $deadlines['first_response_due_at']);
}
if (empty($deadlines['resolved_at']) && !empty($deadlines['resolution_due_at']) && $deadlines['resolution_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $deadlines['resolution_due_at']);
$pdo->prepare('UPDATE tickets SET is_overdue = 1 WHERE id = ?')->execute([$ticketId]);
}
return;
}
if ($wasPaused && !$willPause) {
$stmt = $pdo->prepare('SELECT sla_paused_at,first_response_due_at,resolution_due_at,first_responded_at FROM tickets WHERE id = ?');
$stmt->execute([$ticketId]); $row = $stmt->fetch();
if (!$row) return;
$now = db_now(); $started = (string) ($row['sla_paused_at'] ?? '');
$seconds = $started !== '' ? ticket_sla_working_seconds_between($started, $now) : 0;
$respondedBeforePause = !empty($row['first_responded_at']) && ($started === '' || (string) $row['first_responded_at'] < $started);
$updates = []; $args = [];
if ($seconds > 0) {
foreach (['first_response_due_at' => 'first_response', 'resolution_due_at' => 'resolution'] as $field => $deadlineType) {
$breach = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id=? AND event_type='overdue' AND deadline_type=?");
$breach->execute([$ticketId, $deadlineType]);
if (!empty($row[$field]) && !($deadlineType === 'first_response' && $respondedBeforePause) && !(int) $breach->fetchColumn()) {
$updates[] = "$field = ?"; $args[] = ticket_sla_shift_deadline((string) $row[$field], $seconds);
}
}
}
$updates[] = 'sla_paused_at = NULL'; $args[] = $ticketId;
$pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($args);
$minutes = (int) floor($seconds / 60);
ticket_sla_history_add($pdo, $ticketId, 'resumed', null, 'Fortgesetzt nach ' . $minutes . ' Minuten Wartezeit', $staffId, $now);
}
}
function ticket_sla_status_transition(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
{
try {
ticket_sla_status_transition_impl($pdo, $ticketId, $oldStatus, $newStatus, $staffId);
} catch (Throwable $e) {
error_log('DeskHub SLA-Pause konnte für Ticket #' . $ticketId . ' nicht aktualisiert werden: ' . $e->getMessage());
}
}
function ticket_sla_transition_rows(PDO $pdo, array $rows, string $newStatus, ?int $staffId = null): void
{
foreach ($rows as $row) ticket_sla_status_transition($pdo, (int) $row['id'], (string) $row['status'], $newStatus, $staffId);
}
function ticket_sla_history_for_ticket(PDO $pdo, array $ticket): array
{
try {
$ticketId = (int) $ticket['id'];
$currentlyPaused = !empty($ticket['sla_paused_at']) || ticket_sla_is_paused_status((string) ($ticket['status'] ?? ''));
if (!$currentlyPaused && !in_array((string) ($ticket['status'] ?? ''), ['resolved', 'closed'], true)) {
$now = db_now();
if (empty($ticket['first_responded_at']) && !empty($ticket['first_response_due_at']) && $ticket['first_response_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'first_response', (string) $ticket['first_response_due_at']);
}
if (empty($ticket['resolved_at']) && !empty($ticket['resolution_due_at']) && $ticket['resolution_due_at'] < $now) {
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $ticket['resolution_due_at']);
}
}
foreach ([['first_response_due_at', 'first_response', 'Erstantwort'], ['resolution_due_at', 'resolution', 'Lösung']] as [$field, $type, $label]) {
if (empty($ticket[$field])) continue;
$check = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = ? AND event_type = 'started' AND deadline_type = ?");
$check->execute([$ticketId, $type]);
if (!(int) $check->fetchColumn()) {
ticket_sla_history_add($pdo, $ticketId, 'started', $type, $label . '-Frist gestartet; fällig am ' . $ticket[$field], null, (string) $ticket['created_at']);
} else {
$pdo->prepare("UPDATE ticket_sla_history SET details = ? WHERE ticket_id = ? AND event_type = 'started' AND deadline_type = ? AND details LIKE 'SLA gestartet; Fristfeld:%'")
->execute([$label . '-Frist gestartet; fällig am ' . $ticket[$field], $ticketId, $type]);
}
}
$stmt = $pdo->prepare('SELECT h.*, s.name AS staff_name FROM ticket_sla_history h LEFT JOIN staff s ON s.id = h.staff_id WHERE h.ticket_id = ? ORDER BY h.created_at DESC, h.id DESC LIMIT 100');
$stmt->execute([$ticketId]);
return $stmt->fetchAll();
} catch (Throwable $e) {
error_log('DeskHub SLA-Verlauf konnte nicht geladen werden: ' . $e->getMessage());
return [];
}
}
function ticket_sla_log_overdue(PDO $pdo, int $ticketId, string $deadlineType, string $dueAt): void
{
try {
$check = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = ? AND event_type = 'overdue' AND deadline_type = ?");
$check->execute([$ticketId, $deadlineType]);
if (!(int) $check->fetchColumn()) ticket_sla_history_add($pdo, $ticketId, 'overdue', $deadlineType, 'SLA-Frist überschritten', null, $dueAt);
} catch (Throwable $e) { error_log('DeskHub SLA-Überschreitung konnte nicht protokolliert werden: ' . $e->getMessage()); }
}
/**
* Aktiver SLA-Plan für ein Ticket: der Plan des Hilfethemas hat Vorrang vor dem der Abteilung.
* null = kein Plan zugeordnet -> es gelten die Zeiten der Priorität.
@@ -558,9 +753,39 @@ function ip_in_list(string $ip, array $list): bool
}
/** Kommt die Anfrage direkt von einem als vertrauenswürdig eingetragenen Reverse-Proxy (config site → trusted_proxies)? */
function cloudflare_proxy_ranges(): array
{
static $ranges = null;
if ($ranges !== null) return $ranges;
$ranges = [];
try {
$active = get_db()->prepare('SELECT is_active FROM plugins WHERE slug = ?');
$active->execute(['cloudflare-protection']);
if ((int) $active->fetchColumn() === 1) {
$dynamic = json_decode((string) get_setting('plugin.cloudflare-protection.ranges', ''), true);
if (is_array($dynamic)) $ranges = array_values(array_filter($dynamic, 'is_string'));
}
} catch (Throwable $e) {
// No dynamic Cloudflare networks are trusted if the plugin is not installed.
}
return $ranges;
}
function trusted_proxy_ranges(): array
{
static $ranges = null;
if ($ranges === null) {
$ranges = array_values(array_unique(array_merge(
array_map('strval', (array) (config()['site']['trusted_proxies'] ?? [])),
cloudflare_proxy_ranges()
)));
}
return $ranges;
}
function request_from_trusted_proxy(): bool
{
$list = (array) (config()['site']['trusted_proxies'] ?? []);
$list = trusted_proxy_ranges();
return $list !== [] && ip_in_list((string) ($_SERVER['REMOTE_ADDR'] ?? ''), $list);
}
@@ -580,7 +805,12 @@ function client_ip(): string
{
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
if (!request_from_trusted_proxy()) return $remote;
$trusted = (array) (config()['site']['trusted_proxies'] ?? []);
$cloudflareRanges = cloudflare_proxy_ranges();
$cloudflareIp = trim((string) ($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''));
if ($cloudflareRanges !== [] && ip_in_list($remote, $cloudflareRanges) && filter_var($cloudflareIp, FILTER_VALIDATE_IP)) {
return $cloudflareIp;
}
$trusted = trusted_proxy_ranges();
$chain = array_reverse(array_map('trim', explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))));
foreach ($chain as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP) && !ip_in_list($ip, $trusted)) return $ip;
@@ -1513,7 +1743,11 @@ function apply_rule_close(PDO $pdo, int $ticketId, array $ruleResult): bool
{
if (empty($ruleResult['close'])) return false;
$now = db_now();
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
$statusStmt = $pdo->prepare('SELECT status FROM tickets WHERE id = ?');
$statusStmt->execute([$ticketId]);
$oldStatus = (string) ($statusStmt->fetchColumn() ?: 'open');
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, resolution_code = 'automated', resolution_note = 'Automatisch durch Ticket-Regel geschlossen', updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
ticket_sla_status_transition($pdo, $ticketId, $oldStatus, 'closed');
log_activity($pdo, $ticketId, null, 'Ticket automatisch geschlossen (Ticket-Regel)');
return true;
}
+2 -1
View File
@@ -151,8 +151,9 @@ function set_ticket_spam(PDO $pdo, array $ticket, int $staffId, bool $restore =
log_activity($pdo, $ticket['id'], $staffId, 'Spam-Markierung aufgehoben');
} else {
$pdo->prepare(
"UPDATE tickets SET is_spam=1, status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
"UPDATE tickets SET is_spam=1, status='closed', resolution_code='spam', resolution_note='Als Spam markiert', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
)->execute([$now, $now, $ticket['id']]);
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'closed', $staffId);
finalize_work_session($pdo, (int) $ticket['id'], $staffId);
apply_tag_to_ticket($pdo, (int) $ticket['id'], 'Spam');
log_activity($pdo, $ticket['id'], $staffId, 'Ticket als Spam markiert');
+128
View File
@@ -0,0 +1,128 @@
<?php
/** Problem records group related tickets and retain root-cause documentation. */
function ticket_resolution_reasons(): array
{
return [
'fixed' => 'Fehler behoben',
'guidance' => 'Anleitung oder Beratung',
'duplicate' => 'Doppelt / zusammengeführt',
'not_reproducible' => 'Nicht reproduzierbar',
'no_action' => 'Keine Maßnahme erforderlich',
'spam' => 'Spam oder Sperre',
'automated' => 'Automatisch geschlossen',
'other' => 'Sonstiges',
];
}
function ticket_problem_statuses(): array
{
return [
'investigating' => 'Wird untersucht',
'workaround' => 'Workaround verfügbar',
'monitoring' => 'Wird beobachtet',
'resolved' => 'Behoben',
];
}
function ticket_problem_number(int $id): string
{
return 'PRB-' . str_pad((string) $id, 6, '0', STR_PAD_LEFT);
}
function ticket_problems(PDO $pdo, bool $activeOnly = false): array
{
$where = $activeOnly ? "WHERE p.status <> 'resolved'" : '';
return $pdo->query("SELECT p.*, s.name AS creator_name, (SELECT COUNT(*) FROM problem_tickets pt WHERE pt.problem_id = p.id) AS ticket_count FROM problems p LEFT JOIN staff s ON s.id = p.created_by $where ORDER BY CASE WHEN p.status = 'resolved' THEN 1 ELSE 0 END, p.updated_at DESC, p.id DESC LIMIT 300")->fetchAll();
}
function ticket_problem_get(PDO $pdo, int $id): ?array
{
$stmt = $pdo->prepare('SELECT p.*, s.name AS creator_name FROM problems p LEFT JOIN staff s ON s.id = p.created_by WHERE p.id = ?');
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
function ticket_problem_create(PDO $pdo, array $input, int $staffId): int
{
$title = trim(mb_substr((string) ($input['title'] ?? ''), 0, 200));
if ($title === '') throw new InvalidArgumentException('Bitte einen Titel für das Problem angeben.');
$status = (string) ($input['status'] ?? 'investigating');
if (!isset(ticket_problem_statuses()[$status])) $status = 'investigating';
$resolution = trim((string) ($input['resolution'] ?? ''));
if ($status === 'resolved' && $resolution === '') throw new InvalidArgumentException('Bitte dokumentieren Sie die dauerhafte Lösung, bevor Sie das Problem als behoben markieren.');
$now = db_now();
$stmt = $pdo->prepare('INSERT INTO problems (title, description, root_cause, workaround, resolution, status, created_by, updated_by, created_at, updated_at, resolved_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)');
$stmt->execute([
$title,
trim((string) ($input['description'] ?? '')) ?: null,
trim((string) ($input['root_cause'] ?? '')) ?: null,
trim((string) ($input['workaround'] ?? '')) ?: null,
$resolution ?: null,
$status, $staffId, $staffId, $now, $now, $status === 'resolved' ? $now : null,
]);
return (int) $pdo->lastInsertId();
}
function ticket_problem_update(PDO $pdo, int $id, array $input, int $staffId): bool
{
$title = trim(mb_substr((string) ($input['title'] ?? ''), 0, 200));
$status = (string) ($input['status'] ?? 'investigating');
if ($title === '') throw new InvalidArgumentException('Bitte einen Titel für das Problem angeben.');
if (!isset(ticket_problem_statuses()[$status])) throw new InvalidArgumentException('Der Problemstatus ist ungültig.');
$resolution = trim((string) ($input['resolution'] ?? ''));
if ($status === 'resolved' && $resolution === '') throw new InvalidArgumentException('Bitte dokumentieren Sie die dauerhafte Lösung, bevor Sie das Problem als behoben markieren.');
$current = ticket_problem_get($pdo, $id);
if (!$current) return false;
$resolvedAt = $status === 'resolved' ? ($current['resolved_at'] ?: db_now()) : null;
$stmt = $pdo->prepare('UPDATE problems SET title = ?, description = ?, root_cause = ?, workaround = ?, resolution = ?, status = ?, updated_by = ?, updated_at = ?, resolved_at = ? WHERE id = ?');
$stmt->execute([
$title,
trim((string) ($input['description'] ?? '')) ?: null,
trim((string) ($input['root_cause'] ?? '')) ?: null,
trim((string) ($input['workaround'] ?? '')) ?: null,
$resolution ?: null,
$status, $staffId, db_now(), $resolvedAt, $id,
]);
return true;
}
function ticket_problem_tickets(PDO $pdo, int $problemId): array
{
$stmt = $pdo->prepare('SELECT t.id, t.ticket_number, t.subject, t.status, t.requester_name, t.created_at, s.name AS linked_by_name FROM problem_tickets pt JOIN tickets t ON t.id = pt.ticket_id LEFT JOIN staff s ON s.id = pt.linked_by WHERE pt.problem_id = ? ORDER BY pt.linked_at DESC, t.id DESC');
$stmt->execute([$problemId]);
return $stmt->fetchAll();
}
function ticket_problem_link(PDO $pdo, int $problemId, int $ticketId, int $staffId): bool
{
if (!ticket_problem_get($pdo, $problemId)) return false;
$ticket = $pdo->prepare('SELECT id FROM tickets WHERE id = ?');
$ticket->execute([$ticketId]);
if (!$ticket->fetchColumn()) return false;
$insertIgnore = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
$stmt = $pdo->prepare("$insertIgnore INTO problem_tickets (problem_id, ticket_id, linked_by, linked_at) VALUES (?,?,?,?)");
$stmt->execute([$problemId, $ticketId, $staffId, db_now()]);
return true;
}
function ticket_problem_unlink(PDO $pdo, int $problemId, int $ticketId): void
{
$pdo->prepare('DELETE FROM problem_tickets WHERE problem_id = ? AND ticket_id = ?')->execute([$problemId, $ticketId]);
}
function ticket_problems_for_ticket(PDO $pdo, int $ticketId): array
{
$stmt = $pdo->prepare('SELECT p.* FROM problems p JOIN problem_tickets pt ON pt.problem_id = p.id WHERE pt.ticket_id = ? ORDER BY p.updated_at DESC, p.id DESC');
$stmt->execute([$ticketId]);
return $stmt->fetchAll();
}
function ticket_record_resolution(PDO $pdo, int $ticketId, string $reason, string $note = ''): bool
{
if (!isset(ticket_resolution_reasons()[$reason])) return false;
$pdo->prepare('UPDATE tickets SET resolution_code = ?, resolution_note = ? WHERE id = ?')
->execute([$reason, trim(mb_substr($note, 0, 2000)) ?: null, $ticketId]);
return true;
}
+1
View File
@@ -16,6 +16,7 @@ function report_dimensions(PDO $pdo): array
'team' => ['Team', 'COALESCE(tm.name, \'ohne Team\')', 'LEFT JOIN teams tm ON tm.id = t.team_id'],
'priority' => ['Priorität', 'p.name', 'JOIN priorities p ON p.id = t.priority_id'],
'status' => ['Status', 't.status', ''],
'resolution_code' => ['Abschlussgrund', "CASE t.resolution_code WHEN 'fixed' THEN 'Fehler behoben' WHEN 'guidance' THEN 'Anleitung oder Beratung' WHEN 'duplicate' THEN 'Doppelt / zusammengeführt' WHEN 'not_reproducible' THEN 'Nicht reproduzierbar' WHEN 'no_action' THEN 'Keine Maßnahme erforderlich' WHEN 'spam' THEN 'Spam oder Sperre' WHEN 'automated' THEN 'Automatisch geschlossen' WHEN 'other' THEN 'Sonstiges' ELSE 'Nicht erfasst' END", ''],
'source' => ['Quelle', 't.source', ''],
'day' => ['Tag', $bucket('%Y-%m-%d', '%Y-%m-%d'), ''],
'week' => ['Woche', $bucket('%Y-W%W', '%x-W%v'), ''],
+176 -15
View File
@@ -51,7 +51,10 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array
if ($type === 'manual') return null;
if ($type === 'auto') {
if ($target !== '') return ['type' => 'http', 'target' => $target, 'label' => 'Webadresse'];
if ($target !== '') {
if (str_contains($name, 'minecraft')) return ['type' => 'minecraft', 'target' => $target, 'label' => 'Minecraft-Server'];
return ['type' => 'http', 'target' => $target, 'label' => 'Webadresse'];
}
if (str_contains($name, 'android') || (str_contains($category, 'app') && str_contains($name, 'mobil'))) return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung'];
if (str_contains($name, 'windows') || str_contains($name, 'desktop') || str_contains($name, 'windows-programm')) return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung'];
if (str_contains($name, 'datenbank') || str_contains($name, 'database')) return ['type' => 'database', 'target' => '', 'label' => 'Datenbank'];
@@ -70,6 +73,7 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array
if ($target === '') $target = trim((string) get_setting('smtp_host', '')) . ':' . ((int) get_setting('smtp_port', '587') ?: 587);
return $target !== ':' ? ['type' => 'tcp', 'target' => $target, 'label' => 'E-Mail-Server'] : null;
}
if ($type === 'tcp' && $target !== '' && str_contains($name, 'minecraft')) return ['type' => 'minecraft', 'target' => $target, 'label' => 'Minecraft-Server'];
if (in_array($type, ['http', 'tcp'], true) && $target !== '') return ['type' => $type, 'target' => $target, 'label' => $type === 'http' ? 'Webadresse' : 'Serververbindung'];
return null;
}
@@ -117,9 +121,13 @@ function status_execute_component_check(PDO $pdo, array $check): array
if ($ok) fclose($socket); else $error = 'Push-Dienst nicht erreichbar';
} else $error = 'Push-Dienst nicht eingerichtet';
} else $error = 'Push-Dienst nicht eingerichtet';
} elseif ($check['type'] === 'tcp') {
if (preg_match('/^([a-zA-Z0-9.-]+):(\\d{1,5})$/', (string) $check['target'], $m) && (int) $m[2] > 0 && (int) $m[2] <= 65535) {
$socket = @stream_socket_client('tcp://' . $m[1] . ':' . $m[2], $errno, $errstr, 4, STREAM_CLIENT_CONNECT);
} elseif (in_array($check['type'], ['tcp', 'minecraft'], true)) {
$endpoint = $check['type'] === 'minecraft'
? status_resolve_minecraft_target((string) $check['target'])
: status_parse_host_port((string) $check['target']);
if ($endpoint !== null) {
$host = strpos($endpoint['host'], ':') !== false ? '[' . $endpoint['host'] . ']' : $endpoint['host'];
$socket = @stream_socket_client('tcp://' . $host . ':' . $endpoint['port'], $errno, $errstr, 4, STREAM_CLIENT_CONNECT);
$ok = is_resource($socket);
if ($ok) fclose($socket); else $error = 'Server oder Port nicht erreichbar';
} else $error = 'Ungültiges Prüfziel';
@@ -147,7 +155,7 @@ function status_execute_component_check(PDO $pdo, array $check): array
return ['ok' => $ok, 'error' => mb_substr($error, 0, 255), 'ms' => (int) round((microtime(true) - $started) * 1000)];
}
function status_run_component_checks(PDO $pdo): int
function status_run_component_checks(PDO $pdo, bool $force = false): int
{
$components = status_components($pdo);
$update = $pdo->prepare('UPDATE status_components SET last_checked_at = ?, last_check_ms = ?, last_check_error = ?, consecutive_failures = ? WHERE id = ?');
@@ -156,6 +164,9 @@ function status_run_component_checks(PDO $pdo): int
foreach ($components as $component) {
$check = status_component_monitor_target($pdo, $component);
if ($check === null) continue;
$intervalMinutes = max(1, (int) ($component['check_interval_minutes'] ?? 1));
$lastChecked = trim((string) ($component['last_checked_at'] ?? ''));
if (!$force && $lastChecked !== '' && strtotime($lastChecked) > time() - $intervalMinutes * 60) continue;
$result = status_execute_component_check($pdo, $check);
$failures = $result['ok'] ? 0 : (int) ($component['consecutive_failures'] ?? 0) + 1;
$update->execute([db_now(), $result['ms'], $result['ok'] ? null : $result['error'], $failures, (int) $component['id']]);
@@ -186,7 +197,7 @@ function status_discover_and_check(PDO $pdo): array
if (status_component_monitor_target($pdo, $component) !== null) $recognized[] = (string) $component['name'];
elseif (($component['monitor_type'] ?? 'auto') === 'auto') $pending[] = (string) $component['name'];
}
$checked = status_run_component_checks($pdo);
$checked = status_run_component_checks($pdo, true);
return ['recognized' => $recognized, 'pending' => $pending, 'checked' => $checked];
}
@@ -201,9 +212,10 @@ function status_incident_states(): array
];
}
function status_components(PDO $pdo): array
function status_components(PDO $pdo, bool $includeDisabled = false): array
{
return $pdo->query("SELECT c.*, COALESCE(cat.name, NULLIF(c.category, '')) AS category_name FROM status_components c LEFT JOIN status_categories cat ON cat.id = c.category_id ORDER BY COALESCE(cat.sort_order, 0), COALESCE(cat.name, c.category, ''), c.sort_order, c.name")->fetchAll();
$where = $includeDisabled ? '' : 'WHERE COALESCE(c.is_enabled, 1) = 1 ';
return $pdo->query("SELECT c.*, COALESCE(cat.name, NULLIF(c.category, '')) AS category_name FROM status_components c LEFT JOIN status_categories cat ON cat.id = c.category_id {$where}ORDER BY COALESCE(cat.sort_order, 0), COALESCE(cat.name, c.category, ''), c.sort_order, c.name")->fetchAll();
}
function status_categories(PDO $pdo): array
@@ -227,6 +239,69 @@ function status_monitor_types(): array
];
}
/** Parses hostname:port, IPv4:port, or [IPv6]:port for TCP service checks. */
function status_parse_host_port(string $target): ?array
{
$target = trim($target);
if (preg_match('/^\[([0-9a-fA-F:.]+)\]:(\d{1,5})$/D', $target, $m)) {
if (filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) return null;
$host = $m[1];
$port = (int) $m[2];
} elseif (preg_match('/^([A-Za-z0-9.-]+):(\d{1,5})$/D', $target, $m)) {
$host = $m[1];
$port = (int) $m[2];
if (strlen($host) > 253) return null;
foreach (explode('.', rtrim($host, '.')) as $label) {
if ($label === '' || strlen($label) > 63 || $label[0] === '-' || substr($label, -1) === '-') return null;
}
} else {
return null;
}
if ($port < 1 || $port > 65535) return null;
return ['host' => $host, 'port' => $port];
}
/** Minecraft accepts a bare host and resolves its Java port through DNS SRV. */
function status_parse_minecraft_target(string $target): ?array
{
$target = trim($target);
$explicit = status_parse_host_port($target);
if ($explicit !== null) return $explicit;
if (preg_match('/^\[([0-9a-fA-F:.]+)\]$/D', $target, $m)) {
return filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
? ['host' => $m[1], 'port' => null]
: null;
}
if (filter_var($target, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) return ['host' => $target, 'port' => null];
if (strlen($target) > 253 || $target === '') return null;
foreach (explode('.', rtrim($target, '.')) as $label) {
if ($label === '' || strlen($label) > 63 || $label[0] === '-' || substr($label, -1) === '-' || !preg_match('/^[A-Za-z0-9-]+$/D', $label)) return null;
}
return ['host' => rtrim($target, '.'), 'port' => null];
}
function status_resolve_minecraft_target(string $target): ?array
{
$endpoint = status_parse_minecraft_target($target);
if ($endpoint === null || $endpoint['port'] !== null) return $endpoint;
$host = $endpoint['host'];
if (filter_var($host, FILTER_VALIDATE_IP) !== false) return ['host' => $host, 'port' => 25565];
if (function_exists('dns_get_record') && defined('DNS_SRV')) {
$records = @dns_get_record('_minecraft._tcp.' . $host, DNS_SRV);
if (is_array($records) && $records) {
usort($records, static fn(array $a, array $b): int => ((int) ($a['pri'] ?? 0)) <=> ((int) ($b['pri'] ?? 0)));
foreach ($records as $record) {
$srvHost = rtrim((string) ($record['target'] ?? ''), '.');
$port = (int) ($record['port'] ?? 0);
if ($srvHost !== '' && $port >= 1 && $port <= 65535 && status_parse_minecraft_target($srvHost) !== null) return ['host' => $srvHost, 'port' => $port];
}
}
}
return ['host' => $host, 'port' => 25565];
}
/** SMTP- und IMAP-Ziele aus den eingerichteten Konten der Installation. */
function status_configured_mail_endpoints(PDO $pdo): array
{
@@ -291,14 +366,23 @@ function status_component_detected_targets(PDO $pdo, array $check): array
return [(string) ($check['target'] ?? '')];
}
function status_set_component_monitor(PDO $pdo, int $id, string $type, string $target): bool
function status_set_component_monitor(PDO $pdo, int $id, string $type, string $target, int $intervalMinutes = 1): bool
{
if (!isset(status_monitor_types()[$type])) return false;
if (!in_array($intervalMinutes, [1, 2, 5, 10, 15, 30, 60], true)) return false;
$target = trim(mb_substr($target, 0, 500));
if ($type === 'http' && ($target === '' || !filter_var($target, FILTER_VALIDATE_URL) || !in_array(strtolower((string) parse_url($target, PHP_URL_SCHEME)), ['http', 'https'], true))) return false;
if ($type === 'tcp' && $target !== '' && !preg_match('/^[a-zA-Z0-9.-]+:\\d{1,5}$/', $target)) return false;
$stmt = $pdo->prepare('UPDATE status_components SET monitor_type = ?, monitor_target = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
$stmt->execute([$type, $target !== '' ? $target : null, $id]);
if ($type === 'tcp' && $target !== '') {
$componentStmt = $pdo->prepare('SELECT name FROM status_components WHERE id = ?');
$componentStmt->execute([$id]);
$componentName = mb_strtolower((string) $componentStmt->fetchColumn());
$validTarget = str_contains($componentName, 'minecraft')
? status_parse_minecraft_target($target) !== null
: status_parse_host_port($target) !== null;
if (!$validTarget) return false;
}
$stmt = $pdo->prepare('UPDATE status_components SET monitor_type = ?, monitor_target = ?, check_interval_minutes = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
$stmt->execute([$type, $target !== '' ? $target : null, $intervalMinutes, $id]);
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
}
@@ -335,17 +419,26 @@ function status_overall(PDO $pdo): array
if (($states[$c['status']][2] ?? 0) > $states[$worst][2]) $worst = $c['status'];
}
// Eine offene Störung ohne zugeordnete Komponente zählt mindestens als „eingeschränkt“
$open = (int) $pdo->query("SELECT COUNT(*) FROM status_incidents WHERE status <> 'resolved'")->fetchColumn();
$open = (int) $pdo->query("SELECT COUNT(*) FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE i.status <> 'resolved' AND (i.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)")->fetchColumn();
if ($open > 0 && $states[$worst][2] < $states['degraded'][2] && $worst !== 'maintenance') $worst = 'degraded';
$activeMaintenance = status_active_maintenance_map($pdo);
$hasActiveMaintenance = isset($activeMaintenance[0]);
if (!$hasActiveMaintenance) {
foreach (status_components($pdo) as $component) {
if (isset($activeMaintenance[(int) $component['id']])) { $hasActiveMaintenance = true; break; }
}
}
if ($hasActiveMaintenance && $states[$worst][2] < $states['maintenance'][2]) $worst = 'maintenance';
return ['key' => $worst, 'label' => $worst === 'operational' ? 'Alle Systeme laufen' : ($worst === 'maintenance' ? 'Wartungsarbeiten' : ($worst === 'outage' ? 'Ausfall' : 'Teilweise eingeschränkt')), 'color' => $states[$worst][1]];
}
/** Offene Störungen und die der letzten Tage (neueste zuerst), jeweils mit ihren Updates. */
function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false): array
function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false, bool $includeDisabledComponents = false): array
{
$since = date('Y-m-d H:i:s', time() - max(1, $days) * 86400);
$componentFilter = $includeDisabledComponents ? '' : ' AND (i.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)';
$sql = "SELECT i.*, c.name AS component_name FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE "
. ($onlyOpen ? "i.status <> 'resolved'" : "(i.status <> 'resolved' OR i.created_at >= ?)") . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50';
. ($onlyOpen ? "i.status <> 'resolved'" : "(i.status <> 'resolved' OR i.created_at >= ?)") . $componentFilter . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50';
$stmt = $pdo->prepare($sql);
$stmt->execute($onlyOpen ? [] : [$since]);
$rows = $stmt->fetchAll();
@@ -357,6 +450,59 @@ function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false): arr
return $rows;
}
/** Liefert geplante und aktuell laufende Wartungsfenster (oder die Admin-Historie). */
function status_maintenance_windows(PDO $pdo, bool $includeHistory = false): array
{
$now = date('Y-m-d H:i:s');
$since = date('Y-m-d H:i:s', time() - 60 * 86400);
$where = $includeHistory
? 'WHERE (w.ends_at >= ? OR w.cancelled_at >= ?)'
: 'WHERE w.cancelled_at IS NULL AND w.ends_at >= ? AND (w.component_id IS NULL OR COALESCE(c.is_enabled, 1) = 1)';
$stmt = $pdo->prepare("SELECT w.*, c.name AS component_name FROM status_maintenance_windows w LEFT JOIN status_components c ON c.id = w.component_id $where ORDER BY w.starts_at ASC, w.id ASC LIMIT 100");
$stmt->execute($includeHistory ? [$since, $since] : [$now]);
return $stmt->fetchAll();
}
function status_create_maintenance_window(PDO $pdo, string $title, string $body, string $startsAt, string $endsAt, ?int $componentId, ?int $staffId): array
{
$title = trim(mb_substr($title, 0, 200));
$body = trim(mb_substr($body, 0, 4000));
$start = DateTimeImmutable::createFromFormat('!Y-m-d\\TH:i', $startsAt);
$end = DateTimeImmutable::createFromFormat('!Y-m-d\\TH:i', $endsAt);
$startErrors = DateTimeImmutable::getLastErrors();
$endErrors = DateTimeImmutable::getLastErrors();
if ($title === '') return ['ok' => false, 'error' => 'Bitte einen Titel für die Wartung angeben.'];
if (!$start || !$end || ($startErrors && ($startErrors['warning_count'] || $startErrors['error_count'])) || ($endErrors && ($endErrors['warning_count'] || $endErrors['error_count']))) return ['ok' => false, 'error' => 'Bitte gültige Start- und Endzeiten angeben.'];
if ($end <= $start || $end <= new DateTimeImmutable()) return ['ok' => false, 'error' => 'Die Endzeit muss nach der Startzeit und in der Zukunft liegen.'];
if ($componentId !== null) {
$component = $pdo->prepare('SELECT COUNT(*) FROM status_components WHERE id = ? AND COALESCE(is_enabled, 1) = 1');
$component->execute([$componentId]);
if (!(int) $component->fetchColumn()) return ['ok' => false, 'error' => 'Der ausgewählte Dienst ist nicht verfügbar.'];
}
$now = db_now();
$pdo->prepare('INSERT INTO status_maintenance_windows (title, body, component_id, starts_at, ends_at, created_by, created_at) VALUES (?,?,?,?,?,?,?)')
->execute([$title, $body !== '' ? $body : null, $componentId, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s'), $staffId, $now]);
return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
}
function status_cancel_maintenance_window(PDO $pdo, int $id): bool
{
$stmt = $pdo->prepare('UPDATE status_maintenance_windows SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL');
$stmt->execute([db_now(), $id]);
return $stmt->rowCount() > 0;
}
/** Aktive Fenster nach Dienst-ID; Schlüssel 0 steht für eine globale Wartung. */
function status_active_maintenance_map(PDO $pdo): array
{
$now = date('Y-m-d H:i:s');
$stmt = $pdo->prepare('SELECT id, component_id FROM status_maintenance_windows WHERE cancelled_at IS NULL AND starts_at <= ? AND ends_at >= ?');
$stmt->execute([$now, $now]);
$active = [];
foreach ($stmt->fetchAll() as $window) $active[(int) ($window['component_id'] ?? 0)] = true;
return $active;
}
function status_set_component(PDO $pdo, int $id, string $status): bool
{
if (!isset(status_component_states()[$status])) return false;
@@ -381,6 +527,13 @@ function status_toggle_component_maintenance(PDO $pdo, int $id): bool
return status_set_component($pdo, $id, $current === 'maintenance' ? 'operational' : 'maintenance');
}
function status_set_component_enabled(PDO $pdo, int $id, bool $enabled): bool
{
$stmt = $pdo->prepare('UPDATE status_components SET is_enabled = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
$stmt->execute([$enabled ? 1 : 0, $id]);
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
}
/** Letzte einzelne Prüfungen sowie Verfügbarkeit der letzten 24 Stunden. */
function status_component_timeline(PDO $pdo, int $componentId, int $checks = 30): array
{
@@ -463,6 +616,7 @@ function status_save_component(PDO $pdo, ?int $id, string $name, string $descrip
function status_delete_component(PDO $pdo, int $id): void
{
$pdo->prepare('UPDATE status_incidents SET component_id = NULL WHERE component_id = ?')->execute([$id]);
$pdo->prepare('UPDATE status_maintenance_windows SET cancelled_at = ? WHERE component_id = ? AND cancelled_at IS NULL')->execute([db_now(), $id]);
$pdo->prepare('DELETE FROM status_component_events WHERE component_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM status_components WHERE id = ?')->execute([$id]);
}
@@ -474,8 +628,10 @@ function status_restore_core_components(PDO $pdo): int
['DeskHub-Supportportal', 'Webseite', 'auto', ['DeskHub Supportportal']],
['DeskHub-API', 'API-Schnittstellen', 'auto', ['DeskHub API']],
['Datenbank', 'Server & Datenbank', 'database', ['Datenbank Server', 'Database']],
['Backup-Server', 'Server & Datenbank', 'tcp', ['Backup Server', 'FTP-Backup-Server']],
['Maildienst', 'E-Mail', 'mail', ['Mail Dienst', 'Mail Server']],
['Push-Dienst', 'Benachrichtigungen', 'fcm', ['Push Dienst']],
['Minecraft-Server', 'Apps', 'tcp', ['Minecraft Server']],
['Android-App', 'Apps', 'android_app', ['Windelgeschichten-Android-App', 'DeskHub-Mitarbeiter-App für Android']],
['Windows-Programm', 'Apps', 'windows_app', ['DeskHub-Mitarbeiter-App für Windows', 'Windows-App']],
];
@@ -512,6 +668,7 @@ function status_restore_core_components(PDO $pdo): int
$id = status_save_component($pdo, null, $name, '', $maxOrder + 10, $categoryId);
if ($id) {
status_set_component_monitor($pdo, $id, $monitorType, '');
if (in_array($name, ['Minecraft-Server', 'Backup-Server'], true)) status_set_component_enabled($pdo, $id, false);
$maxOrder += 10;
$restored++;
}
@@ -594,5 +751,9 @@ function status_summary(PDO $pdo): array
'createdAt' => (string) $i['created_at'], 'updatedAt' => (string) $i['updated_at'], 'resolvedAt' => $i['resolved_at'],
'updates' => array_map(fn($u) => ['status' => (string) $u['status'], 'statusLabel' => status_incident_states()[$u['status']] ?? $u['status'], 'body' => (string) ($u['body'] ?? ''), 'createdAt' => (string) $u['created_at']], $i['updates']),
], status_incidents($pdo, 14)),
'maintenanceWindows' => array_map(static fn(array $w): array => [
'id' => (int) $w['id'], 'title' => (string) $w['title'], 'body' => (string) ($w['body'] ?? ''),
'component' => (string) ($w['component_name'] ?? ''), 'startsAt' => (string) $w['starts_at'], 'endsAt' => (string) $w['ends_at'],
], status_maintenance_windows($pdo)),
];
}