430 lines
20 KiB
PHP
430 lines
20 KiB
PHP
<?php
|
||
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');
|
||
header('Cache-Control: no-store');
|
||
header('Pragma: no-cache');
|
||
|
||
// Browser-Erweiterungen (Chrome/Edge/Brave: chrome-extension://…, Firefox: moz-extension://…) dürfen die API aufrufen. Ohne diese Antwort hängt der
|
||
// Zugriff davon ab, ob der Browser die Server-Berechtigung der Erweiterung schon angewendet hat (nach der ersten Erlaubnis oft erst nach einem Neustart).
|
||
// Es werden keine Cookies gesendet; wer die API nutzt, braucht weiterhin das Token bzw. Passwort.
|
||
$__origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? '');
|
||
if ($__origin !== '' && preg_match('#^(chrome|moz)-extension://[a-z0-9-]{8,64}$#', $__origin)) {
|
||
header('Access-Control-Allow-Origin: ' . $__origin);
|
||
header('Vary: Origin');
|
||
header('Access-Control-Allow-Headers: Authorization, X-DeskHub-Authorization, Content-Type, Cache-Control, Accept');
|
||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||
header('Access-Control-Max-Age: 600');
|
||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
||
http_response_code(204);
|
||
exit;
|
||
}
|
||
}
|
||
unset($__origin);
|
||
|
||
const MOBILE_API_STATUSES =['open', 'pending', 'answered', 'on_hold', 'resolved', 'closed'];
|
||
|
||
set_exception_handler(function (Throwable $error): void {
|
||
error_log('Mobile Staff API: ' . $error->getMessage());
|
||
api_error(500, 'INTERNAL_ERROR', 'Die Anfrage konnte nicht verarbeitet werden.');
|
||
});
|
||
|
||
function api_json(array $payload, int $status = 200): never
|
||
{
|
||
http_response_code($status);
|
||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
exit;
|
||
}
|
||
|
||
function api_error(int $status, string $code, string $message, array $details = []): never
|
||
{
|
||
api_json(['error' => ['code' => $code, 'message' => $message, 'details' => $details]], $status);
|
||
}
|
||
|
||
function api_require_method(string ...$methods): void
|
||
{
|
||
if (!in_array($_SERVER['REQUEST_METHOD'] ?? '', $methods, true)) {
|
||
header('Allow: ' . implode(', ', $methods));
|
||
api_error(405, 'METHOD_NOT_ALLOWED', 'Diese HTTP-Methode wird nicht unterstützt.');
|
||
}
|
||
}
|
||
|
||
function api_request_body(): array
|
||
{
|
||
$length = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
|
||
if ($length > 1024 * 1024) {
|
||
api_error(413, 'PAYLOAD_TOO_LARGE', 'Die Anfrage ist zu groß.');
|
||
}
|
||
$raw = file_get_contents('php://input');
|
||
if ($raw === false || trim($raw) === '') {
|
||
return [];
|
||
}
|
||
try {
|
||
$body = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
|
||
} catch (JsonException $e) {
|
||
api_error(400, 'INVALID_JSON', 'Der JSON-Inhalt ist ungültig.');
|
||
}
|
||
if (!is_array($body)) {
|
||
api_error(400, 'INVALID_JSON', 'Ein JSON-Objekt wird erwartet.');
|
||
}
|
||
return $body;
|
||
}
|
||
|
||
function api_require_https(): void
|
||
{
|
||
$https = request_is_https();
|
||
$local = in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true);
|
||
$allowInsecure = getenv('DESKHUB_API_ALLOW_INSECURE') === '1';
|
||
if (!$https && !$local && !$allowInsecure) {
|
||
api_error(400, 'HTTPS_REQUIRED', 'Die mobile Anmeldung ist ausschließlich über HTTPS erlaubt.');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Liest die Anmeldung der Apps. Viele Hoster (Apache mit PHP-FPM/FastCGI, z. B. Plesk) reichen den Header "Authorization" nicht an PHP weiter.
|
||
* Deshalb senden die Apps denselben Wert zusätzlich als "X-DeskHub-Authorization", der überall ankommt; hier gilt der erste vorhandene.
|
||
*/
|
||
function api_authorization_header(): string
|
||
{
|
||
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
|
||
if (trim((string) $header) === '') $header = $_SERVER['HTTP_X_DESKHUB_AUTHORIZATION'] ?? '';
|
||
if (trim((string) $header) === '' && function_exists('getallheaders')) {
|
||
foreach (getallheaders() as $name => $value) {
|
||
if (in_array(strtolower((string) $name), ['authorization', 'x-deskhub-authorization'], true) && trim((string) $value) !== '') { $header = $value; break; }
|
||
}
|
||
}
|
||
return trim((string) $header);
|
||
}
|
||
|
||
function api_bearer_token(): ?string
|
||
{
|
||
$header = api_authorization_header();
|
||
if (!preg_match('/^Bearer\s+([A-Za-z0-9_-]{40,})$/i', $header, $matches)) {
|
||
return null;
|
||
}
|
||
return $matches[1];
|
||
}
|
||
|
||
function api_issue_token(PDO $pdo, int $staffId, ?string $deviceName): array
|
||
{
|
||
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
||
$days = max(1, min(365, (int) get_setting('mobile_api_token_days', '30')));
|
||
$expiresAt = date('Y-m-d H:i:s', time() + ($days * 86400));
|
||
$deviceName = trim((string) $deviceName);
|
||
if ($deviceName === '') {
|
||
$deviceName = null;
|
||
} else {
|
||
$deviceName = mb_substr($deviceName, 0, 120);
|
||
}
|
||
|
||
$pdo->prepare(
|
||
'INSERT INTO mobile_api_tokens (staff_id, token_hash, device_name, expires_at, created_at, last_used_at)
|
||
VALUES (?,?,?,?,?,?)'
|
||
)->execute([$staffId, hash('sha256', $token), $deviceName, $expiresAt, db_now(), db_now()]);
|
||
|
||
return ['token' => $token, 'expires_at' => $expiresAt];
|
||
}
|
||
|
||
function api_require_staff(): array
|
||
{
|
||
$token = api_bearer_token();
|
||
if ($token === null) {
|
||
api_error(401, 'UNAUTHENTICATED', 'Ein gültiges Bearer-Token ist erforderlich.');
|
||
}
|
||
|
||
$pdo = get_db();
|
||
$stmt = $pdo->prepare(
|
||
'SELECT s.*, mt.id AS mobile_token_id, mt.last_used_at AS mobile_token_last_used_at
|
||
FROM mobile_api_tokens mt
|
||
JOIN staff s ON s.id = mt.staff_id
|
||
WHERE mt.token_hash = ? AND mt.revoked_at IS NULL AND mt.expires_at > ? AND s.is_active = 1
|
||
LIMIT 1'
|
||
);
|
||
$stmt->execute([hash('sha256', $token), db_now()]);
|
||
$staff = $stmt->fetch();
|
||
if (!$staff) {
|
||
api_error(401, 'TOKEN_EXPIRED', 'Die Anmeldung ist abgelaufen. Bitte melden Sie sich erneut an.');
|
||
}
|
||
|
||
// Jede authentifizierte Anfrage ist ein App-Heartbeat. So bleibt der Status
|
||
// auch bei regelmaessiger Nutzung mit weniger als 5 Minuten Abstand aktuell.
|
||
$pdo->prepare('UPDATE mobile_api_tokens SET last_used_at = ? WHERE id = ?')
|
||
->execute([db_now(), $staff['mobile_token_id']]);
|
||
|
||
// Aktionen in der App (Schreib-Aufrufe) zählen als Aktivität; reines Abfragen im Hintergrund und die Push-Anmeldung nicht
|
||
if ($_SERVER['REQUEST_METHOD'] !== 'GET' && !in_array(basename((string) ($_SERVER['SCRIPT_NAME'] ?? '')), ['push_device.php', 'crash_report.php', 'logout.php'], true)) {
|
||
staff_touch_activity($pdo, (int) $staff['id']);
|
||
}
|
||
|
||
// "Eingebauter" Ersatz für echte Server-Cronjobs (siehe includes/pseudo_cron.php):
|
||
// 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;
|
||
}
|
||
|
||
function api_staff_payload(array $staff): array
|
||
{
|
||
$permissionNames = array_keys(permission_labels()); // alle Rechte des Katalogs (includes/permissions.php)
|
||
$permissions = [];
|
||
foreach ($permissionNames as $permission) {
|
||
$permissions[$permission] = has_permission($staff, $permission);
|
||
}
|
||
return [
|
||
'id' => (int) $staff['id'],
|
||
'name' => $staff['name'],
|
||
'email' => $staff['email'],
|
||
'role' => $staff['role'],
|
||
'permissions' => $permissions,
|
||
];
|
||
}
|
||
|
||
/** $departmentId: Abteilung des Tickets – dort kann eine eigene Rolle (erweiterter Zugriff) gelten. */
|
||
function api_require_permission(array $staff, string $permission, ?int $departmentId = null): void
|
||
{
|
||
if (!has_permission($staff, $permission, $departmentId)) {
|
||
api_error(403, 'FORBIDDEN', 'Für diese Aktion fehlt die erforderliche Berechtigung.');
|
||
}
|
||
}
|
||
|
||
function api_require_admin(array $staff): void
|
||
{
|
||
if ($staff['role'] !== 'admin') {
|
||
api_error(403, 'FORBIDDEN', 'Diese Funktion ist nur für Administratoren verfügbar.');
|
||
}
|
||
}
|
||
|
||
/** Wie api_require_admin(), lässt aber Agenten mit passend zugewiesener Co-Admin-Berechtigung durch. */
|
||
function api_require_admin_or_permission(array $staff, string $permission): void
|
||
{
|
||
if ($staff['role'] !== 'admin' && !has_permission($staff, $permission)) {
|
||
api_error(403, 'FORBIDDEN', 'Für diese Funktion fehlt die erforderliche Berechtigung.');
|
||
}
|
||
}
|
||
|
||
function api_find_ticket(PDO $pdo, array $staff, string $number): array
|
||
{
|
||
$stmt = $pdo->prepare(
|
||
'SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name,
|
||
s.name AS staff_name
|
||
FROM tickets t
|
||
JOIN priorities p ON p.id = t.priority_id
|
||
JOIN departments d ON d.id = t.department_id
|
||
LEFT JOIN staff s ON s.id = t.assigned_staff_id
|
||
WHERE t.ticket_number = ?'
|
||
);
|
||
$stmt->execute([$number]);
|
||
$ticket = $stmt->fetch();
|
||
if (!$ticket) {
|
||
api_error(404, 'TICKET_NOT_FOUND', 'Das Ticket wurde nicht gefunden.');
|
||
}
|
||
$departmentIds = staff_department_ids($staff);
|
||
if ($departmentIds !== null && !in_array((int) $ticket['department_id'], $departmentIds, true)) {
|
||
api_error(403, 'TICKET_FORBIDDEN', 'Auf dieses Ticket besteht kein Zugriff.');
|
||
}
|
||
return $ticket;
|
||
}
|
||
|
||
/** Adresse des ersten Bildes in einem HTML-Text (absolut, nur http/https); leer, wenn keines vorhanden ist. */
|
||
function api_first_image_url(?string $html): string
|
||
{
|
||
if (!preg_match('/<img\b[^>]*\bsrc\s*=\s*(["\'])(.*?)\1/is', (string) $html, $m)) return '';
|
||
$src = trim(html_entity_decode($m[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||
if (str_starts_with($src, '//')) $src = 'https:' . $src;
|
||
elseif (str_starts_with($src, '/')) $src = base_url() . $src;
|
||
return preg_match('#^https?://#i', $src) ? $src : '';
|
||
}
|
||
|
||
function api_plain_text(?string $html): string
|
||
{
|
||
$text = preg_replace('/<br\s*\/?>/i', "\n", (string) $html);
|
||
$text = preg_replace('/<\/p\s*>/i', "\n\n", $text);
|
||
// Weitere Blöcke (div, Listen, Tabellenzeilen, Überschriften …) enden ebenfalls mit einem Zeilenumbruch, Listenpunkte bekommen einen Punkt
|
||
$text = preg_replace('/<li\b[^>]*>/i', '• ', $text);
|
||
$text = preg_replace('/<\/(div|li|tr|h[1-6]|blockquote|pre|ul|ol|table)\s*>/i', "\n", $text);
|
||
$text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
$text = preg_replace('/[ \t]+\n/', "\n", $text); // Leerzeichen am Zeilenende
|
||
$text = preg_replace('/\n{3,}/', "\n\n", $text); // höchstens eine Leerzeile
|
||
return trim($text);
|
||
}
|
||
|
||
function api_ticket_summary(array $ticket): array
|
||
{
|
||
return [
|
||
'id' => (int) $ticket['id'],
|
||
'number' => $ticket['ticket_number'],
|
||
'subject' => $ticket['subject'],
|
||
'requesterName' => $ticket['requester_name'],
|
||
'requesterEmail' => $ticket['requester_email'],
|
||
'status' => $ticket['status'],
|
||
'resolutionCode' => $ticket['resolution_code'] ?? null,
|
||
'resolutionNote' => $ticket['resolution_note'] ?? null,
|
||
'priority' => [
|
||
'id' => (int) $ticket['priority_id'],
|
||
'name' => $ticket['priority_name'],
|
||
'color' => $ticket['priority_color'],
|
||
],
|
||
'department' => [
|
||
'id' => (int) $ticket['department_id'],
|
||
'name' => $ticket['department_name'],
|
||
],
|
||
'assignedStaff' => $ticket['assigned_staff_id'] === null ? null : [
|
||
'id' => (int) $ticket['assigned_staff_id'],
|
||
'name' => $ticket['staff_name'],
|
||
],
|
||
'slaPaused' => !empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']),
|
||
'slaPausedAt' => $ticket['sla_paused_at'] ?? null,
|
||
'isOverdue' => !in_array($ticket['status'], ['resolved', 'closed'], true) && (!empty($ticket['is_overdue']) || (empty($ticket['sla_paused_at']) && !empty($ticket['resolution_due_at'])
|
||
&& strtotime($ticket['resolution_due_at']) < time()
|
||
)),
|
||
// Fertig formatierter Fälligkeitstext ("in 3 Std 12 Min" / "vor 2 Std 5 Min
|
||
// (überfällig)") – bewusst serverseitig über dieselbe time_until()-Funktion wie im
|
||
// Web berechnet, statt das Datumsformat in Kotlin nachzubauen (siehe die Lehren aus
|
||
// den PHP-Datums-Bugs dieser Session: eine einzige Quelle der Wahrheit fürs Format).
|
||
'dueLabel' => in_array($ticket['status'], ['resolved', 'closed'], true) ? null : (!empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']) ? 'Pausiert' : (!empty($ticket['resolution_due_at']) ? time_until($ticket['resolution_due_at']) : null)),
|
||
'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]);
|
||
}
|
||
|
||
/** Cc-Empfänger eines Tickets für die Apps. */
|
||
function api_ticket_collaborators(PDO $pdo, int $ticketId): array
|
||
{
|
||
$s = $pdo->prepare('SELECT id, email, name FROM ticket_collaborators WHERE ticket_id = ? ORDER BY created_at, id');
|
||
$s->execute([$ticketId]);
|
||
return array_map(fn($r) => ['id' => (int) $r['id'], 'email' => (string) $r['email'], 'name' => (string) ($r['name'] ?? '')], $s->fetchAll());
|
||
}
|
||
|
||
function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null, ?string $resolutionCode = null, string $resolutionNote = ''): int
|
||
{
|
||
$permission = $type === 'note' ? 'post_note' : 'reply_ticket';
|
||
api_require_permission($staff, $permission, (int) $ticket['department_id']);
|
||
if (!in_array($type, ['reply', 'note'], true)) {
|
||
api_error(422, 'INVALID_MESSAGE_TYPE', 'Der Nachrichtentyp ist ungültig.');
|
||
}
|
||
|
||
$message = sanitize_staff_html(resolve_canned_vars(trim($rawMessage), $ticket, $staff));
|
||
if (trim(strip_tags($message)) === '') {
|
||
api_error(422, 'MESSAGE_REQUIRED', 'Bitte geben Sie eine Nachricht ein.');
|
||
}
|
||
if (strlen($message) > 100000) {
|
||
api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.');
|
||
}
|
||
|
||
$isNote = $type === 'note';
|
||
|
||
// Live-Chat-Sperre (siehe includes/functions.php): eine öffentliche Antwort auf
|
||
// einen Chat, der gerade frisch von einem anderen Mitarbeiter (egal ob Web oder
|
||
// App) "besessen" wird, wird abgelehnt – interne Notizen bleiben immer erlaubt,
|
||
// die sind ja ohnehin nur für Mitarbeiter sichtbar und kein Konflikt für den Kunden.
|
||
if (!$isNote && $ticket['source'] === 'chat' && $staff['role'] !== 'admin') {
|
||
$lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo);
|
||
if ($lockedByOtherName) {
|
||
api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen.");
|
||
}
|
||
}
|
||
|
||
// Generelle Ticket-Sperre für alle anderen Tickets (siehe ticket.php – wer die GET-
|
||
// Anfrage erfolgreich gemacht hat, besitzt die Sperre bereits; das hier fängt nur
|
||
// den seltenen Fall ab, dass sie zwischen Laden und Absenden abgelaufen ist bzw. ein
|
||
// anderer Mitarbeiter sie in der Zwischenzeit übernommen hat).
|
||
// (Administratoren dürfen auch bei fremder Sperre antworten.)
|
||
if ($ticket['source'] !== 'chat' && $staff['role'] !== 'admin') {
|
||
$lockedByOtherName = ticket_locked_by_other($ticket, (int) $staff['id'], $pdo);
|
||
if ($lockedByOtherName) {
|
||
api_error(409, 'TICKET_LOCKED', "Dieses Ticket wird gerade von \"$lockedByOtherName\" bearbeitet.");
|
||
}
|
||
}
|
||
|
||
$newStatus = $setStatus ?: 'answered';
|
||
if (!$isNote) {
|
||
if (!in_array($newStatus, MOBILE_API_STATUSES, true)) {
|
||
api_error(422, 'INVALID_STATUS', 'Der Zielstatus ist ungültig.');
|
||
}
|
||
if (in_array($newStatus, ['resolved', 'closed'], true)) {
|
||
api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']);
|
||
if (!isset(ticket_resolution_reasons()[$resolutionCode ?? ''])) {
|
||
api_error(422, 'RESOLUTION_CODE_REQUIRED', 'Bitte wählen Sie einen Abschlussgrund.');
|
||
}
|
||
}
|
||
}
|
||
$now = db_now();
|
||
$pdo->beginTransaction();
|
||
try {
|
||
$stmt = $pdo->prepare(
|
||
'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at)
|
||
VALUES (?,?,?,?,?,?)'
|
||
);
|
||
$stmt->execute([$ticket['id'], 'staff', $staff['id'], $message, $isNote ? 1 : 0, $now]);
|
||
$threadId = (int) $pdo->lastInsertId();
|
||
|
||
if ($isNote) {
|
||
save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
|
||
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Interne Notiz hinzugefügt (Mobile App)');
|
||
} else {
|
||
$firstResponse = empty($ticket['first_responded_at']);
|
||
$autoAssign = empty($ticket['assigned_staff_id']);
|
||
$terminal = in_array($newStatus, ['resolved', 'closed'], true);
|
||
$sql = 'UPDATE tickets SET status = ?, updated_at = ?'
|
||
. ($terminal ? ', resolution_code = ?, resolution_note = ?' : '')
|
||
. ($newStatus === 'resolved' && empty($ticket['resolved_at']) ? ', resolved_at = ?' : '')
|
||
. ($newStatus === 'closed' && empty($ticket['closed_at']) ? ', closed_at = ?' : '')
|
||
. ($firstResponse ? ', first_responded_at = ?' : '')
|
||
. ($autoAssign ? ', assigned_staff_id = ?' : '')
|
||
. ' WHERE id = ?';
|
||
$args = [$newStatus, $now];
|
||
if ($terminal) { $args[] = $resolutionCode; $args[] = trim($resolutionNote); }
|
||
if ($newStatus === 'resolved' && empty($ticket['resolved_at'])) $args[] = $now;
|
||
if ($newStatus === 'closed' && empty($ticket['closed_at'])) $args[] = $now;
|
||
if ($firstResponse) $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'], $newStatus, (int) $staff['id']);
|
||
save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
|
||
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Öffentliche Antwort gesendet (Mobile App)');
|
||
}
|
||
$pdo->commit();
|
||
} catch (Throwable $e) {
|
||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||
throw $e;
|
||
}
|
||
|
||
if (!$isNote) {
|
||
// Signatur: optional vom Aufrufer gewählt (own, dept, none), sonst eigene, ersatzweise die der Abteilung
|
||
$signature = resolve_reply_signature($pdo, $staff, (int) $ticket['department_id'], $signatureChoice);
|
||
$collabStmt = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE ticket_id = ?');
|
||
$collabStmt->execute([$ticket['id']]);
|
||
notify_customer_new_reply($ticket, $message, $signature, $collabStmt->fetchAll(PDO::FETCH_COLUMN));
|
||
}
|
||
webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'staff', 'internal' => $isNote]);
|
||
return $threadId;
|
||
}
|