prepare('SELECT * FROM ticket_csat WHERE ticket_id = ?'); $s->execute([$ticketId]); return $s->fetch() ?: null; } function csat_find_by_token(PDO $pdo, string $token): ?array { if (!preg_match('/^[a-f0-9]{40}$/', $token)) return null; $s = $pdo->prepare('SELECT c.*, t.ticket_number, t.subject FROM ticket_csat c JOIN tickets t ON t.id = c.ticket_id WHERE c.token = ?'); $s->execute([$token]); return $s->fetch() ?: null; } /** * Speichert die Bewertung. Jede Abfrage lässt sich nur einmal beantworten. * @return array{ok:bool,error?:string} */ function csat_record(PDO $pdo, string $token, int $rating, string $comment): array { $row = csat_find_by_token($pdo, $token); if (!$row) return ['ok' => false, 'error' => 'Dieser Link ist ungültig oder abgelaufen.']; if ($row['answered_at'] !== null) return ['ok' => false, 'error' => 'Vielen Dank, Sie haben dieses Ticket bereits bewertet.']; if ($rating < 1 || $rating > 5) return ['ok' => false, 'error' => 'Bitte wählen Sie 1 bis 5 Sterne.']; $comment = trim(mb_substr($comment, 0, 2000)); $pdo->prepare('UPDATE ticket_csat SET rating = ?, comment = ?, answered_at = ? WHERE id = ? AND answered_at IS NULL') ->execute([$rating, $comment !== '' ? $comment : null, db_now(), $row['id']]); try { log_activity($pdo, (int) $row['ticket_id'], null, 'Zufriedenheit bewertet: ' . $rating . ' von 5 Sternen'); } catch (Throwable $e) { } return ['ok' => true]; } /** Text der Mail an den Kunden. */ function csat_mail_html(array $ticket, string $token): string { $name = trim((string) ($ticket['requester_name'] ?? '')); $stars = ''; for ($i = 1; $i <= 5; $i++) { $stars .= '★'; } return '

Wie zufrieden waren Sie?

' . '

' . ($name !== '' ? 'Hallo ' . e($name) . ', ' : '') . 'Ihr Ticket [#' . e((string) $ticket['ticket_number']) . '] ' . e((string) $ticket['subject']) . ' ist abgeschlossen. ' . 'Eine kurze Bewertung hilft uns, besser zu werden. Es dauert nur einen Klick.

' . '

' . $stars . '

' . '

1 Stern = unzufrieden · 5 Sterne = sehr zufrieden

'; } /** * Verschickt die fälligen Abfragen. Fällig: Ticket geschlossen oder gelöst (seit mindestens der eingestellten Wartezeit, höchstens 14 Tage), * kein Spam, kein Live-Chat, gültige Kundenadresse, noch keine Abfrage, und geschlossen nach dem Einschalten der Funktion. */ function csat_send_due(PDO $pdo, int $limit = 50): int { if (!csat_enabled()) return 0; $now = time(); $until = date('Y-m-d H:i:s', $now - csat_delay_minutes() * 60); $oldest = date('Y-m-d H:i:s', $now - 14 * 86400); $since = (string) get_setting('csat_since', ''); if ($since !== '' && $since > $oldest) $oldest = $since; $stmt = $pdo->prepare( "SELECT t.* FROM tickets t WHERE t.status IN ('closed','resolved') AND t.is_spam = 0 AND t.source <> 'chat' AND COALESCE(t.closed_at, t.resolved_at, t.updated_at) <= ? AND COALESCE(t.closed_at, t.resolved_at, t.updated_at) >= ? AND t.requester_email IS NOT NULL AND t.requester_email <> '' AND NOT EXISTS (SELECT 1 FROM ticket_csat c WHERE c.ticket_id = t.id) ORDER BY t.id LIMIT " . max(1, $limit) ); $stmt->execute([$until, $oldest]); $sent = 0; foreach ($stmt->fetchAll() as $t) { if (!filter_var($t['requester_email'], FILTER_VALIDATE_EMAIL)) continue; $token = bin2hex(random_bytes(20)); try { $pdo->prepare('INSERT INTO ticket_csat (ticket_id, token, staff_id, department_id, sent_at) VALUES (?,?,?,?,?)') ->execute([$t['id'], $token, $t['assigned_staff_id'] ?: null, $t['department_id'] ?: null, db_now()]); } catch (Throwable $e) { continue; // parallel schon angelegt } $from = resolve_from_address($t['department_id'] ? (int) $t['department_id'] : null, $t['priority_id'] ? (int) $t['priority_id'] : null); email_send_and_log((string) $t['requester_email'], 'Wie zufrieden waren Sie? [#' . $t['ticket_number'] . ']', email_wrap(csat_mail_html($t, $token)), $from, 'Zufriedenheitsabfrage', (string) $t['ticket_number']); $sent++; } return $sent; } /** * Auswertung für einen Zeitraum. * @param array|null $deptIds null = alle Abteilungen * @return array{sent:int,answered:int,avg:?float,rate:?float,dist:array,byStaff:list,recent:list} */ function csat_stats(PDO $pdo, string $from, string $to, ?array $deptIds = null): array { $where = 'c.sent_at >= ? AND c.sent_at <= ?'; $args = [$from, $to]; if ($deptIds !== null) { if (!$deptIds) return ['sent' => 0, 'answered' => 0, 'avg' => null, 'rate' => null, 'dist' => [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0], 'byStaff' => [], 'recent' => []]; $where .= ' AND c.department_id IN (' . implode(',', array_map('intval', $deptIds)) . ')'; } $q = $pdo->prepare("SELECT COUNT(*) AS sent, SUM(CASE WHEN c.rating IS NOT NULL THEN 1 ELSE 0 END) AS answered, AVG(c.rating) AS avg FROM ticket_csat c WHERE $where"); $q->execute($args); $r = $q->fetch() ?: []; $sent = (int) ($r['sent'] ?? 0); $answered = (int) ($r['answered'] ?? 0); $dist = [1 => 0, 2 => 0, 3 => 0, 4 => 0, 5 => 0]; $d = $pdo->prepare("SELECT c.rating AS r, COUNT(*) AS n FROM ticket_csat c WHERE $where AND c.rating IS NOT NULL GROUP BY c.rating"); $d->execute($args); foreach ($d->fetchAll() as $row) $dist[(int) $row['r']] = (int) $row['n']; $b = $pdo->prepare("SELECT s.id, s.name, COUNT(*) AS n, AVG(c.rating) AS avg FROM ticket_csat c JOIN staff s ON s.id = c.staff_id WHERE $where AND c.rating IS NOT NULL GROUP BY s.id, s.name ORDER BY AVG(c.rating) DESC, COUNT(*) DESC"); $b->execute($args); $byStaff = array_map(fn($x) => ['id' => (int) $x['id'], 'name' => (string) $x['name'], 'count' => (int) $x['n'], 'avg' => round((float) $x['avg'], 2)], $b->fetchAll()); $rc = $pdo->prepare("SELECT c.rating, c.comment, c.answered_at, t.ticket_number, t.subject FROM ticket_csat c JOIN tickets t ON t.id = c.ticket_id WHERE $where AND c.rating IS NOT NULL ORDER BY c.answered_at DESC LIMIT 10"); $rc->execute($args); return [ 'sent' => $sent, 'answered' => $answered, 'avg' => $answered ? round((float) $r['avg'], 2) : null, 'rate' => $sent ? round($answered * 100 / $sent, 1) : null, 'dist' => $dist, 'byStaff' => $byStaff, 'recent' => array_map(fn($x) => ['rating' => (int) $x['rating'], 'comment' => (string) ($x['comment'] ?? ''), 'answeredAt' => (string) $x['answered_at'], 'number' => (string) $x['ticket_number'], 'subject' => (string) $x['subject']], $rc->fetchAll()), ]; } /** „★★★★☆“ für Anzeigen. */ function csat_stars(?int $rating): string { $rating = max(0, min(5, (int) $rating)); return str_repeat('★', $rating) . str_repeat('☆', 5 - $rating); }