prepare( 'SELECT t.*, d.name AS department_name FROM tickets t JOIN departments d ON d.id = t.department_id WHERE t.ticket_number = ? AND t.source = \'chat\'' . ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : '') ); $stmt->execute(array_merge([$number], $deptFilterParams)); return $stmt->fetch() ?: null; } function lc_api_messages(PDO $pdo, int $ticketId, int $since): array { $stmt = $pdo->prepare( 'SELECT tt.id, tt.author_type AS authorType, tt.message, tt.created_at AS createdAt, s.name AS staffName 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' ); $stmt->execute([$ticketId, $since]); $attStmt = $pdo->prepare('SELECT id, original_name AS name, filesize AS size FROM ticket_attachments WHERE thread_id = ? ORDER BY id'); return array_map(function (array $m) use ($attStmt) { $attStmt->execute([$m['id']]); return [ 'id' => (int) $m['id'], 'authorType' => $m['authorType'], 'authorName' => $m['authorType'] === 'staff' ? ($m['staffName'] ?: 'Support') : null, 'message' => api_plain_text($m['message']), 'createdAt' => $m['createdAt'], 'attachments' => array_map(fn(array $a) => ['id' => (int) $a['id'], 'name' => $a['name'], 'size' => (int) $a['size']], $attStmt->fetchAll()), ]; }, $stmt->fetchAll()); } if ($_SERVER['REQUEST_METHOD'] === 'GET') { $action = $_GET['action'] ?? ''; if ($action === 'list') { $listStmt = $pdo->prepare( "SELECT t.ticket_number AS number, t.requester_name AS name, t.updated_at AS updatedAt, (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 lastAuthor, (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 lastMessage 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); api_json(['items' => array_map(fn(array $c) => [ 'number' => $c['number'], 'name' => $c['name'], 'preview' => mb_substr(strip_tags((string) $c['lastMessage']), 0, 60), 'needsReply' => $c['lastAuthor'] === 'customer', 'updatedAt' => $c['updatedAt'], ], $listStmt->fetchAll())]); } if ($action === 'messages') { $number = trim((string) ($_GET['number'] ?? '')); if ($number === '') api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.'); $ticket = lc_api_find_ticket($pdo, $number, $deptFilterSql, $deptFilterParams); if (!$ticket) api_error(404, 'TICKET_NOT_FOUND', 'Chat nicht gefunden oder kein Zugriff.'); $since = (int) ($_GET['since'] ?? 0); // Sperre übernehmen/auffrischen bzw. feststellen, dass gerade jemand anders aktiv // dran ist – wie im Web-Postfach (siehe staff/live_chat.php). Lesen bleibt für // alle möglich, nur das Schreiben wird unten (Aktion "send") verwehrt. $lockedByOtherName = null; if ($ticket['status'] !== 'closed') { $lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo); if (!$lockedByOtherName) { live_chat_acquire_lock((int) $ticket['id'], (int) $staff['id'], $pdo); } } $customerTyping = !empty($ticket['typing_customer_until']) && strtotime($ticket['typing_customer_until']) > time(); api_json([ 'status' => $ticket['status'], 'requesterName' => $ticket['requester_name'], 'requesterEmail' => $ticket['requester_email'], 'departmentName' => $ticket['department_name'], 'lockedByOtherName' => $lockedByOtherName, 'customerTyping' => $customerTyping, 'messages' => lc_api_messages($pdo, (int) $ticket['id'], $since), ]); } api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.'); } // ── POST: send / end / typing ─────────────────────────────────────────────── $body = api_request_body(); $action = $body['action'] ?? ''; $number = trim((string) ($body['number'] ?? '')); if ($number === '') api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.'); $ticket = lc_api_find_ticket($pdo, $number, $deptFilterSql, $deptFilterParams); if (!$ticket) api_error(404, 'TICKET_NOT_FOUND', 'Chat nicht gefunden oder kein Zugriff.'); if ($action === 'typing') { if ($ticket['status'] !== 'closed' && !live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) { $pdo->prepare('UPDATE tickets SET typing_staff_until = ? WHERE id = ?') ->execute([date('Y-m-d H:i:s', time() + 6), $ticket['id']]); } api_json(['ok' => true]); } if ($action === 'send') { api_require_permission($staff, 'reply_ticket', (int) $ticket['department_id']); if ($ticket['status'] === 'closed') api_error(409, 'CHAT_CLOSED', 'Dieser Chat wurde bereits beendet.'); if ($lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) { api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen."); } $message = sanitize_staff_html(trim((string) ($body['message'] ?? ''))); if (trim(strip_tags($message)) === '') api_error(422, 'MESSAGE_REQUIRED', 'Bitte eine Nachricht eingeben.'); if (strlen($message) > 100000) api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.'); $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'] === ''); $updateFirstResponse = $ticket['first_responded_at'] === null; $sql = 'UPDATE tickets SET status = ?, updated_at = ?' . ($updateFirstResponse ? ', first_responded_at = ?' : '') . ($autoAssign ? ', assigned_staff_id = ?' : '') . ' WHERE id = ?'; $args = ['answered', $now]; if ($updateFirstResponse) $args[] = $now; if ($autoAssign) $args[] = $staff['id']; $args[] = $ticket['id']; $pdo->prepare($sql)->execute($args); ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'answered', (int) $staff['id']); if ($autoAssign) log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Automatisch zugewiesen (Live-Chat, Mobile App)'); save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket); log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Live-Chat-Antwort gesendet (Mobile App)'); finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); api_json(['id' => $threadId, 'createdAt' => $now, 'status' => 'answered']); } if ($action === 'end') { api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']); if ($lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) { api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen."); } $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, (int) $ticket['id'], (int) $staff['id'], 'Live-Chat beendet (Mobile App)'); finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']); if ($ticket['requester_email']) { try { notify_customer_chat_transcript($ticket, lc_api_messages($pdo, (int) $ticket['id'], 0)); } catch (Throwable $e) { error_log('live_chat.php (Mobile App) transcript mail: ' . $e->getMessage()); } } api_json(['ok' => true]); } api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');