0) { @ob_end_flush(); } @flush(); } if (session_status() === PHP_SESSION_ACTIVE) session_write_close(); } /** JSON-taugliche CSRF-Prüfung für die AJAX-Endpunkte dieser Seite – verify_csrf() * bricht bei Fehlschlag mit einer reinen Textmeldung ab, die fetch().json() nicht * parsen kann. Rotiert den Token nach Verbrauch genauso wie verify_csrf(). */ function lc_verify_csrf(): void { $token = $_POST['csrf_token'] ?? ''; if (!$token || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) { lc_json(['error' => 'Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.', 'csrfExpired' => true]); } // Token gilt für die ganze Sitzung (keine Erneuerung pro Benutzung, sonst scheitern parallel geöffnete Tabs) } [$deptFilterSql, $deptFilterParams] = dept_filter_sql($myDeptIds, 't.department_id'); // Sperre gegen versehentliches gleichzeitiges Antworten zweier Mitarbeiter im selben // Chat (auch mit der Mobile-App geteilt, siehe live_chat_locked_by_other() in // includes/functions.php): wer den Chat gerade offen hat, "besitzt" ihn für ein paar // Minuten. Andere Mitarbeiter können den Chat trotzdem öffnen und live mitlesen // (bewusst kein Redirect wie bei der normalen Ticketsperre in ticket_view.php – beim // Live-Chat will man durchaus mitschauen können), das Schreiben wird ihnen aber sowohl // in der Oberfläche als auch serverseitig (s.u.) verwehrt. function lc_locked_by_other(array $ticket, array $staff, PDO $pdo): ?string { return live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo); } // ── AJAX: Nachrichten pollen ──────────────────────────────────────────────── if (($_GET['action'] ?? '') === 'poll') { $number = $_GET['n'] ?? ''; $since = (int) ($_GET['since'] ?? 0); $stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : '')); $stmt->execute(array_merge([$number], $deptFilterParams)); $ticket = $stmt->fetch(); if (!$ticket) lc_json(['error' => 'Ticket nicht gefunden.']); $msgStmt = $pdo->prepare( 'SELECT tt.id, tt.author_type, tt.message, tt.created_at, s.name AS staff_name FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 AND tt.id > ? ORDER BY tt.created_at ASC, tt.id ASC' ); $msgStmt->execute([$ticket['id'], $since]); $lcAttStmt = $pdo->prepare('SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id'); $polledMessages = array_map(function (array $m) use ($lcAttStmt) { $lcAttStmt->execute([$m['id']]); $m['attachments'] = $lcAttStmt->fetchAll(); return $m; }, $msgStmt->fetchAll()); $customerTyping = !empty($ticket['typing_customer_until']) && strtotime($ticket['typing_customer_until']) > time(); lc_json(['status' => $ticket['status'], 'customerTyping' => $customerTyping, 'messages' => $polledMessages]); } // ── AJAX: "Ich tippe gerade" (für den Tippt-Indikator beim Kunden) ───────────── if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'typing') { // Token wird geprüft, aber bewusst NICHT rotiert (wie im Kunden-Widget, siehe // live_chat.php): feuert bei jedem Tastenanschlag (gedrosselt), eine Rotation würde // sonst mit einem parallel abgeschickten "send" um denselben Token wettlaufen. $submittedCsrf = $_POST['csrf_token'] ?? ''; if (!$submittedCsrf || !hash_equals($_SESSION['csrf_token'] ?? '', $submittedCsrf)) { lc_json(['error' => 'Ihre Sitzung ist abgelaufen. Bitte laden Sie die Seite neu.', 'csrfExpired' => true]); } $number = $_POST['n'] ?? ''; $stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : '')); $stmt->execute(array_merge([$number], $deptFilterParams)); $ticket = $stmt->fetch(); if ($ticket && $ticket['status'] !== 'closed' && !lc_locked_by_other($ticket, $staff, $pdo)) { $pdo->prepare('UPDATE tickets SET typing_staff_until = ?, updated_at = updated_at WHERE id = ?') ->execute([date('Y-m-d H:i:s', time() + 6), $ticket['id']]); } lc_json(['ok' => true]); } // ── AJAX: Chat-Liste pollen (für Live-Aktualisierung + Ton-Benachrichtigung) ──── if (($_GET['action'] ?? '') === 'list') { $listStmt = $pdo->prepare( "SELECT t.ticket_number, t.requester_name, t.updated_at, (SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS last_author, (SELECT tt.message FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS last_message FROM tickets t WHERE t.source = 'chat' AND t.status != 'closed'" . ($deptFilterSql !== '' ? " AND $deptFilterSql" : '') . " ORDER BY t.updated_at DESC LIMIT 100" ); $listStmt->execute($deptFilterParams); lc_json(['items' => array_map(fn(array $c) => [ 'number' => $c['ticket_number'], 'name' => $c['requester_name'], 'preview' => mb_substr(strip_tags((string) $c['last_message']), 0, 60), 'needsReply' => $c['last_author'] === 'customer', 'updatedAt' => $c['updated_at'], ], $listStmt->fetchAll())]); } // ── AJAX: Chat beenden ─────────────────────────────────────────────────────── if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'end') { lc_verify_csrf(); $number = $_POST['n'] ?? ''; $stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : '')); $stmt->execute(array_merge([$number], $deptFilterParams)); $ticket = $stmt->fetch(); if (!$ticket) lc_json(['error' => 'Ticket nicht gefunden oder kein Zugriff.']); if ($lockedByName = lc_locked_by_other($ticket, $staff, $pdo)) { lc_json(['error' => "Dieser Chat wird gerade von \"$lockedByName\" bearbeitet. Sie können nur zuschauen.", 'locked' => true]); } $now = db_now(); $pdo->prepare( "UPDATE tickets SET status='closed', resolution_code='other', resolution_note='Live-Chat beendet', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?" )->execute([$now, $now, $ticket['id']]); ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'closed', (int) $staff['id']); log_activity($pdo, $ticket['id'], $staff['id'], 'Live-Chat beendet'); finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); // Mitarbeiter nicht auf den SMTP-Versand warten lassen – Antwort sofort raus, // Chatverlauf-Mail danach im Hintergrund (abschaltbar unter Einstellungen → // Allgemein, siehe notify_customer_chat_transcript() in includes/mailer.php). lc_flush_response(['ok' => true]); $transcriptStmt = $pdo->prepare( 'SELECT tt.author_type AS authorType, tt.message, tt.created_at AS createdAt, s.name AS staff_name FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 ORDER BY tt.created_at ASC, tt.id ASC' ); $transcriptStmt->execute([$ticket['id']]); $transcriptMessages = array_map(fn(array $m) => [ 'authorType' => $m['authorType'], 'authorName' => $m['staff_name'], 'message' => $m['message'], 'createdAt' => $m['createdAt'], ], $transcriptStmt->fetchAll()); if ($ticket['requester_email']) { try { notify_customer_chat_transcript($ticket, $transcriptMessages); } catch (Throwable $e) { error_log('live_chat transcript mail: ' . $e->getMessage()); } } exit; } // ── AJAX: Antwort senden ──────────────────────────────────────────────────── if ($_SERVER['REQUEST_METHOD'] === 'POST') { lc_verify_csrf(); $number = $_POST['n'] ?? ''; $message = sanitize_staff_html(trim($_POST['message'] ?? '')); $stmt = $pdo->prepare('SELECT * FROM tickets t WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : '')); $stmt->execute(array_merge([$number], $deptFilterParams)); $ticket = $stmt->fetch(); if (!$ticket) lc_json(['error' => 'Ticket nicht gefunden oder kein Zugriff.']); if ($lockedByName = lc_locked_by_other($ticket, $staff, $pdo)) { lc_json(['error' => "Dieser Chat wird gerade von \"$lockedByName\" bearbeitet. Sie können nur zuschauen.", 'locked' => true]); } if (trim(strip_tags($message)) === '') lc_json(['error' => 'Bitte eine Nachricht eingeben.']); $now = db_now(); $pdo->prepare( 'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)' )->execute([$ticket['id'], 'staff', $staff['id'], $message, $now]); $threadId = (int) $pdo->lastInsertId(); $autoAssign = ($ticket['assigned_staff_id'] === null || $ticket['assigned_staff_id'] === ''); $newStatus = 'answered'; $updateFirstResponse = $ticket['first_responded_at'] === null; $stmt = $pdo->prepare( 'UPDATE tickets SET status = ?, updated_at = ?' . ($updateFirstResponse ? ', first_responded_at = ?' : '') . ($autoAssign ? ', assigned_staff_id = ?' : '') . ' WHERE id = ?' ); $args = [$newStatus, $now]; if ($updateFirstResponse) $args[] = $now; if ($autoAssign) $args[] = $staff['id']; $args[] = $ticket['id']; $stmt->execute($args); ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']); if ($autoAssign) log_activity($pdo, $ticket['id'], $staff['id'], 'Automatisch zugewiesen (Live-Chat)'); save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket); log_activity($pdo, $ticket['id'], $staff['id'], 'Live-Chat-Antwort gesendet'); finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); lc_json(['ok' => true, 'id' => $threadId, 'createdAt' => $now, 'status' => $newStatus]); } // ── Normale Seite ──────────────────────────────────────────────────────────── $listSql = "SELECT t.*, d.name AS department_name, (SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS last_author, (SELECT tt.message FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS last_message FROM tickets t JOIN departments d ON d.id = t.department_id WHERE t.source = 'chat' AND t.status != 'closed'" . ($deptFilterSql !== '' ? " AND $deptFilterSql" : '') . " ORDER BY t.updated_at DESC LIMIT 100"; $listStmt = $pdo->prepare($listSql); $listStmt->execute($deptFilterParams); $chats = $listStmt->fetchAll(); $activeNumber = $_GET['n'] ?? ($chats[0]['ticket_number'] ?? null); $activeTicket = null; $activeMessages = []; if ($activeNumber) { foreach ($chats as $c) { if ($c['ticket_number'] === $activeNumber) { $activeTicket = $c; break; } } if (!$activeTicket) { // Ticket ggf. außerhalb der aktiven Liste (z.B. gerade geschlossen) – direkt laden $stmt = $pdo->prepare('SELECT t.*, d.name AS department_name FROM tickets t JOIN departments d ON d.id=t.department_id WHERE t.ticket_number = ?' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : '')); $stmt->execute(array_merge([$activeNumber], $deptFilterParams)); $activeTicket = $stmt->fetch() ?: null; } if ($activeTicket) { $msgStmt = $pdo->prepare( 'SELECT tt.id, tt.author_type, tt.message, tt.created_at, s.name AS staff_name FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 ORDER BY tt.created_at ASC, tt.id ASC' ); $msgStmt->execute([$activeTicket['id']]); $activeMessages = $msgStmt->fetchAll(); $lcInitAttStmt = $pdo->prepare('SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id'); foreach ($activeMessages as &$__m) { $lcInitAttStmt->execute([$__m['id']]); $__m['attachments'] = $lcInitAttStmt->fetchAll(); } unset($__m); } } // Sperre übernehmen/auffrischen bzw. feststellen, dass ein anderer Mitarbeiter gerade // aktiv dran ist (siehe lc_locked_by_other() oben). $lockedByOtherName = null; if ($activeTicket && $activeTicket['status'] !== 'closed') { $lockedByOtherName = lc_locked_by_other($activeTicket, $staff, $pdo); if (!$lockedByOtherName) { live_chat_acquire_lock((int) $activeTicket['id'], (int) $staff['id'], $pdo); } } $pageTitle = 'Live-Chat'; $activePage = 'live_chat'; require __DIR__ . '/_header.php'; ?>