Files
2026-11-04 07:55:19 +00:00

136 lines
8.0 KiB
PHP

<?php
/**
* Zeitversetzt gesendete Antworten: Eine öffentliche Antwort wird geschrieben und zu einem späteren Zeitpunkt an den Kunden
* geschickt (Hintergrundjob cron/send_scheduled_replies.php, alle Minute über den eingebauten Cron oder echten Cronjob).
* Bis dahin steht sie im Ticket als „geplant“ und lässt sich vom Verfasser oder einem Administrator abbrechen.
* Anhänge werden bei zeitversetztem Senden nicht unterstützt.
*/
const SCHEDULED_REPLY_MAX_DAYS = 90;
/** Parst die Eingabe eines datetime-local-Feldes (Y-m-d\TH:i). Gibt "Y-m-d H:i:s" oder null zurück. */
function scheduled_reply_parse_time(string $input): ?string
{
$input = trim($input);
if (!preg_match('/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::\d{2})?$/', $input, $m)) return null;
$ts = strtotime($m[1] . ' ' . $m[2] . ':00');
return $ts === false ? null : date('Y-m-d H:i:s', $ts);
}
/** Legt eine geplante Antwort an. Gibt eine Fehlermeldung zurück oder null bei Erfolg. $message ist bereits bereinigtes HTML. */
function scheduled_reply_create(PDO $pdo, array $ticket, int $staffId, string $message, ?string $sendAt, array $opts = []): ?string
{
if (trim(strip_tags($message)) === '') return 'Bitte geben Sie eine Nachricht ein.';
if ($sendAt === null) return 'Bitte Datum und Uhrzeit für das Senden angeben.';
$ts = strtotime($sendAt);
if ($ts === false || $ts < time() + 30) return 'Der Sendezeitpunkt muss in der Zukunft liegen.';
if ($ts > 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<array<string,mixed>> 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];
}