prepare( 'SELECT tt.*, s.name AS staff_name, u.name AS user_name FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id LEFT JOIN users u ON u.id = tt.user_id WHERE tt.ticket_id = ? ORDER BY tt.created_at ASC, tt.id ASC' ); $threadStmt->execute([$ticket['id']]); // Eingebettete Bilder einer Mail (Logos, Fotos im Text) gehören zur Darstellung der Nachricht und sind keine Anhänge $attachmentStmt = $pdo->prepare( 'SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ?' . (ticket_mail_html_ready($pdo) ? ' AND COALESCE(is_inline, 0) = 0' : '') . ' ORDER BY id' ); $commentStmt = $pdo->prepare( 'SELECT tc.id, tc.message, tc.created_at, s.id AS staff_id, s.name AS staff_name FROM ticket_thread_comments tc JOIN staff s ON s.id = tc.staff_id WHERE tc.thread_id = ? ORDER BY tc.created_at, tc.id' ); $threads = []; foreach ($threadStmt->fetchAll() as $row) { $attachmentStmt->execute([$row['id']]); $commentStmt->execute([$row['id']]); $threads[] = [ 'id' => (int) $row['id'], 'authorType' => $row['author_type'], 'authorName' => $row['staff_name'] ?: $row['user_name'] ?: $ticket['requester_name'], 'message' => api_plain_text($row['message']), 'isInternalNote' => (bool) $row['is_internal_note'], 'createdAt' => $row['created_at'], 'attachments' => array_map(fn(array $item) => [ 'id' => (int) $item['id'], 'name' => $item['original_name'], 'size' => (int) $item['filesize'], ], $attachmentStmt->fetchAll()), 'comments' => array_map(fn(array $item) => [ 'id' => (int) $item['id'], 'message' => api_plain_text($item['message']), 'staff' => ['id' => (int) $item['staff_id'], 'name' => $item['staff_name']], 'createdAt' => $item['created_at'], ], $commentStmt->fetchAll()), ]; } // Live-Chat-Sperre: wie im Web-Backend (staff/live_chat.php) "besitzt" der Mitarbeiter, // der den Chat gerade ansieht, ihn für ein paar Minuten – verhindert, dass App und Web // gleichzeitig unbemerkt im selben Chat antworten. Nur für Live-Chat-Tickets relevant, // normale Tickets bleiben unangetastet. Lesen bleibt für alle möglich, nur das // Schreiben wird in api_add_ticket_message() serverseitig blockiert. $lockedByOtherName = null; if ($ticket['source'] === 'chat' && $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); ticket_assign_on_open($pdo, $ticket, $staff); } } ticket_ack($pdo, (int) $ticket['id'], (int) $staff['id']); // gesehen: verschwindet aus dem Zähler der Apps $summary = api_ticket_summary($ticket); $summary['source'] = $ticket['source']; $summary['firstResponseDueAt'] = $ticket['first_response_due_at']; $summary['resolutionDueAt'] = $ticket['resolution_due_at']; $summary['lockedByOtherName'] = $lockedByOtherName; $summary['lockedBy'] = $ticketLockedBy; // Name, wenn ein Administrator ein fremd gesperrtes Ticket geöffnet hat $summary['canUnlock'] = $staff['role'] === 'admin'; // darf die Sperre eines anderen aufheben $summary['threads'] = $threads; // Rechte für GENAU dieses Ticket: gelten pro Abteilung (Rolle des Mitarbeiters in der Abteilung des Tickets) $summary['permissions'] = []; foreach (['reply_ticket', 'post_note', 'close_ticket', 'assign_ticket', 'transfer_ticket', 'delete_ticket'] as $__perm) { $summary['permissions'][$__perm] = has_permission($staff, $__perm, (int) $ticket['department_id']); } // Signaturen zur Auswahl beim Antworten (eigene / Abteilung / keine) als Klartext mit Hinweis auf Bilder $sigOptions = reply_signature_options($pdo, $staff, (int) $ticket['department_id']); $sigEntry = fn(string $html) => ['available' => $html !== '', 'text' => api_plain_text($html), 'html' => $html, 'hasImage' => stripos($html, ' api_first_image_url($html)]; $summary['replySignatures'] = [ 'default' => $sigOptions['default'], 'own' => $sigEntry($sigOptions['own']), 'department' => $sigEntry($sigOptions['dept']) + ['name' => $sigOptions['deptName']], ]; // Zusatzfelder des Tickets (vom Kunden im Formular ausgefüllt), in der Reihenfolge des Formulars $cfStmt = $pdo->prepare( 'SELECT cf.label, cf.field_type, v.value FROM ticket_field_values v JOIN custom_fields cf ON cf.id = v.custom_field_id WHERE v.ticket_id = ? ORDER BY cf.sort_order, cf.label' ); $cfStmt->execute([(int) $ticket['id']]); $summary['customFields'] = array_map(fn($r) => [ 'label' => (string) $r['label'], 'type' => (string) $r['field_type'], 'value' => $r['field_type'] === 'checkbox' ? ($r['value'] ? 'Ja' : 'Nein') : (string) $r['value'], ], $cfStmt->fetchAll()); try { $summary['similar'] = ticket_similar($pdo, $ticket, staff_department_ids($staff)); } catch (Throwable $e) { $summary['similar'] = ['articles' => [], 'tickets' => []]; } // Cc-Empfänger, Beobachter und Bewertung des Kunden try { $summary['collaborators'] = api_ticket_collaborators($pdo, (int) $ticket['id']); $summary['followers'] = array_map(fn($f) => ['id' => (int) $f['id'], 'name' => (string) $f['name']], ticket_followers($pdo, (int) $ticket['id'])); $__csat = csat_for_ticket($pdo, (int) $ticket['id']); $summary['csat'] = $__csat ? ['rating' => $__csat['rating'] !== null ? (int) $__csat['rating'] : null, 'comment' => (string) ($__csat['comment'] ?? ''), 'sentAt' => $__csat['sent_at'], 'answeredAt' => $__csat['answered_at']] : null; } catch (Throwable $e) { $summary += ['collaborators' => [], 'followers' => [], 'csat' => null]; } api_json(['ticket' => $summary]);