diff --git a/DeskHub-Webseite/includes/bootstrap.php b/DeskHub-Webseite/includes/bootstrap.php index 32bbbfb3..fc1eb26d 100644 --- a/DeskHub-Webseite/includes/bootstrap.php +++ b/DeskHub-Webseite/includes/bootstrap.php @@ -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'; diff --git a/DeskHub-Webseite/includes/functions.php b/DeskHub-Webseite/includes/functions.php index 14775012..938d6622 100644 --- a/DeskHub-Webseite/includes/functions.php +++ b/DeskHub-Webseite/includes/functions.php @@ -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; } diff --git a/DeskHub-Webseite/includes/imap_spam.php b/DeskHub-Webseite/includes/imap_spam.php index b67b0537..1ba8b391 100644 --- a/DeskHub-Webseite/includes/imap_spam.php +++ b/DeskHub-Webseite/includes/imap_spam.php @@ -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'); diff --git a/DeskHub-Webseite/includes/problems.php b/DeskHub-Webseite/includes/problems.php new file mode 100644 index 00000000..cc1a6108 --- /dev/null +++ b/DeskHub-Webseite/includes/problems.php @@ -0,0 +1,128 @@ + '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; +} + diff --git a/DeskHub-Webseite/includes/reports.php b/DeskHub-Webseite/includes/reports.php index 6bc01897..7b4202df 100644 --- a/DeskHub-Webseite/includes/reports.php +++ b/DeskHub-Webseite/includes/reports.php @@ -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'), ''], diff --git a/DeskHub-Webseite/includes/status_page.php b/DeskHub-Webseite/includes/status_page.php index 92537a6c..a02104a3 100644 --- a/DeskHub-Webseite/includes/status_page.php +++ b/DeskHub-Webseite/includes/status_page.php @@ -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)), ]; }