From 234099d0e7f83261f8ab8dd0710c3906724d703a Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 23 Oct 2026 17:22:17 +0000 Subject: [PATCH] Upload via GUI (290 Dateien) --- DeskHub-Webseite/cron/process_snoozes.php | 23 ++ DeskHub-Webseite/cron/remind_customers.php | 63 ++++ DeskHub-Webseite/database/migrate.php | 103 ++++++ DeskHub-Webseite/includes/assistant.php | 347 ++++++++++++++++++ .../includes/assistant_widget.php | 207 +++++++++++ DeskHub-Webseite/includes/auth.php | 17 +- DeskHub-Webseite/includes/bootstrap.php | 1 + DeskHub-Webseite/includes/fcm.php | 4 +- DeskHub-Webseite/includes/footer_customer.php | 9 +- DeskHub-Webseite/includes/functions.php | 1 + 10 files changed, 768 insertions(+), 7 deletions(-) create mode 100644 DeskHub-Webseite/cron/process_snoozes.php create mode 100644 DeskHub-Webseite/cron/remind_customers.php create mode 100644 DeskHub-Webseite/includes/assistant.php create mode 100644 DeskHub-Webseite/includes/assistant_widget.php diff --git a/DeskHub-Webseite/cron/process_snoozes.php b/DeskHub-Webseite/cron/process_snoozes.php new file mode 100644 index 00000000..b81ad3c0 --- /dev/null +++ b/DeskHub-Webseite/cron/process_snoozes.php @@ -0,0 +1,23 @@ + $n, 'log' => ['[' . date('Y-m-d H:i:s') . "] Wiedervorlagen geprüft, {$n} Ticket(s) zurückgeholt."]]; +} + +if (php_sapi_name() === 'cli') { + foreach (run_process_snoozes(get_db(), true)['log'] as $line) echo $line . "\n"; +} diff --git a/DeskHub-Webseite/cron/remind_customers.php b/DeskHub-Webseite/cron/remind_customers.php new file mode 100644 index 00000000..ab9e83e9 --- /dev/null +++ b/DeskHub-Webseite/cron/remind_customers.php @@ -0,0 +1,63 @@ + 0, 'log' => ['[' . date('Y-m-d H:i:s') . '] Kundenerinnerung ist deaktiviert – nichts zu tun.']]; + } + $days = customer_reminder_days(); + $threshold = date('Y-m-d H:i:s', time() - $days * 86400); + $closeDays = get_setting('auto_close_enabled', '0') === '1' ? max(1, (int) get_setting('auto_close_days', '7')) : 0; + + $stmt = $pdo->prepare( + "SELECT t.*, d.name AS department_name FROM tickets t LEFT JOIN departments d ON d.id = t.department_id + WHERE t.status = 'answered' AND t.is_spam = 0 AND t.source <> 'chat' AND t.updated_at < ? + AND (t.reminder_sent_at IS NULL OR t.reminder_sent_at < t.updated_at) + AND (t.snoozed_until IS NULL OR t.snoozed_until <= ?) + ORDER BY t.updated_at LIMIT 100" + ); + $stmt->execute([$threshold, db_now()]); + $sent = 0; + foreach ($stmt->fetchAll() as $t) { + if (empty($t['requester_email']) || !filter_var($t['requester_email'], FILTER_VALIDATE_EMAIL)) continue; + $closeNote = ''; + if ($closeDays > $days) { + $closeNote = '

Hören wir bis dahin nichts von Ihnen, schließen wir das Ticket in ' . ($closeDays - $days) . ' ' . (($closeDays - $days) === 1 ? 'Tag' : 'Tagen') . ' automatisch. Sie können es jederzeit wieder öffnen, indem Sie antworten.

'; + } elseif ($closeDays > 0) { + $closeNote = '

Hören wir von Ihnen nichts mehr, schließen wir das Ticket in Kürze automatisch. Sie können es jederzeit wieder öffnen, indem Sie antworten.

'; + } + [$subject, $body] = render_email_template('customer_reminder', [ + 'ticket_number' => $t['ticket_number'], + 'subject' => e($t['subject']), + 'customer_name' => e($t['requester_name'] ?? ''), + 'ticket_url' => e(ticket_access_url($t)), + 'close_note' => $closeNote, + ], "Erinnerung zu Ihrem Ticket [#{$t['ticket_number']}]", email_customer_reminder_default_body()); + $from = resolve_from_address($t['department_id'] ?? null, $t['priority_id'] ?? null); + email_send_and_log($t['requester_email'], $subject, email_wrap($body), $from, 'Erinnerung an den Kunden', $t['ticket_number']); + // updated_at bleibt unverändert, damit das automatische Schließen seine Frist behält + $pdo->prepare('UPDATE tickets SET reminder_sent_at = ?, updated_at = updated_at WHERE id = ?')->execute([db_now(), $t['id']]); + log_activity($pdo, (int) $t['id'], null, 'Erinnerung an den Kunden gesendet'); + $sent++; + if ($verbose) $log[] = "[ok] Erinnerung für Ticket #{$t['ticket_number']}"; + } + set_setting('cron_remind_customers_last_run', db_now()); + $log[] = '[' . date('Y-m-d H:i:s') . "] Kundenerinnerung: {$sent} Mail(s) gesendet (nach {$days} Tagen ohne Reaktion)."; + return ['sent' => $sent, 'log' => $log]; +} + +if (php_sapi_name() === 'cli') { + foreach (run_remind_customers(get_db(), true)['log'] as $line) echo $line . "\n"; +} diff --git a/DeskHub-Webseite/database/migrate.php b/DeskHub-Webseite/database/migrate.php index 754d9c80..7104ff74 100644 --- a/DeskHub-Webseite/database/migrate.php +++ b/DeskHub-Webseite/database/migrate.php @@ -1227,6 +1227,109 @@ function run_migrations(PDO $pdo, bool $verbose = false): array } } + // Migration 0044: Antworten des digitalen Assistenten (zusätzlich zur Wissensdatenbank). + if (!isset($done['php_0044_assistant_answers'])) { + try { + $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY'; + $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; + $pdo->exec("CREATE TABLE IF NOT EXISTS assistant_answers ( + id $ai, + title VARCHAR(255) NOT NULL, + keywords TEXT NULL, + answer TEXT NOT NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + hits INT NOT NULL DEFAULT 0, + created_at DATETIME NULL, + updated_at DATETIME NULL + )$eng"); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0044_assistant_answers']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0044_assistant_answers'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0044_assistant_answers: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0044_assistant_answers - ' . $e->getMessage(); + } + } + + // Migration 0045: Protokoll der Fragen an den digitalen Assistenten. + if (!isset($done['php_0045_assistant_log'])) { + try { + $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY'; + $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; + $pdo->exec("CREATE TABLE IF NOT EXISTS assistant_log ( + id $ai, + question VARCHAR(300) NOT NULL, + result VARCHAR(20) NOT NULL, + source VARCHAR(10) NULL, + matched_id INT NULL, + matched_title VARCHAR(255) NULL, + ticket_number VARCHAR(40) NULL, + created_at DATETIME NOT NULL + )$eng"); + $pdo->exec('CREATE INDEX idx_assistant_log_created ON assistant_log (created_at)'); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0045_assistant_log']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0045_assistant_log'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0045_assistant_log: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0045_assistant_log - ' . $e->getMessage(); + } + } + + // Migration 0046: Wiedervorlage, Beobachter, Kundenerinnerung, Rückmeldung zu Antworten des Assistenten. + if (!isset($done['php_0046_ticket_extras'])) { + try { + $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $has = function (string $table, string $col) use ($pdo): bool { + try { $pdo->query("SELECT $col FROM $table LIMIT 1"); return true; } catch (Throwable $e) { return false; } + }; + if (!$has('tickets', 'snoozed_until')) $pdo->exec('ALTER TABLE tickets ADD COLUMN snoozed_until DATETIME NULL'); + if (!$has('tickets', 'snoozed_by')) $pdo->exec('ALTER TABLE tickets ADD COLUMN snoozed_by INT NULL'); + if (!$has('tickets', 'reminder_sent_at')) $pdo->exec('ALTER TABLE tickets ADD COLUMN reminder_sent_at DATETIME NULL'); + $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; + $pdo->exec("CREATE TABLE IF NOT EXISTS ticket_followers ( + ticket_id INT NOT NULL, + staff_id INT NOT NULL, + created_at DATETIME NOT NULL, + PRIMARY KEY (ticket_id, staff_id) + )$eng"); + if (!$has('assistant_log', 'helpful')) $pdo->exec('ALTER TABLE assistant_log ADD COLUMN helpful TINYINT NULL'); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0046_ticket_extras']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0046_ticket_extras'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0046_ticket_extras: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0046_ticket_extras - ' . $e->getMessage(); + } + } + + // Migration 0047: einmalige Anmelde-Adressen für die Übergabe von der App an die Weboberfläche. + if (!isset($done['php_0047_web_login_tokens'])) { + try { + $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY'; + $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; + $pdo->exec("CREATE TABLE IF NOT EXISTS web_login_tokens ( + id $ai, + staff_id INT NOT NULL, + token_hash VARCHAR(64) NOT NULL, + path VARCHAR(255) NOT NULL, + created_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL + )$eng"); + $pdo->exec('CREATE UNIQUE INDEX idx_web_login_tokens_hash ON web_login_tokens (token_hash)'); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0047_web_login_tokens']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0047_web_login_tokens'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0047_web_login_tokens: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0047_web_login_tokens - ' . $e->getMessage(); + } + } + return $result; } diff --git a/DeskHub-Webseite/includes/assistant.php b/DeskHub-Webseite/includes/assistant.php new file mode 100644 index 00000000..d3f80e47 --- /dev/null +++ b/DeskHub-Webseite/includes/assistant.php @@ -0,0 +1,347 @@ +query( + "SELECT COUNT(*) FROM staff s INNER JOIN staff_availability sa ON sa.staff_id = s.id + WHERE s.is_active = 1 AND sa.status = 'available'" + ); + return (int) $stmt->fetchColumn() > 0; +} + +/** Darf der Assistent jetzt Kunden ansprechen? */ +function assistant_active(PDO $pdo): bool +{ + if (!assistant_enabled()) return false; + if (get_setting('helpdesk_status', 'online') === 'offline') return false; + if (get_setting('assistant_mode', 'offline_only') === 'always') return true; + if (get_setting('live_chat_enabled', '0') !== '1') return true; // Live-Chat ausgeschaltet + return !assistant_staff_available($pdo); // Live-Chat an, aber niemand erreichbar +} + +// ── Suche ──────────────────────────────────────────────────────────────────── + +const ASSISTANT_STOPWORDS = [ + 'der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine', 'einen', 'einem', 'einer', 'und', 'oder', 'aber', 'ich', 'du', 'wir', 'ihr', 'sie', + 'er', 'es', 'ist', 'sind', 'war', 'hat', 'haben', 'habe', 'kann', 'koennen', 'kannst', 'wie', 'was', 'wo', 'wann', 'warum', 'wer', 'wird', + 'werden', 'mein', 'meine', 'meinen', 'meinem', 'mir', 'mich', 'dir', 'dich', 'auch', 'nicht', 'kein', 'keine', 'bitte', 'hallo', 'guten', + 'tag', 'danke', 'mit', 'fuer', 'von', 'zu', 'zum', 'zur', 'im', 'in', 'an', 'auf', 'aus', 'bei', 'nach', 'ueber', 'um', 'so', 'wenn', + 'dass', 'da', 'dort', 'hier', 'noch', 'nur', 'schon', 'mal', 'gibt', 'moechte', 'moechten', 'wuerde', 'will', 'brauche', 'hilfe', 'frage', + 'bekomme', 'bekommen', 'bekommt', 'erhalte', 'erhalten', 'kriege', 'kriegen', 'finde', 'finden', 'moechtest', 'gerne', 'gern', 'eigentlich', 'denn', 'bin', 'bist', 'habt', 'hab', 'gibts', + 'wieso', 'weshalb', 'welche', 'welcher', 'welches', 'muss', 'soll', 'sollen', 'man', 'etwas', 'mehr', 'sehr', 'ganz', 'dann', 'doch', +]; + +/** Kleinbuchstaben ohne Umlaute und Sonderzeichen, Wörter getrennt. */ +function assistant_normalize(string $s): string +{ + $s = mb_strtolower($s, 'UTF-8'); + $s = strtr($s, ['ä' => 'ae', 'ö' => 'oe', 'ü' => 'ue', 'ß' => 'ss', 'é' => 'e', 'è' => 'e', 'à' => 'a', 'á' => 'a']); + return trim((string) preg_replace('/[^a-z0-9]+/', ' ', $s)); +} + +/** Grobe Wortstamm-Bildung, damit "Passwörter" und "Passwort" oder "Rechnungen" und "Rechnung" zusammenpassen. */ +function assistant_stem(string $w): string +{ + foreach (['ungen', 'ung', 'heit', 'keit', 'en', 'er', 'es', 'em', 'e', 'n', 's'] as $suffix) { + $len = strlen($w); + if ($len >= strlen($suffix) + 4 && str_ends_with($w, $suffix)) return substr($w, 0, $len - strlen($suffix)); + } + return $w; +} + +/** + * Gleichbedeutende Wörter (normalisiert, ohne Umlaute): Frage und Artikel müssen nicht dieselben Worte benutzen, z. B. „Zugang zum Discord“ + * und „Wie trete ich dem Server bei?“. Das erste Wort einer Gruppe steht für alle. + */ +const ASSISTANT_SYNONYMS = [ + ['beitreten', 'beitritt', 'beitrete', 'trete', 'tritt', 'zugang', 'zutritt', 'einladung', 'einladungslink', 'invite', 'join', 'joinen', 'anschliessen', 'eintreten', 'teilnehmen', 'mitmachen'], + ['loeschen', 'entfernen', 'entfernt', 'geloescht', 'loesche'], + ['fehler', 'problem', 'probleme', 'stoerung', 'bug', 'absturz', 'abstuerzen', 'kaputt', 'funktioniert'], + ['gesperrt', 'sperre', 'sperren', 'bann', 'ban', 'gebannt', 'blockiert'], + ['kontakt', 'erreichen', 'erreiche', 'ansprechen', 'ansprechpartner', 'schreiben'], + ['kosten', 'preis', 'preise', 'gebuehr', 'gebuehren', 'bezahlen', 'kostenlos', 'gratis'], + ['regel', 'regeln', 'richtlinie', 'richtlinien', 'verhaltensregeln'], + ['senden', 'einsenden', 'einreichen', 'hochladen', 'schicken', 'uebermitteln', 'einschicken', 'veroeffentlichen'], + ['passwort', 'kennwort', 'zugangsdaten', 'anmeldedaten', 'login'], +]; + +/** + * Vom Admin gepflegte Gruppen (Einstellung "assistant_synonyms": eine Gruppe je Zeile, Wörter durch Komma getrennt). + * + * @return array normalisierte Einzelwörter je Gruppe + */ +function assistant_custom_synonyms(?string $text = null): array +{ + $text ??= (string) get_setting('assistant_synonyms', ''); + $groups = []; + foreach (preg_split('/\R/', $text) ?: [] as $line) { + $words = []; + foreach (explode(',', $line) as $w) { + $n = assistant_normalize($w); + if ($n !== '' && !str_contains($n, ' ') && strlen($n) >= 3) $words[$n] = true; + } + if (count($words) >= 2) $groups[] = array_slice(array_keys($words), 0, 20); + if (count($groups) >= 100) break; + } + return $groups; +} + +/** @return array Wortstamm => Stamm des Gruppenworts (eigene Gruppen überschreiben die eingebauten) */ +function assistant_synonym_map(): array +{ + static $map = null; + if ($map === null) { + $map = []; + foreach (array_merge(ASSISTANT_SYNONYMS, assistant_custom_synonyms()) as $group) { + $head = assistant_stem($group[0]); + foreach ($group as $w) $map[assistant_stem($w)] = $head; + } + } + return $map; +} + +/** @return string[] eindeutige Wortstämme ohne Füllwörter */ +function assistant_tokens(string $text): array +{ + $out = []; + $syn = assistant_synonym_map(); + foreach (explode(' ', assistant_normalize($text)) as $w) { + if (strlen($w) < 3 || in_array($w, ASSISTANT_STOPWORDS, true)) continue; + $s = assistant_stem($w); + $out[$syn[$s] ?? $s] = true; + } + return array_keys($out); +} + +/** Text aus HTML, gekürzt an einer Wort- bzw. Satzgrenze. */ +function assistant_excerpt(string $html, int $max = 600): string +{ + $text = mail_html_to_text($html); + if (mb_strlen($text) <= $max) return $text; + $cut = mb_substr($text, 0, $max); + $dot = max((int) mb_strrpos($cut, '. '), (int) mb_strrpos($cut, "\n")); + if ($dot > $max * 0.5) return rtrim(mb_substr($cut, 0, $dot + 1)) . ' …'; + $space = (int) mb_strrpos($cut, ' '); + return rtrim($space > 0 ? mb_substr($cut, 0, $space) : $cut) . ' …'; +} + +/** + * Sucht die beste Antwort auf eine Frage: zuerst die im Backend hinterlegten Antworten (Stichwörter), dann die Artikel der + * Wissensdatenbank (Titel zählt mehr als Text). Gibt null zurück, wenn nichts ausreichend passt. + * + * @return array{source:string,text:string,title:string,link:?string,id:int,others:array}|null + */ +function assistant_search(PDO $pdo, string $question, bool $countHit = true): ?array +{ + $q = assistant_tokens($question); + if (!$q) return null; + $need = max(1, (int) ceil(count($q) / 2)); // mindestens die Hälfte der Suchwörter muss vorkommen + $candidates = []; + + // Zusätzlich im Backend hinterlegte Antworten + try { + foreach ($pdo->query('SELECT id, title, keywords, answer FROM assistant_answers WHERE is_active = 1')->fetchAll() as $a) { + $words = array_flip(assistant_tokens($a['title'] . ' ' . ($a['keywords'] ?? ''))); + $kw = array_flip(assistant_tokens((string) ($a['keywords'] ?? ''))); + $hit = 0; + $kwHit = 0; + foreach ($q as $t) { if (isset($words[$t])) $hit++; if (isset($kw[$t])) $kwHit++; } + // Ein ausdrücklich hinterlegtes Stichwort genügt (z. B. „Discord“); Wörter nur aus dem Titel müssen zur Hälfte passen + if ($hit >= $need || $kwHit >= 1) $candidates[] = ['score' => 5.0 * max($hit, $kwHit) / count($q) + 100 + $kwHit, 'source' => 'custom', 'id' => (int) $a['id'], 'title' => (string) $a['title'], 'text' => trim((string) $a['answer']), 'link' => null]; + } + } catch (Throwable $e) { + // Tabelle fehlt (Datenbank noch nicht aktualisiert): nur die Wissensdatenbank + } + + // Wissensdatenbank (nur wenn sie im Kundenportal eingeschaltet ist) + if (get_setting('kb_enabled', '1') === '1') { + foreach ($pdo->query('SELECT a.id, a.title, a.slug, a.body, c.name AS category FROM kb_articles a LEFT JOIN kb_categories c ON c.id = a.category_id WHERE a.is_published = 1 ORDER BY a.id DESC LIMIT 500')->fetchAll() as $a) { + $titleWords = array_flip(assistant_tokens($a['title'] . ' ' . (string) ($a['category'] ?? ''))); // die Kategorie (z. B. „Discord“) zählt wie der Titel + $bodyWords = array_flip(assistant_tokens(mb_substr(strip_tags((string) $a['body']), 0, 20000))); + $score = 0.0; + $hit = 0; + foreach ($q as $t) { + if (isset($titleWords[$t])) { $score += 4; $hit++; } + elseif (isset($bodyWords[$t])) { $score += 1.5; $hit++; } + } + if ($hit >= $need && $score >= 2.5) { + $candidates[] = ['score' => $score / count($q), 'source' => 'kb', 'id' => (int) $a['id'], 'title' => (string) $a['title'], 'text' => assistant_excerpt((string) $a['body']), 'link' => '/kb_article.php?slug=' . rawurlencode((string) $a['slug'])]; + } + } + } + if (!$candidates) return null; + usort($candidates, fn($a, $b) => $b['score'] <=> $a['score']); + $best = $candidates[0]; + $others = []; + foreach (array_slice($candidates, 1) as $c) { + // nur wirklich ähnlich gute Artikel als weiterer Hinweis (nicht jeder schwache Treffer) + $strong = $best['source'] === 'kb' ? $c['score'] >= $best['score'] * 0.75 : $c['score'] >= 3.0; + if ($c['source'] === 'kb' && $strong && count($others) < 2) $others[] = ['title' => $c['title'], 'link' => $c['link']]; + } + if ($countHit && $best['source'] === 'custom') { + try { $pdo->prepare('UPDATE assistant_answers SET hits = hits + 1 WHERE id = ?')->execute([$best['id']]); } catch (Throwable $e) { /* Zähler ist unwichtig */ } + } + return ['source' => $best['source'], 'text' => $best['text'], 'title' => $best['title'], 'link' => $best['link'], 'id' => $best['id'], 'others' => $others]; +} + +// ── Protokoll der Fragen ───────────────────────────────────────────────────── + +/** Ergebnisse im Protokoll (Schlüssel => Beschriftung). */ +function assistant_log_results(): array +{ + return ['custom' => 'Meine Antwort gefunden', 'kb' => 'Wissensdatenbank', 'none' => 'Keine Antwort', 'ticket_request' => 'Wollte ein Ticket', 'ticket_created' => 'Ticket erstellt']; +} + +/** Wie viele Tage Fragen aufbewahrt werden (0 = es wird nichts protokolliert). */ +function assistant_log_days(): int +{ + return max(0, min(3650, (int) get_setting('assistant_log_days', '90'))); +} + +/** + * Hält eine Frage und das Ergebnis fest. Es werden bewusst nur Frage, Ergebnis und Treffer gespeichert (keine Adresse, kein Name); + * Fehler dabei dürfen den Assistenten nie stören. Alte Einträge werden nebenbei gelöscht. + */ +function assistant_log_write(PDO $pdo, string $question, string $result, ?array $match = null, ?string $ticketNumber = null): ?int +{ + $days = assistant_log_days(); + if ($days === 0) return null; + try { + $pdo->prepare('INSERT INTO assistant_log (question, result, source, matched_id, matched_title, ticket_number, created_at) VALUES (?,?,?,?,?,?,?)') + ->execute([mb_substr($question, 0, 300), $result, $match['source'] ?? null, isset($match['id']) ? (int) $match['id'] : null, isset($match['title']) ? mb_substr((string) $match['title'], 0, 255) : null, $ticketNumber, db_now()]); + $id = (int) $pdo->lastInsertId(); + if (random_int(1, 25) === 1) assistant_log_cleanup($pdo); + return $id > 0 ? $id : null; + } catch (Throwable $e) { + // Tabelle fehlt (Datenbank noch nicht aktualisiert) oder ähnliches: nicht schlimm + return null; + } +} + +/** Auswahl im Filter des Fragen-Logs: Ergebnisse plus die Rückmeldung der Kunden. */ +function assistant_log_filters(): array +{ + return assistant_log_results() + ['helpful' => 'Hat geholfen', 'not_helpful' => 'Hat nicht geholfen']; +} + +/** Löscht Einträge, die älter als die Aufbewahrungszeit sind. @return int Anzahl gelöschter Einträge */ +function assistant_log_cleanup(PDO $pdo): int +{ + $days = assistant_log_days(); + $cut = date('Y-m-d H:i:s', time() - max(1, $days) * 86400); + $stmt = $pdo->prepare('DELETE FROM assistant_log WHERE created_at < ?'); + $stmt->execute([$cut]); + return $stmt->rowCount(); +} + +/** + * Liest das Protokoll mit Filtern: result (Schlüssel aus assistant_log_results), period (today|7|30|all), q (Suchtext). + * + * @return array{rows:array,total:int,pages:int,page:int} + */ +function assistant_log_list(PDO $pdo, array $filter, int $page = 1, int $perPage = 50): array +{ + $where = []; + $params = []; + $result = (string) ($filter['result'] ?? ''); + if ($result === 'helpful') { $where[] = 'helpful = 1'; } + elseif ($result === 'not_helpful') { $where[] = 'helpful = 0'; } + elseif (array_key_exists($result, assistant_log_results())) { $where[] = 'result = ?'; $params[] = $result; } + $since = match ((string) ($filter['period'] ?? 'all')) { + 'today' => date('Y-m-d 00:00:00'), + '7' => date('Y-m-d H:i:s', time() - 7 * 86400), + '30' => date('Y-m-d H:i:s', time() - 30 * 86400), + default => null, + }; + if ($since !== null) { $where[] = 'created_at >= ?'; $params[] = $since; } + $q = trim((string) ($filter['q'] ?? '')); + if ($q !== '') { + $like = '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], mb_substr($q, 0, 100)) . '%'; + $where[] = "(question LIKE ? ESCAPE '\\' OR matched_title LIKE ? ESCAPE '\\')"; + array_push($params, $like, $like); + } + $sqlWhere = $where ? ' WHERE ' . implode(' AND ', $where) : ''; + $count = $pdo->prepare('SELECT COUNT(*) FROM assistant_log' . $sqlWhere); + $count->execute($params); + $total = (int) $count->fetchColumn(); + $pages = max(1, (int) ceil($total / $perPage)); + $page = max(1, min($pages, $page)); + $stmt = $pdo->prepare('SELECT * FROM assistant_log' . $sqlWhere . ' ORDER BY id DESC LIMIT ' . $perPage . ' OFFSET ' . (($page - 1) * $perPage)); + $stmt->execute($params); + return ['rows' => $stmt->fetchAll(), 'total' => $total, 'pages' => $pages, 'page' => $page]; +} + +/** Kennzahlen des Protokolls im gewählten Zeitraum: Anzahl je Ergebnis. @return array */ +function assistant_log_stats(PDO $pdo, string $period = 'all'): array +{ + $stats = array_fill_keys(array_keys(assistant_log_filters()), 0); + $since = match ($period) { 'today' => date('Y-m-d 00:00:00'), '7' => date('Y-m-d H:i:s', time() - 7 * 86400), '30' => date('Y-m-d H:i:s', time() - 30 * 86400), default => null }; + $stmt = $pdo->prepare('SELECT result, COUNT(*) AS n FROM assistant_log' . ($since !== null ? ' WHERE created_at >= ?' : '') . ' GROUP BY result'); + $stmt->execute($since !== null ? [$since] : []); + foreach ($stmt->fetchAll() as $r) $stats[$r['result']] = (int) $r['n']; + $fb = $pdo->prepare('SELECT helpful, COUNT(*) AS n FROM assistant_log WHERE helpful IS NOT NULL' . ($since !== null ? ' AND created_at >= ?' : '') . ' GROUP BY helpful'); + $fb->execute($since !== null ? [$since] : []); + $stats['helpful'] = 0; + $stats['not_helpful'] = 0; + foreach ($fb->fetchAll() as $r) $stats[(int) $r['helpful'] === 1 ? 'helpful' : 'not_helpful'] = (int) $r['n']; + return $stats; +} + +/** + * Häufig gestellte Fragen ohne Antwort (gleiche Fragen zusammengefasst, am häufigsten zuerst): Vorlage für neue Antworten. + * + * @return array + */ +function assistant_log_unanswered(PDO $pdo, string $period = 'all', int $limit = 10): array +{ + $since = match ($period) { 'today' => date('Y-m-d 00:00:00'), '7' => date('Y-m-d H:i:s', time() - 7 * 86400), '30' => date('Y-m-d H:i:s', time() - 30 * 86400), default => null }; + $stmt = $pdo->prepare("SELECT question, created_at FROM assistant_log WHERE result = 'none'" . ($since !== null ? ' AND created_at >= ?' : '') . ' ORDER BY id DESC LIMIT 2000'); + $stmt->execute($since !== null ? [$since] : []); + $groups = []; + foreach ($stmt->fetchAll() as $r) { + $key = implode(' ', assistant_tokens($r['question'])) ?: assistant_normalize($r['question']); + if ($key === '') continue; + if (!isset($groups[$key])) $groups[$key] = ['question' => $r['question'], 'n' => 0, 'last' => $r['created_at']]; + $groups[$key]['n']++; + } + usort($groups, fn($a, $b) => [$b['n'], $b['last']] <=> [$a['n'], $a['last']]); + return array_slice(array_values($groups), 0, $limit); +} + +/** Öffentliche Hilfethemen als Auswahl für die Ticket-Erstellung. @return array */ +function assistant_help_topics(PDO $pdo): array +{ + $out = []; + foreach ($pdo->query('SELECT id, name FROM help_topics WHERE is_active = 1 AND is_public = 1 ORDER BY sort_order, name')->fetchAll() as $t) { + $out[] = ['id' => (int) $t['id'], 'name' => (string) $t['name']]; + } + return $out; +} + +/** Texte des Assistenten (aus den Einstellungen, mit Vorgaben). */ +function assistant_texts(): array +{ + return [ + 'name' => trim((string) get_setting('assistant_name', '')) ?: 'Digitaler Assistent', + 'greeting' => trim((string) get_setting('assistant_greeting', '')) ?: 'Hallo! Ich bin der digitale Assistent. Gerade ist niemand direkt erreichbar, aber ich kann Ihre Fragen beantworten oder ein Ticket für Sie erstellen. Wie kann ich helfen?', + 'no_answer' => trim((string) get_setting('assistant_no_answer', '')) ?: 'Dazu habe ich leider keine passende Antwort. Soll ich ein Ticket für Sie erstellen? Unser Team meldet sich dann per E-Mail.', + ]; +} diff --git a/DeskHub-Webseite/includes/assistant_widget.php b/DeskHub-Webseite/includes/assistant_widget.php new file mode 100644 index 00000000..c7843d0e --- /dev/null +++ b/DeskHub-Webseite/includes/assistant_widget.php @@ -0,0 +1,207 @@ + + + + + diff --git a/DeskHub-Webseite/includes/auth.php b/DeskHub-Webseite/includes/auth.php index dd251094..cadf5e28 100644 --- a/DeskHub-Webseite/includes/auth.php +++ b/DeskHub-Webseite/includes/auth.php @@ -84,7 +84,13 @@ function require_staff_login(): array { $staff = current_staff(); if (!$staff) { - flash('error', 'Bitte melden Sie sich an, um fortzufahren.'); + // War die Sitzung gerade wegen Inaktivität abgelaufen (ein Hintergrund-Abruf hat sie schon beendet), den Grund nennen + if (!empty($_SESSION['staff_timed_out'])) { + unset($_SESSION['staff_timed_out']); + flash('error', 'Sie wurden wegen Inaktivität abgemeldet. Bitte melden Sie sich erneut an.'); + } else { + flash('error', 'Bitte melden Sie sich an, um fortzufahren.'); + } redirect(staff_base_path() . '/login.php'); } // Inaktivitäts-Timeout @@ -94,14 +100,17 @@ function require_staff_login(): array if ($last && (time() - $last) > ($timeout * 60)) { staff_logout(); if (session_status() !== PHP_SESSION_ACTIVE) session_start(); - if (!$userNavigation) { - // Hintergrund-Abruf (Aktualisierung der Liste, Chat-Abfrage …): kurze Antwort statt Weiterleitung auf die Anmeldeseite + $_SESSION['staff_timed_out'] = time(); // merken, damit die Anmeldeseite den Grund nennen kann (siehe oben) + if (!staff_is_document_request()) { + // Hintergrund-Abruf (Aktualisierung der Liste, Chat-Abfrage …): kurze Antwort statt Weiterleitung auf die Anmeldeseite. + // Das Laden einer Seite dagegen führt immer zur Anmeldung, auch wenn der Browser es selbst ausgelöst hat (Tab neu geladen). http_response_code(401); header('Content-Type: application/json; charset=utf-8'); echo json_encode(['error' => 'Session abgelaufen', 'sessionExpired' => true]); exit; } - $_SESSION['flash'] = [['type'=>'error','message'=>'Sie wurden wegen Inaktivität abgemeldet.']]; + unset($_SESSION['staff_timed_out']); + $_SESSION['flash'] = [['type'=>'error','message'=>'Sie wurden wegen Inaktivität abgemeldet. Bitte melden Sie sich erneut an.']]; redirect('/staff/login.php'); } // Nur echte Bedienung hält die Sitzung am Leben: ein vom Nutzer ausgelöster Seitenaufruf oder der Ping der Seite bei Klicks und diff --git a/DeskHub-Webseite/includes/bootstrap.php b/DeskHub-Webseite/includes/bootstrap.php index 32c4640f..2d6e347f 100644 --- a/DeskHub-Webseite/includes/bootstrap.php +++ b/DeskHub-Webseite/includes/bootstrap.php @@ -22,6 +22,7 @@ require_once __DIR__ . '/reports.php'; require_once __DIR__ . '/fcm.php'; require_once __DIR__ . '/staff_activity.php'; require_once __DIR__ . '/notifications.php'; +require_once __DIR__ . '/ticket_extras.php'; require_once __DIR__ . '/plugins.php'; require_once __DIR__ . '/app_downloads.php'; require_once __DIR__ . '/theme_catalog.php'; diff --git a/DeskHub-Webseite/includes/fcm.php b/DeskHub-Webseite/includes/fcm.php index ecb956ca..cc61b544 100644 --- a/DeskHub-Webseite/includes/fcm.php +++ b/DeskHub-Webseite/includes/fcm.php @@ -371,7 +371,7 @@ function fcm_recipients_for_department(PDO $pdo, ?int $deptId): array /** Text der Benachrichtigung: standardmäßig nur mit Ticketnummer (privat), mit "Details" auch Betreff und Kunde. */ function fcm_message(string $type, array $ticket): array { - $titles = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Erwähnung']; + $titles = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Erwähnung', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket']; $title = $titles[$type] ?? 'DeskHub'; $number = (string) ($ticket['ticket_number'] ?? ''); if (get_setting('fcm_show_details', '0') === '1') { @@ -431,7 +431,7 @@ function fcm_notify_assignment(array $ticket, int $staffId, ?int $actorId = null function fcm_event_types(): array { return ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Kundenantwort', 'ticket_assigned' => 'Ticket mir zugewiesen', 'chat_new' => 'Neuer Live-Chat', - 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Ich wurde erwähnt']; + 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Ich wurde erwähnt', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket aktualisiert']; } /** diff --git a/DeskHub-Webseite/includes/footer_customer.php b/DeskHub-Webseite/includes/footer_customer.php index 38b85d0b..446ba202 100644 --- a/DeskHub-Webseite/includes/footer_customer.php +++ b/DeskHub-Webseite/includes/footer_customer.php @@ -67,7 +67,14 @@ document.addEventListener('keydown', function(e){ if(e.key==='Escape') closeLega Copyright © – Alle Rechte vorbehalten. - + +