'; } function check_honeypot(): void { // Wenn das versteckte Feld ausgefüllt wurde, ist es ein Bot if (!empty($_POST['hp_website'])) { http_response_code(400); die('Bad Request.'); } } /** Stimmt der Formular-Token mit dem der Sitzung überein? (ohne Abbruch, für Anmeldeseiten mit freundlicher Meldung) */ function csrf_valid(): bool { $token = $_POST['csrf_token'] ?? ''; return $token !== '' && hash_equals($_SESSION['csrf_token'] ?? '', $token); } function verify_csrf(): void { $token = $_POST['csrf_token'] ?? ''; if (!$token || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) { http_response_code(403); die('Ungültige Anfrage (CSRF-Token fehlt oder ist ungültig). Bitte gehen Sie zurück und versuchen Sie es erneut.'); } // Der Token gilt für die ganze Sitzung und wird NICHT nach jeder Benutzung erneuert: Sonst würden alle anderen offenen Tabs desselben // Browsers beim nächsten Absenden "CSRF-Token ungültig" melden (typisch bei mehreren Tickets in Tabs). Er wird beim Anmelden neu erzeugt. } function flash(string $type, string $message): void { $_SESSION['flash'][] = ['type' => $type, 'message' => $message]; } function get_flashes(): array { $flashes = $_SESSION['flash'] ?? []; unset($_SESSION['flash']); return $flashes; } /** * Merkt sich zusätzlich zur normalen (HTML-escapten) Flash-Meldung, welches Ticket gerade * durch einen anderen Mitarbeiter gesperrt war. flash() allein kann keinen Button * enthalten (die Nachricht wird per e() ausgegeben) – Admins bekommen darüber direkt * unter der Fehlermeldung eine "Sperre jetzt aufheben"-Aktion angezeigt (siehe * staff/_header.php), damit sie nicht bis zum automatischen Timeout warten müssen. * Wird wie flash() einmalig ausgelesen und danach verworfen. */ function flash_locked_ticket(string $ticketNumber, string $lockedByName): void { $_SESSION['locked_ticket_override'] = ['number' => $ticketNumber, 'name' => $lockedByName]; } function get_locked_ticket_override(): ?array { $data = $_SESSION['locked_ticket_override'] ?? null; unset($_SESSION['locked_ticket_override']); return $data; } /** Erzeugt eine eindeutige, gut lesbare Ticketnummer, z.B. 251023-0001 */ function generate_ticket_number(PDO $pdo): string { $prefix = date('ymd'); do { $number = $prefix . '-' . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT); $stmt = $pdo->prepare('SELECT COUNT(*) FROM tickets WHERE ticket_number = ?'); $stmt->execute([$number]); $exists = (int) $stmt->fetchColumn() > 0; } while ($exists); return $number; } function claim_ticket_for_customer(PDO $pdo, int $ticketId, array $user): bool { $userId = (int) ($user['id'] ?? 0); $email = trim((string) ($user['email'] ?? '')); if ($ticketId < 1 || $userId < 1 || $email === '') { return false; } $stmt = $pdo->prepare( 'UPDATE tickets SET user_id = ? WHERE id = ? AND (user_id IS NULL OR user_id = ?) AND LOWER(requester_email) = LOWER(?)' ); $stmt->execute([$userId, $ticketId, $userId, $email]); return $stmt->rowCount() === 1; } /** * Liefert ein dauerhaftes, zufälliges Zugriffstoken für einen Ticket-Link. * Das Klartext-Token wird verschlüsselt gespeichert und nur sein Hash zur Prüfung verwendet. */ function ticket_access_token(int $ticketId): string { $pdo = get_db(); $stmt = $pdo->prepare( 'SELECT token_hash, token_enc FROM ticket_access_tokens WHERE ticket_id = ? AND revoked_at IS NULL ORDER BY id DESC LIMIT 1' ); $stmt->execute([$ticketId]); $stored = $stmt->fetch(); if ($stored) { $token = decrypt_secret($stored['token_enc']); if (preg_match('/^[a-f0-9]{96}$/', $token) && hash_equals($stored['token_hash'], hash('sha256', $token))) { return $token; } } $token = bin2hex(random_bytes(48)); $pdo->prepare( 'INSERT INTO ticket_access_tokens (ticket_id, token_hash, token_enc, created_at) VALUES (?,?,?,?)' )->execute([$ticketId, hash('sha256', $token), encrypt_secret($token), db_now()]); return $token; } function verify_ticket_access_token(int $ticketId, string $token): bool { if (!preg_match('/^[a-f0-9]{96}$/', $token)) { return false; } $stmt = get_db()->prepare( 'SELECT token_hash FROM ticket_access_tokens WHERE ticket_id = ? AND token_hash = ? AND revoked_at IS NULL LIMIT 1' ); $stmt->execute([$ticketId, hash('sha256', $token)]); $storedHash = $stmt->fetchColumn(); return is_string($storedHash) && hash_equals($storedHash, hash('sha256', $token)); } function ticket_access_url(array $ticket): string { $ticketId = (int) ($ticket['id'] ?? 0); if ($ticketId < 1) { $stmt = get_db()->prepare('SELECT id FROM tickets WHERE ticket_number = ?'); $stmt->execute([$ticket['ticket_number'] ?? '']); $ticketId = (int) $stmt->fetchColumn(); } if ($ticketId < 1) { throw new RuntimeException('Ticket für den Kunden-Zugriffslink nicht gefunden.'); } return base_url() . '/ticket_view.php?n=' . urlencode($ticket['ticket_number']) . '&token=' . urlencode(ticket_access_token($ticketId)); } /** * Berechnet SLA-Fälligkeiten anhand einer Prioritäts-Zeile (response_hours/resolution_hours). * * Stunden werden auf max. 5 Jahre gekappt (SLA_MAX_HOURS): ein versehentlich riesiger oder * negativer Wert bei einer Priorität (z.B. Millionen statt normaler Stundenzahl, oder ein * Tippfehler mit zu vielen Nullen) würde sonst ein Datum weit außerhalb dessen ergeben, was * die DATETIME-Spalte speichern kann (MySQL wirft dann eine harte SQL-Exception und die * komplette Ticket-/Chat-Erstellung schlägt fehl – live an genau dieser Stelle beobachtet). */ function calc_sla_dates(array $priority, ?string $from = null, ?int $departmentId = null, ?int $helpTopicId = null): array { $slaMaxHours = 43800; // 5 Jahre – weit über jedem sinnvollen SLA-Wert, aber sicher innerhalb DATETIME-Grenzen $from = $from ?? db_now(); // Ein zugeordneter SLA-Plan (Hilfethema vor Abteilung) ersetzt die Zeiten der Priorität if ($plan = sla_plan_for(get_db(), $departmentId, $helpTopicId)) { $priority['response_hours'] = $plan['response_hours']; $priority['resolution_hours'] = $plan['resolution_hours']; } $responseHours = max(0, min($slaMaxHours, (int) $priority['response_hours'])); $resolutionHours = max(0, min($slaMaxHours, (int) $priority['resolution_hours'])); if (get_setting('business_hours_enabled', '0') === '1') { return [ 'first_response_due_at' => add_business_hours($from, $responseHours), 'resolution_due_at' => add_business_hours($from, $resolutionHours), ]; } return [ 'first_response_due_at' => add_hours($from, $responseHours), 'resolution_due_at' => add_hours($from, $resolutionHours), ]; } 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; } } /** Startet die SLA eines wieder geöffneten Tickets neu: neue Lösungsfrist ab jetzt, Überfällig-Markierung weg. */ function ticket_sla_restart(PDO $pdo, int $ticketId, ?int $staffId = null): void { $q = $pdo->prepare('SELECT t.priority_id, t.department_id, t.help_topic_id FROM tickets t WHERE t.id = ?'); $q->execute([$ticketId]); $t = $q->fetch(); if (!$t) return; $p = $pdo->prepare('SELECT * FROM priorities WHERE id = ?'); $p->execute([(int) $t['priority_id']]); $priority = $p->fetch(); if (!$priority) { $pdo->prepare('UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); return; } $now = db_now(); $dates = calc_sla_dates($priority, $now, (int) $t['department_id'] ?: null, (int) $t['help_topic_id'] ?: null); $pdo->prepare('UPDATE tickets SET resolution_due_at = ?, is_overdue = 0, sla_paused_at = NULL, updated_at = updated_at WHERE id = ?')->execute([$dates['resolution_due_at'], $ticketId]); ticket_sla_history_add($pdo, $ticketId, 'restarted', 'resolution', 'SLA neu gestartet: Ticket wurde wieder geöffnet', $staffId, $now); } /** 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; // Wieder geöffnet (zum Beispiel weil der Kunde auf ein gelöstes Ticket antwortet): Die alte Lösungsfrist ist längst abgelaufen und würde // sofort „SLA überschritten“ auslösen. Die SLA beginnt deshalb neu, mit den Zeiten der Priorität bzw. des SLA-Plans ab jetzt. $terminal = ['resolved', 'closed']; if (in_array($oldStatus, $terminal, true) && !in_array($newStatus, $terminal, true)) { ticket_sla_restart($pdo, $ticketId, $staffId); } // Erledigt (gelöst oder geschlossen) ist dasselbe: Die SLA zählt nicht weiter, „überfällig“ und eine Wiedervorlage entfallen if (!in_array($oldStatus, $terminal, true) && in_array($newStatus, $terminal, true)) { try { $pdo->prepare('UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); } catch (Throwable $e) { error_log('SLA erledigt: ' . $e->getMessage()); } try { $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); } catch (Throwable $e) { /* Spalte fehlt vor dem Datenbank-Update */ } } $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, updated_at = updated_at 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. */ function sla_plan_for(PDO $pdo, ?int $departmentId, ?int $helpTopicId): ?array { $ids = []; if ($helpTopicId) { $s = $pdo->prepare('SELECT sla_plan_id FROM help_topics WHERE id = ?'); $s->execute([$helpTopicId]); $ids[] = $s->fetchColumn(); } if ($departmentId) { $s = $pdo->prepare('SELECT sla_plan_id FROM departments WHERE id = ?'); $s->execute([$departmentId]); $ids[] = $s->fetchColumn(); } $stmt = $pdo->prepare('SELECT * FROM sla_plans WHERE id = ? AND is_active = 1'); foreach ($ids as $id) { if (!$id) continue; $stmt->execute([(int) $id]); if ($plan = $stmt->fetch()) return $plan; } return null; } function slugify(string $text): string { $text = preg_replace('~[^\pL\d]+~u', '-', $text); $text = trim($text, '-'); $text = iconv('UTF-8', 'ASCII//TRANSLIT', $text) ?: $text; $text = strtolower($text); $text = preg_replace('~[^-a-z0-9]+~', '', $text); return $text !== '' ? $text : 'n-a'; } /** Statuslabel + Badge-Farbe für die Oberfläche */ function status_label(string $status): array { $map = [ 'open' => ['Offen', '#2563eb'], 'pending' => ['Wartet auf Kunde', '#a855f7'], 'answered' => ['Beantwortet', '#0d9488'], 'on_hold' => ['Pausiert', '#6b7280'], 'resolved' => ['Gelöst', '#16a34a'], 'closed' => ['Geschlossen', '#374151'], ]; return $map[$status] ?? [$status, '#888888']; } function format_dt(?string $dt): string { if (!$dt) return '–'; return date('d.m.Y H:i', strtotime($dt)); } function time_until(?string $dt): string { if (!$dt) return '–'; $diff = strtotime($dt) - time(); $abs = abs($diff); $hours = floor($abs / 3600); $mins = floor(($abs % 3600) / 60); $label = $hours > 0 ? "{$hours} Std {$mins} Min" : "{$mins} Min"; return $diff >= 0 ? "in {$label}" : "vor {$label} (überfällig)"; } /** * Die zur Wahl stehenden Signaturen für eine Antwort in einem Ticket (Web, Windows- und Android-App): * eigene und die der Abteilung (bereinigtes HTML), Abteilungsname und die Voreinstellung ('own', 'dept' oder 'none'). * * @return array{own: string, dept: string, deptName: string, default: string} */ function reply_signature_options(PDO $pdo, array $staff, int $departmentId): array { $own = trim((string) ($staff['signature'] ?? '')); $stmt = $pdo->prepare('SELECT name, signature FROM departments WHERE id = ?'); $stmt->execute([$departmentId]); $row = $stmt->fetch() ?: ['name' => '', 'signature' => '']; $dept = trim((string) $row['signature']); return [ 'own' => $own !== '' ? sanitize_staff_html($own) : '', 'dept' => $dept !== '' ? sanitize_staff_html($dept) : '', 'deptName' => (string) $row['name'], 'default' => $own !== '' ? 'own' : ($dept !== '' ? 'dept' : 'none'), ]; } /** * Bestimmt die Signatur, die an eine öffentliche Antwort angehängt wird. * $choice: 'own' = Signatur des Mitarbeiters, 'dept' = Signatur der Abteilung des Tickets, 'none' = keine, * sonst (oder leer) das bisherige Verhalten: eigene Signatur, ersatzweise die der Abteilung. * Gibt bereinigtes HTML zurück. */ function resolve_reply_signature(PDO $pdo, array $staff, int $departmentId, ?string $choice = null): string { $own = trim((string) ($staff['signature'] ?? '')); $deptStmt = $pdo->prepare('SELECT signature FROM departments WHERE id = ?'); $deptStmt->execute([$departmentId]); $dept = trim((string) $deptStmt->fetchColumn()); $signature = match ($choice) { 'own' => $own, 'dept' => $dept, 'none' => '', default => $own !== '' ? $own : $dept, }; return $signature === '' ? '' : sanitize_staff_html($signature); } /** Unterstützte Favicon-Formate: Endung => MIME-Typ. */ function favicon_types(): array { return ['ico' => 'image/x-icon', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml']; } /** Prüft, ob die Datei wirklich ein Bild des angegebenen Formats ist (nicht nur die Endung). Gibt null oder eine Fehlermeldung zurück. */ function favicon_validate_file(string $path, string $ext): ?string { $head = (string) @file_get_contents($path, false, null, 0, 16); switch ($ext) { case 'ico': return str_starts_with($head, "\x00\x00\x01\x00") || str_starts_with($head, "\x00\x00\x02\x00") ? null : 'Das ist keine gültige ICO-Datei.'; case 'png': return str_starts_with($head, "\x89PNG") ? null : 'Das ist keine gültige PNG-Datei.'; case 'jpg': case 'jpeg': return str_starts_with($head, "\xFF\xD8\xFF") ? null : 'Das ist keine gültige JPG-Datei.'; case 'gif': return str_starts_with($head, 'GIF8') ? null : 'Das ist keine gültige GIF-Datei.'; case 'webp': return str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WEBP' ? null : 'Das ist keine gültige WebP-Datei.'; case 'svg': $svg = (string) @file_get_contents($path, false, null, 0, 1048576); if (!preg_match('/-Tags für das Browser-Symbol (Tab, Lesezeichen, Startbildschirm). Ein eigenes Favicon (Einstellungen → Design) ersetzt alle * Standard-Symbole, damit der Browser nicht ein anderes auswählt. Ohne Favicon gelten die Standard-Symbole und, falls vorhanden, das Logo. */ function site_icon_links(): string { $favicon = (string) get_setting('favicon_path', ''); $types = favicon_types(); if ($favicon !== '') { $ext = strtolower(pathinfo((string) parse_url($favicon, PHP_URL_PATH), PATHINFO_EXTENSION)); $type = $types[$ext] ?? 'image/png'; $href = e($favicon); $out = '' . "\n" . '' . "\n"; $out .= in_array($ext, ['png', 'jpg', 'jpeg'], true) ? '' : ''; return $out . "\n"; } $out = '' . "\n" . '' . "\n" . '' . "\n"; $logo = (string) get_setting('logo_path', ''); if ($logo !== '') $out .= '' . "\n"; return $out; } function base_url(): string { return rtrim(get_setting('site_url', config()['site']['url'] ?? 'http://localhost'), '/'); } /** * Baut ein sicheres SQL-Filterfragment für eine Abteilungs-Zugriffsbeschränkung. * $deptIds === null -> kein Filter (Admin, voller Zugriff): ['', []] * $deptIds === [] -> kein Zugriff auf irgendeine Abteilung: ['AND 1=0', []] * $deptIds === [1,2] -> ['AND col IN (?,?)', [1,2]] */ function dept_filter_sql(?array $deptIds, string $column): array { if ($deptIds === null) { return ['', []]; } if (count($deptIds) === 0) { return [' AND 1=0', []]; } $placeholders = implode(',', array_fill(0, count($deptIds), '?')); return [" AND {$column} IN ({$placeholders})", $deptIds]; } /** Liste der Abteilungs-IDs, auf die ein Mitarbeiter Zugriff hat (Admins: null = alle) */ /** Darf dieser Mitarbeiter Tickets der Abteilung sehen? Admins alle, alle anderen nur ihre Abteilungen. */ function staff_can_access_department(array $staff, int $departmentId): bool { $ids = staff_department_ids($staff); return $ids === null || in_array($departmentId, $ids, true); } /** Ist der Mitarbeiter gerade abwesend (Urlaubsmodus)? Im Urlaub gibt es keine Benachrichtigungen (Mail, Push, Android). */ function staff_is_on_vacation(int $staffId): bool { try { $q = get_db()->prepare('SELECT is_on_vacation FROM staff WHERE id = ?'); $q->execute([$staffId]); return (int) $q->fetchColumn() === 1; } catch (Throwable $e) { return false; } } function staff_department_ids(array $staff): ?array { if ($staff['role'] === 'admin') { return null; // null = kein Filter, voller Zugriff } $ids = []; if (!empty($staff['department_id'])) { $ids[] = (int) $staff['department_id']; } $stmt = get_db()->prepare('SELECT department_id FROM staff_department WHERE staff_id = ?'); $stmt->execute([$staff['id']]); foreach ($stmt->fetchAll() as $row) { $ids[] = (int) $row['department_id']; } foreach (staff_substitution_department_ids($staff) as $d) $ids[] = $d; return array_values(array_unique($ids)); } /** * Abteilungen, auf die ein Mitarbeiter nur als Vertretung Zugriff hat: Ist ein Kollege im Urlaub (Profil → Abwesenheit oder Plugin * „Urlaub und Vertretung“) und hat diesen Mitarbeiter als Vertretung eingetragen, gelten dessen Abteilungen (Haupt- und zusätzliche) mit, * solange die Abwesenheit dauert. Ketten (A vertritt B, B ist selbst im Urlaub und von C vertreten) folgen bis zu drei Stufen. * Ist der Abwesende Administrator (sieht alle Abteilungen), kommen nur die Abteilungen der Tickets dazu, die der Vertretung gerade * zugewiesen sind. Ohne die Spalten (Datenbank noch nicht aktualisiert) ergibt sich eine leere Liste. * @return list */ function staff_substitution_department_ids(array $staff): array { if (($staff['role'] ?? '') === 'admin' || empty($staff['id'])) return []; $pdo = get_db(); $out = []; try { $frontier = [(int) $staff['id']]; $seen = $frontier; for ($level = 0; $level < 3 && $frontier; $level++) { $in = implode(',', array_fill(0, count($frontier), '?')); $q = $pdo->prepare("SELECT id, role, department_id FROM staff WHERE is_active = 1 AND is_on_vacation = 1 AND vacation_substitute_id IN ($in)"); $q->execute($frontier); $next = []; $adminAbsent = false; foreach ($q->fetchAll() as $abs) { $aid = (int) $abs['id']; if (in_array($aid, $seen, true)) continue; $seen[] = $aid; $next[] = $aid; if ($abs['role'] === 'admin') { $adminAbsent = true; continue; } if (!empty($abs['department_id'])) $out[] = (int) $abs['department_id']; $d = $pdo->prepare('SELECT department_id FROM staff_department WHERE staff_id = ?'); $d->execute([$aid]); foreach ($d->fetchAll(PDO::FETCH_COLUMN) as $did) $out[] = (int) $did; } if ($adminAbsent) { $t = $pdo->prepare("SELECT DISTINCT department_id FROM tickets WHERE assigned_staff_id = ? AND status NOT IN ('resolved','closed')"); $t->execute([(int) $staff['id']]); foreach ($t->fetchAll(PDO::FETCH_COLUMN) as $did) $out[] = (int) $did; } $frontier = $next; } } catch (Throwable $e) { return []; } return array_values(array_unique(array_filter($out))); } /** * Beim Öffnen eines noch niemandem zugewiesenen Tickets wird es dem öffnenden Mitarbeiter zugewiesen (Einstellung "Beim Öffnen * automatisch zuweisen", Standard an). Nicht bei geschlossenen, gelösten, zusammengeführten oder als Spam markierten Tickets und nicht, * wenn der Mitarbeiter in dieser Abteilung nicht antworten darf. Aktualisiert $ticket (assigned_staff_id, staff_name) und gibt zurück, * ob zugewiesen wurde. */ function ticket_assign_on_open(PDO $pdo, array &$ticket, array $staff): bool { if (get_setting('auto_assign_on_open', '1') !== '1') return false; if (!empty($ticket['assigned_staff_id']) || !empty($ticket['merged_into']) || !empty($ticket['is_spam'])) return false; if (in_array((string) ($ticket['status'] ?? ''), ['closed', 'resolved'], true)) return false; if (!has_permission($staff, 'reply_ticket', (int) ($ticket['department_id'] ?? 0))) return false; $stmt = $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ? AND assigned_staff_id IS NULL'); $stmt->execute([(int) $staff['id'], (int) $ticket['id']]); if ($stmt->rowCount() !== 1) return false; $ticket['assigned_staff_id'] = (int) $staff['id']; $ticket['staff_name'] = $staff['name'] ?? null; log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Beim Öffnen automatisch zugewiesen'); return true; } /** Arten der automatischen Zuweisung neuer Tickets einer Abteilung. */ function department_auto_assign_modes(): array { return ['none' => 'Nicht automatisch zuweisen', 'staff' => 'Immer an einen bestimmten Mitarbeiter', 'round_robin' => 'Reihum an die Mitarbeiter der Abteilung']; } /** * Wem ein neues Ticket dieser Abteilung laut Einstellung der Abteilung automatisch zugewiesen wird (Mitarbeiter-ID) oder null. * - "staff": immer derselbe Mitarbeiter; ist er im Urlaub, geht es an seine Vertretung, ohne Vertretung bleibt das Ticket unzugewiesen * - "round_robin": der Reihe nach an die Mitarbeiter der Abteilung (Hauptabteilung oder zusätzlich zugewiesen), die nicht im Urlaub und * gerade erreichbar sind (nicht offline, in der Schicht); ist niemand erreichbar, bekommt der nächste aus der Reihe es trotzdem * Fehlende Spalten (Datenbank noch nicht aktualisiert) und unbekannte Abteilungen ergeben null. */ function department_auto_assignee(PDO $pdo, int $departmentId): ?int { try { $s = $pdo->prepare('SELECT auto_assign_mode, auto_assign_staff_id, auto_assign_last_id FROM departments WHERE id = ?'); $s->execute([$departmentId]); $d = $s->fetch(); } catch (Throwable $e) { return null; } if (!$d) return null; $mode = (string) ($d['auto_assign_mode'] ?? 'none'); if ($mode === 'staff') { $id = (int) ($d['auto_assign_staff_id'] ?? 0); if ($id <= 0) return null; $st = $pdo->prepare('SELECT id, is_active, is_on_vacation, vacation_substitute_id FROM staff WHERE id = ?'); $st->execute([$id]); $row = $st->fetch(); if (!$row || !(int) $row['is_active']) return null; if (!(int) $row['is_on_vacation']) return $id; $sub = (int) ($row['vacation_substitute_id'] ?? 0); if ($sub > 0) { $st->execute([$sub]); $subRow = $st->fetch(); if ($subRow && (int) $subRow['is_active'] && !(int) $subRow['is_on_vacation']) return $sub; } return null; } if ($mode === 'round_robin') { $c = $pdo->prepare( 'SELECT id FROM staff WHERE is_active = 1 AND is_on_vacation = 0 AND (department_id = ? OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ?)) ORDER BY id' ); $c->execute([$departmentId, $departmentId]); $candidates = array_map('intval', $c->fetchAll(PDO::FETCH_COLUMN)); if (!$candidates) return null; $available = array_values(array_filter($candidates, fn(int $id) => is_staff_available($pdo, $id))); $pool = $available ?: $candidates; $last = (int) ($d['auto_assign_last_id'] ?? 0); $next = null; foreach ($pool as $id) { if ($id > $last) { $next = $id; break; } } $next ??= $pool[0]; $pdo->prepare('UPDATE departments SET auto_assign_last_id = ? WHERE id = ?')->execute([$next, $departmentId]); return $next; } return null; } /** Löscht ein Ticket dauerhaft, protokolliert es vorher für Berichte */ function delete_ticket(PDO $pdo, array $ticket, ?int $deletedByStaffId): void { $stmt = $pdo->prepare( 'INSERT INTO deleted_tickets_log (ticket_number, department_id, priority_id, help_topic_id, assigned_staff_id, deleted_by, deleted_at) VALUES (?,?,?,?,?,?,?)' ); $stmt->execute([ $ticket['ticket_number'], $ticket['department_id'] ?? null, $ticket['priority_id'] ?? null, $ticket['help_topic_id'] ?? null, $ticket['assigned_staff_id'] ?? null, $deletedByStaffId, db_now(), ]); if (function_exists('ticket_links_purge')) ticket_links_purge($pdo, (int) $ticket['id']); $pdo->prepare('DELETE FROM tickets WHERE id = ?')->execute([$ticket['id']]); } /** Prüft, ob eine IP in einer Liste aus Einzeladressen und CIDR-Bereichen (IPv4/IPv6) steht. */ function ip_in_list(string $ip, array $list): bool { $bin = @inet_pton($ip); if ($bin === false) return false; foreach ($list as $entry) { $entry = trim((string) $entry); if ($entry === '') continue; [$net, $bits] = array_pad(explode('/', $entry, 2), 2, null); $nbin = @inet_pton($net); if ($nbin === false || strlen($nbin) !== strlen($bin)) continue; $bits = $bits === null ? strlen($bin) * 8 : max(0, min(strlen($bin) * 8, (int) $bits)); $bytes = intdiv($bits, 8); if ($bytes > 0 && substr($bin, 0, $bytes) !== substr($nbin, 0, $bytes)) continue; $rest = $bits % 8; if ($rest > 0) { $mask = (0xFF << (8 - $rest)) & 0xFF; if ((ord($bin[$bytes]) & $mask) !== (ord($nbin[$bytes]) & $mask)) continue; } return true; } return false; } /** 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 = trusted_proxy_ranges(); return $list !== [] && ip_in_list((string) ($_SERVER['REMOTE_ADDR'] ?? ''), $list); } /** Wurde die Anfrage per HTTPS gestellt (direkt oder über einen vertrauenswürdigen Proxy, der X-Forwarded-Proto setzt)? */ function request_is_https(): bool { if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') return true; return request_from_trusted_proxy() && strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0])) === 'https'; } /** * Ermittelt die Client-IP für Rate-Limiting. Ohne eingetragene Proxys zählt nur REMOTE_ADDR (ein Besucher kann * X-Forwarded-For selbst setzen und die Sperre sonst umgehen). Steht der Proxy in "trusted_proxies", gilt die * erste Adresse von rechts in X-Forwarded-For, die nicht selbst ein vertrauenswürdiger Proxy ist. */ function client_ip(): string { $remote = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown'); if (!request_from_trusted_proxy()) return $remote; $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; } return $remote; } /** Schreibt einen Eintrag ins Admin-Aktionsprotokoll (Einstellungen, Stammdaten – getrennt vom Ticket-Aktivitätsprotokoll) */ function audit_log(?int $staffId, string $action): void { // Spalte ist VARCHAR(255) NOT NULL – bei MySQL im Strict-Modus wirft ein zu langer // Wert sonst eine harte Exception statt einfach zu kürzen. get_db()->prepare('INSERT INTO admin_audit_log (staff_id, action, created_at) VALUES (?,?,?)') ->execute([$staffId, mb_substr($action, 0, 255), db_now()]); } /** * Einfache Rate-Begrenzung: prüft, ob unter $bucket/$identifier in den letzten * $windowMinutes Minuten bereits $maxAttempts Einträge protokolliert wurden. * Bei Erlaubnis wird der Versuch sofort selbst protokolliert (Aufruf = Verbrauch). */ function rate_limit_check(string $bucket, string $identifier, int $maxAttempts, int $windowMinutes): bool { // Alte Einträge regelmäßig löschen (ca. 1% der Aufrufe) if (random_int(1, 100) === 1) { get_db()->prepare('DELETE FROM rate_limit_log WHERE created_at < ?') ->execute([date('Y-m-d H:i:s', time() - 86400)]); } $pdo = get_db(); $since = date('Y-m-d H:i:s', time() - $windowMinutes * 60); $stmt = $pdo->prepare('SELECT COUNT(*) FROM rate_limit_log WHERE bucket = ? AND identifier = ? AND created_at >= ?'); $stmt->execute([$bucket, $identifier, $since]); $count = (int) $stmt->fetchColumn(); if ($count >= $maxAttempts) { return false; } $pdo->prepare('INSERT INTO rate_limit_log (bucket, identifier, created_at) VALUES (?,?,?)') ->execute([$bucket, $identifier, db_now()]); return true; } /** Erzeugt ein Passwort-Reset-Token, speichert nur dessen Hash, gibt das Klartext-Token zurück (für den Link) */ function create_password_reset(string $accountType, string $email): string { $pdo = get_db(); // 48 Bytes = 96 hex chars – ausreichend Entropie gegen Brute-Force $token = bin2hex(random_bytes(48)); // Alte, ungenutzte Tokens für diese E-Mail ungültig machen $pdo->prepare('UPDATE password_resets SET used_at = ? WHERE account_type = ? AND email = ? AND used_at IS NULL') ->execute([db_now(), $accountType, $email]); $pdo->prepare('INSERT INTO password_resets (account_type, email, token_hash, expires_at, created_at) VALUES (?,?,?,?,?)') ->execute([$accountType, $email, hash('sha256', $token), date('Y-m-d H:i:s', time() + 3600), db_now()]); return $token; } /** * Prüft ob ein Passwort die Mindestanforderungen erfüllt. * Gibt null zurück wenn OK, sonst eine Fehlermeldung. */ function validate_password_strength(string $password): ?string { if (strlen($password) < 10) { return 'Das Passwort muss mindestens 10 Zeichen lang sein.'; } if (!preg_match('/[A-Z]/', $password)) { return 'Das Passwort muss mindestens einen Großbuchstaben enthalten.'; } if (!preg_match('/[a-z]/', $password)) { return 'Das Passwort muss mindestens einen Kleinbuchstaben enthalten.'; } if (!preg_match('/[0-9]/', $password)) { return 'Das Passwort muss mindestens eine Ziffer enthalten.'; } if (!preg_match('/[^A-Za-z0-9]/', $password)) { return 'Das Passwort muss mindestens ein Sonderzeichen enthalten (!@#$%&* usw.).'; } // Triviale Passwörter blockieren $trivial = ['password','passwort','12345678','123456789','1234567890','qwertzui','admin123','welcome1']; if (in_array(strtolower($password), $trivial, true)) { return 'Dieses Passwort ist zu häufig verwendet. Bitte wählen Sie ein sichereres Passwort.'; } return null; } /** * Verschlüsselt einen sensiblen Wert (z.B. SMTP-Passwort) mit AES-256-GCM. * Schlüssel kommt aus config app_key oder wird aus DB-Credentials abgeleitet. */ function encrypt_secret(string $plaintext): string { $key = _get_app_key(); $iv = random_bytes(12); $tag = ''; $encrypted = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16); return base64_encode($iv . $tag . $encrypted); } /** Entschlüsselt einen mit encrypt_secret() verschlüsselten Wert. */ function decrypt_secret(string $ciphertext): string { if ($ciphertext === '') return ''; // Legacy: wenn nicht base64-verschlüsselt, Klartext zurückgeben (Migration) $raw = base64_decode($ciphertext, true); if ($raw === false || strlen($raw) < 28) return $ciphertext; $key = _get_app_key(); $iv = substr($raw, 0, 12); $tag = substr($raw, 12, 16); $encrypted = substr($raw, 28); $decrypted = openssl_decrypt($encrypted, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag); return $decrypted !== false ? $decrypted : $ciphertext; // Fallback auf Klartext } /** Interner Schlüssel für Verschlüsselung – 32 Bytes. */ /** * Interner Schlüssel für AES-256-GCM-Verschlüsselung – 32 Bytes. * * Priorität: * 1. settings-Tabelle (key = 'app_key') → überlebt Updates ohne config.php-Änderung * 2. config.php → app_key → gesetzt vom Installer oder manuell * 3. Fallback aus DB-Credentials → schwach, aber nie null (Warnung wird angezeigt) */ function _get_app_key(): string { // 1. DB-Setting hat höchste Priorität (überlebt Updates) try { $stmt = get_db()->prepare('SELECT setting_value FROM settings WHERE setting_key = ?'); $stmt->execute(['app_key']); $dbKey = $stmt->fetchColumn(); if ($dbKey !== false && $dbKey !== '') { return substr(hash('sha256', $dbKey, true), 0, 32); } } catch (\Throwable $e) { // DB noch nicht verfügbar (z.B. während Installation) → weiter zum Fallback } // 2. config.php $cfg = config(); if (!empty($cfg['app_key'])) { return substr(hash('sha256', $cfg['app_key'], true), 0, 32); } // 3. Schwacher Fallback (Warnung wird im Admin-Header angezeigt) $seed = ($cfg['db']['pass'] ?? '') . ($cfg['db']['name'] ?? '') . ($cfg['db']['host'] ?? 'localhost'); return substr(hash('sha256', $seed . 'deskhub-secret', true), 0, 32); } /** Setzt einen Rate-Limit-Zähler zurück (z.B. nach erfolgreichem Login) */ function rate_limit_reset(string $action, string $key): void { try { get_db()->prepare('DELETE FROM rate_limit_log WHERE bucket = ? AND identifier = ?') ->execute([$action, $key]); } catch (Throwable $e) { /* ignorieren */ } } /** Prüft ein Passwort-Reset-Token und gibt die zugehörige E-Mail-Adresse zurück, oder null wenn ungültig/abgelaufen/verbraucht */ function verify_password_reset(string $accountType, string $token): ?string { $pdo = get_db(); $stmt = $pdo->prepare( "SELECT * FROM password_resets WHERE account_type = ? AND token_hash = ? AND used_at IS NULL AND expires_at >= ? ORDER BY id DESC LIMIT 1" ); $stmt->execute([$accountType, hash('sha256', $token), db_now()]); $row = $stmt->fetch(); return $row ? $row['email'] : null; } /** Markiert ein Reset-Token als verbraucht (nach erfolgreichem Zurücksetzen) */ function consume_password_reset(string $accountType, string $token): void { $pdo = get_db(); $pdo->prepare('UPDATE password_resets SET used_at = ? WHERE account_type = ? AND token_hash = ?') ->execute([db_now(), $accountType, hash('sha256', $token)]); } /** * Wandelt eine kleine, sichere Teilmenge von Markdown in HTML um (für Ticket-Antworten). * Escaped IMMER zuerst den kompletten Text, wendet danach nur feste Muster an – * dadurch ist eingegebenes HTML niemals aktiv, unabhängig vom Absender. */ function markdown_to_html(string $text): string { $text = trim($text); if ($text === '') return ''; $escaped = e($text); // Code-Abschnitte `code` $escaped = preg_replace('/`([^`\n]+)`/', '$1', $escaped); // Fett **text** $escaped = preg_replace('/\*\*([^\*\n]+)\*\*/', '$1', $escaped); // Kursiv *text* $escaped = preg_replace('/\*([^\*\n]+)\*/', '$1', $escaped); // Links [text](https://...) - nur http(s) erlaubt $escaped = preg_replace_callback('/\[([^\]\n]+)\]\((https?:\/\/[^\s\)]+)\)/', function ($m) { return '' . $m[1] . ''; }, $escaped); // Zeilen in Absätze/Listen umwandeln $lines = preg_split('/\r?\n/', $escaped); $html = ''; $inList = false; foreach ($lines as $line) { if (preg_match('/^-\s+(.*)/', $line, $m)) { if (!$inList) { $html .= ''; $inList = false; } $html .= trim($line) === '' ? '
' : '

' . $line . '

'; } } if ($inList) $html .= ''; return $html; } /** Schreibt einen Eintrag ins Aktivitätsprotokoll eines Tickets */ function log_activity(PDO $pdo, int $ticketId, ?int $staffId, string $action): void { $stmt = $pdo->prepare('INSERT INTO activity_log (ticket_id, staff_id, action, created_at) VALUES (?,?,?,?)'); $stmt->execute([$ticketId, $staffId, $action, db_now()]); } /** * Sperre für Live-Chat-Tickets (source='chat'): verhindert, dass zwei Mitarbeiter * unbemerkt gleichzeitig im selben Chat antworten – egal ob über das Web-Backend * (staff/live_chat.php) oder die Mobile-App (api/staff/v1/ticket_message.php), beide * teilen sich diese eine Logik, damit sie nicht auseinanderlaufen. Wer den Chat öffnet * bzw. beantwortet, "besitzt" ihn für ein paar Minuten; danach läuft die Sperre * automatisch ab (kein Hängenbleiben, wenn z.B. die App einfach geschlossen wird). * Andere Mitarbeiter dürfen den Chat trotzdem weiter LESEN – nur das Schreiben wird * ihnen verwehrt, siehe Aufrufer. */ const LIVE_CHAT_LOCK_TIMEOUT_MINUTES = 5; /** Name des Mitarbeiters, der $ticket gerade frisch gesperrt hält – oder null, wenn * frei, selbst gesperrt, oder die Sperre abgelaufen ist. */ function live_chat_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string { $lockFresh = !empty($ticket['locked_at']) && (time() - strtotime($ticket['locked_at'])) < LIVE_CHAT_LOCK_TIMEOUT_MINUTES * 60; if (empty($ticket['locked_by']) || (int) $ticket['locked_by'] === $staffId || !$lockFresh) { return null; } // Administratoren werden nie ausgesperrt: sie dürfen auch einen fremd "besessenen" Chat lesen und beantworten (und übernehmen die Sperre) $r = $pdo->prepare('SELECT role FROM staff WHERE id = ?'); $r->execute([$staffId]); if ($r->fetchColumn() === 'admin') return null; $stmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?'); $stmt->execute([$ticket['locked_by']]); return $stmt->fetchColumn() ?: 'ein anderer Mitarbeiter'; } /** Übernimmt/erneuert die Sperre für $staffId (nur aufrufen, wenn nicht bereits * frisch von jemand anderem gehalten – siehe live_chat_locked_by_other()). */ function live_chat_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void { $pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ?, updated_at = updated_at WHERE id = ?') ->execute([$staffId, db_now(), $ticketId]); } /** * Generelle Ticket-Sperre für ALLE Tickets (nicht Live-Chat-spezifisch, siehe oben) – * verhindert, dass zwei Mitarbeiter unbemerkt gleichzeitig am selben Ticket arbeiten. * Nutzt dieselben locked_by/locked_at-Spalten, aber eine "harte" Semantik: anders als * beim Live-Chat wird hier nicht nur das Schreiben, sondern auch das Öffnen selbst * verweigert (siehe Aufrufer in staff/ticket_view.php und api/staff/v1/ticket.php). * Für Live-Chat-Tickets (source='chat') gilt weiterhin die separate, mildere * live_chat_locked_by_other()-Logik – NIEMALS beide auf dasselbe Ticket anwenden, sonst * würden sich die Timeouts widersprechen. * (Ursprünglich 10 Minuten, auf Nutzerwunsch verkürzt: wer ein Ticket nur kurz öffnet und * dann wegnavigiert ohne zu antworten/freizugeben, blockiert es sonst unnötig lange für * andere. Admins können eine fremde Sperre zusätzlich jederzeit sofort aufheben, siehe * flash_locked_ticket()/get_locked_ticket_override().) */ const TICKET_LOCK_TIMEOUT_MINUTES = 5; /** Name des Mitarbeiters, der $ticket gerade frisch gesperrt hält – oder null, wenn * frei, selbst gesperrt, oder die Sperre abgelaufen ist. */ function ticket_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string { $lockFresh = !empty($ticket['locked_at']) && (time() - strtotime($ticket['locked_at'])) < TICKET_LOCK_TIMEOUT_MINUTES * 60; if (empty($ticket['locked_by']) || (int) $ticket['locked_by'] === $staffId || !$lockFresh) { return null; } $stmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?'); $stmt->execute([$ticket['locked_by']]); return $stmt->fetchColumn() ?: 'einem anderen Mitarbeiter'; } /** Übernimmt/erneuert die generelle Ticket-Sperre (nur aufrufen, wenn nicht bereits * frisch von jemand anderem gehalten – siehe ticket_locked_by_other()). */ function ticket_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void { $pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ?, updated_at = updated_at WHERE id = ?') ->execute([$staffId, db_now(), $ticketId]); } /** Liest eine Einstellung aus der settings-Tabelle (z.B. Logo-Pfad) */ /** Geteilter In-Request-Cache für get_setting / set_setting (per Referenz). */ function &_settings_cache(): array { static $cache = []; return $cache; } /** Liest eine Einstellung aus der DB (mit In-Request-Cache). */ function get_setting(string $key, ?string $default = null): ?string { $cache = &_settings_cache(); if (array_key_exists($key, $cache)) { return $cache[$key]; } $stmt = get_db()->prepare('SELECT setting_value FROM settings WHERE setting_key = ?'); $stmt->execute([$key]); $value = $stmt->fetchColumn(); return $cache[$key] = ($value !== false ? $value : $default); } /** * Speichert eine Einstellung (atomares Upsert, treiber-unabhängig) * und aktualisiert sofort den In-Request-Cache. */ function set_setting(string $key, ?string $value): void { $pdo = get_db(); $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); if ($driver === 'sqlite') { $pdo->prepare('INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES (?,?)') ->execute([$key, $value]); } else { $pdo->prepare( 'INSERT INTO settings (setting_key, setting_value) VALUES (?,?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)' )->execute([$key, $value]); } // Cache sofort aktualisieren – nachfolgende get_setting()-Aufrufe im selben // Request liefern den neuen Wert ohne erneuten DB-Trip. $cache = &_settings_cache(); $cache[$key] = $value; } /** Formatiert einen Zeitstempel als relative "vor X Min./Std./Tagen"-Angabe (für Cron-/Postfach-Status-Anzeigen). */ function cron_last_run_label(?string $dt): string { if (!$dt) return 'Noch nie'; $mins = (int) round((time() - strtotime($dt)) / 60); if ($mins < 1) return 'Gerade eben'; if ($mins < 60) return "vor {$mins} Min."; $h = (int) round($mins / 60); if ($h < 48) return "vor {$h} Std."; return 'vor ' . (int) round($h / 24) . ' Tagen'; } /** * Sorgt dafür, dass die E-Mail-Adresse einer Abteilung automatisch auch als * Eintrag in der Routing-Tabelle (email_addresses) existiert, damit eingehende * Mails an diese Adresse ohne separaten Schritt in der richtigen Abteilung * landen (siehe staff/departments.php, staff/email_addresses.php). * Bereits vorhandene Routing-Einträge für DIESELBE Adresse werden nie * überschrieben – auch nicht, wenn sie auf eine andere Abteilung zeigen. */ function sync_department_email_route(PDO $pdo, int $departmentId, string $email): void { $email = trim(strtolower($email)); if (!filter_var($email, FILTER_VALIDATE_EMAIL)) return; $stmt = $pdo->prepare('SELECT id FROM email_addresses WHERE LOWER(email) = ?'); $stmt->execute([$email]); if ($stmt->fetchColumn()) { return; // Adresse ist bereits geroutet (an diese oder eine andere Abteilung) – nicht anfassen } $pdo->prepare('UPDATE email_addresses SET is_default = 0 WHERE department_id = ?')->execute([$departmentId]); $pdo->prepare('INSERT INTO email_addresses (email, label, department_id, priority_id, is_default, created_at) VALUES (?,?,?,NULL,1,?)') ->execute([$email, 'Automatisch (Abteilungs-E-Mail)', $departmentId, db_now()]); } /** * Abteilungen für Auswahllisten, in denen etwas NEU zugeordnet wird (Weiterleiten, Routing, * Regeln, Hilfethemen …): nur aktive Abteilungen plus die bereits gewählten ($keepIds), damit * bestehende Zuordnungen beim Bearbeiten nicht verloren gehen. */ function departments_for_select(PDO $pdo, array $keepIds = []): array { $keep = array_values(array_filter(array_map('intval', $keepIds))); $sql = 'SELECT * FROM departments WHERE is_active = 1'; if ($keep) $sql .= ' OR id IN (' . implode(',', $keep) . ')'; return $pdo->query($sql . ' ORDER BY name')->fetchAll(); } /** Liefert die ID einer aktiven Abteilung: $id selbst, sonst die Standardabteilung, sonst irgendeine aktive. */ function active_department_id(PDO $pdo, ?int $id): ?int { if ($id) { $stmt = $pdo->prepare('SELECT COUNT(*) FROM departments WHERE id = ? AND is_active = 1'); $stmt->execute([$id]); if ((int) $stmt->fetchColumn()) return $id; } $fallback = $pdo->query('SELECT id FROM departments WHERE is_active = 1 ORDER BY is_default DESC, id ASC LIMIT 1')->fetchColumn(); return $fallback ? (int) $fallback : $id; } /** * Hilfethemen als flache, hierarchisch sortierte Liste (Eltern vor Kindern). * Jede Zeile enthält zusätzlich 'path' ("Eltern / Kind") und 'depth'. * Kunden sehen nur aktive, öffentliche Themen ($publicOnly), Mitarbeiter alle aktiven. */ function help_topic_options(PDO $pdo, bool $publicOnly = false, bool $activeOnly = true): array { $rows = $pdo->query( 'SELECT h.*, COALESCE(d.is_active, 1) AS dept_active FROM help_topics h LEFT JOIN departments d ON d.id = h.department_id ORDER BY h.sort_order, h.name' )->fetchAll(); $byParent = []; $byId = []; foreach ($rows as $r) { $byId[(int) $r['id']] = $r; $pid = !empty($r['parent_id']) ? (int) $r['parent_id'] : 0; $byParent[$pid][] = $r; } $out = []; $walk = function (int $parent, string $prefix, int $depth, array $seen) use (&$walk, &$out, $byParent, $publicOnly, $activeOnly) { foreach ($byParent[$parent] ?? [] as $r) { if (isset($seen[(int) $r['id']])) continue; // Schutz vor Zyklen $path = $prefix === '' ? $r['name'] : $prefix . ' / ' . $r['name']; $visible = (!$activeOnly || ($r['is_active'] && $r['dept_active'])) && (!$publicOnly || ($r['is_public'] ?? 1)); if ($visible) { $r['path'] = $path; $r['depth'] = $depth; $out[] = $r; } $walk((int) $r['id'], $path, $depth + 1, $seen + [(int) $r['id'] => true]); } }; $walk(0, '', 0, []); // Themen, deren Elternthema nicht existiert, trotzdem anzeigen $shown = array_column($out, 'id'); foreach ($rows as $r) { if (!empty($r['parent_id']) && !isset($byId[(int) $r['parent_id']]) && !in_array($r['id'], $shown, true)) { if ((!$activeOnly || ($r['is_active'] && $r['dept_active'])) && (!$publicOnly || ($r['is_public'] ?? 1))) { $r['path'] = $r['name']; $r['depth'] = 0; $out[] = $r; } } } return $out; } /** Gibt die erlaubten Upload-Dateiendungen zurück (aus DB-Setting oder config-Fallback). */ function get_allowed_extensions(): array { $setting = get_setting('upload_allowed_extensions', ''); if ($setting) { $exts = array_filter(array_map('trim', explode(',', strtolower($setting)))); if ($exts) return array_values($exts); } return config()['uploads']['allowed_extensions']; } /** Zusätzliche Dateiendungen, die nur für Anhänge eingehender E-Mails erlaubt sind (Einstellungen → Uploads); Standard: gängige Büro- und Bildformate. */ const MAIL_ATTACHMENT_EXTRA_DEFAULT = 'doc, xls, ppt, pptx, odt, ods, odp, csv, rtf, msg, heic, webp'; /** @return string[] */ function mail_attachment_extra_extensions(): array { $list = array_filter(array_map('trim', explode(',', strtolower(get_setting('mail_attachment_extra_extensions', MAIL_ATTACHMENT_EXTRA_DEFAULT))))); return array_values(array_unique(array_map(fn($e) => ltrim($e, '.'), $list))); } /** Gibt die maximale Upload-Größe in Bytes zurück (aus DB-Setting oder config-Fallback). */ function get_max_upload_size(): int { $setting = get_setting('upload_max_size_mb', ''); if ($setting && (int)$setting > 0) { return (int)$setting * 1024 * 1024; } return config()['uploads']['max_size_bytes']; } /** * @param bool $dryRun nur prüfen (Name, Endung, Größe, Inhalt), nichts speichern * @param array $extraAllowed zusätzlich erlaubte Endungen (nur für Dateien, die DeskHub selbst aus einer Mail übernimmt: Bilder, .eml) * @param bool $httpUpload true bei einem Formular-Upload (move_uploaded_file); false für eine Datei, die DeskHub selbst angelegt hat * (zum Beispiel ein Anhang aus einer abgerufenen E-Mail). Die Prüfungen sind in beiden Fällen dieselben. */ function handle_ticket_upload(array $file, bool $httpUpload = true, array $extraAllowed = [], bool $dryRun = false): array { $cfg = config()['uploads']; $allowed = array_values(array_unique(array_merge(get_allowed_extensions(), $extraAllowed))); $maxSize = get_max_upload_size(); if ($file['error'] !== UPLOAD_ERR_OK) { return ['error' => 'Der Datei-Upload ist fehlgeschlagen.']; } if ($file['size'] > $maxSize) { return ['error' => 'Die Datei ist zu groß (max. ' . round($maxSize / 1024 / 1024) . ' MB).']; } // Null-Byte im Dateinamen verhindern (PHP < 8 war anfällig) if (strpos($file['name'], "\0") !== false) { return ['error' => 'Ungültiger Dateiname.']; } // .htaccess und ähnliche Systemdateien explizit blockieren $basename = strtolower(basename($file['name'])); if (in_array($basename, ['.htaccess', '.htpasswd', 'web.config', '.env', 'php.ini'], true)) { return ['error' => 'Dieser Dateiname ist nicht erlaubt.']; } // Endung prüfen (nur letzte Endung – verhindert doppelte wie shell.php.jpg) $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); // Doppelte Endungen blockieren (z.B. evil.php.jpg) // Auch wenn ein Admin die Liste der erlaubten Endungen erweitert: Typen, die ein Browser auf unserer Domain // als Seite/Skript ausführen würde (Uploads sind direkt abrufbar), und ausführbare Dateien bleiben gesperrt. $dangerousExts = ['php','php3','php4','php5','php7','php8','phtml','phar','asp','aspx','jsp','jspx','sh','py','pl','cgi','exe', 'html','htm','xhtml','shtml','svg','svgz','xml','xsl','xslt','js','mjs','htc','swf','hta','jar','vbs','bat','cmd','ps1','msi','dll','com','scr','cer','crt']; // Gespeichert wird unter einem Zufallsnamen mit nur der letzten Endung. Namen wie „www.example.com.pdf“ oder „scan.js.2024.jpg“ // sind daher harmlos und werden nicht abgelehnt; geprüft wird die tatsächliche Endung. if (in_array($ext, $dangerousExts, true)) { return ['error' => 'Dieser Dateityp (.' . $ext . ') ist aus Sicherheitsgründen nicht erlaubt: ' . $file['name']]; } if (!in_array($ext, $allowed, true)) { return ['error' => 'Dieser Dateityp ist nicht erlaubt (' . e($ext) . '). Erlaubt: ' . implode(', ', $allowed)]; } // MIME-Typ per finfo prüfen (verhindert verschleierte Dateitypen) $dangerousMimes = [ 'text/x-php','application/x-php','application/php','text/php', 'application/x-httpd-php','application/x-httpd-php-source', 'text/x-script.php','application/x-sh','text/x-shellscript', ]; if (function_exists('finfo_open')) { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); if (in_array($mimeType, $dangerousMimes, true)) { return ['error' => 'Der Inhalt der Datei „' . $file['name'] . '“ sieht nach einem Skript aus und ist aus Sicherheitsgründen nicht erlaubt.']; } } if ($dryRun) return ['original_name' => $file['name'], 'stored_name' => '', 'filesize' => $file['size']]; // Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz $storedName = bin2hex(random_bytes(16)) . '.' . $ext; $uploadsDir = realpath(rtrim($cfg['path'], '/')); $dest = $uploadsDir . '/' . $storedName; if (!$uploadsDir || strpos($dest, $uploadsDir) !== 0) { return ['error' => 'Ungültiger Upload-Pfad.']; } $moved = $httpUpload ? move_uploaded_file($file['tmp_name'], $dest) : @copy($file['tmp_name'], $dest); if (!$moved) { return ['error' => 'Die Datei konnte nicht gespeichert werden.']; } return ['original_name' => $file['name'], 'stored_name' => $storedName, 'filesize' => $file['size']]; } /** * Speichert einen Anhang aus einer E-Mail (Rohdaten) als Anhang einer Nachricht. Es gelten dieselben Regeln wie beim Upload * (erlaubte Endungen, Größe, gesperrte Typen). * * @return array{ok:bool,error?:string,name:string} */ function store_ticket_attachment_data(PDO $pdo, int $threadId, string $name, string $data, array $opts = []): array { $name = trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', '', basename(str_replace(chr(92), '/', $name)))); if ($name === '') $name = 'Anhang'; $name = mb_substr($name, 0, 255); if ($data === '') return ['ok' => false, 'error' => 'Der Anhang ist leer.', 'name' => $name]; $tmp = tempnam(sys_get_temp_dir(), 'dhmail'); if ($tmp === false || file_put_contents($tmp, $data) === false) return ['ok' => false, 'error' => 'Der Anhang konnte nicht zwischengespeichert werden.', 'name' => $name]; $res = handle_ticket_upload(['name' => $name, 'type' => '', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => strlen($data)], false, (array) ($opts['extra_allowed'] ?? [])); @unlink($tmp); if (isset($res['error'])) return ['ok' => false, 'error' => (string) $res['error'], 'name' => $name]; if (!empty($opts['content_id']) && ticket_mail_html_ready($pdo)) { $pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at, content_id, is_inline) VALUES (?,?,?,?,?,?,?)') ->execute([$threadId, $res['original_name'], $res['stored_name'], $res['filesize'], db_now(), mb_substr((string) $opts['content_id'], 0, 255), empty($opts['inline']) ? 0 : 1]); } else { $pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)') ->execute([$threadId, $res['original_name'], $res['stored_name'], $res['filesize'], db_now()]); } return ['ok' => true, 'name' => $name]; } /** Gibt es die Spalten für die HTML-Fassung von Mails (Datenbank-Update php_0070 eingespielt)? Bis dahin verhält sich alles wie bisher. */ function ticket_mail_html_ready(PDO $pdo): bool { static $cache = []; $key = spl_object_id($pdo); if (isset($cache[$key])) return $cache[$key]; try { $rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(ticket_threads)' : 'SHOW COLUMNS FROM ticket_threads')->fetchAll(); foreach ($rows as $r) if (($r['name'] ?? $r['Field'] ?? '') === 'message_html') return $cache[$key] = true; } catch (Throwable $e) { // Tabelle fehlt: nichts zu tun } return $cache[$key] = false; } /** Verarbeitet mehrere Datei-Uploads auf einmal (multi-file input). * Gibt ein Array von Ergebnissen zurück – jedes entweder mit 'error' oder mit den Upload-Daten. */ function handle_ticket_uploads_multi(array $filesInput): array { $results = []; // $_FILES['attachments'] hat Struktur: ['name'=>[...], 'tmp_name'=>[...], ...] $count = is_array($filesInput['name']) ? count($filesInput['name']) : 0; for ($i = 0; $i < $count; $i++) { if (empty($filesInput['name'][$i])) continue; $single = [ 'name' => $filesInput['name'][$i], 'type' => $filesInput['type'][$i], 'tmp_name' => $filesInput['tmp_name'][$i], 'error' => $filesInput['error'][$i], 'size' => $filesInput['size'][$i], ]; $results[] = handle_ticket_upload($single); } return $results; } /** Gruppiert ein Aggregat-Ergebnis (Spalten "grp"/"c") als [Schlüssel => Anzahl]; "none" für NULL-Gruppe */ function fetch_grouped_counts(PDO $pdo, string $sql, array $params): array { $out = []; $stmt = $pdo->prepare($sql); $stmt->execute($params); foreach ($stmt->fetchAll() as $row) { $key = $row['grp'] === null ? 'none' : (string) $row['grp']; $out[$key] = (int) $row['c']; } return $out; } function report_avg_response_hours_expression(string $driver): string { return $driver === 'sqlite' ? '(julianday(t.first_responded_at) - julianday(t.created_at)) * 24' : 'TIMESTAMPDIFF(SECOND, t.created_at, t.first_responded_at) / 3600.0'; } /** Baut die Statistik-Tabelle (Geöffnet/Zugewiesen/Überfällig/Geschlossen/Wieder geöffnet/Gelöscht/Servicezeit/Reaktionszeit) je Dimension */ /** Baut die Statistik-Tabelle (Geöffnet/Zugewiesen/Überfällig/Geschlossen/Wieder geöffnet/Gelöscht/Servicezeit/Reaktionszeit) je Dimension. * $deptIds: null = kein Filter (Admin, alle Abteilungen), Array = nur diese Abteilungen (abteilungsbeschränkte Mitarbeiter). */ function build_report_table(PDO $pdo, string $dimColumn, string $from, string $to, ?array $deptIds = null): array { $tCol = "t.{$dimColumn}"; [$deptFilterT, $deptParamsT] = dept_filter_sql($deptIds, 't.department_id'); [$deptFilterPlain, $deptParamsPlain] = dept_filter_sql($deptIds, 'department_id'); $geoeffnet = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t WHERE t.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); $geschlossen = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t WHERE t.closed_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); $zugewiesen = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id WHERE al.action LIKE 'Mitarbeiter zugewiesen:%' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); $ueberfaellig = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id WHERE al.action LIKE 'SLA %' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); $wiederGeoeffnet = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id WHERE al.action = 'Ticket wieder geöffnet' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]); $geloescht = fetch_grouped_counts($pdo, "SELECT {$dimColumn} AS grp, COUNT(*) c FROM deleted_tickets_log WHERE deleted_at BETWEEN ? AND ?{$deptFilterPlain} GROUP BY {$dimColumn}", [$from, $to, ...$deptParamsPlain]); $serviceTimes = []; $stmt = $pdo->prepare("SELECT {$tCol} AS grp, t.created_at, t.resolved_at FROM tickets t WHERE t.resolved_at BETWEEN ? AND ?{$deptFilterT}"); $stmt->execute([$from, $to, ...$deptParamsT]); foreach ($stmt->fetchAll() as $r) { $key = $r['grp'] === null ? 'none' : (string) $r['grp']; $serviceTimes[$key][] = (strtotime($r['resolved_at']) - strtotime($r['created_at'])) / 3600; } $responseTimes = []; $stmt = $pdo->prepare("SELECT {$tCol} AS grp, t.created_at, t.first_responded_at FROM tickets t WHERE t.first_responded_at BETWEEN ? AND ?{$deptFilterT}"); $stmt->execute([$from, $to, ...$deptParamsT]); foreach ($stmt->fetchAll() as $r) { $key = $r['grp'] === null ? 'none' : (string) $r['grp']; $responseTimes[$key][] = (strtotime($r['first_responded_at']) - strtotime($r['created_at'])) / 3600; } $allKeys = array_unique(array_merge( array_keys($geoeffnet), array_keys($geschlossen), array_keys($zugewiesen), array_keys($ueberfaellig), array_keys($wiederGeoeffnet), array_keys($geloescht), array_keys($serviceTimes), array_keys($responseTimes) )); $rows = []; foreach ($allKeys as $key) { $rows[$key] = [ 'geoeffnet' => $geoeffnet[$key] ?? 0, 'zugewiesen' => $zugewiesen[$key] ?? 0, 'ueberfaellig' => $ueberfaellig[$key] ?? 0, 'geschlossen' => $geschlossen[$key] ?? 0, 'wieder_geoeffnet' => $wiederGeoeffnet[$key] ?? 0, 'geloescht' => $geloescht[$key] ?? 0, 'servicezeit' => isset($serviceTimes[$key]) ? round(array_sum($serviceTimes[$key]) / count($serviceTimes[$key]), 1) : null, 'reaktionszeit' => isset($responseTimes[$key]) ? round(array_sum($responseTimes[$key]) / count($responseTimes[$key]), 1) : null, ]; } return $rows; } /** * Allowlist-Sanitizer für Rich-Text-HTML (Antworten, Wissensdatenbank, Vorlagen) und für HTML-E-Mails * fremder Absender, die im Mitarbeiterbereich angezeigt werden. Nutzt bewusst keine DOM-Erweiterung * (siehe README: keine zusätzlichen PHP-Erweiterungen nötig). * * Vorgehen: Der Text wird in Tags und Text zerlegt. Jedes Tag wird nur mit festem Namen und festen, * neu geschriebenen Attributen ausgegeben; alles andere (auch ein nicht geschlossenes "<" im Text) * wird als Text maskiert. Dadurch kann kein Rest-Markup entstehen, das sich mit nachfolgendem HTML * der Seite zu einem Tag verbindet. Attributwerte werden vor der Prüfung entschlüsselt (Entities), * weil der Browser sie ebenfalls entschlüsselt, und beim Ausgeben neu maskiert. */ /** * Ob eine Nachricht HTML enthält (echte HTML-Tags wie

,

,
, …) und deshalb als HTML dargestellt werden muss. * Eine Adresse in spitzen Klammern ("Olaf ") ist kein HTML: Wird sie dafür gehalten, verschwinden die Zeilenumbrüche * des Textes (HTML kennt keine). */ function message_is_html(string $text): bool { return (bool) preg_match('/<\/?(?:a|abbr|b|big|blockquote|body|br|caption|center|code|col|colgroup|div|em|figcaption|figure|font|h[1-6]|head|hr|html|i|img|li|meta|ol|p|pre|s|small|span|strike|strong|style|sub|sup|table|tbody|td|tfoot|th|thead|title|tr|u|ul|o:p)(?=[\s\/>])[^>]*>/i', $text); } /** * @param array{remote_images?:string,cid_map?:array} $opts * remote_images: 'placeholder' = Bilder von fremden Adressen (https) werden nicht geladen, sondern durch einen Platzhalter ersetzt * (Datenschutz: ein Bild von außen verrät dem Absender, wann und von wo die Mail geöffnet wurde). * cid_map: Content-ID (klein, ohne <>) => Adresse des Bildes, das aus der Mail übernommen wurde (cid:-Bilder im Text) */ function sanitize_staff_html(string $html, array $opts = []): string { $placeholderRemote = ($opts['remote_images'] ?? '') === 'placeholder'; $cidMap = (array) ($opts['cid_map'] ?? []); if (trim($html) === '') return ''; // Gefährliche Elemente komplett inkl. Inhalt entfernen $blocked = 'script|style|iframe|frame|frameset|object|embed|applet|link|meta|base|form|input|textarea|select|option|button|svg|math|template|noscript|title|head|xml'; $html = preg_replace('/<(' . $blocked . ')\b[^>]*>.*?<\/\1\s*>/is', '', $html); $html = preg_replace('/<\/?(' . $blocked . ')\b[^>]*>/is', '', $html); // Kommentare, Doctype, bedingte Kommentare und Verarbeitungsanweisungen entfernen $html = preg_replace('//s', '', $html); $html = preg_replace('/<[!?][^>]*>/s', '', $html); $allowed = [ 'b','strong','i','em','u','s','ul','ol','li','br','p','div','span','a', 'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col', 'h1','h2','h3','h4','h5','h6','hr','blockquote','pre','code','img', 'figure','figcaption','small','sup','sub', ]; $styleTags = ['img','td','th','tr','table','div','span','p','blockquote']; $extraAttrs = [ 'img' => ['alt','width','height'], 'td' => ['colspan','rowspan','align','valign','width'], 'th' => ['colspan','rowspan','align','valign','width'], 'table' => ['border','cellpadding','cellspacing','width'], ]; // Attributwert lesen (Anführungszeichen Pflicht, Name nicht Teil eines anderen Namens), Entities auflösen $attr = static function (string $raw, string $name): ?string { if (!preg_match('/(?{}]|/\*|expression|javascript|behavior|-moz-binding|url\s*\(|image\s*\(|var\s*\(|attr\s*\(#i', $val)) continue; $plain = preg_replace('/\b(?:rgb|rgba|hsl|hsla)\([0-9.,%\s\/]*\)/i', '', $val); if (str_contains((string) $plain, '(') || strlen($val) > 120) continue; $out[] = $prop . ': ' . $val; } return implode('; ', $out); }; $e = static fn(string $v): string => htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $parts = preg_split('/(<[^<>]*>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE); $result = ''; foreach ($parts as $i => $part) { if ($part === '') continue; if ($i % 2 === 0) { // Text: nie ein "<" oder ">" durchlassen, das der Browser als Markup lesen könnte $result .= str_replace(['<', '>'], ['<', '>'], $part); continue; } if (!preg_match('/^<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s[^<>]*)?)(\/?)>$/s', $part, $m)) { $result .= str_replace(['<', '>'], ['<', '>'], $part); // kein Tag (z. B. "a < b und c > d"): als Text behalten continue; } $tag = strtolower($m[2]); if (!in_array($tag, $allowed, true)) continue; if ($m[1] === '/') { $result .= ""; continue; } $raw = $m[3]; $out = ''; if ($tag === 'a') { $href = $attr($raw, 'href'); if ($href !== null && preg_match('#^(https?:|mailto:|cid:)#i', $href)) $out .= ' href="' . $e($href) . '"'; $result .= ''; continue; } if ($tag === 'img') { $src = $attr($raw, 'src'); if ($src !== null) { // Nur https, data:image (Rasterbilder) und cid: erlauben, alles andere durch ein leeres Bild ersetzen $blank = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI='; if (preg_match('#^cid:#i', $src)) { $cid = strtolower(trim(substr($src, 4), " \t\r\n<>")); if (isset($cidMap[$cid])) $out .= ' src="' . $e((string) $cidMap[$cid]) . '"'; elseif ($placeholderRemote) $out .= ' src="' . $blank . '" title="Bild aus der E-Mail nicht verfügbar"'; else $out .= ' src="' . $e($src) . '"'; } elseif ($placeholderRemote && preg_match('#^https://#i', $src)) { $out .= ' src="' . $blank . '" title="Externes Bild aus Datenschutzgründen nicht geladen" data-blocked-remote="1"'; } elseif (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);)#i', $src)) { $out .= ' src="' . $e($src) . '"'; } else { $out .= ' src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=" title="Bild aus Datenschutzgründen blockiert"'; } } } foreach ($extraAttrs[$tag] ?? [] as $name) { $v = $attr($raw, $name); if ($v !== null) $out .= ' ' . $name . '="' . $e($v) . '"'; } if (in_array($tag, $styleTags, true)) { $st = $attr($raw, 'style'); if ($st !== null) { $st = $cleanStyle($st); if ($st !== '') $out .= ' style="' . $e($st) . '"'; } } $result .= ($m[4] === '/') ? "<{$tag}{$out}/>" : "<{$tag}{$out}>"; } return $result; } /** * Ersetzt Variablen in einem Canned-Response-Text. * * Verfügbare Variablen: * {{kundename}} Name des Ticketersellers * {{vorname}} Erster Teil des Namens * {{email}} E-Mail des Kunden * {{ticketnummer}} Ticket-Nummer (z.B. #532085) * {{betreff}} Betreff des Tickets * {{abteilung}} Name der Abteilung * {{prioritaet}} Priorität * {{status}} Aktueller Status * {{mitarbeiter}} Name des zugewiesenen Mitarbeiters * {{datum}} Heutiges Datum (TT.MM.JJJJ) * {{uhrzeit}} Aktuelle Uhrzeit (HH:MM) * {{signatur}} Signatur des eingeloggten Mitarbeiters */ function resolve_canned_vars(string $text, array $ticket, array $staff): string { $name = $ticket['requester_name'] ?? ''; $vorname = explode(' ', trim($name))[0] ?? $name; $vars = [ '{{kundename}}' => $name, '{{vorname}}' => $vorname, '{{email}}' => $ticket['requester_email'] ?? '', '{{ticketnummer}}' => '#' . ($ticket['ticket_number'] ?? ''), '{{betreff}}' => $ticket['subject'] ?? '', '{{abteilung}}' => $ticket['department_name'] ?? '', '{{prioritaet}}' => $ticket['priority_name'] ?? '', '{{status}}' => $ticket['status'] ?? '', '{{mitarbeiter}}' => $ticket['staff_name'] ?? $staff['name'] ?? '', '{{datum}}' => date('d.m.Y'), '{{uhrzeit}}' => date('H:i'), '{{signatur}}' => $staff['signature'] ?? '', ]; return str_replace(array_keys($vars), array_values($vars), $text); } /** * Prüft ob ein Mitarbeiter aktuell verfügbar ist (nicht im Urlaub, * Status nicht 'offline', und falls Schichten definiert: innerhalb der Schicht). */ function is_staff_available(PDO $pdo, int $staffId): bool { // Urlaub $s = $pdo->prepare('SELECT is_on_vacation FROM staff WHERE id = ?'); $s->execute([$staffId]); $row = $s->fetch(); if (!$row || $row['is_on_vacation']) return false; // Echtzeit-Status try { staff_run_auto_offline($pdo); $s = $pdo->prepare('SELECT status FROM staff_availability WHERE staff_id = ?'); $s->execute([$staffId]); $avail = $s->fetchColumn(); if ($avail === 'offline') return false; } catch (Throwable $e) { /* Tabelle noch nicht angelegt */ } // Schichtplan – nur prüfen wenn Schichten definiert sind try { $s = $pdo->prepare('SELECT COUNT(*) FROM staff_shifts WHERE staff_id = ? AND is_active = 1'); $s->execute([$staffId]); if ((int)$s->fetchColumn() > 0) { $dow = (int)date('N'); // 1=Mo … 7=So $time = date('H:i:s'); $s = $pdo->prepare( 'SELECT COUNT(*) FROM staff_shifts WHERE staff_id = ? AND weekday = ? AND is_active = 1 AND start_time <= ? AND end_time > ?' ); $s->execute([$staffId, $dow, $time, $time]); if ((int)$s->fetchColumn() === 0) return false; // außerhalb Schicht } } catch (Throwable $e) { /* ignorieren */ } return true; } /** Prüft, ob eine E-Mail-Adresse (per exaktem Treffer oder Domain) auf der Sperrliste steht */ function is_banned(string $email): bool { $email = trim(strtolower($email)); if ($email === '') return false; $domain = substr(strrchr($email, '@'), 1) ?: ''; $stmt = get_db()->prepare( "SELECT 1 FROM banlist WHERE (match_type = 'email' AND LOWER(value) = ?) OR (match_type = 'domain' AND LOWER(value) = ?) LIMIT 1" ); $stmt->execute([$email, $domain]); return (bool) $stmt->fetchColumn(); } /** * Wertet alle aktiven Ticket-Regeln (sort_order) gegen die Daten eines neuen Tickets aus * und liefert die anzuwendenden Aktionen zurück. $ticketData braucht die Schlüssel: * subject, message, requester_email, requester_name, source. * Sobald eine Regel mit der Aktion "reject" zutrifft, werden keine weiteren Regeln mehr geprüft. */ function evaluate_ticket_rules(PDO $pdo, array $ticketData): array { $result = [ 'reject' => false, 'set_department' => null, 'set_priority' => null, 'set_help_topic' => null, 'assign_staff' => null, 'add_tags' => [], 'auto_reply' => null, 'close' => false, 'matched_rules' => [], ]; $rules = $pdo->query('SELECT * FROM ticket_rules WHERE is_active = 1 ORDER BY sort_order, id')->fetchAll(); foreach ($rules as $rule) { $condStmt = $pdo->prepare('SELECT * FROM ticket_rule_conditions WHERE rule_id = ?'); $condStmt->execute([$rule['id']]); $conditions = $condStmt->fetchAll(); if (!$conditions) continue; $matchAll = $rule['match_type'] === 'all'; $matched = $matchAll; // bei "all" optimistisch starten, bei "any" pessimistisch foreach ($conditions as $cond) { $fieldValue = mb_strtolower((string) ($ticketData[$cond['field']] ?? '')); $condValue = mb_strtolower($cond['value']); $hit = match ($cond['operator']) { 'contains' => $condValue !== '' && str_contains($fieldValue, $condValue), 'not_contains' => $condValue === '' || !str_contains($fieldValue, $condValue), 'equals' => $fieldValue === $condValue, 'starts_with' => str_starts_with($fieldValue, $condValue), 'ends_with' => str_ends_with($fieldValue, $condValue), default => false, }; if ($matchAll && !$hit) { $matched = false; break; } if (!$matchAll && $hit) { $matched = true; break; } } if (!$matched) continue; $result['matched_rules'][] = $rule['name']; $actStmt = $pdo->prepare('SELECT * FROM ticket_rule_actions WHERE rule_id = ?'); $actStmt->execute([$rule['id']]); foreach ($actStmt->fetchAll() as $act) { switch ($act['action_type']) { case 'reject': $result['reject'] = true; break; case 'close': $result['close'] = true; break; case 'set_department': $result['set_department'] = (int) $act['action_value']; break; case 'set_priority': $result['set_priority'] = (int) $act['action_value']; break; case 'set_help_topic': $result['set_help_topic'] = (int) $act['action_value']; break; case 'assign_staff': $result['assign_staff'] = (int) $act['action_value']; break; case 'add_tag': $result['add_tags'][] = $act['action_value']; break; case 'auto_reply': $result['auto_reply'] = (int) $act['action_value']; break; } } if ($result['reject']) break; // weitere Regeln sind irrelevant, Ticket wird ohnehin verworfen } return $result; } /** * Schließt ein neu angelegtes Ticket, wenn eine Regel die Aktion "Ticket schließen" enthält. Gibt true zurück, wenn geschlossen wurde; * dann sollten keine Benachrichtigungen an Mitarbeiter oder Kunden mehr verschickt werden. */ function apply_rule_close(PDO $pdo, int $ticketId, array $ruleResult): bool { if (empty($ruleResult['close'])) return false; $now = db_now(); $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; } /** * Verschickt automatisch einen hinterlegten Textbaustein als Antwort, wenn eine * Ticket-Regel eine "Auto-Antwort senden"-Aktion ausgelöst hat (siehe * evaluate_ticket_rules(), Ergebnis-Schlüssel 'auto_reply' = Textbaustein-ID). * Direkt nach der Ticket-Erstellung aufrufen – NUR an Stellen, an denen das Ticket * automatisiert (nicht durch einen Mitarbeiter) entsteht: ticket_new.php, * live_chat.php, cron/fetch_emails.php. Bei manuell von Mitarbeitern angelegten * Tickets (staff/ticket_create.php) bewusst NICHT aufrufen, da dort schon ein * Mensch draufgeschaut hat. */ function apply_auto_reply(PDO $pdo, int $ticketId, ?int $cannedResponseId): void { if (!$cannedResponseId) return; $cannedStmt = $pdo->prepare('SELECT body FROM canned_responses WHERE id = ?'); $cannedStmt->execute([$cannedResponseId]); $body = $cannedStmt->fetchColumn(); if ($body === false) return; // Textbaustein wurde inzwischen gelöscht $ticketStmt = $pdo->prepare( 'SELECT t.*, d.name AS department_name, p.name AS priority_name FROM tickets t JOIN departments d ON d.id = t.department_id JOIN priorities p ON p.id = t.priority_id WHERE t.id = ?' ); $ticketStmt->execute([$ticketId]); $ticket = $ticketStmt->fetch(); if (!$ticket) return; // author_type='staff' mit staff_id=NULL statt 'system': so rendert die Antwort // überall (Kundenansicht, Live-Chat-Widget, Mobile App) korrekt als Support-Bubble // statt versehentlich wie eine Kunden-Nachricht auszusehen – der bestehende Code // fragt an vielen Stellen nur author_type==='staff' ab, nicht 'system'. $message = resolve_canned_vars($body, $ticket, []); $now = db_now(); $pdo->prepare( "INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,'staff',NULL,?,0,?)" )->execute([$ticketId, $message, $now]); $updateFirstResponse = empty($ticket['first_responded_at']); $sql = "UPDATE tickets SET status = 'answered', updated_at = ?" . ($updateFirstResponse ? ', first_responded_at = ?' : '') . ' WHERE id = ?'; $params = [$now]; if ($updateFirstResponse) $params[] = $now; $params[] = $ticketId; $pdo->prepare($sql)->execute($params); log_activity($pdo, $ticketId, null, 'Automatische Antwort gesendet (Ticket-Regel)'); try { notify_customer_new_reply($ticket, $message); } catch (Throwable $e) { error_log('apply_auto_reply: ' . $e->getMessage()); } } /** Verknüpft (und legt bei Bedarf an) einen Tag mit einem Ticket – gleiche Logik wie staff/ticket_view.php */ function apply_tag_to_ticket(PDO $pdo, int $ticketId, string $tagName): void { $tagName = trim($tagName); if ($tagName === '') return; $stmt = $pdo->prepare('SELECT id FROM tags WHERE name = ?'); $stmt->execute([$tagName]); $tagId = $stmt->fetchColumn(); if (!$tagId) { $palette = ['#0f6f6a', '#2563eb', '#d97706', '#a855f7', '#16a34a', '#dc2626']; $color = $palette[array_rand($palette)]; $pdo->prepare('INSERT INTO tags (name, color) VALUES (?,?)')->execute([$tagName, $color]); $tagId = (int) $pdo->lastInsertId(); } $linkStmt = $pdo->prepare('SELECT COUNT(*) FROM ticket_tags WHERE ticket_id = ? AND tag_id = ?'); $linkStmt->execute([$ticketId, $tagId]); if ((int) $linkStmt->fetchColumn() === 0) { $pdo->prepare('INSERT INTO ticket_tags (ticket_id, tag_id) VALUES (?,?)')->execute([$ticketId, $tagId]); } } /** Anzeigenamen der 16 deutschen Bundesländer, Kürzel → Name (für das Feiertags-Dropdown * in staff/business_hours.php und als Whitelist gültiger Codes für german_public_holidays()). */ const GERMAN_STATE_NAMES = [ 'BW' => 'Baden-Württemberg', 'BY' => 'Bayern', 'BE' => 'Berlin', 'BB' => 'Brandenburg', 'HB' => 'Bremen', 'HH' => 'Hamburg', 'HE' => 'Hessen', 'MV' => 'Mecklenburg-Vorpommern', 'NI' => 'Niedersachsen', 'NW' => 'Nordrhein-Westfalen', 'RP' => 'Rheinland-Pfalz', 'SL' => 'Saarland', 'SN' => 'Sachsen', 'ST' => 'Sachsen-Anhalt', 'SH' => 'Schleswig-Holstein', 'TH' => 'Thüringen', ]; /** * Liefert den Buß- und Bettag (nur Sachsen) für ein Jahr: der Mittwoch vor dem 23. * November – ausgehend vom 22.11. rückwärts zum nächsten Mittwoch gesucht, damit auch der * Sonderfall "23.11. ist selbst ein Mittwoch" korrekt eine Woche früher landet (z.B. 2022: * 23.11. war ein Mittwoch → Buß- und Bettag war der 16.11., nicht der 23.11. selbst). */ function german_buss_und_bettag(int $year): string { $d = new DateTime("{$year}-11-22"); while ((int) $d->format('N') !== 3) { // 3 = Mittwoch (ISO-8601: 1=Montag..7=Sonntag) $d->modify('-1 day'); } return $d->format('Y-m-d'); } /** * Berechnet den Termin des Ostersonntags (gregorianischer Kalender) nach dem * Gauß/Meeus-Algorithmus – bewusst NICHT PHP's eingebaute easter_date(): die liefert in * diesem Environment nachweislich ein Datum zu früh (geprüft gegen mehrere bekannte * Ostertermine, z.B. 2023: PHP gibt den 8.4. statt korrekt 9.4. zurück – konsequent einen * Tag daneben, über alle getesteten Jahre). Diese Eigenimplementierung ist eine * eigenständige, gut dokumentierte Standardformel ohne Abhängigkeit von der * PHP-Calendar-Extension oder deren Plattform-Eigenheiten. */ function german_easter_sunday(int $year): DateTime { $a = $year % 19; $b = intdiv($year, 100); $c = $year % 100; $d = intdiv($b, 4); $e = $b % 4; $f = intdiv($b + 8, 25); $g = intdiv($b - $f + 1, 3); $h = (19 * $a + $b - $d - $g + 15) % 30; $i = intdiv($c, 4); $k = $c % 4; $l = (32 + 2 * $e + 2 * $i - $h - $k) % 7; $m = intdiv($a + 11 * $h + 22 * $l, 451); $month = intdiv($h + $l - 7 * $m + 114, 31); $day = (($h + $l - 7 * $m + 114) % 31) + 1; return new DateTime(sprintf('%04d-%02d-%02d', $year, $month, $day)); } /** * Berechnet die gesetzlichen Feiertage eines deutschen Bundeslandes für ein Kalenderjahr – * inkl. beweglicher, von Ostern abhängiger Feiertage (siehe german_easter_sunday() oben). * Wird für den "Feiertage automatisch übernehmen"-Import in staff/business_hours.php * genutzt, damit nicht jeder Feiertag einzeln von Hand eingetragen werden muss – einfach * Bundesland + Jahr wählen. * * Quelle der Regeln: bundesweit einheitliche Feiertage + je Bundesland zusätzliche, nach * aktuellem Stand der Feiertagsgesetze der Länder. Mariä Himmelfahrt (Bayern) gilt offiziell * nur in Gemeinden mit überwiegend katholischer Bevölkerung – wird hier trotzdem als * Bayern-Feiertag mitgeführt (üblicher Standard in Feiertagslisten), im Zweifel manuell * wieder entfernen, falls der Standort davon nicht betroffen ist. * * @return array sortiert nach Datum */ function german_public_holidays(string $stateCode, int $year): array { if (!isset(GERMAN_STATE_NAMES[$stateCode])) { throw new InvalidArgumentException("Unbekanntes Bundesland-Kürzel: {$stateCode}"); } $easter = german_easter_sunday($year); $offset = function (int $days) use ($easter): string { $d = clone $easter; $d->modify("{$days} days"); return $d->format('Y-m-d'); }; $fixed = fn (string $monthDay): string => "{$year}-{$monthDay}"; // Bundesweit einheitliche Feiertage $holidays = [ $fixed('01-01') => 'Neujahr', $offset(-2) => 'Karfreitag', $offset(1) => 'Ostermontag', $fixed('05-01') => 'Tag der Arbeit', $offset(39) => 'Christi Himmelfahrt', $offset(50) => 'Pfingstmontag', $fixed('10-03') => 'Tag der Deutschen Einheit', $fixed('12-25') => '1. Weihnachtstag', $fixed('12-26') => '2. Weihnachtstag', ]; $dreiKoenige = [$fixed('01-06') => 'Heilige Drei Könige']; $fronleichnam = [$offset(60) => 'Fronleichnam']; $mariaHimmelfahrt = [$fixed('08-15') => 'Mariä Himmelfahrt']; $allerheiligen = [$fixed('11-01') => 'Allerheiligen']; $reformationstag = [$fixed('10-31') => 'Reformationstag']; $frauentag = [$fixed('03-08') => 'Internationaler Frauentag']; $weltkindertag = [$fixed('09-20') => 'Weltkindertag']; $bussUndBettag = [german_buss_und_bettag($year) => 'Buß- und Bettag']; $byState = [ 'BW' => $dreiKoenige + $fronleichnam + $allerheiligen, 'BY' => $dreiKoenige + $fronleichnam + $mariaHimmelfahrt + $allerheiligen, 'BE' => $frauentag, 'BB' => $reformationstag, 'HB' => $reformationstag, 'HH' => $reformationstag, 'HE' => $fronleichnam, 'MV' => $reformationstag + $weltkindertag, 'NI' => $reformationstag, 'NW' => $fronleichnam + $allerheiligen, 'RP' => $fronleichnam + $allerheiligen, 'SL' => $fronleichnam + $mariaHimmelfahrt + $allerheiligen, 'SN' => $reformationstag + $bussUndBettag, 'ST' => $dreiKoenige + $reformationstag, 'SH' => $reformationstag, 'TH' => $weltkindertag + $reformationstag, ]; $holidays = array_merge($holidays, $byState[$stateCode] ?? []); ksort($holidays); $result = []; foreach ($holidays as $date => $name) { $result[] = ['date' => $date, 'name' => $name]; } return $result; } /** * Addiert eine Anzahl Arbeitsstunden zu einem Zeitpunkt, unter Berücksichtigung der * konfigurierten Geschäftszeiten (Tabelle business_hours) und Feiertage (business_holidays). * Liegt der Startzeitpunkt außerhalb der Geschäftszeiten, zählt die Frist erst ab dem * nächsten Öffnungszeitpunkt. */ function add_business_hours(string $from, int $hours): string { $pdo = get_db(); $rows = $pdo->query('SELECT weekday, is_open, open_time, close_time FROM business_hours')->fetchAll(); $byWeekday = []; foreach ($rows as $r) $byWeekday[(int) $r['weekday']] = $r; $holidays = $pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN); $holidaySet = array_flip($holidays); $remainingMinutes = $hours * 60; $cursor = new DateTime($from); $maxIterations = 3650; // Sicherheitsgrenze (~10 Jahre Tage), falls z.B. alle Tage geschlossen sind $iterations = 0; while ($remainingMinutes > 0 && $iterations < $maxIterations) { $iterations++; $weekday = (int) $cursor->format('w'); $dateStr = $cursor->format('Y-m-d'); $dayConfig = $byWeekday[$weekday] ?? null; if (!$dayConfig || !$dayConfig['is_open'] || isset($holidaySet[$dateStr])) { $cursor->modify('+1 day'); $cursor->setTime(0, 0, 0); continue; } $dayOpen = new DateTime($dateStr . ' ' . $dayConfig['open_time']); $dayClose = new DateTime($dateStr . ' ' . $dayConfig['close_time']); if ($cursor < $dayOpen) $cursor = clone $dayOpen; if ($cursor >= $dayClose) { $cursor->modify('+1 day'); $cursor->setTime(0, 0, 0); continue; } // WICHTIG (echter Bug, live auf Produktion nachgewiesen): ganzzahlig per intdiv() // statt "/ 60" – eine Fließkommazahl mit Nachkommastellen (z.B. 0.3333…) führt bei // DateTime::modify("+X minutes") zu einem komplett falschen Sprung um tausende // Jahre statt der paar Sekunden Rundungsdifferenz, die hier eigentlich gemeint war // (bekannter PHP-DateTime-Fallstrick mit Dezimalwerten in relativen Formaten). $availableMinutesToday = intdiv($dayClose->getTimestamp() - $cursor->getTimestamp(), 60); if ($remainingMinutes <= $availableMinutesToday) { $cursor->modify("+{$remainingMinutes} minutes"); $remainingMinutes = 0; } else { $remainingMinutes -= $availableMinutesToday; $cursor = clone $dayClose; $cursor->modify('+1 day'); $cursor->setTime(0, 0, 0); } } return $cursor->format('Y-m-d H:i:s'); } // ==================================================================== // Rollen & Berechtigungen // ==================================================================== // Rechte-Katalog, Standardrechte und Vorlagen: siehe includes/permissions.php (permission_catalog(), default_permissions(), admin_section_permissions()) /** * Prüft ob ein Mitarbeiter eine bestimmte Berechtigung hat. * Admins haben immer alle Rechte. Bei Agenten greift ggf. eine benutzerdefinierte Rolle. */ function has_permission(array $staff, string $permission, ?int $departmentId = null): bool { if ($staff['role'] === 'admin') return true; $roleId = !empty($staff['role_id']) ? (int) $staff['role_id'] : 0; // Rolle pro Abteilung (erweiterter Zugriff): überschreibt die Hauptrolle nur für diese Abteilung. // Ohne eigene Rolle an der Zugriffszeile gilt weiter die Hauptrolle. if ($departmentId !== null && $departmentId !== (int) ($staff['department_id'] ?? 0)) { static $deptRoleCache = []; $key = $staff['id'] . ':' . $departmentId; if (!array_key_exists($key, $deptRoleCache)) { $stmt = get_db()->prepare('SELECT role_id FROM staff_department WHERE staff_id = ? AND department_id = ?'); $stmt->execute([$staff['id'], $departmentId]); $deptRoleCache[$key] = $stmt->fetchColumn() ?: null; } if ($deptRoleCache[$key] !== null) $roleId = (int) $deptRoleCache[$key]; } if ($roleId > 0) { static $roleCache = []; $rid = $roleId; if (!array_key_exists($rid, $roleCache)) { $stmt = get_db()->prepare('SELECT permissions, is_active FROM roles WHERE id = ?'); $stmt->execute([$rid]); $row = $stmt->fetch(); // Deaktivierte oder gelöschte Rolle: es gelten wieder die Standard-Mitarbeiterrechte $roleCache[$rid] = ($row && ($row['is_active'] ?? 1)) ? (json_decode((string) $row['permissions'], true) ?? []) : null; } $perms = $roleCache[$rid]; // Fehlt ein Recht in der gespeicherten Rolle: ein altes Recht gilt wie bisher als „nein“, ein neueres mit dem Standardwert des Katalogs if ($perms !== null) return array_key_exists($permission, $perms) ? (bool) $perms[$permission] : (!in_array($permission, permission_legacy_keys(), true) && (bool) (default_permissions('agent')[$permission] ?? false)); } $defaults = default_permissions('agent'); return (bool) ($defaults[$permission] ?? false); } // ── @Erwähnungen ──────────────────────────────────────────────── /** Parst @Name-Erwähnungen aus einer Nachricht und gibt Staff-IDs zurück */ function parse_mentions(PDO $pdo, string $message): array { preg_match_all('/@([^\s<,;]+)/u', strip_tags($message), $matches); if (empty($matches[1])) return []; $found = []; foreach ($matches[1] as $name) { $stmt = $pdo->prepare("SELECT id FROM staff WHERE is_active=1 AND (name=? OR name LIKE ?) LIMIT 1"); $stmt->execute([$name, $name . ' %']); $id = $stmt->fetchColumn(); if ($id) $found[(int)$id] = true; } return array_keys($found); } /** Speichert Erwähnungen und benachrichtigt betroffene Mitarbeiter */ function save_mentions(PDO $pdo, int $ticketId, int $threadId, int $byStaffId, string $message, array $ticket): void { $ids = parse_mentions($pdo, $message); foreach ($ids as $mentionedId) { if ($mentionedId === $byStaffId) continue; $pdo->prepare('INSERT INTO staff_mentions (ticket_id,thread_id,mentioned_staff_id,mentioned_by_staff_id,created_at) VALUES (?,?,?,?,?)') ->execute([$ticketId, $threadId, $mentionedId, $byStaffId, db_now()]); fcm_notify_mention($ticket, $mentionedId, $byStaffId); // Push auf die Android-App // E-Mail an erwähnten Mitarbeiter // Im Urlaub geht die Mail an die Vertretung $mailTo = vacation_expand_ids($pdo, [$mentionedId]); $stmt = $pdo->prepare('SELECT email, name FROM staff WHERE id=?'); $stmt->execute([$mailTo ? $mailTo[0] : 0]); $mentioned = $stmt->fetch(); if ($mentioned) { $by = $pdo->prepare('SELECT name FROM staff WHERE id=?'); $by->execute([$byStaffId]); $byName = $by->fetchColumn() ?: 'Ein Mitarbeiter'; $link = rtrim(config()['site']['url'], '/') . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']); $inner = email_p("Hallo " . e($mentioned['name']) . ",") . email_p("" . e($byName) . " hat Sie in Ticket #{$ticket['ticket_number']}: " . e($ticket['subject']) . " erwähnt.", '24px') . email_button('Zum Ticket', e($link)); email_send_and_log( $mentioned['email'], "Sie wurden in Ticket #{$ticket['ticket_number']} erwähnt", email_wrap($inner), null, 'Erwähnung-Mail', $ticket['ticket_number'] ); } } } // ── Zeiterfassung ──────────────────────────────────────────────── /** Gibt die Summe der erfassten Minuten für ein Ticket zurück */ function ticket_total_minutes(PDO $pdo, int $ticketId): int { $stmt = $pdo->prepare('SELECT COALESCE(SUM(minutes),0) FROM ticket_time_entries WHERE ticket_id=?'); $stmt->execute([$ticketId]); return (int) $stmt->fetchColumn(); } /** * Eigenes Ticket-Formular einer Abteilung. Gibt null zurück, wenn die Abteilung das Standardformular nutzt. * Sonst die Texte/Optionen (mit Standardwerten ergänzt) und die Felder der Abteilung. * * @return array{title:string,intro:string,subject_show:bool,subject_label:string,message_label:string,message_hint:string,attachment_show:bool,submit_label:string,fields:array}|null */ function department_form(PDO $pdo, ?int $departmentId): ?array { if (!$departmentId) return null; $stmt = $pdo->prepare('SELECT name, own_form, form_config FROM departments WHERE id = ?'); $stmt->execute([$departmentId]); $d = $stmt->fetch(); if (!$d || empty($d['own_form'])) return null; $cfg = json_decode((string) ($d['form_config'] ?? ''), true); $cfg = is_array($cfg) ? $cfg : []; $str = fn(string $k, string $def) => (isset($cfg[$k]) && is_string($cfg[$k]) && trim($cfg[$k]) !== '') ? trim($cfg[$k]) : $def; $fields = $pdo->prepare('SELECT * FROM custom_fields WHERE department_id = ? AND is_active = 1 ORDER BY sort_order, label'); $fields->execute([$departmentId]); $out = [ 'title' => $str('title', ''), 'intro' => $str('intro', ''), 'subject_show' => !array_key_exists('subject_show', $cfg) || !empty($cfg['subject_show']), 'subject_label' => $str('subject_label', 'Betreff'), 'message_label' => $str('message_label', 'Beschreibung'), 'message_show' => !array_key_exists('message_show', $cfg) || !empty($cfg['message_show']), 'subject_width' => in_array($cfg['subject_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $cfg['subject_width'] : 'full', 'message_width' => in_array($cfg['message_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $cfg['message_width'] : 'full', 'message_hint' => $str('message_hint', 'Beschreiben Sie Ihr Anliegen so detailliert wie möglich…'), 'attachment_show' => !array_key_exists('attachment_show', $cfg) || !empty($cfg['attachment_show']), 'submit_label' => $str('submit_label', 'Ticket erstellen'), 'department_name' => (string) $d['name'], 'contact_title' => $str('contact_title', 'Ihre Kontaktdaten'), 'issue_title' => $str('issue_title', 'Ihr Anliegen'), 'details_title' => $str('details_title', 'Ihr Anliegen'), 'fields' => [], ]; $form = $out; foreach ($fields->fetchAll() as $f) { $sec = trim((string) ($f['form_section'] ?? '')); if ($sec === '') $f['_section'] = 'extra'; elseif ($sec === '@topic') $f['_section'] = 'topic'; elseif ($sec === '@topic_top') $f['_section'] = 'topic_top'; elseif ($sec === '@contact_top') $f['_section'] = 'contact_top'; elseif ($sec === '@contact') $f['_section'] = 'contact'; elseif ($sec === '@details') $f['_section'] = 'issue'; elseif ($sec === '@details_top') $f['_section'] = 'issue_top'; elseif ($sec === '@details_mid') $f['_section'] = 'issue_mid'; elseif (mb_strtolower($sec) === mb_strtolower($form['contact_title'])) $f['_section'] = 'contact'; elseif (in_array(mb_strtolower($sec), [mb_strtolower($form['issue_title']), mb_strtolower($form['details_title'])], true)) $f['_section'] = 'issue'; else $f['_section'] = $sec; $f['_section_title'] = in_array($f['_section'], ['topic', 'topic_top'], true) ? $form['issue_title'] : ($f['_section'] === 'extra' ? 'Zusätzliche Angaben' : (in_array($f['_section'], ['contact', 'contact_top'], true) ? $form['contact_title'] : (in_array($f['_section'], ['issue', 'issue_top', 'issue_mid'], true) ? $form['details_title'] : $sec))); $form['fields'][] = $f; } return $form; } /** * Hinweistext eines Formularfeldes als sicheres HTML: Text wird maskiert, Links schreibt man als [Text](https://adresse) * (nur http, https oder Adressen auf dieser Seite, die mit / beginnen). Zeilenumbrüche bleiben erhalten. */ function form_help_html(?string $text, string $linkLabel = ''): string { // Steht nur eine Adresse im Text, wird die Bezeichnung des Feldes zum Link (statt der nackten Adresse) if ($linkLabel !== '' && preg_match('#^\s*(https?://|/)[^\s)"\'<>]{1,500}\s*$#', (string) $text)) { $text = '[' . $linkLabel . '](' . trim((string) $text) . ')'; } $html = e((string) $text); $html = preg_replace_callback('/\[([^\]\r\n]{1,120})\]\(((?:https?:\/\/|\/)[^\s)"\'<>]{1,500})\)/u', function ($m) { return '' . $m[1] . ''; }, $html); // Adressen ohne [Text](…) werden ebenfalls anklickbar (nicht, wenn sie schon in einem Link stehen) $html = preg_replace_callback('~(]*>.*?)|(https?://[^\s<>"\']+)~u', function ($m) { if (!empty($m[1])) return $m[1]; $url = rtrim($m[2], '.,;:!?)'); $rest = substr($m[2], strlen($url)); return '' . $url . '' . $rest; }, $html); return nl2br($html, false); } /** Setzt Hinweistext, Link-Adresse und verlinkten Textteil (Eingabefelder im Admin) zu gespeichertem Text mit [Wort](Adresse) zusammen. */ function form_help_compose(string $text, string $url, string $word, string $fallbackLabel = ''): string { $text = trim($text); $url = trim($url); $word = trim($word); if (preg_match('#^(https?://|/)[^\s)"\'<>]{1,500}$#', $text) && $url === '') return '[' . ($word !== '' ? $word : ($fallbackLabel !== '' ? $fallbackLabel : 'Mehr erfahren')) . '](' . $text . ')'; if ($url === '' || !preg_match('#^(https?://|/)[^\s)"\'<>]{1,500}$#', $url)) return mb_substr($text, 0, 1000); if ($text === '') return '[' . ($word !== '' ? $word : ($fallbackLabel !== '' ? $fallbackLabel : 'Mehr erfahren')) . '](' . $url . ')'; if ($word !== '' && ($pos = mb_stripos($text, $word)) !== false) { $found = mb_substr($text, $pos, mb_strlen($word)); return mb_substr(mb_substr($text, 0, $pos) . '[' . $found . '](' . $url . ')' . mb_substr($text, $pos + mb_strlen($word)), 0, 1000); } return mb_substr($text . ' [' . ($word !== '' ? $word : 'Mehr erfahren') . '](' . $url . ')', 0, 1000); } /** Gegenstück zu form_help_compose(): zerlegt gespeicherten Text in [Text ohne Link-Syntax, Adresse, verlinkter Teil]. */ function form_help_split(?string $stored): array { $stored = (string) $stored; if (preg_match('/\[([^\]\r\n]{1,120})\]\(((?:https?:\/\/|\/)[^\s)"\'<>]{1,500})\)/u', $stored, $m, PREG_OFFSET_CAPTURE)) { $plain = substr($stored, 0, $m[0][1]) . $m[1][0] . substr($stored, $m[0][1] + strlen($m[0][0])); return [$plain, $m[2][0], $m[1][0]]; } return [$stored, '', '']; }