136 lines
8.0 KiB
PHP
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];
|
|
}
|