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

170 lines
9.2 KiB
PHP

<?php
/**
* Datenschutz-Werkzeuge für Auskunft und Löschung auf Anfrage eines Kunden (DSGVO Art. 15 und 17).
* Nur für Administratoren und nur, wenn unter Einstellungen → Datenschutz eingeschaltet (gdpr_tools_enabled).
*
* - gdpr_customer_export(): alle zum Kunden gespeicherten Daten als Struktur (wird als JSON-Datei geliefert)
* - gdpr_customer_erase(): anonymisiert Tickets, entfernt Nachrichten und Anhänge des Kunden, Notizen und das Kundenkonto
*
* Ein Kunde ist entweder ein Konto (user_id) oder ein Gast, der nur über seine E-Mail-Adresse in Tickets vorkommt.
*/
function gdpr_tools_enabled(): bool
{
return get_setting('gdpr_tools_enabled', '0') === '1';
}
/**
* @param array{user_id?:int,email?:string} $ref
* @return array{email:string,user:?array,tickets:list<array>}|null
*/
function gdpr_resolve_customer(PDO $pdo, array $ref): ?array
{
$user = null;
$email = trim((string) ($ref['email'] ?? ''));
if (!empty($ref['user_id'])) {
$stmt = $pdo->prepare('SELECT u.*, o.name AS org_name FROM users u LEFT JOIN organizations o ON o.id = u.organization_id WHERE u.id = ?');
$stmt->execute([(int) $ref['user_id']]);
$user = $stmt->fetch() ?: null;
if (!$user) return null;
$email = (string) $user['email'];
}
if ($email === '') return null;
$stmt = $pdo->prepare('SELECT * FROM tickets WHERE requester_email = ? OR (user_id IS NOT NULL AND user_id = ?) ORDER BY created_at');
$stmt->execute([$email, $user ? (int) $user['id'] : 0]);
$tickets = $stmt->fetchAll();
if (!$user && !$tickets) return null;
return ['email' => $email, 'user' => $user, 'tickets' => $tickets];
}
function gdpr_plain(string $html): string
{
$t = preg_replace('~<(br|/p|/div|/li|/tr)\b[^>]*>~i', "\n", $html) ?? $html;
return trim(html_entity_decode(strip_tags($t), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
/** Alle gespeicherten Daten zu einem Kunden. @return array<string,mixed>|null */
function gdpr_customer_export(PDO $pdo, array $ref): ?array
{
$c = gdpr_resolve_customer($pdo, $ref);
if ($c === null) return null;
$user = $c['user'];
$out = [
'erstellt_am' => date('c'),
'hinweis' => 'Auskunft nach Art. 15 DSGVO. Interne Notizen der Mitarbeiter sind als intern gekennzeichnet und vor der Weitergabe zu prüfen. Dateiinhalte der Anhänge sind nicht enthalten, nur deren Namen.',
'kunde' => [
'name' => $user['name'] ?? ($c['tickets'][0]['requester_name'] ?? ''),
'email' => $c['email'],
'telefon' => $user['phone'] ?? null,
'organisation' => $user['org_name'] ?? null,
'konto_angelegt_am' => $user['created_at'] ?? null,
'hat_konto' => $user !== null,
],
'tickets' => [],
'notizen_zum_kunden' => [],
];
$dept = $pdo->prepare('SELECT name FROM departments WHERE id = ?');
$threads = $pdo->prepare('SELECT t.*, s.name AS staff_name FROM ticket_threads t LEFT JOIN staff s ON s.id = t.staff_id WHERE t.ticket_id = ? ORDER BY t.created_at, t.id');
$atts = $pdo->prepare('SELECT a.original_name, a.filesize, a.created_at FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id WHERE t.ticket_id = ? ORDER BY a.id');
$collab = $pdo->prepare('SELECT email, name FROM ticket_collaborators WHERE ticket_id = ?');
$rating = $pdo->prepare('SELECT rating, comment, created_at FROM ticket_ratings WHERE ticket_id = ?');
$csat = $pdo->prepare('SELECT rating, comment, answered_at FROM ticket_csat WHERE ticket_id = ? AND answered_at IS NOT NULL');
foreach ($c['tickets'] as $t) {
$dept->execute([(int) $t['department_id']]);
$threads->execute([(int) $t['id']]);
$atts->execute([(int) $t['id']]);
$collab->execute([(int) $t['id']]);
$rating->execute([(int) $t['id']]);
$csat->execute([(int) $t['id']]);
$messages = [];
foreach ($threads->fetchAll() as $m) {
$messages[] = [
'zeitpunkt' => $m['created_at'],
'von' => $m['author_type'] === 'customer' ? 'Kunde' : ($m['author_type'] === 'staff' ? 'Mitarbeiter' : 'System'),
'mitarbeiter' => $m['author_type'] === 'staff' ? ($m['staff_name'] ?? null) : null,
'intern' => (bool) $m['is_internal_note'],
'text' => gdpr_plain((string) $m['message']),
];
}
$out['tickets'][] = [
'nummer' => $t['ticket_number'], 'betreff' => $t['subject'], 'status' => $t['status'], 'quelle' => $t['source'],
'abteilung' => (string) ($dept->fetchColumn() ?: ''), 'angelegt_am' => $t['created_at'], 'geschlossen_am' => $t['closed_at'] ?: null,
'name_im_ticket' => $t['requester_name'], 'email_im_ticket' => $t['requester_email'], 'ip_adresse' => $t['requester_ip'] ?: null,
'nachrichten' => $messages,
'anhaenge' => array_map(fn($a) => ['name' => $a['original_name'], 'groesse_bytes' => (int) $a['filesize'], 'hinzugefuegt_am' => $a['created_at']], $atts->fetchAll()),
'mitleser' => $collab->fetchAll(PDO::FETCH_ASSOC),
'bewertung' => $rating->fetch(PDO::FETCH_ASSOC) ?: ($csat->fetch(PDO::FETCH_ASSOC) ?: null),
];
}
if ($user) {
$n = $pdo->prepare('SELECT note, created_at FROM customer_notes WHERE user_id = ? ORDER BY created_at');
$n->execute([(int) $user['id']]);
$out['notizen_zum_kunden'] = array_map(fn($r) => ['zeitpunkt' => $r['created_at'], 'text' => $r['note']], $n->fetchAll());
}
return $out;
}
/**
* Löscht die personenbezogenen Daten eines Kunden. Ticketnummer, Betreff, Status und Zeiten bleiben für die Statistik erhalten.
* Offene Tickets verhindern die Löschung, solange $force nicht gesetzt ist.
* @return array{error:?string,tickets:int,messages:int,files:int}
*/
function gdpr_customer_erase(PDO $pdo, array $ref, bool $force = false): array
{
$res = ['error' => null, 'tickets' => 0, 'messages' => 0, 'files' => 0];
$c = gdpr_resolve_customer($pdo, $ref);
if ($c === null) { $res['error'] = 'Kunde nicht gefunden.'; return $res; }
$open = array_filter($c['tickets'], fn($t) => !in_array($t['status'], ['resolved', 'closed'], true));
if ($open && !$force) {
$res['error'] = count($open) . ' Ticket(s) sind noch offen. Bitte zuerst schließen oder die Löschung ausdrücklich erzwingen.';
return $res;
}
$user = $c['user'];
$email = $c['email'];
$anon = 'anon_' . substr(hash('sha256', $email . 'deskhub-anon'), 0, 16) . '@deleted.local';
$uploads = realpath((string) (config()['uploads']['path'] ?? '')) ?: '';
$files = [];
$pdo->beginTransaction();
try {
foreach ($c['tickets'] as $t) {
$tid = (int) $t['id'];
// Dateien der Kunden-Nachrichten merken und Zeilen entfernen
$stmt = $pdo->prepare("SELECT a.id, a.stored_name FROM ticket_attachments a JOIN ticket_threads th ON th.id = a.thread_id WHERE th.ticket_id = ? AND th.author_type = 'customer'");
$stmt->execute([$tid]);
foreach ($stmt->fetchAll() as $a) {
$files[] = (string) $a['stored_name'];
$pdo->prepare('DELETE FROM ticket_attachments WHERE id = ?')->execute([(int) $a['id']]);
}
$hasHtml = function_exists('ticket_mail_html_ready') && ticket_mail_html_ready($pdo);
$upd = $pdo->prepare("UPDATE ticket_threads SET message = ?, " . ($hasHtml ? 'message_html = NULL, ' : '') . "user_id = NULL WHERE ticket_id = ? AND author_type = 'customer'");
$upd->execute(['[Auf Anfrage des Betroffenen gelöscht]', $tid]);
$res['messages'] += $upd->rowCount();
$pdo->prepare('DELETE FROM ticket_collaborators WHERE ticket_id = ? AND LOWER(email) = LOWER(?)')->execute([$tid, $email]);
$pdo->prepare('UPDATE ticket_ratings SET comment = NULL, user_id = NULL WHERE ticket_id = ?')->execute([$tid]);
$pdo->prepare('UPDATE ticket_csat SET comment = NULL WHERE ticket_id = ?')->execute([$tid]);
$pdo->prepare("UPDATE tickets SET requester_name = 'Gelöscht', requester_email = ?, requester_ip = '0.0.0.0', user_id = NULL WHERE id = ?")->execute([$anon, $tid]);
$res['tickets']++;
}
if ($user) {
$uid = (int) $user['id'];
$pdo->prepare('DELETE FROM customer_notes WHERE user_id = ?')->execute([$uid]);
$pdo->prepare('UPDATE tickets SET user_id = NULL WHERE user_id = ?')->execute([$uid]);
$pdo->prepare('UPDATE ticket_threads SET user_id = NULL WHERE user_id = ?')->execute([$uid]);
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$uid]);
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$res['error'] = 'Löschen fehlgeschlagen: ' . $e->getMessage();
return $res;
}
// Dateien erst nach erfolgreichem Abschluss löschen (nur innerhalb des Upload-Ordners)
foreach (array_unique($files) as $name) {
if ($name === '' || $uploads === '' || $name !== basename($name)) continue;
$path = $uploads . DIRECTORY_SEPARATOR . $name;
if (is_file($path) && @unlink($path)) $res['files']++;
}
return $res;
}