From b207fa4944b955483190e79af0b5117e153e9841 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Wed, 4 Nov 2026 07:55:25 +0000 Subject: [PATCH] Upload via GUI (396 Dateien) --- DeskHub-Webseite/includes/ticket_links.php | 91 ++++++++++++++++++++ DeskHub-Webseite/includes/wait_reminders.php | 80 +++++++++++++++++ DeskHub-Webseite/includes/web_handoff.php | 1 + DeskHub-Webseite/staff/customer_profile.php | 50 +++++++++++ 4 files changed, 222 insertions(+) create mode 100644 DeskHub-Webseite/includes/ticket_links.php create mode 100644 DeskHub-Webseite/includes/wait_reminders.php diff --git a/DeskHub-Webseite/includes/ticket_links.php b/DeskHub-Webseite/includes/ticket_links.php new file mode 100644 index 00000000..a69c3cdf --- /dev/null +++ b/DeskHub-Webseite/includes/ticket_links.php @@ -0,0 +1,91 @@ + Art => [Bezeichnung vom Quell-Ticket aus, Bezeichnung vom Ziel-Ticket aus] */ +function ticket_link_types(): array +{ + return [ + 'related' => ['Verwandt mit', 'Verwandt mit'], + 'duplicate' => ['Doppelt zu', 'Hat Duplikat'], + 'blocks' => ['Blockiert', 'Blockiert durch'], + ]; +} + +/** + * Verknüpft zwei Tickets. Gibt eine Fehlermeldung zurück oder null bei Erfolg. + * Der Mitarbeiter muss beide Tickets sehen dürfen ($deptScope: Abteilungs-IDs des Mitarbeiters, null = alle). + */ +function ticket_link_add(PDO $pdo, int $ticketId, string $targetNumber, string $type, int $staffId, ?array $deptScope = null): ?string +{ + if (!isset(ticket_link_types()[$type])) return 'Ungültige Art der Verknüpfung.'; + $targetNumber = ltrim(trim($targetNumber), '#'); + if ($targetNumber === '') return 'Bitte eine Ticketnummer angeben.'; + $stmt = $pdo->prepare('SELECT id, department_id FROM tickets WHERE ticket_number = ?'); + $stmt->execute([$targetNumber]); + $target = $stmt->fetch(); + if (!$target) return 'Ticket #' . $targetNumber . ' wurde nicht gefunden.'; + $targetId = (int) $target['id']; + if ($targetId === $ticketId) return 'Ein Ticket lässt sich nicht mit sich selbst verknüpfen.'; + if ($deptScope !== null && !in_array((int) $target['department_id'], array_map('intval', $deptScope), true)) { + return 'Ticket #' . $targetNumber . ' gehört zu einer Abteilung, auf die Sie keinen Zugriff haben.'; + } + $exists = $pdo->prepare('SELECT 1 FROM ticket_links WHERE (ticket_id = ? AND linked_ticket_id = ?) OR (ticket_id = ? AND linked_ticket_id = ?)'); + $exists->execute([$ticketId, $targetId, $targetId, $ticketId]); + if ($exists->fetchColumn()) return 'Die beiden Tickets sind bereits verknüpft.'; + $pdo->prepare('INSERT INTO ticket_links (ticket_id, linked_ticket_id, link_type, created_by, created_at) VALUES (?,?,?,?,?)') + ->execute([$ticketId, $targetId, $type, $staffId > 0 ? $staffId : null, db_now()]); + return null; +} + +/** Entfernt eine Verknüpfung, sofern sie zu diesem Ticket gehört. */ +function ticket_link_remove(PDO $pdo, int $linkId, int $ticketId): bool +{ + $stmt = $pdo->prepare('DELETE FROM ticket_links WHERE id = ? AND (ticket_id = ? OR linked_ticket_id = ?)'); + $stmt->execute([$linkId, $ticketId, $ticketId]); + return $stmt->rowCount() > 0; +} + +/** + * Alle Verknüpfungen eines Tickets aus dessen Sicht. Tickets aus Abteilungen, die der Mitarbeiter nicht sehen darf, bleiben weg. + * @return list + */ +function ticket_links_for(PDO $pdo, int $ticketId, ?array $deptScope = null): array +{ + $stmt = $pdo->prepare( + 'SELECT l.id, l.ticket_id, l.linked_ticket_id, l.link_type, t.ticket_number, t.subject, t.status, t.department_id + FROM ticket_links l + JOIN tickets t ON t.id = CASE WHEN l.ticket_id = ? THEN l.linked_ticket_id ELSE l.ticket_id END + WHERE l.ticket_id = ? OR l.linked_ticket_id = ? + ORDER BY l.id' + ); + $stmt->execute([$ticketId, $ticketId, $ticketId]); + $types = ticket_link_types(); + $scope = $deptScope === null ? null : array_map('intval', $deptScope); + $out = []; + foreach ($stmt->fetchAll() as $row) { + if ($scope !== null && !in_array((int) $row['department_id'], $scope, true)) continue; + $outgoing = (int) $row['ticket_id'] === $ticketId; + $labels = $types[$row['link_type']] ?? ['Verknüpft mit', 'Verknüpft mit']; + $out[] = [ + 'id' => (int) $row['id'], 'type' => (string) $row['link_type'], 'label' => $outgoing ? $labels[0] : $labels[1], + 'number' => (string) $row['ticket_number'], 'subject' => (string) $row['subject'], 'status' => (string) $row['status'], + ]; + } + return $out; +} + +/** Offene Tickets, die dieses Ticket blockieren (Hinweis vor dem Schließen). */ +function ticket_open_blockers(PDO $pdo, int $ticketId): array +{ + return array_values(array_filter(ticket_links_for($pdo, $ticketId), fn($l) => $l['type'] === 'blocks' && $l['label'] === 'Blockiert durch' && !in_array($l['status'], ['resolved', 'closed'], true))); +} + +/** Beim Löschen eines Tickets die Verknüpfungen mit entfernen. */ +function ticket_links_purge(PDO $pdo, int $ticketId): void +{ + $pdo->prepare('DELETE FROM ticket_links WHERE ticket_id = ? OR linked_ticket_id = ?')->execute([$ticketId, $ticketId]); +} diff --git a/DeskHub-Webseite/includes/wait_reminders.php b/DeskHub-Webseite/includes/wait_reminders.php new file mode 100644 index 00000000..3a12fa13 --- /dev/null +++ b/DeskHub-Webseite/includes/wait_reminders.php @@ -0,0 +1,80 @@ + 0, 'sent' => 0, 'log' => []]; + if (!wait_reminder_enabled()) return $res; + $now = $now ?? db_now(); + $needSeconds = wait_reminder_hours() * 3600; + $snooze = function_exists('ticket_not_snoozed_sql') ? ticket_not_snoozed_sql('t') : '1=1'; + + $stmt = $pdo->prepare( + "SELECT t.*, s.name AS staff_name, s.email AS staff_email, + COALESCE((SELECT MAX(th.created_at) FROM ticket_threads th WHERE th.ticket_id = t.id AND th.author_type = 'customer'), t.created_at) AS last_customer_at + FROM tickets t + JOIN staff s ON s.id = t.assigned_staff_id AND s.is_active = 1 + WHERE t.status = 'open' AND t.is_spam = 0 AND t.merged_into IS NULL AND t.source <> 'staff' + AND t.sla_paused_at IS NULL AND t.is_overdue = 0 + AND (t.resolution_due_at IS NULL OR t.resolution_due_at > ?) + AND {$snooze} + ORDER BY t.id + LIMIT 500" + ); + $stmt->execute([$now]); + $mark = $pdo->prepare('UPDATE tickets SET wait_reminded_at = ?, updated_at = updated_at WHERE id = ?'); + + foreach ($stmt->fetchAll() as $t) { + $res['checked']++; + $since = (string) $t['last_customer_at']; + // Pro Kundennachricht nur eine Erinnerung + if (!empty($t['wait_reminded_at']) && (string) $t['wait_reminded_at'] >= $since) continue; + $waited = ticket_sla_working_seconds_between($since, $now); + if ($waited < $needSeconds) continue; + + $mark->execute([$now, (int) $t['id']]); + $hours = (int) floor($waited / 3600); + $assigned = (int) $t['assigned_staff_id']; + log_activity($pdo, (int) $t['id'], null, "Erinnerung an den Bearbeiter: wartet seit {$hours} Std. auf Antwort"); + if (function_exists('fcm_notify_staff') && fcm_configured()) { + fcm_notify_staff([$assigned], fcm_data('ticket_wait', $t), (int) $t['department_id']); + } + if (function_exists('push_to_staff')) { + [$title, $body] = function_exists('fcm_message') ? fcm_message('ticket_wait', $t) : ['Ticket wartet auf Antwort', '#' . $t['ticket_number']]; + try { push_to_staff($assigned, $title, $body, '/staff/ticket_view.php?n=' . urlencode((string) $t['ticket_number'])); } catch (Throwable $e) { error_log('wait_reminder push: ' . $e->getMessage()); } + } + if (wait_reminder_email() && trim((string) $t['staff_email']) !== '') { + $url = base_url() . '/staff/ticket_view.php?n=' . urlencode((string) $t['ticket_number']); + $inner = email_p('Hallo ' . e(explode(' ', trim((string) $t['staff_name']))[0]) . ', das Ticket #' . e((string) $t['ticket_number']) . ' „' . e(mb_strimwidth((string) $t['subject'], 0, 80, '…')) . '“ wartet seit etwa ' . $hours . ' Stunden auf deine Antwort.') + . email_button('Ticket öffnen', $url); + email_send_and_log((string) $t['staff_email'], 'Ticket #' . $t['ticket_number'] . ' wartet auf deine Antwort', email_wrap($inner), null, 'Erinnerung-an-Bearbeiter-Mail', (string) $t['ticket_number']); + } + $res['sent']++; + $res['log'][] = "[ok] Erinnerung an {$t['staff_name']}: Ticket #{$t['ticket_number']} wartet seit {$hours} Std."; + } + return $res; +} diff --git a/DeskHub-Webseite/includes/web_handoff.php b/DeskHub-Webseite/includes/web_handoff.php index 11e73079..ebe80a9a 100644 --- a/DeskHub-Webseite/includes/web_handoff.php +++ b/DeskHub-Webseite/includes/web_handoff.php @@ -21,6 +21,7 @@ function web_handoff_pages(array $staff): array // Alle Mitarbeiter ['mentions', 'Erwähnungen', '/staff/mentions.php', 'Allgemein', true], ['agent_directory', 'Mitarbeiterverzeichnis', '/staff/agent_directory.php', 'Allgemein', true], + ['apps', 'Apps und Downloads', '/staff/apps.php', 'Allgemein', true], // Mit Recht ['assistant', 'Digitaler Assistent (Antworten, Fragen-Log)', '/staff/assistant.php', 'Verwaltung', $perm('manage_assistant')], ['imap_accounts', 'Postfächer (E-Mail-Abruf)', '/staff/imap_accounts.php', 'Verwaltung', $perm('manage_email_addresses')], diff --git a/DeskHub-Webseite/staff/customer_profile.php b/DeskHub-Webseite/staff/customer_profile.php index 47b94bfd..4a2f0d6a 100644 --- a/DeskHub-Webseite/staff/customer_profile.php +++ b/DeskHub-Webseite/staff/customer_profile.php @@ -44,6 +44,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { redirect('/staff/customer_profile.php?email=' . urlencode($guestEmail)); } + if ($action === 'gdpr_export' || $action === 'gdpr_erase') { + if ($staff['role'] !== 'admin' || !gdpr_tools_enabled()) { + flash('error', 'Die Datenschutz-Werkzeuge sind nicht verfügbar (nur Administratoren, in den Einstellungen einzuschalten).'); + redirect($isGuest ? '/staff/customer_profile.php?email=' . urlencode($guestEmail) : '/staff/customer_profile.php?id=' . $userId); + } + $ref = $isGuest ? ['email' => $guestEmail] : ['user_id' => $userId]; + if ($action === 'gdpr_export') { + $data = gdpr_customer_export($pdo, $ref); + audit_log($staff['id'], 'Kundendaten exportiert (' . ($isGuest ? 'Gast ' . $guestEmail : 'Kunde #' . $userId) . ')'); + header('Content-Type: application/json; charset=utf-8'); + header('Content-Disposition: attachment; filename="kundendaten-' . ($isGuest ? 'gast' : $userId) . '-' . date('Ymd') . '.json"'); + header('X-Content-Type-Options: nosniff'); + echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + exit; + } + if (trim((string) ($_POST['confirm_email'] ?? '')) !== (string) $customer['email']) { + flash('error', 'Zur Bestätigung muss die E-Mail-Adresse des Kunden genau eingegeben werden.'); + redirect($isGuest ? '/staff/customer_profile.php?email=' . urlencode($guestEmail) : '/staff/customer_profile.php?id=' . $userId); + } + $res = gdpr_customer_erase($pdo, $ref, !empty($_POST['force'])); + if ($res['error']) { + flash('error', $res['error']); + redirect($isGuest ? '/staff/customer_profile.php?email=' . urlencode($guestEmail) : '/staff/customer_profile.php?id=' . $userId); + } + audit_log($staff['id'], 'Kundendaten gelöscht (' . ($isGuest ? 'Gast' : 'Kunde #' . $userId) . '): ' . $res['tickets'] . ' Ticket(s) anonymisiert, ' . $res['messages'] . ' Nachricht(en) und ' . $res['files'] . ' Datei(en) entfernt'); + flash('success', 'Kundendaten gelöscht: ' . $res['tickets'] . ' Ticket(s) anonymisiert, ' . $res['messages'] . ' Nachricht(en) und ' . $res['files'] . ' Datei(en) entfernt.'); + redirect('/staff/customers.php'); + } + if ($action === 'add_note') { $note = trim($_POST['note'] ?? ''); if ($note !== '') { @@ -219,6 +248,27 @@ require __DIR__ . '/_header.php'; + +
+
Datenschutz (DSGVO)
+
+ + + +

Auskunft: Stammdaten, alle Tickets mit Nachrichten und Anhangsnamen, Notizen. Die Datei bitte vor der Weitergabe prüfen.

+
+
+ + + + + + +

Tickets bleiben als anonymisierte Einträge (Nummer, Betreff, Status, Zeiten). Nachrichten und Dateien des Kunden, Notizen und das Konto werden entfernt.

+
+
+ +