}|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|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; }