188 lines
9.8 KiB
PHP
188 lines
9.8 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
require_once __DIR__ . '/common.php';
|
||
|
||
/**
|
||
* api/staff/v1/live_chat.php
|
||
*
|
||
* Mobile-Pendant zu staff/live_chat.php (Web-Postfach) – eigener Endpunkt statt
|
||
* Wiederverwendung von ticket.php/ticket_message.php, weil die App eine dedizierte,
|
||
* auf schnelles Chatten zugeschnittene Ansicht bekommt (eigener Tab, siehe
|
||
* mobile/.../ui/DeskHubApp.kt LiveChatScreen), analog zum separaten Web-Postfach.
|
||
* Teilt sich die Sperr-Logik (live_chat_locked_by_other()) und den Transkript-Mailversand
|
||
* (notify_customer_chat_transcript()) mit dem Web-Backend – siehe includes/functions.php
|
||
* bzw. includes/mailer.php.
|
||
*/
|
||
|
||
api_require_method('GET', 'POST');
|
||
$staff = api_require_staff();
|
||
$pdo = get_db();
|
||
[$deptFilterSql, $deptFilterParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
|
||
|
||
function lc_api_find_ticket(PDO $pdo, string $number, string $deptFilterSql, array $deptFilterParams): ?array
|
||
{
|
||
$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 = ? 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 = ?, updated_at = updated_at 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.');
|