time() + SCHEDULED_REPLY_MAX_DAYS * 86400) return 'Der Sendezeitpunkt darf höchstens ' . SCHEDULED_REPLY_MAX_DAYS . ' Tage entfernt liegen.'; $status = (string) ($opts['set_status'] ?? 'answered'); if (!in_array($status, ['answered', 'pending', 'resolved', 'closed'], true)) $status = 'answered'; $code = (string) ($opts['resolution_code'] ?? ''); if (in_array($status, ['resolved', 'closed'], true) && !isset(ticket_resolution_reasons()[$code])) return 'Bitte beim Schließen einen Abschlussgrund auswählen.'; $pdo->prepare( 'INSERT INTO scheduled_replies (ticket_id, staff_id, message, set_status, resolution_code, resolution_note, signature_choice, send_at, state, created_at) VALUES (?,?,?,?,?,?,?,?,?,?)' )->execute([ (int) $ticket['id'], $staffId, $message, $status, in_array($status, ['resolved', 'closed'], true) ? $code : null, trim(mb_substr((string) ($opts['resolution_note'] ?? ''), 0, 2000)) ?: null, isset($opts['signature_choice']) ? (string) $opts['signature_choice'] : null, $sendAt, 'pending', db_now(), ]); return null; } /** @return list> Ausstehende geplante Antworten eines Tickets. */ function scheduled_replies_pending(PDO $pdo, int $ticketId): array { $stmt = $pdo->prepare("SELECT r.*, s.name AS staff_name FROM scheduled_replies r LEFT JOIN staff s ON s.id = r.staff_id WHERE r.ticket_id = ? AND r.state = 'pending' ORDER BY r.send_at"); $stmt->execute([$ticketId]); return $stmt->fetchAll(); } /** Bricht eine geplante Antwort ab (Verfasser oder Administrator). */ function scheduled_reply_cancel(PDO $pdo, int $id, int $ticketId, array $staff): bool { $stmt = $pdo->prepare("UPDATE scheduled_replies SET state = 'cancelled' WHERE id = ? AND ticket_id = ? AND state = 'pending' AND (staff_id = ? OR ? = 'admin')"); $stmt->execute([$id, $ticketId, (int) $staff['id'], (string) ($staff['role'] ?? '')]); return $stmt->rowCount() > 0; } /** * Sendet eine fällige geplante Antwort: legt die Nachricht im Ticket an, setzt den Status, benachrichtigt den Kunden und die Beobachter. * Gibt true bei Erfolg zurück; bei Fehlern steht der Grund in der Zeile (state = failed). */ function scheduled_reply_deliver(PDO $pdo, array $row): bool { // Atomar übernehmen: nur ein Lauf darf die Antwort senden $claim = $pdo->prepare("UPDATE scheduled_replies SET state = 'sending' WHERE id = ? AND state = 'pending'"); $claim->execute([(int) $row['id']]); if ($claim->rowCount() === 0) return false; $fail = function (string $why) use ($pdo, $row): bool { $pdo->prepare("UPDATE scheduled_replies SET state = 'failed', error = ? WHERE id = ?")->execute([mb_substr($why, 0, 500), (int) $row['id']]); return false; }; try { $t = $pdo->prepare('SELECT * FROM tickets WHERE id = ?'); $t->execute([(int) $row['ticket_id']]); $ticket = $t->fetch(); if (!$ticket) return $fail('Das Ticket existiert nicht mehr.'); $s = $pdo->prepare('SELECT * FROM staff WHERE id = ?'); $s->execute([(int) $row['staff_id']]); $staff = $s->fetch(); if (!$staff || (isset($staff['is_active']) && (int) $staff['is_active'] === 0)) return $fail('Der Verfasser ist nicht mehr aktiv.'); $now = db_now(); $message = (string) $row['message']; $newStatus = (string) $row['set_status']; $pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?)') ->execute([$ticket['id'], 'staff', $staff['id'], $message, 0, $now]); $threadId = (int) $pdo->lastInsertId(); $terminal = in_array($newStatus, ['resolved', 'closed'], true); $resolvedAt = $newStatus === 'resolved' && empty($ticket['resolved_at']) ? $now : $ticket['resolved_at']; $closedAt = $newStatus === 'closed' && empty($ticket['closed_at']) ? $now : $ticket['closed_at']; $pdo->prepare('UPDATE tickets SET status = ?, updated_at = ?, first_responded_at = COALESCE(first_responded_at, ?), assigned_staff_id = COALESCE(assigned_staff_id, ?), resolved_at = ?, closed_at = ?, resolution_code = ?, resolution_note = ? WHERE id = ?') ->execute([ $newStatus, $now, $now, (int) $staff['id'], $resolvedAt, $closedAt, $terminal ? $row['resolution_code'] : $ticket['resolution_code'], $terminal ? ($row['resolution_note'] !== null && $row['resolution_note'] !== '' ? $row['resolution_note'] : $ticket['resolution_note']) : $ticket['resolution_note'], $ticket['id'], ]); if (function_exists('ticket_sla_status_transition')) ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']); $signature = resolve_reply_signature($pdo, $staff, (int) $ticket['department_id'], $row['signature_choice'] !== null ? (string) $row['signature_choice'] : null); $collab = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE ticket_id = ?'); $collab->execute([$ticket['id']]); $ccEmails = array_column($collab->fetchAll(), 'email'); notify_customer_new_reply($ticket, $message, $signature, $ccEmails); save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket); log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Geplante Antwort gesendet (eingeplant am ' . format_dt((string) $row['created_at']) . ')'); if (file_exists(__DIR__ . '/push.php')) { require_once __DIR__ . '/push.php'; if (function_exists('push_new_reply')) push_new_reply($ticket, $staff['name']); } ticket_followers_notify($ticket, 'Antwort von ' . $staff['name'], (int) $staff['id']); webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'staff', 'internal' => false, 'scheduled' => true]); $pdo->prepare("UPDATE scheduled_replies SET state = 'sent', sent_at = ? WHERE id = ?")->execute([$now, (int) $row['id']]); return true; } catch (Throwable $e) { return $fail($e->getMessage()); } } /** Sendet alle fälligen geplanten Antworten. @return array{sent:int,failed:int} */ function scheduled_replies_run_due(PDO $pdo, int $limit = 50): array { $stmt = $pdo->prepare("SELECT * FROM scheduled_replies WHERE state = 'pending' AND send_at <= ? ORDER BY send_at LIMIT " . (int) $limit); $stmt->execute([db_now()]); $sent = 0; $failed = 0; foreach ($stmt->fetchAll() as $row) { scheduled_reply_deliver($pdo, $row) ? $sent++ : $failed++; } return ['sent' => $sent, 'failed' => $failed]; }