From 78cf43591a3db90cbbad8d33ffe4f6efef0e4c61 Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 23 Oct 2026 17:22:26 +0000 Subject: [PATCH] Upload via GUI (290 Dateien) --- DeskHub-Webseite/README.md | 31 ++++ DeskHub-Webseite/api/staff/v1/assistant.php | 171 ++++++++++++++++++ DeskHub-Webseite/api/staff/v1/common.php | 23 +++ DeskHub-Webseite/assistant.php | 136 +++++++++++++++ DeskHub-Webseite/tests/web_handoff.php | 181 ++++++++++++++++++++ DeskHub-Webseite/version.php | 2 +- 6 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 DeskHub-Webseite/api/staff/v1/assistant.php create mode 100644 DeskHub-Webseite/assistant.php create mode 100644 DeskHub-Webseite/tests/web_handoff.php diff --git a/DeskHub-Webseite/README.md b/DeskHub-Webseite/README.md index 86c79eb2..ac3a2b77 100644 --- a/DeskHub-Webseite/README.md +++ b/DeskHub-Webseite/README.md @@ -307,6 +307,37 @@ Jeder Mitarbeiter stellt seinen Status ein (Verfügbar, Beschäftigt, Pause, Off Im Browser wird ein Mitarbeiter nach der eingestellten Zeit ohne Bedienung abgemeldet (Einstellungen → Allgemein → Sitzungs-Timeout, Standard 30 Minuten; oben rechts zeigt ein Zähler die Restzeit). **Als Bedienung zählen** Klicks, Tastatureingaben und Mausbewegungen auf der Seite sowie jeder vom Nutzer selbst ausgelöste Seitenaufruf. **Nicht als Bedienung zählen** die Abfragen, die eine offene Seite im Hintergrund stellt (Ticketliste, Chat, Anwesenheit, „Seite aktualisieren“ bei neuen Antworten): Eine unbeachtet offene Seite hält die Sitzung also nicht am Leben. Mehrere Tabs teilen sich die Zeit; der Zähler gleicht sich alle 30 Sekunden mit dem Server ab. Windows-Programm und Android-App melden sich über ihr eigenes Anmelde-Token an und sind davon nicht betroffen. +## Digitaler Assistent + +Der Assistent beantwortet Fragen im Kundenportal und ersetzt dabei das Live-Chat-Fenster, **solange der Live-Chat ausgeschaltet oder gerade niemand erreichbar ist**. Sobald ein Mitarbeiter als „Verfügbar“ gemeldet ist (und der Live-Chat läuft), sehen Kunden wieder den normalen Live-Chat. Im Wartungsmodus ist er nicht aktiv. + +- **Antworten:** zuerst die im Backend hinterlegten **zusätzlichen Antworten** (Frage, Stichwörter, Text), dann die veröffentlichten Artikel der **Wissensdatenbank** (nur, wenn sie im Kundenportal eingeschaltet ist). Gesucht wird über die Wörter der Frage, ohne externen KI-Dienst: Kundenfragen verlassen den Server nicht. +- **Keine Antwort:** Der Assistent fragt, ob er ein Ticket erstellen soll, und erfragt Thema (falls öffentliche Hilfethemen eingerichtet sind), Name, E-Mail-Adresse, Betreff und Beschreibung. Er zeigt eine Zusammenfassung und legt das Ticket nach Bestätigung an. Es durchläuft Ticket-Regeln und automatische Zuweisung, trägt den Tag „Assistent“ und der Kunde bekommt die übliche Eingangsbestätigung. +- **Einrichten:** Adminportal → **Digitaler Assistent** (Menü „Verwalten“): ein-/ausschalten, Name, Begrüßung, Text ohne Antwort, Abteilung und Priorität der Tickets, zusätzliche Antworten pflegen und mit „Frage ausprobieren“ testen. Mit der Einstellung „Immer (zum Testen)“ siehst du den Assistenten auch bei besetztem Chat. +- **Rückmeldung und Wörter:** „Das hat geholfen“ und „Ticket erstellen“ nach einer Antwort werden im Fragen-Log als 👍/👎 festgehalten (Filter „Hat geholfen“ / „Hat nicht geholfen“). Unter „Gleichbedeutende Wörter“ (Tab Einstellungen) pflegst du eigene Gruppen wie „Zugang, Einladung, beitreten“, die der Assistent gleich behandelt. +- **Fragen-Log:** Auf derselben Seite unter „Fragen-Log“ siehst du jede Kundenfrage mit Ergebnis (eigene Antwort gefunden, Wissensdatenbank, keine Antwort, Ticket gewünscht/erstellt) und der gefundenen Antwort. Filter nach Ergebnis, Zeitraum und Text; „Häufige Fragen ohne Antwort“ fasst gleiche Fragen zusammen und legt mit einem Klick eine neue Antwort an. Gespeichert werden nur Frage und Ergebnis (kein Name, keine E-Mail); Aufbewahrung einstellbar (Standard 90 Tage, 0 = nichts speichern), „Log leeren“ löscht alles. Dafür einmal unter System-Update „Datenbank aktualisieren“ ausführen (Migration 0045). +- **Recht für Mitarbeiter:** In einer Rolle das Recht **„Digitalen Assistenten verwalten“** vergeben (Admins haben es immer). +- **Missbrauchsschutz:** höchstens 30 Fragen je 10 Minuten und Sitzung, höchstens 3 Tickets pro Stunde und Adresse, verstecktes Spam-Feld, gültiger Sitzungs-Token. + +## Wiedervorlage, Beobachter, ähnliche Tickets, Kundenerinnerung + +- **Wiedervorlage:** Auf der Ticketseite rechts „Auf später legen“ (in 1 oder 3 Stunden, morgen früh, in 3 Tagen, 1 Woche oder zu einem eigenen Zeitpunkt). Das Ticket verschwindet aus „Offen“ und „Alle“ und steht im Tab „💤 Wiedervorlage“. Zur gewählten Zeit kommt es zurück, der Mitarbeiter bekommt eine Benachrichtigung (Browser-Push und Android-App, Art „Wiedervorlage fällig“). Antwortet der Kunde früher, kommt es sofort zurück. Der Job „Wiedervorlagen“ läuft alle 2 Minuten über den eingebauten Cron oder per Crontab: `*/5 * * * * php cron/process_snoozes.php`. +- **Beobachter:** „Ticket beobachten“ auf der Ticketseite. Beobachter bekommen eine Benachrichtigung bei Kundenantworten und Antworten anderer Mitarbeiter („Beobachtetes Ticket aktualisiert“, in den Push-Einstellungen abschaltbar), ohne dass ihnen das Ticket zugewiesen ist. +- **Dazu passend:** Beim Öffnen eines Tickets zeigt die Karte passende Artikel der Wissensdatenbank (mit „Link kopieren“) und ähnliche bereits gelöste Tickets. +- **Kunden erinnern:** Einstellungen → Allgemein → „Kunden erinnern“. Reagiert ein Kunde nach einer Antwort X Tage nicht, bekommt er einmalig eine freundliche Mail; ist das automatische Schließen an, nennt sie die Restzeit. Die Frist zum Schließen ändert sich dadurch nicht. Der Text lässt sich unter E-Mail-Vorlagen mit dem Schlüssel `customer_reminder` ersetzen (Platzhalter `{customer_name}`, `{ticket_number}`, `{subject}`, `{ticket_url}`, `{close_note}`). Crontab: `0 * * * * php cron/remind_customers.php`. +- **App-Schnittstelle:** `PATCH /api/staff/v1/ticket_update.php` nimmt `snoozeUntil` (Datum/Uhrzeit oder `null`) und `following` (true/false) an; Ticketlisten liefern `snoozedUntil` und `following`, Wiedervorlagen mit `?snoozed=1`. Die Android-App (ab 2.7.0) und das Windows-Programm (ab 2.5.0) nutzen das: Knöpfe „Auf später“ und „Beobachten“ im Ticket, Reiter bzw. Filter „Wiedervorlage“ und „Beobachtet“ in der Liste und die Karte „Dazu passend“. Wiedervorlage und Beobachten funktionieren auch bei einem gerade bearbeiteten (gesperrten) Ticket. +- Für alles: einmal unter System-Update „Datenbank aktualisieren“ (Migration 0046). +## Digitaler Assistent in den Apps + +Android-App (ab 2.9.0) und Windows-Programm (ab 2.7.0) haben die Verwaltung des Assistenten fest eingebaut (Verwaltung → „Digitaler Assistent“) mit fünf Bereichen: Einstellungen, zusätzliche Antworten (anlegen, ändern, löschen), Fragen-Log (Kennzahlen, Filter, Suche, häufige Fragen ohne Antwort mit „Antwort anlegen“, Log leeren), Gleichbedeutende Wörter und „Frage ausprobieren". Der Bereich erscheint nur für Administratoren und Mitarbeiter mit dem Recht „Digitalen Assistenten verwalten“; die Schnittstelle `api/staff/v1/assistant.php` prüft das Recht bei jedem Aufruf und trägt jede Änderung ins Admin-Protokoll ein. + +## Weboberfläche aus den Apps öffnen + +Android-App (ab 2.8.0) und Windows-Programm (ab 2.6.0) zeigen in der Verwaltung zusätzlich die Seiten, die sie nicht selbst nachbauen: Einstellungen, Digitaler Assistent, Wiederkehrende Tickets, Textbausteine, Ticket-Felder, benutzerdefinierte Listen, Seiten, Webhooks, API-Schlüssel, Plugins, Backup, System-Update, Sicherheitsprüfung, Postfächer, osTicket-Import sowie Erwähnungen und Mitarbeiterverzeichnis. Im Ticket öffnet „Im Browser“ die Ticketseite (zusammenführen, aufteilen, drucken). + +- **Rechte:** Der Server (`api/staff/v1/web_pages.php`) liefert nur Seiten, die der Mitarbeiter öffnen darf (Administrator: alle; „Digitalen Assistenten verwalten“ und „E-Mail-Adressen verwalten“ als Einzelrechte; Erwähnungen und Verzeichnis für alle). Die Weboberfläche prüft die Rechte danach noch einmal selbst. +- **Anmeldung:** Die App holt eine einmalige Adresse (`staff/app_login.php?t=…`), die 60 Sekunden gilt und nur einmal funktioniert; sie meldet im Browser an und leitet auf die Seite weiter. Jede Übergabe steht im Admin-Protokoll. Abgelaufene, benutzte oder erfundene Adressen führen zur Anmeldeseite. Dafür einmal „Datenbank aktualisieren“ (Migration 0047). + ## Ticket-Sperre Wer ein Ticket öffnet, sperrt es für andere (5 Minuten, bei Inaktivität läuft die Sperre ab), damit nicht zwei Personen gleichzeitig daran arbeiten. Normale Mitarbeiter bekommen bei einem gesperrten Ticket einen Hinweis mit dem Namen des Bearbeiters. **Administratoren** können ein gesperrtes Ticket trotzdem **lesen und bearbeiten** (Antworten, Notizen, Status) und sehen oben den Hinweis „wird gerade von … bearbeitet“ mit dem Knopf **„Sperre aufheben und übernehmen“**. Das gibt es im Browser, im Windows-Programm und in der Android-App; das Aufheben steht im Ticket-Verlauf. Jeder darf seine eigene Sperre freigeben. Ein Live-Chat sperrt Administratoren nie aus. diff --git a/DeskHub-Webseite/api/staff/v1/assistant.php b/DeskHub-Webseite/api/staff/v1/assistant.php new file mode 100644 index 00000000..5cf4f59f --- /dev/null +++ b/DeskHub-Webseite/api/staff/v1/assistant.php @@ -0,0 +1,171 @@ + Einstellungen, Zustand, Abteilungen, Prioritäten, Synonyme + * GET ?section=answers -> zusätzliche Antworten + * GET ?section=log&result=&period=&q=&page= -> Fragen-Log mit Kennzahlen und häufigen Fragen ohne Antwort + * POST {action: save_settings|save_answer|delete_answer|save_synonyms|clear_log|test, ...} + */ +require_once __DIR__ . '/common.php'; +require_once __DIR__ . '/../../../includes/assistant.php'; + +api_require_method('GET', 'POST'); +$staff = api_require_staff(); +if ($staff['role'] !== 'admin' && !has_permission($staff, 'manage_assistant')) { + api_error(403, 'FORBIDDEN', 'Für diese Aktion fehlt die erforderliche Berechtigung.'); +} +$pdo = get_db(); + +/** Sind die Tabellen schon da? (sonst: Datenbank aktualisieren) */ +function assistant_api_db_ready(PDO $pdo): bool +{ + try { $pdo->query('SELECT id FROM assistant_answers LIMIT 1'); return true; } catch (Throwable $e) { return false; } +} + +function assistant_api_answer(array $a): array +{ + return ['id' => (int) $a['id'], 'title' => $a['title'], 'keywords' => (string) ($a['keywords'] ?? ''), 'answer' => $a['answer'], + 'isActive' => (bool) $a['is_active'], 'hits' => (int) $a['hits'], 'updatedAt' => $a['updated_at']]; +} + +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $section = (string) ($_GET['section'] ?? 'overview'); + if ($section === 'overview') { + $texts = assistant_texts(); + api_json([ + 'dbReady' => assistant_api_db_ready($pdo), + 'settings' => [ + 'enabled' => assistant_enabled(), + 'mode' => get_setting('assistant_mode', 'offline_only') === 'always' ? 'always' : 'offline_only', + 'name' => get_setting('assistant_name', ''), + 'greeting' => get_setting('assistant_greeting', ''), + 'noAnswer' => get_setting('assistant_no_answer', ''), + 'departmentId' => (int) get_setting('assistant_department_id', '0'), + 'priorityId' => (int) get_setting('assistant_priority_id', '0'), + 'logDays' => assistant_log_days(), + 'synonyms' => get_setting('assistant_synonyms', ''), + ], + 'defaults' => ['name' => $texts['name'], 'greeting' => $texts['greeting'], 'noAnswer' => $texts['no_answer']], + 'state' => [ + 'activeNow' => assistant_active($pdo), + 'liveChatEnabled' => get_setting('live_chat_enabled', '0') === '1', + 'kbEnabled' => get_setting('kb_enabled', '1') === '1', + 'helpdeskOffline' => get_setting('helpdesk_status', 'online') === 'offline', + ], + 'departments' => array_map(fn($d) => ['id' => (int) $d['id'], 'name' => $d['name']], $pdo->query('SELECT id, name FROM departments WHERE is_active = 1 ORDER BY name')->fetchAll()), + 'priorities' => array_map(fn($p) => ['id' => (int) $p['id'], 'name' => $p['name']], $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll()), + ]); + } + if ($section === 'answers') { + try { + $items = array_map('assistant_api_answer', $pdo->query('SELECT * FROM assistant_answers ORDER BY title')->fetchAll()); + } catch (Throwable $e) { + api_error(503, 'DB_OUTDATED', 'Die Datenbank ist noch nicht aktuell. Bitte im Adminportal unter System-Update „Datenbank aktualisieren“ klicken.'); + } + api_json(['items' => $items]); + } + if ($section === 'log') { + $period = in_array($_GET['period'] ?? '', ['today', '7', '30', 'all'], true) ? (string) $_GET['period'] : '30'; + $filter = ['result' => (string) ($_GET['result'] ?? ''), 'period' => $period, 'q' => trim((string) ($_GET['q'] ?? ''))]; + try { + $page = assistant_log_list($pdo, $filter, max(1, (int) ($_GET['page'] ?? 1))); + $stats = assistant_log_stats($pdo, $period); + $top = assistant_log_unanswered($pdo, $period); + } catch (Throwable $e) { + api_error(503, 'DB_OUTDATED', 'Das Log ist noch nicht verfügbar. Bitte im Adminportal unter System-Update „Datenbank aktualisieren“ klicken.'); + } + api_json([ + 'filters' => assistant_log_filters(), + 'stats' => $stats, + 'top' => $top, + 'total' => $page['total'], 'pages' => $page['pages'], 'page' => $page['page'], + 'items' => array_map(fn(array $r) => [ + 'id' => (int) $r['id'], 'question' => $r['question'], 'result' => $r['result'], 'matchedTitle' => $r['matched_title'], + 'ticketNumber' => $r['ticket_number'], 'helpful' => $r['helpful'] === null ? null : ((int) $r['helpful'] === 1), 'createdAt' => $r['created_at'], + ], $page['rows']), + ]); + } + api_error(422, 'INVALID_SECTION', 'Unbekannter Bereich.'); +} + +$body = api_request_body(); +$action = (string) ($body['action'] ?? ''); + +if ($action === 'save_settings') { + $dept = (int) ($body['departmentId'] ?? 0); + $prio = (int) ($body['priorityId'] ?? 0); + if ($dept > 0) { $c = $pdo->prepare('SELECT COUNT(*) FROM departments WHERE id = ? AND is_active = 1'); $c->execute([$dept]); if (!(int) $c->fetchColumn()) $dept = 0; } + if ($prio > 0) { $c = $pdo->prepare('SELECT COUNT(*) FROM priorities WHERE id = ?'); $c->execute([$prio]); if (!(int) $c->fetchColumn()) $prio = 0; } + set_setting('assistant_enabled', !empty($body['enabled']) ? '1' : '0'); + set_setting('assistant_mode', ($body['mode'] ?? 'offline_only') === 'always' ? 'always' : 'offline_only'); + set_setting('assistant_name', mb_substr(trim(strip_tags((string) ($body['name'] ?? ''))), 0, 60)); + set_setting('assistant_greeting', mb_substr(trim(strip_tags((string) ($body['greeting'] ?? ''))), 0, 500)); + set_setting('assistant_no_answer', mb_substr(trim(strip_tags((string) ($body['noAnswer'] ?? ''))), 0, 500)); + set_setting('assistant_department_id', (string) $dept); + set_setting('assistant_priority_id', (string) $prio); + set_setting('assistant_log_days', (string) max(0, min(3650, (int) ($body['logDays'] ?? 90)))); + audit_log((int) $staff['id'], 'Einstellungen des digitalen Assistenten geändert (App)'); + api_json(['ok' => true]); +} + +if ($action === 'save_synonyms') { + $text = mb_substr(trim(strip_tags((string) ($body['text'] ?? ''))), 0, 5000); + set_setting('assistant_synonyms', $text); + audit_log((int) $staff['id'], 'Gleichbedeutende Wörter des digitalen Assistenten geändert (App)'); + api_json(['ok' => true, 'groups' => count(assistant_custom_synonyms($text))]); +} + +if ($action === 'save_answer') { + if (!assistant_api_db_ready($pdo)) api_error(503, 'DB_OUTDATED', 'Die Datenbank ist noch nicht aktuell. Bitte „Datenbank aktualisieren“ ausführen.'); + $id = (int) ($body['id'] ?? 0); + $title = trim(mb_substr((string) ($body['title'] ?? ''), 0, 255)); + $keywords = trim(mb_substr((string) ($body['keywords'] ?? ''), 0, 1000)); + $answer = trim(mb_substr((string) ($body['answer'] ?? ''), 0, 4000)); + $active = !empty($body['isActive']) ? 1 : 0; + if ($title === '' || $answer === '') api_error(422, 'FIELDS_REQUIRED', 'Bitte eine Frage (Titel) und die Antwort angeben.'); + $now = db_now(); + if ($id > 0) { + $pdo->prepare('UPDATE assistant_answers SET title=?, keywords=?, answer=?, is_active=?, updated_at=? WHERE id=?')->execute([$title, $keywords ?: null, $answer, $active, $now, $id]); + audit_log((int) $staff['id'], "Antwort des digitalen Assistenten geändert (App): {$title}"); + } else { + $pdo->prepare('INSERT INTO assistant_answers (title, keywords, answer, is_active, created_at, updated_at) VALUES (?,?,?,?,?,?)')->execute([$title, $keywords ?: null, $answer, $active, $now, $now]); + $id = (int) $pdo->lastInsertId(); + audit_log((int) $staff['id'], "Antwort des digitalen Assistenten angelegt (App): {$title}"); + } + $row = $pdo->prepare('SELECT * FROM assistant_answers WHERE id = ?'); + $row->execute([$id]); + api_json(['item' => assistant_api_answer($row->fetch())]); +} + +if ($action === 'delete_answer') { + $id = (int) ($body['id'] ?? 0); + $t = $pdo->prepare('SELECT title FROM assistant_answers WHERE id = ?'); + $t->execute([$id]); + $title = $t->fetchColumn(); + if ($title === false) api_error(404, 'NOT_FOUND', 'Die Antwort gibt es nicht.'); + $pdo->prepare('DELETE FROM assistant_answers WHERE id = ?')->execute([$id]); + audit_log((int) $staff['id'], "Antwort des digitalen Assistenten gelöscht (App): {$title}"); + api_json(['ok' => true]); +} + +if ($action === 'clear_log') { + try { + $n = $pdo->exec('DELETE FROM assistant_log'); + } catch (Throwable $e) { + api_error(503, 'DB_OUTDATED', 'Das Log ist noch nicht verfügbar. Bitte „Datenbank aktualisieren“ ausführen.'); + } + audit_log((int) $staff['id'], 'Fragen-Log des digitalen Assistenten geleert (App)'); + api_json(['ok' => true, 'deleted' => (int) $n]); +} + +if ($action === 'test') { + $q = trim(mb_substr((string) ($body['question'] ?? ''), 0, 300)); + if ($q === '') api_error(422, 'QUESTION_REQUIRED', 'Bitte eine Frage eingeben.'); + $r = assistant_search($pdo, $q, false); + api_json(['found' => $r !== null, 'result' => $r ? ['source' => $r['source'], 'title' => $r['title'], 'text' => $r['text'], 'others' => array_map(fn($o) => $o['title'], $r['others'])] : null]); +} + +api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); diff --git a/DeskHub-Webseite/api/staff/v1/common.php b/DeskHub-Webseite/api/staff/v1/common.php index de97475c..3979ce46 100644 --- a/DeskHub-Webseite/api/staff/v1/common.php +++ b/DeskHub-Webseite/api/staff/v1/common.php @@ -2,6 +2,7 @@ declare(strict_types=1); require_once __DIR__ . '/../../../includes/bootstrap.php'; +require_once __DIR__ . '/../../../includes/ticket_extras.php'; // falls bootstrap.php noch die alte Version ist require_once __DIR__ . '/../../../includes/totp.php'; header('Content-Type: application/json; charset=utf-8'); @@ -147,6 +148,7 @@ function api_require_staff(): array // auch App-Nutzung hält die automatischen Hintergrund-Jobs am Laufen. pseudo_cron_schedule_background(); + $GLOBALS['__api_staff'] = $staff; // für api_is_following() return $staff; } @@ -271,9 +273,30 @@ function api_ticket_summary(array $ticket): array 'isSpam' => !empty($ticket['is_spam']), 'createdAt' => $ticket['created_at'], 'updatedAt' => $ticket['updated_at'], + 'snoozedUntil' => !empty($ticket['snoozed_until']) && $ticket['snoozed_until'] > db_now() ? $ticket['snoozed_until'] : null, + 'following' => api_is_following((int) $ticket['id']), ]; } +/** Beobachtet der angemeldete Mitarbeiter dieses Ticket? (für die App) */ +function api_is_following(int $ticketId): bool +{ + static $mine = null; + if ($mine === null) { + $mine = []; + try { + $staff = $GLOBALS['__api_staff'] ?? null; + if ($staff) { + $stmt = get_db()->prepare('SELECT ticket_id FROM ticket_followers WHERE staff_id = ?'); + $stmt->execute([(int) $staff['id']]); + $mine = array_flip(array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN))); + } + } catch (Throwable $e) { + } + } + return isset($mine[$ticketId]); +} + function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null): int { $permission = $type === 'note' ? 'post_note' : 'reply_ticket'; diff --git a/DeskHub-Webseite/assistant.php b/DeskHub-Webseite/assistant.php new file mode 100644 index 00000000..82ed7b1f --- /dev/null +++ b/DeskHub-Webseite/assistant.php @@ -0,0 +1,136 @@ + {active, name, greeting, helpTopics, user} + * ask {message} -> {type: "answer"|"none"|"ticket", ...} + * ticket {name, email, subject, message, helpTopicId?} -> {ok, number} + * Jeder Aufruf braucht den CSRF-Token der Sitzung (csrf_token). Fragen und Tickets sind je Sitzung bzw. Adresse begrenzt. + */ +require_once __DIR__ . '/includes/bootstrap.php'; +require_once __DIR__ . '/includes/assistant.php'; + +header('Content-Type: application/json; charset=utf-8'); +header('Cache-Control: no-store'); + +function assistant_json(array $data, int $status = 200): never +{ + http_response_code($status); + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; +} + +$pdo = get_db(); +$action = (string) ($_POST['action'] ?? $_GET['action'] ?? ''); + +if (!in_array($action, ['status', 'ask', 'ticket', 'feedback'], true)) assistant_json(['error' => 'Unbekannte Aktion.'], 400); + +// Anfragen müssen vom eigenen Formular der Seite kommen (Token der Sitzung) +$token = (string) ($_POST['csrf_token'] ?? $_GET['csrf_token'] ?? ''); +if ($token === '' || !hash_equals((string) ($_SESSION['csrf_token'] ?? ''), $token)) { + assistant_json(['error' => 'Die Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.', 'csrfExpired' => true], 403); +} + +$active = assistant_active($pdo); +$texts = assistant_texts(); + +if ($action === 'status') { + $user = current_user(); + assistant_json([ + 'active' => $active, + 'name' => $texts['name'], + 'greeting' => $texts['greeting'], + 'noAnswer' => $texts['no_answer'], + 'helpTopics' => $active ? assistant_help_topics($pdo) : [], + 'user' => $user ? ['name' => (string) ($user['name'] ?? ''), 'email' => (string) ($user['email'] ?? '')] : null, + ]); +} + +if (!$active) assistant_json(['error' => 'Der Assistent ist gerade nicht verfügbar.', 'active' => false], 409); +if ($_SERVER['REQUEST_METHOD'] !== 'POST') assistant_json(['error' => 'Nur POST.'], 405); + +if ($action === 'feedback') { + // Rückmeldung zu einer Antwort ("Das hat geholfen" oder Ticket statt Antwort); nur für Antworten dieser Sitzung + $id = (int) ($_POST['logId'] ?? 0); + if ($id > 0 && in_array($id, array_map('intval', (array) ($_SESSION['assistant_log_ids'] ?? [])), true)) { + try { + $pdo->prepare("UPDATE assistant_log SET helpful = ? WHERE id = ? AND result IN ('custom','kb')")->execute([!empty($_POST['helpful']) ? 1 : 0, $id]); + } catch (Throwable $e) { + // Datenbank noch nicht aktualisiert + } + } + assistant_json(['ok' => true]); +} + +if ($action === 'ask') { + // höchstens 30 Fragen je 10 Minuten und Sitzung + $now = time(); + $log = array_values(array_filter((array) ($_SESSION['assistant_asks'] ?? []), fn($t) => $t > $now - 600)); + if (count($log) >= 30) assistant_json(['error' => 'Das waren viele Fragen in kurzer Zeit. Bitte warten Sie einen Moment.'], 429); + $log[] = $now; + $_SESSION['assistant_asks'] = $log; + + $message = trim(mb_substr((string) ($_POST['message'] ?? ''), 0, 300)); + if ($message === '') assistant_json(['error' => 'Bitte eine Frage eingeben.'], 422); + + // Wunsch nach einem Ticket oder einem Menschen: nicht erst suchen + $norm = assistant_normalize($message); + if (preg_match('/\b(ticket|mitarbeiter|support|beschwerde|reklamation|kundenservice|ansprechpartner)\b/', $norm) + && preg_match('/\b(erstell|eroeffn|anleg|oeffn|schreib|melden|sprechen|kontakt|will|moechte|brauche|bitte)/', $norm)) { + assistant_log_write($pdo, $message, 'ticket_request'); + assistant_json(['type' => 'ticket', 'text' => 'Gern, ich erstelle ein Ticket für Sie.']); + } + // Begrüßung und Dank + if (preg_match('/^(hallo|hi|hey|guten (tag|morgen|abend)|moin|servus)\b/', $norm) && count(assistant_tokens($message)) === 0) { + assistant_json(['type' => 'smalltalk', 'text' => 'Hallo! Was möchten Sie wissen?']); + } + if (preg_match('/^(danke|vielen dank|dankeschoen|super danke|ok danke)\b/', $norm)) { + assistant_json(['type' => 'smalltalk', 'text' => 'Gern geschehen! Kann ich noch etwas für Sie tun?']); + } + + $found = assistant_search($pdo, $message); + if (!$found) { + assistant_log_write($pdo, $message, 'none'); + assistant_json(['type' => 'none', 'text' => $texts['no_answer']]); + } + $logId = assistant_log_write($pdo, $message, $found['source'] === 'custom' ? 'custom' : 'kb', ['source' => $found['source'], 'id' => $found['id'], 'title' => $found['title']]); + if ($logId) $_SESSION['assistant_log_ids'] = array_slice(array_merge((array) ($_SESSION['assistant_log_ids'] ?? []), [$logId]), -50); + assistant_json(['logId' => $logId, 'type' => 'answer', 'source' => $found['source'], 'title' => $found['title'], 'text' => $found['text'], 'link' => $found['link'], 'others' => $found['others']]); +} + +// ── Ticket erstellen ───────────────────────────────────────────────────────── +if (!empty($_POST['hp_website'])) assistant_json(['error' => 'Anfrage abgelehnt.'], 400); // Spam-Falle: das versteckte Feld bleibt bei Menschen leer + +$ip = client_ip(); +// höchstens 3 Tickets je Stunde und Adresse über den Assistenten +$recent = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE requester_ip = ? AND created_at >= ? AND id IN (SELECT ticket_id FROM activity_log WHERE action LIKE 'Ticket über den digitalen Assistenten%')"); +$recent->execute([$ip, date('Y-m-d H:i:s', time() - 3600)]); +if ((int) $recent->fetchColumn() >= 3) assistant_json(['error' => 'Über den Assistenten wurden bereits mehrere Tickets erstellt. Bitte versuchen Sie es später noch einmal oder antworten Sie auf die E-Mail zu Ihrem Ticket.'], 429); + +$in = [ + 'name' => trim((string) ($_POST['name'] ?? '')), + 'email' => trim((string) ($_POST['email'] ?? '')), + 'subject' => trim((string) ($_POST['subject'] ?? '')), + 'message' => trim((string) ($_POST['message'] ?? '')), +]; +$topicId = (int) ($_POST['helpTopicId'] ?? 0); +if ($topicId > 0) { + $ok = false; + foreach (assistant_help_topics($pdo) as $t) if ($t['id'] === $topicId) $ok = true; + if (!$ok) assistant_json(['error' => 'Das gewählte Thema gibt es nicht.'], 422); + $in['helpTopicId'] = $topicId; +} +// Abteilung und Priorität für Assistenten-Tickets (Einstellung); ein gewähltes Hilfethema bestimmt sie selbst +if ($topicId <= 0) { + $dept = (int) get_setting('assistant_department_id', '0'); + $prio = (int) get_setting('assistant_priority_id', '0'); + if ($dept > 0) $in['departmentId'] = $dept; + if ($prio > 0) $in['priorityId'] = $prio; +} +try { + $created = public_ticket_create($pdo, $in, $ip, ['activity' => 'Ticket über den digitalen Assistenten erstellt', 'tag' => 'Assistent']); +} catch (AdminModelException $e) { + assistant_json(['error' => $e->getMessage()], $e->httpStatus()); +} +assistant_log_write($pdo, mb_substr($in['subject'], 0, 300), 'ticket_created', null, $created['number']); +assistant_json(['ok' => true, 'number' => $created['number']], 201); diff --git a/DeskHub-Webseite/tests/web_handoff.php b/DeskHub-Webseite/tests/web_handoff.php new file mode 100644 index 00000000..366140b1 --- /dev/null +++ b/DeskHub-Webseite/tests/web_handoff.php @@ -0,0 +1,181 @@ +getPathname(), strlen($root) + 1)); + if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue; + if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; } + @mkdir(dirname($tmp . '/' . $rel), 0755, true); + copy($f->getPathname(), $tmp . '/' . $rel); +} +@mkdir($tmp . '/uploads'); +@mkdir($tmp . '/config'); +file_put_contents($tmp . '/config/config.php', ' bin2hex(random_bytes(32)), + 'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'], + 'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'], + 'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'], +], true) . ';'); + +putenv('DESKHUB_CONFIG_JSON'); +putenv('DESKHUB_SQLITE_PATH'); +putenv('DESKHUB_TESTING'); +putenv('DESKHUB_API_ALLOW_INSECURE=1'); +require_once $root . '/database/sqlite_schema.php'; +require_once $root . '/database/migrate.php'; +$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite'); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +$db->exec('PRAGMA foreign_keys = ON'); +$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql'))); +$mig = run_migrations($db, true); +$now = date('Y-m-d H:i:s'); +$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); +$db->exec('DELETE FROM staff'); +$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)'); +$ids = []; +foreach ([['Chef', 'chef@test.de', 'admin'], ['Agent', 'agent@test.de', 'agent'], ['Pfleger', 'pfleger@test.de', 'agent']] as [$n, $e, $r]) { + $ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]); + $ids[$n] = (int) $db->lastInsertId(); +} +$db = null; + +$failures = []; +$total = 0; +$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; }; +$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp); + +try { + for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); } + $pdo = function () use ($tmp): PDO { + $p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite'); + $p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + return $p; + }; + $api = function (string $method, string $path, ?array $json = null, ?string $bearer = null) use ($webPort): array { + $ch = curl_init("http://127.0.0.1:$webPort$path"); + $h = ['Content-Type: application/json']; + if ($bearer) $h[] = 'Authorization: Bearer ' . $bearer; + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 60, CURLOPT_HTTPHEADER => $h, CURLOPT_CUSTOMREQUEST => $method]); + if ($json !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($json)); + $b = (string) curl_exec($ch); + $s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + return [$s, json_decode($b, true) ?? $b]; + }; + $web = function (string $path, string $jar) use ($webPort): array { + $ch = curl_init("http://127.0.0.1:$webPort$path"); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_HEADER => true, CURLOPT_TIMEOUT => 60, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar, CURLOPT_HTTPHEADER => ['Sec-Fetch-Mode: navigate', 'Sec-Fetch-User: ?1']]); + $b = (string) curl_exec($ch); + $s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $hs = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); + curl_close($ch); + $head = substr($b, 0, $hs); + preg_match('/^Location: (.+)$/mi', $head, $m); + return [$s, trim($m[1] ?? ''), substr($b, $hs)]; + }; + $loginApi = function (string $email) use ($api): string { + [, $r] = $api('POST', '/api/staff/v1/login.php', ['email' => $email, 'password' => 'Sehr-Sicher-1234', 'deviceName' => 'Test']); + return (string) ($r['token'] ?? ''); + }; + $db = $pdo(); + $db->prepare('INSERT INTO roles (name, permissions) VALUES (?,?)')->execute(['Assistent-Pfleger', json_encode(['reply_ticket' => true, 'manage_assistant' => true])]); + $roleId = (int) $db->lastInsertId(); + $db->prepare('UPDATE staff SET role_id = ? WHERE email = ?')->execute([$roleId, 'pfleger@test.de']); + $now = date('Y-m-d H:i:s'); + $dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); + $prio = (int) $db->query('SELECT id FROM priorities ORDER BY sort_order LIMIT 1')->fetchColumn(); + $db->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)') + ->execute(['T7001', 'Testticket', 'Kunde', 'kunde@example.org', $dept, $prio, 'open', 'web', $now, $now]); + + $chef = $loginApi('chef@test.de'); + $agent = $loginApi('agent@test.de'); + $pfleger = $loginApi('pfleger@test.de'); + $check($chef !== '' && $agent !== '' && $pfleger !== '', 'Anmeldung der drei Mitarbeiter in der App-Schnittstelle'); + + // ── Welche Seiten sieht wer? ─────────────────────────────────────────── + $keys = fn(string $t): array => array_column(($api('GET', '/api/staff/v1/web_pages.php', null, $t)[1]['pages'] ?? []), 'key'); + $kc = $keys($chef); $ka = $keys($agent); $kp = $keys($pfleger); + $check(count($kc) === 17 && in_array('settings', $kc, true) && in_array('backup', $kc, true) && in_array('assistant', $kc, true), 'Administrator sieht alle 17 Seiten'); + $check($ka === ['mentions', 'agent_directory'], 'Mitarbeiter ohne Rechte sehen nur Erwähnungen und Verzeichnis ' . json_encode($ka)); + $check(in_array('assistant', $kp, true) && !in_array('settings', $kp, true) && !in_array('backup', $kp, true) && !in_array('plugins', $kp, true), 'Mit dem Recht „Assistent“ kommt genau diese Seite dazu, aber keine Systemseiten ' . json_encode($kp)); + + // ── Übergabe ─────────────────────────────────────────────────────────── + [$s] = $api('POST', '/api/staff/v1/web_pages.php', ['path' => '/staff/settings.php'], $agent); + $check($s === 403, 'Mitarbeiter ohne Recht bekommt keine Adresse für die Einstellungen (HTTP ' . $s . ')'); + [$s] = $api('POST', '/api/staff/v1/web_pages.php', ['path' => '/staff/assistant.php'], $agent); + $check($s === 403, '… und auch nicht für den Assistenten'); + [$s] = $api('POST', '/api/staff/v1/web_pages.php', ['path' => '/staff/staff_manage.php'], $chef); + $check($s === 403, 'Seiten, die nicht in der Liste stehen, werden auch Administratoren nicht übergeben'); + [$s] = $api('POST', '/api/staff/v1/web_pages.php', ['path' => '/staff/ticket_view.php?n=T7001&x=