1973 lines
87 KiB
PHP
1973 lines
87 KiB
PHP
<?php
|
||
/**
|
||
* Allgemeine Hilfsfunktionen
|
||
*/
|
||
|
||
function e(?string $value): string
|
||
{
|
||
return htmlspecialchars($value ?? '', ENT_QUOTES, 'UTF-8');
|
||
}
|
||
|
||
function redirect(string $path): void
|
||
{
|
||
// Nur relative Pfade erlauben – verhindert open redirects
|
||
if (!str_starts_with($path, '/') && !str_starts_with($path, 'http')) {
|
||
$path = '/' . $path;
|
||
}
|
||
// Externe URLs nur auf gleiche Domain erlauben
|
||
if (str_starts_with($path, 'http')) {
|
||
$host = parse_url($path, PHP_URL_HOST);
|
||
if ($host !== ($_SERVER['HTTP_HOST'] ?? '')) {
|
||
$path = '/'; // Auf Home umleiten statt externer URL
|
||
}
|
||
}
|
||
header('Location: ' . $path);
|
||
exit;
|
||
}
|
||
|
||
function csrf_token(): string
|
||
{
|
||
if (empty($_SESSION['csrf_token'])) {
|
||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||
}
|
||
return $_SESSION['csrf_token'];
|
||
}
|
||
|
||
function csrf_field(): string
|
||
{
|
||
return '<input type="hidden" name="csrf_token" value="' . e(csrf_token()) . '">';
|
||
}
|
||
|
||
function check_honeypot(): void
|
||
{
|
||
// Wenn das versteckte Feld ausgefüllt wurde, ist es ein Bot
|
||
if (!empty($_POST['hp_website'])) {
|
||
http_response_code(400);
|
||
die('Bad Request.');
|
||
}
|
||
}
|
||
|
||
function verify_csrf(): void
|
||
{
|
||
$token = $_POST['csrf_token'] ?? '';
|
||
if (!$token || !hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
|
||
http_response_code(403);
|
||
die('Ungültige Anfrage (CSRF-Token fehlt oder ist ungültig). Bitte gehen Sie zurück und versuchen Sie es erneut.');
|
||
}
|
||
// Token nach Verbrauch rotieren
|
||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||
}
|
||
|
||
function flash(string $type, string $message): void
|
||
{
|
||
$_SESSION['flash'][] = ['type' => $type, 'message' => $message];
|
||
}
|
||
|
||
function get_flashes(): array
|
||
{
|
||
$flashes = $_SESSION['flash'] ?? [];
|
||
unset($_SESSION['flash']);
|
||
return $flashes;
|
||
}
|
||
|
||
/**
|
||
* Merkt sich zusätzlich zur normalen (HTML-escapten) Flash-Meldung, welches Ticket gerade
|
||
* durch einen anderen Mitarbeiter gesperrt war. flash() allein kann keinen Button
|
||
* enthalten (die Nachricht wird per e() ausgegeben) – Admins bekommen darüber direkt
|
||
* unter der Fehlermeldung eine "Sperre jetzt aufheben"-Aktion angezeigt (siehe
|
||
* staff/_header.php), damit sie nicht bis zum automatischen Timeout warten müssen.
|
||
* Wird wie flash() einmalig ausgelesen und danach verworfen.
|
||
*/
|
||
function flash_locked_ticket(string $ticketNumber, string $lockedByName): void
|
||
{
|
||
$_SESSION['locked_ticket_override'] = ['number' => $ticketNumber, 'name' => $lockedByName];
|
||
}
|
||
|
||
function get_locked_ticket_override(): ?array
|
||
{
|
||
$data = $_SESSION['locked_ticket_override'] ?? null;
|
||
unset($_SESSION['locked_ticket_override']);
|
||
return $data;
|
||
}
|
||
|
||
/** Erzeugt eine eindeutige, gut lesbare Ticketnummer, z.B. 251023-0001 */
|
||
function generate_ticket_number(PDO $pdo): string
|
||
{
|
||
$prefix = date('ymd');
|
||
do {
|
||
$number = $prefix . '-' . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM tickets WHERE ticket_number = ?');
|
||
$stmt->execute([$number]);
|
||
$exists = (int) $stmt->fetchColumn() > 0;
|
||
} while ($exists);
|
||
|
||
return $number;
|
||
}
|
||
|
||
function claim_ticket_for_customer(PDO $pdo, int $ticketId, array $user): bool
|
||
{
|
||
$userId = (int) ($user['id'] ?? 0);
|
||
$email = trim((string) ($user['email'] ?? ''));
|
||
if ($ticketId < 1 || $userId < 1 || $email === '') {
|
||
return false;
|
||
}
|
||
|
||
$stmt = $pdo->prepare(
|
||
'UPDATE tickets SET user_id = ?
|
||
WHERE id = ? AND (user_id IS NULL OR user_id = ?) AND LOWER(requester_email) = LOWER(?)'
|
||
);
|
||
$stmt->execute([$userId, $ticketId, $userId, $email]);
|
||
|
||
return $stmt->rowCount() === 1;
|
||
}
|
||
|
||
/**
|
||
* Liefert ein dauerhaftes, zufälliges Zugriffstoken für einen Ticket-Link.
|
||
* Das Klartext-Token wird verschlüsselt gespeichert und nur sein Hash zur Prüfung verwendet.
|
||
*/
|
||
function ticket_access_token(int $ticketId): string
|
||
{
|
||
$pdo = get_db();
|
||
$stmt = $pdo->prepare(
|
||
'SELECT token_hash, token_enc FROM ticket_access_tokens
|
||
WHERE ticket_id = ? AND revoked_at IS NULL ORDER BY id DESC LIMIT 1'
|
||
);
|
||
$stmt->execute([$ticketId]);
|
||
$stored = $stmt->fetch();
|
||
|
||
if ($stored) {
|
||
$token = decrypt_secret($stored['token_enc']);
|
||
if (preg_match('/^[a-f0-9]{96}$/', $token) && hash_equals($stored['token_hash'], hash('sha256', $token))) {
|
||
return $token;
|
||
}
|
||
}
|
||
|
||
$token = bin2hex(random_bytes(48));
|
||
$pdo->prepare(
|
||
'INSERT INTO ticket_access_tokens (ticket_id, token_hash, token_enc, created_at)
|
||
VALUES (?,?,?,?)'
|
||
)->execute([$ticketId, hash('sha256', $token), encrypt_secret($token), db_now()]);
|
||
|
||
return $token;
|
||
}
|
||
|
||
function verify_ticket_access_token(int $ticketId, string $token): bool
|
||
{
|
||
if (!preg_match('/^[a-f0-9]{96}$/', $token)) {
|
||
return false;
|
||
}
|
||
|
||
$stmt = get_db()->prepare(
|
||
'SELECT token_hash FROM ticket_access_tokens
|
||
WHERE ticket_id = ? AND token_hash = ? AND revoked_at IS NULL LIMIT 1'
|
||
);
|
||
$stmt->execute([$ticketId, hash('sha256', $token)]);
|
||
$storedHash = $stmt->fetchColumn();
|
||
|
||
return is_string($storedHash) && hash_equals($storedHash, hash('sha256', $token));
|
||
}
|
||
|
||
function ticket_access_url(array $ticket): string
|
||
{
|
||
$ticketId = (int) ($ticket['id'] ?? 0);
|
||
if ($ticketId < 1) {
|
||
$stmt = get_db()->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
|
||
$stmt->execute([$ticket['ticket_number'] ?? '']);
|
||
$ticketId = (int) $stmt->fetchColumn();
|
||
}
|
||
if ($ticketId < 1) {
|
||
throw new RuntimeException('Ticket für den Kunden-Zugriffslink nicht gefunden.');
|
||
}
|
||
|
||
return base_url() . '/ticket_view.php?n=' . urlencode($ticket['ticket_number'])
|
||
. '&token=' . urlencode(ticket_access_token($ticketId));
|
||
}
|
||
|
||
/**
|
||
* Berechnet SLA-Fälligkeiten anhand einer Prioritäts-Zeile (response_hours/resolution_hours).
|
||
*
|
||
* Stunden werden auf max. 5 Jahre gekappt (SLA_MAX_HOURS): ein versehentlich riesiger oder
|
||
* negativer Wert bei einer Priorität (z.B. Millionen statt normaler Stundenzahl, oder ein
|
||
* Tippfehler mit zu vielen Nullen) würde sonst ein Datum weit außerhalb dessen ergeben, was
|
||
* die DATETIME-Spalte speichern kann (MySQL wirft dann eine harte SQL-Exception und die
|
||
* komplette Ticket-/Chat-Erstellung schlägt fehl – live an genau dieser Stelle beobachtet).
|
||
*/
|
||
function calc_sla_dates(array $priority, ?string $from = null, ?int $departmentId = null, ?int $helpTopicId = null): array
|
||
{
|
||
$slaMaxHours = 43800; // 5 Jahre – weit über jedem sinnvollen SLA-Wert, aber sicher innerhalb DATETIME-Grenzen
|
||
$from = $from ?? db_now();
|
||
// Ein zugeordneter SLA-Plan (Hilfethema vor Abteilung) ersetzt die Zeiten der Priorität
|
||
if ($plan = sla_plan_for(get_db(), $departmentId, $helpTopicId)) {
|
||
$priority['response_hours'] = $plan['response_hours'];
|
||
$priority['resolution_hours'] = $plan['resolution_hours'];
|
||
}
|
||
$responseHours = max(0, min($slaMaxHours, (int) $priority['response_hours']));
|
||
$resolutionHours = max(0, min($slaMaxHours, (int) $priority['resolution_hours']));
|
||
if (get_setting('business_hours_enabled', '0') === '1') {
|
||
return [
|
||
'first_response_due_at' => add_business_hours($from, $responseHours),
|
||
'resolution_due_at' => add_business_hours($from, $resolutionHours),
|
||
];
|
||
}
|
||
return [
|
||
'first_response_due_at' => add_hours($from, $responseHours),
|
||
'resolution_due_at' => add_hours($from, $resolutionHours),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Aktiver SLA-Plan für ein Ticket: der Plan des Hilfethemas hat Vorrang vor dem der Abteilung.
|
||
* null = kein Plan zugeordnet -> es gelten die Zeiten der Priorität.
|
||
*/
|
||
function sla_plan_for(PDO $pdo, ?int $departmentId, ?int $helpTopicId): ?array
|
||
{
|
||
$ids = [];
|
||
if ($helpTopicId) {
|
||
$s = $pdo->prepare('SELECT sla_plan_id FROM help_topics WHERE id = ?');
|
||
$s->execute([$helpTopicId]);
|
||
$ids[] = $s->fetchColumn();
|
||
}
|
||
if ($departmentId) {
|
||
$s = $pdo->prepare('SELECT sla_plan_id FROM departments WHERE id = ?');
|
||
$s->execute([$departmentId]);
|
||
$ids[] = $s->fetchColumn();
|
||
}
|
||
$stmt = $pdo->prepare('SELECT * FROM sla_plans WHERE id = ? AND is_active = 1');
|
||
foreach ($ids as $id) {
|
||
if (!$id) continue;
|
||
$stmt->execute([(int) $id]);
|
||
if ($plan = $stmt->fetch()) return $plan;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function slugify(string $text): string
|
||
{
|
||
$text = preg_replace('~[^\pL\d]+~u', '-', $text);
|
||
$text = trim($text, '-');
|
||
$text = iconv('UTF-8', 'ASCII//TRANSLIT', $text) ?: $text;
|
||
$text = strtolower($text);
|
||
$text = preg_replace('~[^-a-z0-9]+~', '', $text);
|
||
return $text !== '' ? $text : 'n-a';
|
||
}
|
||
|
||
/** Statuslabel + Badge-Farbe für die Oberfläche */
|
||
function status_label(string $status): array
|
||
{
|
||
$map = [
|
||
'open' => ['Offen', '#2563eb'],
|
||
'pending' => ['Wartet auf Kunde', '#a855f7'],
|
||
'answered' => ['Beantwortet', '#0d9488'],
|
||
'on_hold' => ['Pausiert', '#6b7280'],
|
||
'resolved' => ['Gelöst', '#16a34a'],
|
||
'closed' => ['Geschlossen', '#374151'],
|
||
];
|
||
return $map[$status] ?? [$status, '#888888'];
|
||
}
|
||
|
||
function format_dt(?string $dt): string
|
||
{
|
||
if (!$dt) return '–';
|
||
return date('d.m.Y H:i', strtotime($dt));
|
||
}
|
||
|
||
function time_until(?string $dt): string
|
||
{
|
||
if (!$dt) return '–';
|
||
$diff = strtotime($dt) - time();
|
||
$abs = abs($diff);
|
||
$hours = floor($abs / 3600);
|
||
$mins = floor(($abs % 3600) / 60);
|
||
$label = $hours > 0 ? "{$hours} Std {$mins} Min" : "{$mins} Min";
|
||
return $diff >= 0 ? "in {$label}" : "vor {$label} (überfällig)";
|
||
}
|
||
|
||
/**
|
||
* Die zur Wahl stehenden Signaturen für eine Antwort in einem Ticket (Web, Windows- und Android-App):
|
||
* eigene und die der Abteilung (bereinigtes HTML), Abteilungsname und die Voreinstellung ('own', 'dept' oder 'none').
|
||
*
|
||
* @return array{own: string, dept: string, deptName: string, default: string}
|
||
*/
|
||
function reply_signature_options(PDO $pdo, array $staff, int $departmentId): array
|
||
{
|
||
$own = trim((string) ($staff['signature'] ?? ''));
|
||
$stmt = $pdo->prepare('SELECT name, signature FROM departments WHERE id = ?');
|
||
$stmt->execute([$departmentId]);
|
||
$row = $stmt->fetch() ?: ['name' => '', 'signature' => ''];
|
||
$dept = trim((string) $row['signature']);
|
||
return [
|
||
'own' => $own !== '' ? sanitize_staff_html($own) : '',
|
||
'dept' => $dept !== '' ? sanitize_staff_html($dept) : '',
|
||
'deptName' => (string) $row['name'],
|
||
'default' => $own !== '' ? 'own' : ($dept !== '' ? 'dept' : 'none'),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Bestimmt die Signatur, die an eine öffentliche Antwort angehängt wird.
|
||
* $choice: 'own' = Signatur des Mitarbeiters, 'dept' = Signatur der Abteilung des Tickets, 'none' = keine,
|
||
* sonst (oder leer) das bisherige Verhalten: eigene Signatur, ersatzweise die der Abteilung.
|
||
* Gibt bereinigtes HTML zurück.
|
||
*/
|
||
function resolve_reply_signature(PDO $pdo, array $staff, int $departmentId, ?string $choice = null): string
|
||
{
|
||
$own = trim((string) ($staff['signature'] ?? ''));
|
||
$deptStmt = $pdo->prepare('SELECT signature FROM departments WHERE id = ?');
|
||
$deptStmt->execute([$departmentId]);
|
||
$dept = trim((string) $deptStmt->fetchColumn());
|
||
$signature = match ($choice) {
|
||
'own' => $own,
|
||
'dept' => $dept,
|
||
'none' => '',
|
||
default => $own !== '' ? $own : $dept,
|
||
};
|
||
return $signature === '' ? '' : sanitize_staff_html($signature);
|
||
}
|
||
|
||
/** Unterstützte Favicon-Formate: Endung => MIME-Typ. */
|
||
function favicon_types(): array
|
||
{
|
||
return ['ico' => 'image/x-icon', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml'];
|
||
}
|
||
|
||
/** Prüft, ob die Datei wirklich ein Bild des angegebenen Formats ist (nicht nur die Endung). Gibt null oder eine Fehlermeldung zurück. */
|
||
function favicon_validate_file(string $path, string $ext): ?string
|
||
{
|
||
$head = (string) @file_get_contents($path, false, null, 0, 16);
|
||
switch ($ext) {
|
||
case 'ico':
|
||
return str_starts_with($head, "\x00\x00\x01\x00") || str_starts_with($head, "\x00\x00\x02\x00") ? null : 'Das ist keine gültige ICO-Datei.';
|
||
case 'png': return str_starts_with($head, "\x89PNG") ? null : 'Das ist keine gültige PNG-Datei.';
|
||
case 'jpg':
|
||
case 'jpeg': return str_starts_with($head, "\xFF\xD8\xFF") ? null : 'Das ist keine gültige JPG-Datei.';
|
||
case 'gif': return str_starts_with($head, 'GIF8') ? null : 'Das ist keine gültige GIF-Datei.';
|
||
case 'webp': return str_starts_with($head, 'RIFF') && substr($head, 8, 4) === 'WEBP' ? null : 'Das ist keine gültige WebP-Datei.';
|
||
case 'svg':
|
||
$svg = (string) @file_get_contents($path, false, null, 0, 1048576);
|
||
if (!preg_match('/<svg\b/i', $svg)) return 'Das ist keine gültige SVG-Datei.';
|
||
// Als Favicon läuft eine SVG im Bild-Kontext ohne Skripte; trotzdem keine Datei mit Skripten oder Ereignis-Handlern annehmen
|
||
if (preg_match('/<script\b|<foreignObject\b|\son[a-z]+\s*=|javascript:/i', $svg)) return 'Die SVG enthält Skripte oder aktive Inhalte und wird nicht angenommen.';
|
||
return null;
|
||
}
|
||
return 'Dieses Format wird nicht unterstützt.';
|
||
}
|
||
|
||
/**
|
||
* Alle <link>-Tags für das Browser-Symbol (Tab, Lesezeichen, Startbildschirm). Ein eigenes Favicon (Einstellungen → Design) ersetzt alle
|
||
* Standard-Symbole, damit der Browser nicht ein anderes auswählt. Ohne Favicon gelten die Standard-Symbole und, falls vorhanden, das Logo.
|
||
*/
|
||
function site_icon_links(): string
|
||
{
|
||
$favicon = (string) get_setting('favicon_path', '');
|
||
$types = favicon_types();
|
||
if ($favicon !== '') {
|
||
$ext = strtolower(pathinfo((string) parse_url($favicon, PHP_URL_PATH), PATHINFO_EXTENSION));
|
||
$type = $types[$ext] ?? 'image/png';
|
||
$href = e($favicon);
|
||
$out = '<link rel="icon" href="' . $href . '" type="' . $type . '">' . "\n" . '<link rel="shortcut icon" href="' . $href . '">' . "\n";
|
||
$out .= in_array($ext, ['png', 'jpg', 'jpeg'], true) ? '<link rel="apple-touch-icon" href="' . $href . '">' : '<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">';
|
||
return $out . "\n";
|
||
}
|
||
$out = '<link rel="icon" href="/favicon.ico" sizes="any">' . "\n" . '<link rel="icon" href="/assets/icons/icon-192.png" type="image/png">' . "\n"
|
||
. '<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">' . "\n";
|
||
$logo = (string) get_setting('logo_path', '');
|
||
if ($logo !== '') $out .= '<link rel="icon" href="' . e($logo) . '">' . "\n";
|
||
return $out;
|
||
}
|
||
|
||
function base_url(): string
|
||
{
|
||
return rtrim(get_setting('site_url', config()['site']['url'] ?? 'http://localhost'), '/');
|
||
}
|
||
|
||
/**
|
||
* Baut ein sicheres SQL-Filterfragment für eine Abteilungs-Zugriffsbeschränkung.
|
||
* $deptIds === null -> kein Filter (Admin, voller Zugriff): ['', []]
|
||
* $deptIds === [] -> kein Zugriff auf irgendeine Abteilung: ['AND 1=0', []]
|
||
* $deptIds === [1,2] -> ['AND col IN (?,?)', [1,2]]
|
||
*/
|
||
function dept_filter_sql(?array $deptIds, string $column): array
|
||
{
|
||
if ($deptIds === null) {
|
||
return ['', []];
|
||
}
|
||
if (count($deptIds) === 0) {
|
||
return [' AND 1=0', []];
|
||
}
|
||
$placeholders = implode(',', array_fill(0, count($deptIds), '?'));
|
||
return [" AND {$column} IN ({$placeholders})", $deptIds];
|
||
}
|
||
|
||
/** Liste der Abteilungs-IDs, auf die ein Mitarbeiter Zugriff hat (Admins: null = alle) */
|
||
/** Darf dieser Mitarbeiter Tickets der Abteilung sehen? Admins alle, alle anderen nur ihre Abteilungen. */
|
||
function staff_can_access_department(array $staff, int $departmentId): bool
|
||
{
|
||
$ids = staff_department_ids($staff);
|
||
return $ids === null || in_array($departmentId, $ids, true);
|
||
}
|
||
|
||
function staff_department_ids(array $staff): ?array
|
||
{
|
||
if ($staff['role'] === 'admin') {
|
||
return null; // null = kein Filter, voller Zugriff
|
||
}
|
||
$ids = [];
|
||
if (!empty($staff['department_id'])) {
|
||
$ids[] = (int) $staff['department_id'];
|
||
}
|
||
$stmt = get_db()->prepare('SELECT department_id FROM staff_department WHERE staff_id = ?');
|
||
$stmt->execute([$staff['id']]);
|
||
foreach ($stmt->fetchAll() as $row) {
|
||
$ids[] = (int) $row['department_id'];
|
||
}
|
||
return array_values(array_unique($ids));
|
||
}
|
||
|
||
/** Löscht ein Ticket dauerhaft, protokolliert es vorher für Berichte */
|
||
function delete_ticket(PDO $pdo, array $ticket, ?int $deletedByStaffId): void
|
||
{
|
||
$stmt = $pdo->prepare(
|
||
'INSERT INTO deleted_tickets_log (ticket_number, department_id, priority_id, help_topic_id, assigned_staff_id, deleted_by, deleted_at)
|
||
VALUES (?,?,?,?,?,?,?)'
|
||
);
|
||
$stmt->execute([
|
||
$ticket['ticket_number'],
|
||
$ticket['department_id'] ?? null,
|
||
$ticket['priority_id'] ?? null,
|
||
$ticket['help_topic_id'] ?? null,
|
||
$ticket['assigned_staff_id'] ?? null,
|
||
$deletedByStaffId,
|
||
db_now(),
|
||
]);
|
||
$pdo->prepare('DELETE FROM tickets WHERE id = ?')->execute([$ticket['id']]);
|
||
}
|
||
|
||
/** Prüft, ob eine IP in einer Liste aus Einzeladressen und CIDR-Bereichen (IPv4/IPv6) steht. */
|
||
function ip_in_list(string $ip, array $list): bool
|
||
{
|
||
$bin = @inet_pton($ip);
|
||
if ($bin === false) return false;
|
||
foreach ($list as $entry) {
|
||
$entry = trim((string) $entry);
|
||
if ($entry === '') continue;
|
||
[$net, $bits] = array_pad(explode('/', $entry, 2), 2, null);
|
||
$nbin = @inet_pton($net);
|
||
if ($nbin === false || strlen($nbin) !== strlen($bin)) continue;
|
||
$bits = $bits === null ? strlen($bin) * 8 : max(0, min(strlen($bin) * 8, (int) $bits));
|
||
$bytes = intdiv($bits, 8);
|
||
if ($bytes > 0 && substr($bin, 0, $bytes) !== substr($nbin, 0, $bytes)) continue;
|
||
$rest = $bits % 8;
|
||
if ($rest > 0) {
|
||
$mask = (0xFF << (8 - $rest)) & 0xFF;
|
||
if ((ord($bin[$bytes]) & $mask) !== (ord($nbin[$bytes]) & $mask)) continue;
|
||
}
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Kommt die Anfrage direkt von einem als vertrauenswürdig eingetragenen Reverse-Proxy (config site → trusted_proxies)? */
|
||
function request_from_trusted_proxy(): bool
|
||
{
|
||
$list = (array) (config()['site']['trusted_proxies'] ?? []);
|
||
return $list !== [] && ip_in_list((string) ($_SERVER['REMOTE_ADDR'] ?? ''), $list);
|
||
}
|
||
|
||
/** Wurde die Anfrage per HTTPS gestellt (direkt oder über einen vertrauenswürdigen Proxy, der X-Forwarded-Proto setzt)? */
|
||
function request_is_https(): bool
|
||
{
|
||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') return true;
|
||
return request_from_trusted_proxy() && strtolower(trim(explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? ''))[0])) === 'https';
|
||
}
|
||
|
||
/**
|
||
* Ermittelt die Client-IP für Rate-Limiting. Ohne eingetragene Proxys zählt nur REMOTE_ADDR (ein Besucher kann
|
||
* X-Forwarded-For selbst setzen und die Sperre sonst umgehen). Steht der Proxy in "trusted_proxies", gilt die
|
||
* erste Adresse von rechts in X-Forwarded-For, die nicht selbst ein vertrauenswürdiger Proxy ist.
|
||
*/
|
||
function client_ip(): string
|
||
{
|
||
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||
if (!request_from_trusted_proxy()) return $remote;
|
||
$trusted = (array) (config()['site']['trusted_proxies'] ?? []);
|
||
$chain = array_reverse(array_map('trim', explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))));
|
||
foreach ($chain as $ip) {
|
||
if (filter_var($ip, FILTER_VALIDATE_IP) && !ip_in_list($ip, $trusted)) return $ip;
|
||
}
|
||
return $remote;
|
||
}
|
||
|
||
/** Schreibt einen Eintrag ins Admin-Aktionsprotokoll (Einstellungen, Stammdaten – getrennt vom Ticket-Aktivitätsprotokoll) */
|
||
function audit_log(?int $staffId, string $action): void
|
||
{
|
||
// Spalte ist VARCHAR(255) NOT NULL – bei MySQL im Strict-Modus wirft ein zu langer
|
||
// Wert sonst eine harte Exception statt einfach zu kürzen.
|
||
get_db()->prepare('INSERT INTO admin_audit_log (staff_id, action, created_at) VALUES (?,?,?)')
|
||
->execute([$staffId, mb_substr($action, 0, 255), db_now()]);
|
||
}
|
||
|
||
/**
|
||
* Einfache Rate-Begrenzung: prüft, ob unter $bucket/$identifier in den letzten
|
||
* $windowMinutes Minuten bereits $maxAttempts Einträge protokolliert wurden.
|
||
* Bei Erlaubnis wird der Versuch sofort selbst protokolliert (Aufruf = Verbrauch).
|
||
*/
|
||
function rate_limit_check(string $bucket, string $identifier, int $maxAttempts, int $windowMinutes): bool
|
||
{
|
||
// Alte Einträge regelmäßig löschen (ca. 1% der Aufrufe)
|
||
if (random_int(1, 100) === 1) {
|
||
get_db()->prepare('DELETE FROM rate_limit_log WHERE created_at < ?')
|
||
->execute([date('Y-m-d H:i:s', time() - 86400)]);
|
||
}
|
||
|
||
$pdo = get_db();
|
||
$since = date('Y-m-d H:i:s', time() - $windowMinutes * 60);
|
||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM rate_limit_log WHERE bucket = ? AND identifier = ? AND created_at >= ?');
|
||
$stmt->execute([$bucket, $identifier, $since]);
|
||
$count = (int) $stmt->fetchColumn();
|
||
if ($count >= $maxAttempts) {
|
||
return false;
|
||
}
|
||
$pdo->prepare('INSERT INTO rate_limit_log (bucket, identifier, created_at) VALUES (?,?,?)')
|
||
->execute([$bucket, $identifier, db_now()]);
|
||
return true;
|
||
}
|
||
|
||
/** Erzeugt ein Passwort-Reset-Token, speichert nur dessen Hash, gibt das Klartext-Token zurück (für den Link) */
|
||
function create_password_reset(string $accountType, string $email): string
|
||
{
|
||
$pdo = get_db();
|
||
// 48 Bytes = 96 hex chars – ausreichend Entropie gegen Brute-Force
|
||
$token = bin2hex(random_bytes(48));
|
||
// Alte, ungenutzte Tokens für diese E-Mail ungültig machen
|
||
$pdo->prepare('UPDATE password_resets SET used_at = ? WHERE account_type = ? AND email = ? AND used_at IS NULL')
|
||
->execute([db_now(), $accountType, $email]);
|
||
$pdo->prepare('INSERT INTO password_resets (account_type, email, token_hash, expires_at, created_at) VALUES (?,?,?,?,?)')
|
||
->execute([$accountType, $email, hash('sha256', $token), date('Y-m-d H:i:s', time() + 3600), db_now()]);
|
||
return $token;
|
||
}
|
||
|
||
/**
|
||
* Prüft ob ein Passwort die Mindestanforderungen erfüllt.
|
||
* Gibt null zurück wenn OK, sonst eine Fehlermeldung.
|
||
*/
|
||
function validate_password_strength(string $password): ?string
|
||
{
|
||
if (strlen($password) < 10) {
|
||
return 'Das Passwort muss mindestens 10 Zeichen lang sein.';
|
||
}
|
||
if (!preg_match('/[A-Z]/', $password)) {
|
||
return 'Das Passwort muss mindestens einen Großbuchstaben enthalten.';
|
||
}
|
||
if (!preg_match('/[a-z]/', $password)) {
|
||
return 'Das Passwort muss mindestens einen Kleinbuchstaben enthalten.';
|
||
}
|
||
if (!preg_match('/[0-9]/', $password)) {
|
||
return 'Das Passwort muss mindestens eine Ziffer enthalten.';
|
||
}
|
||
if (!preg_match('/[^A-Za-z0-9]/', $password)) {
|
||
return 'Das Passwort muss mindestens ein Sonderzeichen enthalten (!@#$%&* usw.).';
|
||
}
|
||
// Triviale Passwörter blockieren
|
||
$trivial = ['password','passwort','12345678','123456789','1234567890','qwertzui','admin123','welcome1'];
|
||
if (in_array(strtolower($password), $trivial, true)) {
|
||
return 'Dieses Passwort ist zu häufig verwendet. Bitte wählen Sie ein sichereres Passwort.';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Verschlüsselt einen sensiblen Wert (z.B. SMTP-Passwort) mit AES-256-GCM.
|
||
* Schlüssel kommt aus config app_key oder wird aus DB-Credentials abgeleitet.
|
||
*/
|
||
function encrypt_secret(string $plaintext): string
|
||
{
|
||
$key = _get_app_key();
|
||
$iv = random_bytes(12);
|
||
$tag = '';
|
||
$encrypted = openssl_encrypt($plaintext, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, '', 16);
|
||
return base64_encode($iv . $tag . $encrypted);
|
||
}
|
||
|
||
/** Entschlüsselt einen mit encrypt_secret() verschlüsselten Wert. */
|
||
function decrypt_secret(string $ciphertext): string
|
||
{
|
||
if ($ciphertext === '') return '';
|
||
// Legacy: wenn nicht base64-verschlüsselt, Klartext zurückgeben (Migration)
|
||
$raw = base64_decode($ciphertext, true);
|
||
if ($raw === false || strlen($raw) < 28) return $ciphertext;
|
||
$key = _get_app_key();
|
||
$iv = substr($raw, 0, 12);
|
||
$tag = substr($raw, 12, 16);
|
||
$encrypted = substr($raw, 28);
|
||
$decrypted = openssl_decrypt($encrypted, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
|
||
return $decrypted !== false ? $decrypted : $ciphertext; // Fallback auf Klartext
|
||
}
|
||
|
||
/** Interner Schlüssel für Verschlüsselung – 32 Bytes. */
|
||
/**
|
||
* Interner Schlüssel für AES-256-GCM-Verschlüsselung – 32 Bytes.
|
||
*
|
||
* Priorität:
|
||
* 1. settings-Tabelle (key = 'app_key') → überlebt Updates ohne config.php-Änderung
|
||
* 2. config.php → app_key → gesetzt vom Installer oder manuell
|
||
* 3. Fallback aus DB-Credentials → schwach, aber nie null (Warnung wird angezeigt)
|
||
*/
|
||
function _get_app_key(): string
|
||
{
|
||
// 1. DB-Setting hat höchste Priorität (überlebt Updates)
|
||
try {
|
||
$stmt = get_db()->prepare('SELECT setting_value FROM settings WHERE setting_key = ?');
|
||
$stmt->execute(['app_key']);
|
||
$dbKey = $stmt->fetchColumn();
|
||
if ($dbKey !== false && $dbKey !== '') {
|
||
return substr(hash('sha256', $dbKey, true), 0, 32);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
// DB noch nicht verfügbar (z.B. während Installation) → weiter zum Fallback
|
||
}
|
||
|
||
// 2. config.php
|
||
$cfg = config();
|
||
if (!empty($cfg['app_key'])) {
|
||
return substr(hash('sha256', $cfg['app_key'], true), 0, 32);
|
||
}
|
||
|
||
// 3. Schwacher Fallback (Warnung wird im Admin-Header angezeigt)
|
||
$seed = ($cfg['db']['pass'] ?? '') . ($cfg['db']['name'] ?? '') . ($cfg['db']['host'] ?? 'localhost');
|
||
return substr(hash('sha256', $seed . 'deskhub-secret', true), 0, 32);
|
||
}
|
||
|
||
/** Setzt einen Rate-Limit-Zähler zurück (z.B. nach erfolgreichem Login) */
|
||
function rate_limit_reset(string $action, string $key): void
|
||
{
|
||
try {
|
||
get_db()->prepare('DELETE FROM rate_limit_log WHERE bucket = ? AND identifier = ?')
|
||
->execute([$action, $key]);
|
||
} catch (Throwable $e) { /* ignorieren */ }
|
||
}
|
||
|
||
/** Prüft ein Passwort-Reset-Token und gibt die zugehörige E-Mail-Adresse zurück, oder null wenn ungültig/abgelaufen/verbraucht */
|
||
function verify_password_reset(string $accountType, string $token): ?string
|
||
{
|
||
$pdo = get_db();
|
||
$stmt = $pdo->prepare(
|
||
"SELECT * FROM password_resets WHERE account_type = ? AND token_hash = ? AND used_at IS NULL AND expires_at >= ?
|
||
ORDER BY id DESC LIMIT 1"
|
||
);
|
||
$stmt->execute([$accountType, hash('sha256', $token), db_now()]);
|
||
$row = $stmt->fetch();
|
||
return $row ? $row['email'] : null;
|
||
}
|
||
|
||
/** Markiert ein Reset-Token als verbraucht (nach erfolgreichem Zurücksetzen) */
|
||
function consume_password_reset(string $accountType, string $token): void
|
||
{
|
||
$pdo = get_db();
|
||
$pdo->prepare('UPDATE password_resets SET used_at = ? WHERE account_type = ? AND token_hash = ?')
|
||
->execute([db_now(), $accountType, hash('sha256', $token)]);
|
||
}
|
||
|
||
/**
|
||
* Wandelt eine kleine, sichere Teilmenge von Markdown in HTML um (für Ticket-Antworten).
|
||
* Escaped IMMER zuerst den kompletten Text, wendet danach nur feste Muster an –
|
||
* dadurch ist eingegebenes HTML niemals aktiv, unabhängig vom Absender.
|
||
*/
|
||
function markdown_to_html(string $text): string
|
||
{
|
||
$text = trim($text);
|
||
if ($text === '') return '';
|
||
|
||
$escaped = e($text);
|
||
|
||
// Code-Abschnitte `code`
|
||
$escaped = preg_replace('/`([^`\n]+)`/', '<code>$1</code>', $escaped);
|
||
// Fett **text**
|
||
$escaped = preg_replace('/\*\*([^\*\n]+)\*\*/', '<strong>$1</strong>', $escaped);
|
||
// Kursiv *text*
|
||
$escaped = preg_replace('/\*([^\*\n]+)\*/', '<em>$1</em>', $escaped);
|
||
// Links [text](https://...) - nur http(s) erlaubt
|
||
$escaped = preg_replace_callback('/\[([^\]\n]+)\]\((https?:\/\/[^\s\)]+)\)/', function ($m) {
|
||
return '<a href="' . e($m[2]) . '" target="_blank" rel="noopener noreferrer">' . $m[1] . '</a>';
|
||
}, $escaped);
|
||
|
||
// Zeilen in Absätze/Listen umwandeln
|
||
$lines = preg_split('/\r?\n/', $escaped);
|
||
$html = '';
|
||
$inList = false;
|
||
foreach ($lines as $line) {
|
||
if (preg_match('/^-\s+(.*)/', $line, $m)) {
|
||
if (!$inList) { $html .= '<ul>'; $inList = true; }
|
||
$html .= '<li>' . $m[1] . '</li>';
|
||
} else {
|
||
if ($inList) { $html .= '</ul>'; $inList = false; }
|
||
$html .= trim($line) === '' ? '<br>' : '<p>' . $line . '</p>';
|
||
}
|
||
}
|
||
if ($inList) $html .= '</ul>';
|
||
return $html;
|
||
}
|
||
|
||
/** Schreibt einen Eintrag ins Aktivitätsprotokoll eines Tickets */
|
||
function log_activity(PDO $pdo, int $ticketId, ?int $staffId, string $action): void
|
||
{
|
||
$stmt = $pdo->prepare('INSERT INTO activity_log (ticket_id, staff_id, action, created_at) VALUES (?,?,?,?)');
|
||
$stmt->execute([$ticketId, $staffId, $action, db_now()]);
|
||
}
|
||
|
||
/**
|
||
* Sperre für Live-Chat-Tickets (source='chat'): verhindert, dass zwei Mitarbeiter
|
||
* unbemerkt gleichzeitig im selben Chat antworten – egal ob über das Web-Backend
|
||
* (staff/live_chat.php) oder die Mobile-App (api/staff/v1/ticket_message.php), beide
|
||
* teilen sich diese eine Logik, damit sie nicht auseinanderlaufen. Wer den Chat öffnet
|
||
* bzw. beantwortet, "besitzt" ihn für ein paar Minuten; danach läuft die Sperre
|
||
* automatisch ab (kein Hängenbleiben, wenn z.B. die App einfach geschlossen wird).
|
||
* Andere Mitarbeiter dürfen den Chat trotzdem weiter LESEN – nur das Schreiben wird
|
||
* ihnen verwehrt, siehe Aufrufer.
|
||
*/
|
||
const LIVE_CHAT_LOCK_TIMEOUT_MINUTES = 5;
|
||
|
||
/** Name des Mitarbeiters, der $ticket gerade frisch gesperrt hält – oder null, wenn
|
||
* frei, selbst gesperrt, oder die Sperre abgelaufen ist. */
|
||
function live_chat_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string
|
||
{
|
||
$lockFresh = !empty($ticket['locked_at'])
|
||
&& (time() - strtotime($ticket['locked_at'])) < LIVE_CHAT_LOCK_TIMEOUT_MINUTES * 60;
|
||
if (empty($ticket['locked_by']) || (int) $ticket['locked_by'] === $staffId || !$lockFresh) {
|
||
return null;
|
||
}
|
||
$stmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?');
|
||
$stmt->execute([$ticket['locked_by']]);
|
||
return $stmt->fetchColumn() ?: 'ein anderer Mitarbeiter';
|
||
}
|
||
|
||
/** Übernimmt/erneuert die Sperre für $staffId (nur aufrufen, wenn nicht bereits
|
||
* frisch von jemand anderem gehalten – siehe live_chat_locked_by_other()). */
|
||
function live_chat_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void
|
||
{
|
||
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?')
|
||
->execute([$staffId, db_now(), $ticketId]);
|
||
}
|
||
|
||
/**
|
||
* Generelle Ticket-Sperre für ALLE Tickets (nicht Live-Chat-spezifisch, siehe oben) –
|
||
* verhindert, dass zwei Mitarbeiter unbemerkt gleichzeitig am selben Ticket arbeiten.
|
||
* Nutzt dieselben locked_by/locked_at-Spalten, aber eine "harte" Semantik: anders als
|
||
* beim Live-Chat wird hier nicht nur das Schreiben, sondern auch das Öffnen selbst
|
||
* verweigert (siehe Aufrufer in staff/ticket_view.php und api/staff/v1/ticket.php).
|
||
* Für Live-Chat-Tickets (source='chat') gilt weiterhin die separate, mildere
|
||
* live_chat_locked_by_other()-Logik – NIEMALS beide auf dasselbe Ticket anwenden, sonst
|
||
* würden sich die Timeouts widersprechen.
|
||
* (Ursprünglich 10 Minuten, auf Nutzerwunsch verkürzt: wer ein Ticket nur kurz öffnet und
|
||
* dann wegnavigiert ohne zu antworten/freizugeben, blockiert es sonst unnötig lange für
|
||
* andere. Admins können eine fremde Sperre zusätzlich jederzeit sofort aufheben, siehe
|
||
* flash_locked_ticket()/get_locked_ticket_override().)
|
||
*/
|
||
const TICKET_LOCK_TIMEOUT_MINUTES = 5;
|
||
|
||
/** Name des Mitarbeiters, der $ticket gerade frisch gesperrt hält – oder null, wenn
|
||
* frei, selbst gesperrt, oder die Sperre abgelaufen ist. */
|
||
function ticket_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string
|
||
{
|
||
$lockFresh = !empty($ticket['locked_at'])
|
||
&& (time() - strtotime($ticket['locked_at'])) < TICKET_LOCK_TIMEOUT_MINUTES * 60;
|
||
if (empty($ticket['locked_by']) || (int) $ticket['locked_by'] === $staffId || !$lockFresh) {
|
||
return null;
|
||
}
|
||
$stmt = $pdo->prepare('SELECT name FROM staff WHERE id = ?');
|
||
$stmt->execute([$ticket['locked_by']]);
|
||
return $stmt->fetchColumn() ?: 'einem anderen Mitarbeiter';
|
||
}
|
||
|
||
/** Übernimmt/erneuert die generelle Ticket-Sperre (nur aufrufen, wenn nicht bereits
|
||
* frisch von jemand anderem gehalten – siehe ticket_locked_by_other()). */
|
||
function ticket_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void
|
||
{
|
||
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?')
|
||
->execute([$staffId, db_now(), $ticketId]);
|
||
}
|
||
|
||
/** Liest eine Einstellung aus der settings-Tabelle (z.B. Logo-Pfad) */
|
||
/** Geteilter In-Request-Cache für get_setting / set_setting (per Referenz). */
|
||
function &_settings_cache(): array
|
||
{
|
||
static $cache = [];
|
||
return $cache;
|
||
}
|
||
|
||
/** Liest eine Einstellung aus der DB (mit In-Request-Cache). */
|
||
function get_setting(string $key, ?string $default = null): ?string
|
||
{
|
||
$cache = &_settings_cache();
|
||
if (array_key_exists($key, $cache)) {
|
||
return $cache[$key];
|
||
}
|
||
$stmt = get_db()->prepare('SELECT setting_value FROM settings WHERE setting_key = ?');
|
||
$stmt->execute([$key]);
|
||
$value = $stmt->fetchColumn();
|
||
return $cache[$key] = ($value !== false ? $value : $default);
|
||
}
|
||
|
||
/**
|
||
* Speichert eine Einstellung (atomares Upsert, treiber-unabhängig)
|
||
* und aktualisiert sofort den In-Request-Cache.
|
||
*/
|
||
function set_setting(string $key, ?string $value): void
|
||
{
|
||
$pdo = get_db();
|
||
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
|
||
if ($driver === 'sqlite') {
|
||
$pdo->prepare('INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES (?,?)')
|
||
->execute([$key, $value]);
|
||
} else {
|
||
$pdo->prepare(
|
||
'INSERT INTO settings (setting_key, setting_value) VALUES (?,?)
|
||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
|
||
)->execute([$key, $value]);
|
||
}
|
||
|
||
// Cache sofort aktualisieren – nachfolgende get_setting()-Aufrufe im selben
|
||
// Request liefern den neuen Wert ohne erneuten DB-Trip.
|
||
$cache = &_settings_cache();
|
||
$cache[$key] = $value;
|
||
}
|
||
|
||
/** Formatiert einen Zeitstempel als relative "vor X Min./Std./Tagen"-Angabe (für Cron-/Postfach-Status-Anzeigen). */
|
||
function cron_last_run_label(?string $dt): string
|
||
{
|
||
if (!$dt) return 'Noch nie';
|
||
$mins = (int) round((time() - strtotime($dt)) / 60);
|
||
if ($mins < 1) return 'Gerade eben';
|
||
if ($mins < 60) return "vor {$mins} Min.";
|
||
$h = (int) round($mins / 60);
|
||
if ($h < 48) return "vor {$h} Std.";
|
||
return 'vor ' . (int) round($h / 24) . ' Tagen';
|
||
}
|
||
|
||
/**
|
||
* Sorgt dafür, dass die E-Mail-Adresse einer Abteilung automatisch auch als
|
||
* Eintrag in der Routing-Tabelle (email_addresses) existiert, damit eingehende
|
||
* Mails an diese Adresse ohne separaten Schritt in der richtigen Abteilung
|
||
* landen (siehe staff/departments.php, staff/email_addresses.php).
|
||
* Bereits vorhandene Routing-Einträge für DIESELBE Adresse werden nie
|
||
* überschrieben – auch nicht, wenn sie auf eine andere Abteilung zeigen.
|
||
*/
|
||
function sync_department_email_route(PDO $pdo, int $departmentId, string $email): void
|
||
{
|
||
$email = trim(strtolower($email));
|
||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) return;
|
||
|
||
$stmt = $pdo->prepare('SELECT id FROM email_addresses WHERE LOWER(email) = ?');
|
||
$stmt->execute([$email]);
|
||
if ($stmt->fetchColumn()) {
|
||
return; // Adresse ist bereits geroutet (an diese oder eine andere Abteilung) – nicht anfassen
|
||
}
|
||
|
||
$pdo->prepare('UPDATE email_addresses SET is_default = 0 WHERE department_id = ?')->execute([$departmentId]);
|
||
$pdo->prepare('INSERT INTO email_addresses (email, label, department_id, priority_id, is_default, created_at) VALUES (?,?,?,NULL,1,?)')
|
||
->execute([$email, 'Automatisch (Abteilungs-E-Mail)', $departmentId, db_now()]);
|
||
}
|
||
|
||
/**
|
||
* Abteilungen für Auswahllisten, in denen etwas NEU zugeordnet wird (Weiterleiten, Routing,
|
||
* Regeln, Hilfethemen …): nur aktive Abteilungen plus die bereits gewählten ($keepIds), damit
|
||
* bestehende Zuordnungen beim Bearbeiten nicht verloren gehen.
|
||
*/
|
||
function departments_for_select(PDO $pdo, array $keepIds = []): array
|
||
{
|
||
$keep = array_values(array_filter(array_map('intval', $keepIds)));
|
||
$sql = 'SELECT * FROM departments WHERE is_active = 1';
|
||
if ($keep) $sql .= ' OR id IN (' . implode(',', $keep) . ')';
|
||
return $pdo->query($sql . ' ORDER BY name')->fetchAll();
|
||
}
|
||
|
||
/** Liefert die ID einer aktiven Abteilung: $id selbst, sonst die Standardabteilung, sonst irgendeine aktive. */
|
||
function active_department_id(PDO $pdo, ?int $id): ?int
|
||
{
|
||
if ($id) {
|
||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM departments WHERE id = ? AND is_active = 1');
|
||
$stmt->execute([$id]);
|
||
if ((int) $stmt->fetchColumn()) return $id;
|
||
}
|
||
$fallback = $pdo->query('SELECT id FROM departments WHERE is_active = 1 ORDER BY is_default DESC, id ASC LIMIT 1')->fetchColumn();
|
||
return $fallback ? (int) $fallback : $id;
|
||
}
|
||
|
||
/**
|
||
* Hilfethemen als flache, hierarchisch sortierte Liste (Eltern vor Kindern).
|
||
* Jede Zeile enthält zusätzlich 'path' ("Eltern / Kind") und 'depth'.
|
||
* Kunden sehen nur aktive, öffentliche Themen ($publicOnly), Mitarbeiter alle aktiven.
|
||
*/
|
||
function help_topic_options(PDO $pdo, bool $publicOnly = false, bool $activeOnly = true): array
|
||
{
|
||
$rows = $pdo->query(
|
||
'SELECT h.*, COALESCE(d.is_active, 1) AS dept_active FROM help_topics h LEFT JOIN departments d ON d.id = h.department_id ORDER BY h.sort_order, h.name'
|
||
)->fetchAll();
|
||
$byParent = [];
|
||
$byId = [];
|
||
foreach ($rows as $r) {
|
||
$byId[(int) $r['id']] = $r;
|
||
$pid = !empty($r['parent_id']) ? (int) $r['parent_id'] : 0;
|
||
$byParent[$pid][] = $r;
|
||
}
|
||
$out = [];
|
||
$walk = function (int $parent, string $prefix, int $depth, array $seen) use (&$walk, &$out, $byParent, $publicOnly, $activeOnly) {
|
||
foreach ($byParent[$parent] ?? [] as $r) {
|
||
if (isset($seen[(int) $r['id']])) continue; // Schutz vor Zyklen
|
||
$path = $prefix === '' ? $r['name'] : $prefix . ' / ' . $r['name'];
|
||
$visible = (!$activeOnly || ($r['is_active'] && $r['dept_active'])) && (!$publicOnly || ($r['is_public'] ?? 1));
|
||
if ($visible) {
|
||
$r['path'] = $path;
|
||
$r['depth'] = $depth;
|
||
$out[] = $r;
|
||
}
|
||
$walk((int) $r['id'], $path, $depth + 1, $seen + [(int) $r['id'] => true]);
|
||
}
|
||
};
|
||
$walk(0, '', 0, []);
|
||
// Themen, deren Elternthema nicht existiert, trotzdem anzeigen
|
||
$shown = array_column($out, 'id');
|
||
foreach ($rows as $r) {
|
||
if (!empty($r['parent_id']) && !isset($byId[(int) $r['parent_id']]) && !in_array($r['id'], $shown, true)) {
|
||
if ((!$activeOnly || ($r['is_active'] && $r['dept_active'])) && (!$publicOnly || ($r['is_public'] ?? 1))) {
|
||
$r['path'] = $r['name']; $r['depth'] = 0; $out[] = $r;
|
||
}
|
||
}
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/** Gibt die erlaubten Upload-Dateiendungen zurück (aus DB-Setting oder config-Fallback). */
|
||
function get_allowed_extensions(): array
|
||
{
|
||
$setting = get_setting('upload_allowed_extensions', '');
|
||
if ($setting) {
|
||
$exts = array_filter(array_map('trim', explode(',', strtolower($setting))));
|
||
if ($exts) return array_values($exts);
|
||
}
|
||
return config()['uploads']['allowed_extensions'];
|
||
}
|
||
|
||
/** Gibt die maximale Upload-Größe in Bytes zurück (aus DB-Setting oder config-Fallback). */
|
||
function get_max_upload_size(): int
|
||
{
|
||
$setting = get_setting('upload_max_size_mb', '');
|
||
if ($setting && (int)$setting > 0) {
|
||
return (int)$setting * 1024 * 1024;
|
||
}
|
||
return config()['uploads']['max_size_bytes'];
|
||
}
|
||
|
||
function handle_ticket_upload(array $file): array
|
||
{
|
||
$cfg = config()['uploads'];
|
||
$allowed = get_allowed_extensions();
|
||
$maxSize = get_max_upload_size();
|
||
|
||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||
return ['error' => 'Der Datei-Upload ist fehlgeschlagen.'];
|
||
}
|
||
if ($file['size'] > $maxSize) {
|
||
return ['error' => 'Die Datei ist zu groß (max. ' . round($maxSize / 1024 / 1024) . ' MB).'];
|
||
}
|
||
// Null-Byte im Dateinamen verhindern (PHP < 8 war anfällig)
|
||
if (strpos($file['name'], "\0") !== false) {
|
||
return ['error' => 'Ungültiger Dateiname.'];
|
||
}
|
||
// .htaccess und ähnliche Systemdateien explizit blockieren
|
||
$basename = strtolower(basename($file['name']));
|
||
if (in_array($basename, ['.htaccess', '.htpasswd', 'web.config', '.env', 'php.ini'], true)) {
|
||
return ['error' => 'Dieser Dateiname ist nicht erlaubt.'];
|
||
}
|
||
// Endung prüfen (nur letzte Endung – verhindert doppelte wie shell.php.jpg)
|
||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||
// Doppelte Endungen blockieren (z.B. evil.php.jpg)
|
||
$allParts = explode('.', strtolower($file['name']));
|
||
// Auch wenn ein Admin die Liste der erlaubten Endungen erweitert: Typen, die ein Browser auf unserer Domain
|
||
// als Seite/Skript ausführen würde (Uploads sind direkt abrufbar), und ausführbare Dateien bleiben gesperrt.
|
||
$dangerousExts = ['php','php3','php4','php5','php7','php8','phtml','phar','asp','aspx','jsp','jspx','sh','py','pl','cgi','exe',
|
||
'html','htm','xhtml','shtml','svg','svgz','xml','xsl','xslt','js','mjs','htc','swf','hta','jar','vbs','bat','cmd','ps1','msi','dll','com','scr','cer','crt'];
|
||
foreach ($allParts as $part) {
|
||
if (in_array($part, $dangerousExts, true)) {
|
||
return ['error' => 'Dieser Dateityp ist aus Sicherheitsgründen nicht erlaubt.'];
|
||
}
|
||
}
|
||
if (!in_array($ext, $allowed, true)) {
|
||
return ['error' => 'Dieser Dateityp ist nicht erlaubt (' . e($ext) . '). Erlaubt: ' . implode(', ', $allowed)];
|
||
}
|
||
// MIME-Typ per finfo prüfen (verhindert verschleierte Dateitypen)
|
||
$dangerousMimes = [
|
||
'text/x-php','application/x-php','application/php','text/php',
|
||
'application/x-httpd-php','application/x-httpd-php-source',
|
||
'text/x-script.php','application/x-sh','text/x-shellscript',
|
||
];
|
||
if (function_exists('finfo_open')) {
|
||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||
$mimeType = finfo_file($finfo, $file['tmp_name']);
|
||
finfo_close($finfo);
|
||
if (in_array($mimeType, $dangerousMimes, true)) {
|
||
return ['error' => 'Dieser Dateityp ist aus Sicherheitsgründen nicht erlaubt.'];
|
||
}
|
||
}
|
||
// Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz
|
||
$storedName = bin2hex(random_bytes(16)) . '.' . $ext;
|
||
$uploadsDir = realpath(rtrim($cfg['path'], '/'));
|
||
$dest = $uploadsDir . '/' . $storedName;
|
||
if (!$uploadsDir || strpos($dest, $uploadsDir) !== 0) {
|
||
return ['error' => 'Ungültiger Upload-Pfad.'];
|
||
}
|
||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||
return ['error' => 'Die Datei konnte nicht gespeichert werden.'];
|
||
}
|
||
return ['original_name' => $file['name'], 'stored_name' => $storedName, 'filesize' => $file['size']];
|
||
}
|
||
|
||
/** Verarbeitet mehrere Datei-Uploads auf einmal (multi-file input).
|
||
* Gibt ein Array von Ergebnissen zurück – jedes entweder mit 'error' oder mit den Upload-Daten. */
|
||
function handle_ticket_uploads_multi(array $filesInput): array
|
||
{
|
||
$results = [];
|
||
// $_FILES['attachments'] hat Struktur: ['name'=>[...], 'tmp_name'=>[...], ...]
|
||
$count = is_array($filesInput['name']) ? count($filesInput['name']) : 0;
|
||
for ($i = 0; $i < $count; $i++) {
|
||
if (empty($filesInput['name'][$i])) continue;
|
||
$single = [
|
||
'name' => $filesInput['name'][$i],
|
||
'type' => $filesInput['type'][$i],
|
||
'tmp_name' => $filesInput['tmp_name'][$i],
|
||
'error' => $filesInput['error'][$i],
|
||
'size' => $filesInput['size'][$i],
|
||
];
|
||
$results[] = handle_ticket_upload($single);
|
||
}
|
||
return $results;
|
||
}
|
||
|
||
/** Gruppiert ein Aggregat-Ergebnis (Spalten "grp"/"c") als [Schlüssel => Anzahl]; "none" für NULL-Gruppe */
|
||
function fetch_grouped_counts(PDO $pdo, string $sql, array $params): array
|
||
{
|
||
$out = [];
|
||
$stmt = $pdo->prepare($sql);
|
||
$stmt->execute($params);
|
||
foreach ($stmt->fetchAll() as $row) {
|
||
$key = $row['grp'] === null ? 'none' : (string) $row['grp'];
|
||
$out[$key] = (int) $row['c'];
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
function report_avg_response_hours_expression(string $driver): string
|
||
{
|
||
return $driver === 'sqlite'
|
||
? '(julianday(t.first_responded_at) - julianday(t.created_at)) * 24'
|
||
: 'TIMESTAMPDIFF(SECOND, t.created_at, t.first_responded_at) / 3600.0';
|
||
}
|
||
|
||
/** Baut die Statistik-Tabelle (Geöffnet/Zugewiesen/Überfällig/Geschlossen/Wieder geöffnet/Gelöscht/Servicezeit/Reaktionszeit) je Dimension */
|
||
/** Baut die Statistik-Tabelle (Geöffnet/Zugewiesen/Überfällig/Geschlossen/Wieder geöffnet/Gelöscht/Servicezeit/Reaktionszeit) je Dimension.
|
||
* $deptIds: null = kein Filter (Admin, alle Abteilungen), Array = nur diese Abteilungen (abteilungsbeschränkte Mitarbeiter). */
|
||
function build_report_table(PDO $pdo, string $dimColumn, string $from, string $to, ?array $deptIds = null): array
|
||
{
|
||
$tCol = "t.{$dimColumn}";
|
||
[$deptFilterT, $deptParamsT] = dept_filter_sql($deptIds, 't.department_id');
|
||
[$deptFilterPlain, $deptParamsPlain] = dept_filter_sql($deptIds, 'department_id');
|
||
|
||
$geoeffnet = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t WHERE t.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]);
|
||
$geschlossen = fetch_grouped_counts($pdo, "SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t WHERE t.closed_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]);
|
||
$zugewiesen = fetch_grouped_counts($pdo,
|
||
"SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id
|
||
WHERE al.action LIKE 'Mitarbeiter zugewiesen:%' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]);
|
||
$ueberfaellig = fetch_grouped_counts($pdo,
|
||
"SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id
|
||
WHERE al.action LIKE 'SLA %' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]);
|
||
$wiederGeoeffnet = fetch_grouped_counts($pdo,
|
||
"SELECT {$tCol} AS grp, COUNT(*) c FROM tickets t JOIN activity_log al ON al.ticket_id = t.id
|
||
WHERE al.action = 'Ticket wieder geöffnet' AND al.created_at BETWEEN ? AND ?{$deptFilterT} GROUP BY {$tCol}", [$from, $to, ...$deptParamsT]);
|
||
$geloescht = fetch_grouped_counts($pdo,
|
||
"SELECT {$dimColumn} AS grp, COUNT(*) c FROM deleted_tickets_log WHERE deleted_at BETWEEN ? AND ?{$deptFilterPlain} GROUP BY {$dimColumn}", [$from, $to, ...$deptParamsPlain]);
|
||
|
||
$serviceTimes = [];
|
||
$stmt = $pdo->prepare("SELECT {$tCol} AS grp, t.created_at, t.resolved_at FROM tickets t WHERE t.resolved_at BETWEEN ? AND ?{$deptFilterT}");
|
||
$stmt->execute([$from, $to, ...$deptParamsT]);
|
||
foreach ($stmt->fetchAll() as $r) {
|
||
$key = $r['grp'] === null ? 'none' : (string) $r['grp'];
|
||
$serviceTimes[$key][] = (strtotime($r['resolved_at']) - strtotime($r['created_at'])) / 3600;
|
||
}
|
||
|
||
$responseTimes = [];
|
||
$stmt = $pdo->prepare("SELECT {$tCol} AS grp, t.created_at, t.first_responded_at FROM tickets t WHERE t.first_responded_at BETWEEN ? AND ?{$deptFilterT}");
|
||
$stmt->execute([$from, $to, ...$deptParamsT]);
|
||
foreach ($stmt->fetchAll() as $r) {
|
||
$key = $r['grp'] === null ? 'none' : (string) $r['grp'];
|
||
$responseTimes[$key][] = (strtotime($r['first_responded_at']) - strtotime($r['created_at'])) / 3600;
|
||
}
|
||
|
||
$allKeys = array_unique(array_merge(
|
||
array_keys($geoeffnet), array_keys($geschlossen), array_keys($zugewiesen),
|
||
array_keys($ueberfaellig), array_keys($wiederGeoeffnet), array_keys($geloescht),
|
||
array_keys($serviceTimes), array_keys($responseTimes)
|
||
));
|
||
|
||
$rows = [];
|
||
foreach ($allKeys as $key) {
|
||
$rows[$key] = [
|
||
'geoeffnet' => $geoeffnet[$key] ?? 0,
|
||
'zugewiesen' => $zugewiesen[$key] ?? 0,
|
||
'ueberfaellig' => $ueberfaellig[$key] ?? 0,
|
||
'geschlossen' => $geschlossen[$key] ?? 0,
|
||
'wieder_geoeffnet' => $wiederGeoeffnet[$key] ?? 0,
|
||
'geloescht' => $geloescht[$key] ?? 0,
|
||
'servicezeit' => isset($serviceTimes[$key]) ? round(array_sum($serviceTimes[$key]) / count($serviceTimes[$key]), 1) : null,
|
||
'reaktionszeit' => isset($responseTimes[$key]) ? round(array_sum($responseTimes[$key]) / count($responseTimes[$key]), 1) : null,
|
||
];
|
||
}
|
||
return $rows;
|
||
}
|
||
|
||
/**
|
||
* Allowlist-Sanitizer für Rich-Text-HTML (Antworten, Wissensdatenbank, Vorlagen) und für HTML-E-Mails
|
||
* fremder Absender, die im Mitarbeiterbereich angezeigt werden. Nutzt bewusst keine DOM-Erweiterung
|
||
* (siehe README: keine zusätzlichen PHP-Erweiterungen nötig).
|
||
*
|
||
* Vorgehen: Der Text wird in Tags und Text zerlegt. Jedes Tag wird nur mit festem Namen und festen,
|
||
* neu geschriebenen Attributen ausgegeben; alles andere (auch ein nicht geschlossenes "<" im Text)
|
||
* wird als Text maskiert. Dadurch kann kein Rest-Markup entstehen, das sich mit nachfolgendem HTML
|
||
* der Seite zu einem Tag verbindet. Attributwerte werden vor der Prüfung entschlüsselt (Entities),
|
||
* weil der Browser sie ebenfalls entschlüsselt, und beim Ausgeben neu maskiert.
|
||
*/
|
||
function sanitize_staff_html(string $html): string
|
||
{
|
||
if (trim($html) === '') return '';
|
||
|
||
// Gefährliche Elemente komplett inkl. Inhalt entfernen
|
||
$blocked = 'script|style|iframe|frame|frameset|object|embed|applet|link|meta|base|form|input|textarea|select|option|button|svg|math|template|noscript|title|head|xml';
|
||
$html = preg_replace('/<(' . $blocked . ')\b[^>]*>.*?<\/\1\s*>/is', '', $html);
|
||
$html = preg_replace('/<\/?(' . $blocked . ')\b[^>]*>/is', '', $html);
|
||
// Kommentare, Doctype, bedingte Kommentare und Verarbeitungsanweisungen entfernen
|
||
$html = preg_replace('/<!--.*?-->/s', '', $html);
|
||
$html = preg_replace('/<[!?][^>]*>/s', '', $html);
|
||
|
||
$allowed = [
|
||
'b','strong','i','em','u','s','ul','ol','li','br','p','div','span','a',
|
||
'table','thead','tbody','tfoot','tr','td','th','caption','colgroup','col',
|
||
'h1','h2','h3','h4','h5','h6','hr','blockquote','pre','code','img',
|
||
'figure','figcaption','small','sup','sub',
|
||
];
|
||
$styleTags = ['img','td','th','tr','table','div','span','p','blockquote'];
|
||
$extraAttrs = [
|
||
'img' => ['alt','width','height'],
|
||
'td' => ['colspan','rowspan','align','valign','width'],
|
||
'th' => ['colspan','rowspan','align','valign','width'],
|
||
'table' => ['border','cellpadding','cellspacing','width'],
|
||
];
|
||
|
||
// Attributwert lesen (Anführungszeichen Pflicht, Name nicht Teil eines anderen Namens), Entities auflösen
|
||
$attr = static function (string $raw, string $name): ?string {
|
||
if (!preg_match('/(?<![\w:-])' . $name . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/is', $raw, $m)) return null;
|
||
$v = html_entity_decode($m[1] !== '' ? $m[1] : ($m[2] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||
return trim((string) preg_replace('/[\x00-\x1F\x7F]+/', '', $v));
|
||
};
|
||
|
||
// CSS auf eine Liste harmloser Eigenschaften beschränken (kein url(), kein position, keine Skripte)
|
||
$cleanStyle = static function (string $css): string {
|
||
$okProps = ['color','background-color','background','font','font-size','font-weight','font-style','font-family','text-align',
|
||
'text-decoration','line-height','margin','margin-top','margin-bottom','margin-left','margin-right','padding','padding-top',
|
||
'padding-bottom','padding-left','padding-right','border','border-top','border-bottom','border-left','border-right',
|
||
'border-color','border-style','border-width','border-collapse','border-spacing','width','height','max-width','min-width',
|
||
'vertical-align','white-space','list-style-type','text-indent','letter-spacing','word-break'];
|
||
$out = [];
|
||
foreach (explode(';', $css) as $decl) {
|
||
if (!str_contains($decl, ':')) continue;
|
||
[$prop, $val] = array_map('trim', explode(':', $decl, 2));
|
||
$prop = strtolower($prop);
|
||
if (!in_array($prop, $okProps, true) || $val === '') continue;
|
||
if (preg_match('#[\\\\@<>{}]|/\*|expression|javascript|behavior|-moz-binding|url\s*\(|image\s*\(|var\s*\(|attr\s*\(#i', $val)) continue;
|
||
$plain = preg_replace('/\b(?:rgb|rgba|hsl|hsla)\([0-9.,%\s\/]*\)/i', '', $val);
|
||
if (str_contains((string) $plain, '(') || strlen($val) > 120) continue;
|
||
$out[] = $prop . ': ' . $val;
|
||
}
|
||
return implode('; ', $out);
|
||
};
|
||
|
||
$e = static fn(string $v): string => htmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||
|
||
$parts = preg_split('/(<[^<>]*>)/', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||
$result = '';
|
||
foreach ($parts as $i => $part) {
|
||
if ($part === '') continue;
|
||
if ($i % 2 === 0) {
|
||
// Text: nie ein "<" oder ">" durchlassen, das der Browser als Markup lesen könnte
|
||
$result .= str_replace(['<', '>'], ['<', '>'], $part);
|
||
continue;
|
||
}
|
||
if (!preg_match('/^<(\/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s[^<>]*)?)(\/?)>$/s', $part, $m)) {
|
||
$result .= str_replace(['<', '>'], ['<', '>'], $part); // kein Tag (z. B. "a < b und c > d"): als Text behalten
|
||
continue;
|
||
}
|
||
$tag = strtolower($m[2]);
|
||
if (!in_array($tag, $allowed, true)) continue;
|
||
if ($m[1] === '/') { $result .= "</{$tag}>"; continue; }
|
||
$raw = $m[3];
|
||
$out = '';
|
||
|
||
if ($tag === 'a') {
|
||
$href = $attr($raw, 'href');
|
||
if ($href !== null && preg_match('#^(https?:|mailto:|cid:)#i', $href)) $out .= ' href="' . $e($href) . '"';
|
||
$result .= '<a' . $out . ' target="_blank" rel="noopener noreferrer">';
|
||
continue;
|
||
}
|
||
if ($tag === 'img') {
|
||
$src = $attr($raw, 'src');
|
||
if ($src !== null) {
|
||
// Nur https, data:image (Rasterbilder) und cid: erlauben, alles andere durch ein leeres Bild ersetzen
|
||
if (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);|cid:)#i', $src)) {
|
||
$out .= ' src="' . $e($src) . '"';
|
||
} else {
|
||
$out .= ' src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=" title="Bild aus Datenschutzgründen blockiert"';
|
||
}
|
||
}
|
||
}
|
||
foreach ($extraAttrs[$tag] ?? [] as $name) {
|
||
$v = $attr($raw, $name);
|
||
if ($v !== null) $out .= ' ' . $name . '="' . $e($v) . '"';
|
||
}
|
||
if (in_array($tag, $styleTags, true)) {
|
||
$st = $attr($raw, 'style');
|
||
if ($st !== null) {
|
||
$st = $cleanStyle($st);
|
||
if ($st !== '') $out .= ' style="' . $e($st) . '"';
|
||
}
|
||
}
|
||
$result .= ($m[4] === '/') ? "<{$tag}{$out}/>" : "<{$tag}{$out}>";
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Ersetzt Variablen in einem Canned-Response-Text.
|
||
*
|
||
* Verfügbare Variablen:
|
||
* {{kundename}} Name des Ticketersellers
|
||
* {{vorname}} Erster Teil des Namens
|
||
* {{email}} E-Mail des Kunden
|
||
* {{ticketnummer}} Ticket-Nummer (z.B. #532085)
|
||
* {{betreff}} Betreff des Tickets
|
||
* {{abteilung}} Name der Abteilung
|
||
* {{prioritaet}} Priorität
|
||
* {{status}} Aktueller Status
|
||
* {{mitarbeiter}} Name des zugewiesenen Mitarbeiters
|
||
* {{datum}} Heutiges Datum (TT.MM.JJJJ)
|
||
* {{uhrzeit}} Aktuelle Uhrzeit (HH:MM)
|
||
* {{signatur}} Signatur des eingeloggten Mitarbeiters
|
||
*/
|
||
function resolve_canned_vars(string $text, array $ticket, array $staff): string
|
||
{
|
||
$name = $ticket['requester_name'] ?? '';
|
||
$vorname = explode(' ', trim($name))[0] ?? $name;
|
||
|
||
$vars = [
|
||
'{{kundename}}' => $name,
|
||
'{{vorname}}' => $vorname,
|
||
'{{email}}' => $ticket['requester_email'] ?? '',
|
||
'{{ticketnummer}}' => '#' . ($ticket['ticket_number'] ?? ''),
|
||
'{{betreff}}' => $ticket['subject'] ?? '',
|
||
'{{abteilung}}' => $ticket['department_name'] ?? '',
|
||
'{{prioritaet}}' => $ticket['priority_name'] ?? '',
|
||
'{{status}}' => $ticket['status'] ?? '',
|
||
'{{mitarbeiter}}' => $ticket['staff_name'] ?? $staff['name'] ?? '',
|
||
'{{datum}}' => date('d.m.Y'),
|
||
'{{uhrzeit}}' => date('H:i'),
|
||
'{{signatur}}' => $staff['signature'] ?? '',
|
||
];
|
||
|
||
return str_replace(array_keys($vars), array_values($vars), $text);
|
||
}
|
||
|
||
/**
|
||
* Prüft ob ein Mitarbeiter aktuell verfügbar ist (nicht im Urlaub,
|
||
* Status nicht 'offline', und falls Schichten definiert: innerhalb der Schicht).
|
||
*/
|
||
function is_staff_available(PDO $pdo, int $staffId): bool
|
||
{
|
||
// Urlaub
|
||
$s = $pdo->prepare('SELECT is_on_vacation FROM staff WHERE id = ?');
|
||
$s->execute([$staffId]);
|
||
$row = $s->fetch();
|
||
if (!$row || $row['is_on_vacation']) return false;
|
||
|
||
// Echtzeit-Status
|
||
try {
|
||
$s = $pdo->prepare('SELECT status FROM staff_availability WHERE staff_id = ?');
|
||
$s->execute([$staffId]);
|
||
$avail = $s->fetchColumn();
|
||
if ($avail === 'offline') return false;
|
||
} catch (Throwable $e) { /* Tabelle noch nicht angelegt */ }
|
||
|
||
// Schichtplan – nur prüfen wenn Schichten definiert sind
|
||
try {
|
||
$s = $pdo->prepare('SELECT COUNT(*) FROM staff_shifts WHERE staff_id = ? AND is_active = 1');
|
||
$s->execute([$staffId]);
|
||
if ((int)$s->fetchColumn() > 0) {
|
||
$dow = (int)date('N'); // 1=Mo … 7=So
|
||
$time = date('H:i:s');
|
||
$s = $pdo->prepare(
|
||
'SELECT COUNT(*) FROM staff_shifts
|
||
WHERE staff_id = ? AND weekday = ? AND is_active = 1
|
||
AND start_time <= ? AND end_time > ?'
|
||
);
|
||
$s->execute([$staffId, $dow, $time, $time]);
|
||
if ((int)$s->fetchColumn() === 0) return false; // außerhalb Schicht
|
||
}
|
||
} catch (Throwable $e) { /* ignorieren */ }
|
||
|
||
return true;
|
||
}
|
||
|
||
/** Prüft, ob eine E-Mail-Adresse (per exaktem Treffer oder Domain) auf der Sperrliste steht */
|
||
function is_banned(string $email): bool
|
||
{
|
||
$email = trim(strtolower($email));
|
||
if ($email === '') return false;
|
||
$domain = substr(strrchr($email, '@'), 1) ?: '';
|
||
|
||
$stmt = get_db()->prepare(
|
||
"SELECT 1 FROM banlist WHERE
|
||
(match_type = 'email' AND LOWER(value) = ?)
|
||
OR (match_type = 'domain' AND LOWER(value) = ?)
|
||
LIMIT 1"
|
||
);
|
||
$stmt->execute([$email, $domain]);
|
||
return (bool) $stmt->fetchColumn();
|
||
}
|
||
|
||
/**
|
||
* Wertet alle aktiven Ticket-Regeln (sort_order) gegen die Daten eines neuen Tickets aus
|
||
* und liefert die anzuwendenden Aktionen zurück. $ticketData braucht die Schlüssel:
|
||
* subject, message, requester_email, requester_name, source.
|
||
* Sobald eine Regel mit der Aktion "reject" zutrifft, werden keine weiteren Regeln mehr geprüft.
|
||
*/
|
||
function evaluate_ticket_rules(PDO $pdo, array $ticketData): array
|
||
{
|
||
$result = [
|
||
'reject' => false,
|
||
'set_department' => null,
|
||
'set_priority' => null,
|
||
'set_help_topic' => null,
|
||
'assign_staff' => null,
|
||
'add_tags' => [],
|
||
'auto_reply' => null,
|
||
'close' => false,
|
||
'matched_rules' => [],
|
||
];
|
||
|
||
$rules = $pdo->query('SELECT * FROM ticket_rules WHERE is_active = 1 ORDER BY sort_order, id')->fetchAll();
|
||
|
||
foreach ($rules as $rule) {
|
||
$condStmt = $pdo->prepare('SELECT * FROM ticket_rule_conditions WHERE rule_id = ?');
|
||
$condStmt->execute([$rule['id']]);
|
||
$conditions = $condStmt->fetchAll();
|
||
if (!$conditions) continue;
|
||
|
||
$matchAll = $rule['match_type'] === 'all';
|
||
$matched = $matchAll; // bei "all" optimistisch starten, bei "any" pessimistisch
|
||
foreach ($conditions as $cond) {
|
||
$fieldValue = mb_strtolower((string) ($ticketData[$cond['field']] ?? ''));
|
||
$condValue = mb_strtolower($cond['value']);
|
||
$hit = match ($cond['operator']) {
|
||
'contains' => $condValue !== '' && str_contains($fieldValue, $condValue),
|
||
'not_contains' => $condValue === '' || !str_contains($fieldValue, $condValue),
|
||
'equals' => $fieldValue === $condValue,
|
||
'starts_with' => str_starts_with($fieldValue, $condValue),
|
||
'ends_with' => str_ends_with($fieldValue, $condValue),
|
||
default => false,
|
||
};
|
||
if ($matchAll && !$hit) { $matched = false; break; }
|
||
if (!$matchAll && $hit) { $matched = true; break; }
|
||
}
|
||
|
||
if (!$matched) continue;
|
||
|
||
$result['matched_rules'][] = $rule['name'];
|
||
$actStmt = $pdo->prepare('SELECT * FROM ticket_rule_actions WHERE rule_id = ?');
|
||
$actStmt->execute([$rule['id']]);
|
||
foreach ($actStmt->fetchAll() as $act) {
|
||
switch ($act['action_type']) {
|
||
case 'reject': $result['reject'] = true; break;
|
||
case 'close': $result['close'] = true; break;
|
||
case 'set_department': $result['set_department'] = (int) $act['action_value']; break;
|
||
case 'set_priority': $result['set_priority'] = (int) $act['action_value']; break;
|
||
case 'set_help_topic': $result['set_help_topic'] = (int) $act['action_value']; break;
|
||
case 'assign_staff': $result['assign_staff'] = (int) $act['action_value']; break;
|
||
case 'add_tag': $result['add_tags'][] = $act['action_value']; break;
|
||
case 'auto_reply': $result['auto_reply'] = (int) $act['action_value']; break;
|
||
}
|
||
}
|
||
|
||
if ($result['reject']) break; // weitere Regeln sind irrelevant, Ticket wird ohnehin verworfen
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Schließt ein neu angelegtes Ticket, wenn eine Regel die Aktion "Ticket schließen" enthält. Gibt true zurück, wenn geschlossen wurde;
|
||
* dann sollten keine Benachrichtigungen an Mitarbeiter oder Kunden mehr verschickt werden.
|
||
*/
|
||
function apply_rule_close(PDO $pdo, int $ticketId, array $ruleResult): bool
|
||
{
|
||
if (empty($ruleResult['close'])) return false;
|
||
$now = db_now();
|
||
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
|
||
log_activity($pdo, $ticketId, null, 'Ticket automatisch geschlossen (Ticket-Regel)');
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Verschickt automatisch einen hinterlegten Textbaustein als Antwort, wenn eine
|
||
* Ticket-Regel eine "Auto-Antwort senden"-Aktion ausgelöst hat (siehe
|
||
* evaluate_ticket_rules(), Ergebnis-Schlüssel 'auto_reply' = Textbaustein-ID).
|
||
* Direkt nach der Ticket-Erstellung aufrufen – NUR an Stellen, an denen das Ticket
|
||
* automatisiert (nicht durch einen Mitarbeiter) entsteht: ticket_new.php,
|
||
* live_chat.php, cron/fetch_emails.php. Bei manuell von Mitarbeitern angelegten
|
||
* Tickets (staff/ticket_create.php) bewusst NICHT aufrufen, da dort schon ein
|
||
* Mensch draufgeschaut hat.
|
||
*/
|
||
function apply_auto_reply(PDO $pdo, int $ticketId, ?int $cannedResponseId): void
|
||
{
|
||
if (!$cannedResponseId) return;
|
||
|
||
$cannedStmt = $pdo->prepare('SELECT body FROM canned_responses WHERE id = ?');
|
||
$cannedStmt->execute([$cannedResponseId]);
|
||
$body = $cannedStmt->fetchColumn();
|
||
if ($body === false) return; // Textbaustein wurde inzwischen gelöscht
|
||
|
||
$ticketStmt = $pdo->prepare(
|
||
'SELECT t.*, d.name AS department_name, p.name AS priority_name
|
||
FROM tickets t
|
||
JOIN departments d ON d.id = t.department_id
|
||
JOIN priorities p ON p.id = t.priority_id
|
||
WHERE t.id = ?'
|
||
);
|
||
$ticketStmt->execute([$ticketId]);
|
||
$ticket = $ticketStmt->fetch();
|
||
if (!$ticket) return;
|
||
|
||
// author_type='staff' mit staff_id=NULL statt 'system': so rendert die Antwort
|
||
// überall (Kundenansicht, Live-Chat-Widget, Mobile App) korrekt als Support-Bubble
|
||
// statt versehentlich wie eine Kunden-Nachricht auszusehen – der bestehende Code
|
||
// fragt an vielen Stellen nur author_type==='staff' ab, nicht 'system'.
|
||
$message = resolve_canned_vars($body, $ticket, []);
|
||
$now = db_now();
|
||
|
||
$pdo->prepare(
|
||
"INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,'staff',NULL,?,0,?)"
|
||
)->execute([$ticketId, $message, $now]);
|
||
|
||
$updateFirstResponse = empty($ticket['first_responded_at']);
|
||
$sql = "UPDATE tickets SET status = 'answered', updated_at = ?" . ($updateFirstResponse ? ', first_responded_at = ?' : '') . ' WHERE id = ?';
|
||
$params = [$now];
|
||
if ($updateFirstResponse) $params[] = $now;
|
||
$params[] = $ticketId;
|
||
$pdo->prepare($sql)->execute($params);
|
||
|
||
log_activity($pdo, $ticketId, null, 'Automatische Antwort gesendet (Ticket-Regel)');
|
||
|
||
try {
|
||
notify_customer_new_reply($ticket, $message);
|
||
} catch (Throwable $e) {
|
||
error_log('apply_auto_reply: ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
/** Verknüpft (und legt bei Bedarf an) einen Tag mit einem Ticket – gleiche Logik wie staff/ticket_view.php */
|
||
function apply_tag_to_ticket(PDO $pdo, int $ticketId, string $tagName): void
|
||
{
|
||
$tagName = trim($tagName);
|
||
if ($tagName === '') return;
|
||
$stmt = $pdo->prepare('SELECT id FROM tags WHERE name = ?');
|
||
$stmt->execute([$tagName]);
|
||
$tagId = $stmt->fetchColumn();
|
||
if (!$tagId) {
|
||
$palette = ['#0f6f6a', '#2563eb', '#d97706', '#a855f7', '#16a34a', '#dc2626'];
|
||
$color = $palette[array_rand($palette)];
|
||
$pdo->prepare('INSERT INTO tags (name, color) VALUES (?,?)')->execute([$tagName, $color]);
|
||
$tagId = (int) $pdo->lastInsertId();
|
||
}
|
||
$linkStmt = $pdo->prepare('SELECT COUNT(*) FROM ticket_tags WHERE ticket_id = ? AND tag_id = ?');
|
||
$linkStmt->execute([$ticketId, $tagId]);
|
||
if ((int) $linkStmt->fetchColumn() === 0) {
|
||
$pdo->prepare('INSERT INTO ticket_tags (ticket_id, tag_id) VALUES (?,?)')->execute([$ticketId, $tagId]);
|
||
}
|
||
}
|
||
|
||
/** Anzeigenamen der 16 deutschen Bundesländer, Kürzel → Name (für das Feiertags-Dropdown
|
||
* in staff/business_hours.php und als Whitelist gültiger Codes für german_public_holidays()). */
|
||
const GERMAN_STATE_NAMES = [
|
||
'BW' => 'Baden-Württemberg',
|
||
'BY' => 'Bayern',
|
||
'BE' => 'Berlin',
|
||
'BB' => 'Brandenburg',
|
||
'HB' => 'Bremen',
|
||
'HH' => 'Hamburg',
|
||
'HE' => 'Hessen',
|
||
'MV' => 'Mecklenburg-Vorpommern',
|
||
'NI' => 'Niedersachsen',
|
||
'NW' => 'Nordrhein-Westfalen',
|
||
'RP' => 'Rheinland-Pfalz',
|
||
'SL' => 'Saarland',
|
||
'SN' => 'Sachsen',
|
||
'ST' => 'Sachsen-Anhalt',
|
||
'SH' => 'Schleswig-Holstein',
|
||
'TH' => 'Thüringen',
|
||
];
|
||
|
||
/**
|
||
* Liefert den Buß- und Bettag (nur Sachsen) für ein Jahr: der Mittwoch vor dem 23.
|
||
* November – ausgehend vom 22.11. rückwärts zum nächsten Mittwoch gesucht, damit auch der
|
||
* Sonderfall "23.11. ist selbst ein Mittwoch" korrekt eine Woche früher landet (z.B. 2022:
|
||
* 23.11. war ein Mittwoch → Buß- und Bettag war der 16.11., nicht der 23.11. selbst).
|
||
*/
|
||
function german_buss_und_bettag(int $year): string
|
||
{
|
||
$d = new DateTime("{$year}-11-22");
|
||
while ((int) $d->format('N') !== 3) { // 3 = Mittwoch (ISO-8601: 1=Montag..7=Sonntag)
|
||
$d->modify('-1 day');
|
||
}
|
||
return $d->format('Y-m-d');
|
||
}
|
||
|
||
/**
|
||
* Berechnet den Termin des Ostersonntags (gregorianischer Kalender) nach dem
|
||
* Gauß/Meeus-Algorithmus – bewusst NICHT PHP's eingebaute easter_date(): die liefert in
|
||
* diesem Environment nachweislich ein Datum zu früh (geprüft gegen mehrere bekannte
|
||
* Ostertermine, z.B. 2023: PHP gibt den 8.4. statt korrekt 9.4. zurück – konsequent einen
|
||
* Tag daneben, über alle getesteten Jahre). Diese Eigenimplementierung ist eine
|
||
* eigenständige, gut dokumentierte Standardformel ohne Abhängigkeit von der
|
||
* PHP-Calendar-Extension oder deren Plattform-Eigenheiten.
|
||
*/
|
||
function german_easter_sunday(int $year): DateTime
|
||
{
|
||
$a = $year % 19;
|
||
$b = intdiv($year, 100);
|
||
$c = $year % 100;
|
||
$d = intdiv($b, 4);
|
||
$e = $b % 4;
|
||
$f = intdiv($b + 8, 25);
|
||
$g = intdiv($b - $f + 1, 3);
|
||
$h = (19 * $a + $b - $d - $g + 15) % 30;
|
||
$i = intdiv($c, 4);
|
||
$k = $c % 4;
|
||
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
|
||
$m = intdiv($a + 11 * $h + 22 * $l, 451);
|
||
$month = intdiv($h + $l - 7 * $m + 114, 31);
|
||
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
|
||
return new DateTime(sprintf('%04d-%02d-%02d', $year, $month, $day));
|
||
}
|
||
|
||
/**
|
||
* Berechnet die gesetzlichen Feiertage eines deutschen Bundeslandes für ein Kalenderjahr –
|
||
* inkl. beweglicher, von Ostern abhängiger Feiertage (siehe german_easter_sunday() oben).
|
||
* Wird für den "Feiertage automatisch übernehmen"-Import in staff/business_hours.php
|
||
* genutzt, damit nicht jeder Feiertag einzeln von Hand eingetragen werden muss – einfach
|
||
* Bundesland + Jahr wählen.
|
||
*
|
||
* Quelle der Regeln: bundesweit einheitliche Feiertage + je Bundesland zusätzliche, nach
|
||
* aktuellem Stand der Feiertagsgesetze der Länder. Mariä Himmelfahrt (Bayern) gilt offiziell
|
||
* nur in Gemeinden mit überwiegend katholischer Bevölkerung – wird hier trotzdem als
|
||
* Bayern-Feiertag mitgeführt (üblicher Standard in Feiertagslisten), im Zweifel manuell
|
||
* wieder entfernen, falls der Standort davon nicht betroffen ist.
|
||
*
|
||
* @return array<int, array{date: string, name: string}> sortiert nach Datum
|
||
*/
|
||
function german_public_holidays(string $stateCode, int $year): array
|
||
{
|
||
if (!isset(GERMAN_STATE_NAMES[$stateCode])) {
|
||
throw new InvalidArgumentException("Unbekanntes Bundesland-Kürzel: {$stateCode}");
|
||
}
|
||
|
||
$easter = german_easter_sunday($year);
|
||
$offset = function (int $days) use ($easter): string {
|
||
$d = clone $easter;
|
||
$d->modify("{$days} days");
|
||
return $d->format('Y-m-d');
|
||
};
|
||
$fixed = fn (string $monthDay): string => "{$year}-{$monthDay}";
|
||
|
||
// Bundesweit einheitliche Feiertage
|
||
$holidays = [
|
||
$fixed('01-01') => 'Neujahr',
|
||
$offset(-2) => 'Karfreitag',
|
||
$offset(1) => 'Ostermontag',
|
||
$fixed('05-01') => 'Tag der Arbeit',
|
||
$offset(39) => 'Christi Himmelfahrt',
|
||
$offset(50) => 'Pfingstmontag',
|
||
$fixed('10-03') => 'Tag der Deutschen Einheit',
|
||
$fixed('12-25') => '1. Weihnachtstag',
|
||
$fixed('12-26') => '2. Weihnachtstag',
|
||
];
|
||
|
||
$dreiKoenige = [$fixed('01-06') => 'Heilige Drei Könige'];
|
||
$fronleichnam = [$offset(60) => 'Fronleichnam'];
|
||
$mariaHimmelfahrt = [$fixed('08-15') => 'Mariä Himmelfahrt'];
|
||
$allerheiligen = [$fixed('11-01') => 'Allerheiligen'];
|
||
$reformationstag = [$fixed('10-31') => 'Reformationstag'];
|
||
$frauentag = [$fixed('03-08') => 'Internationaler Frauentag'];
|
||
$weltkindertag = [$fixed('09-20') => 'Weltkindertag'];
|
||
$bussUndBettag = [german_buss_und_bettag($year) => 'Buß- und Bettag'];
|
||
|
||
$byState = [
|
||
'BW' => $dreiKoenige + $fronleichnam + $allerheiligen,
|
||
'BY' => $dreiKoenige + $fronleichnam + $mariaHimmelfahrt + $allerheiligen,
|
||
'BE' => $frauentag,
|
||
'BB' => $reformationstag,
|
||
'HB' => $reformationstag,
|
||
'HH' => $reformationstag,
|
||
'HE' => $fronleichnam,
|
||
'MV' => $reformationstag + $weltkindertag,
|
||
'NI' => $reformationstag,
|
||
'NW' => $fronleichnam + $allerheiligen,
|
||
'RP' => $fronleichnam + $allerheiligen,
|
||
'SL' => $fronleichnam + $mariaHimmelfahrt + $allerheiligen,
|
||
'SN' => $reformationstag + $bussUndBettag,
|
||
'ST' => $dreiKoenige + $reformationstag,
|
||
'SH' => $reformationstag,
|
||
'TH' => $weltkindertag + $reformationstag,
|
||
];
|
||
|
||
$holidays = array_merge($holidays, $byState[$stateCode] ?? []);
|
||
ksort($holidays);
|
||
|
||
$result = [];
|
||
foreach ($holidays as $date => $name) {
|
||
$result[] = ['date' => $date, 'name' => $name];
|
||
}
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Addiert eine Anzahl Arbeitsstunden zu einem Zeitpunkt, unter Berücksichtigung der
|
||
* konfigurierten Geschäftszeiten (Tabelle business_hours) und Feiertage (business_holidays).
|
||
* Liegt der Startzeitpunkt außerhalb der Geschäftszeiten, zählt die Frist erst ab dem
|
||
* nächsten Öffnungszeitpunkt.
|
||
*/
|
||
function add_business_hours(string $from, int $hours): string
|
||
{
|
||
$pdo = get_db();
|
||
$rows = $pdo->query('SELECT weekday, is_open, open_time, close_time FROM business_hours')->fetchAll();
|
||
$byWeekday = [];
|
||
foreach ($rows as $r) $byWeekday[(int) $r['weekday']] = $r;
|
||
$holidays = $pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN);
|
||
$holidaySet = array_flip($holidays);
|
||
|
||
$remainingMinutes = $hours * 60;
|
||
$cursor = new DateTime($from);
|
||
$maxIterations = 3650; // Sicherheitsgrenze (~10 Jahre Tage), falls z.B. alle Tage geschlossen sind
|
||
$iterations = 0;
|
||
|
||
while ($remainingMinutes > 0 && $iterations < $maxIterations) {
|
||
$iterations++;
|
||
$weekday = (int) $cursor->format('w');
|
||
$dateStr = $cursor->format('Y-m-d');
|
||
$dayConfig = $byWeekday[$weekday] ?? null;
|
||
|
||
if (!$dayConfig || !$dayConfig['is_open'] || isset($holidaySet[$dateStr])) {
|
||
$cursor->modify('+1 day');
|
||
$cursor->setTime(0, 0, 0);
|
||
continue;
|
||
}
|
||
|
||
$dayOpen = new DateTime($dateStr . ' ' . $dayConfig['open_time']);
|
||
$dayClose = new DateTime($dateStr . ' ' . $dayConfig['close_time']);
|
||
|
||
if ($cursor < $dayOpen) $cursor = clone $dayOpen;
|
||
if ($cursor >= $dayClose) {
|
||
$cursor->modify('+1 day');
|
||
$cursor->setTime(0, 0, 0);
|
||
continue;
|
||
}
|
||
|
||
// WICHTIG (echter Bug, live auf Produktion nachgewiesen): ganzzahlig per intdiv()
|
||
// statt "/ 60" – eine Fließkommazahl mit Nachkommastellen (z.B. 0.3333…) führt bei
|
||
// DateTime::modify("+X minutes") zu einem komplett falschen Sprung um tausende
|
||
// Jahre statt der paar Sekunden Rundungsdifferenz, die hier eigentlich gemeint war
|
||
// (bekannter PHP-DateTime-Fallstrick mit Dezimalwerten in relativen Formaten).
|
||
$availableMinutesToday = intdiv($dayClose->getTimestamp() - $cursor->getTimestamp(), 60);
|
||
if ($remainingMinutes <= $availableMinutesToday) {
|
||
$cursor->modify("+{$remainingMinutes} minutes");
|
||
$remainingMinutes = 0;
|
||
} else {
|
||
$remainingMinutes -= $availableMinutesToday;
|
||
$cursor = clone $dayClose;
|
||
$cursor->modify('+1 day');
|
||
$cursor->setTime(0, 0, 0);
|
||
}
|
||
}
|
||
|
||
return $cursor->format('Y-m-d H:i:s');
|
||
}
|
||
|
||
// ====================================================================
|
||
// Rollen & Berechtigungen
|
||
// ====================================================================
|
||
|
||
/**
|
||
* Standard-Berechtigungen je Admin/Agent-Rolle.
|
||
* Kann durch eine benutzerdefinierte Rolle (roles.permissions) überschrieben werden.
|
||
*/
|
||
function default_permissions(string $role): array
|
||
{
|
||
if ($role === 'admin') {
|
||
return array_fill_keys(array_merge([
|
||
'reply_ticket','close_ticket','delete_ticket','transfer_ticket',
|
||
'post_note','assign_ticket','manage_canned','view_reports',
|
||
'manage_kb','create_ticket',
|
||
], array_keys(admin_section_permissions())), true);
|
||
}
|
||
// Standard-Agent
|
||
return array_merge([
|
||
'reply_ticket' => true,
|
||
'close_ticket' => true,
|
||
'delete_ticket' => false,
|
||
'transfer_ticket' => true,
|
||
'post_note' => true,
|
||
'assign_ticket' => true,
|
||
'manage_canned' => false,
|
||
'view_reports' => true,
|
||
'manage_kb' => false,
|
||
'create_ticket' => true,
|
||
], array_fill_keys(array_keys(admin_section_permissions()), false));
|
||
}
|
||
|
||
/**
|
||
* Feinkörnige Rechte für einzelne Adminportal-Bereiche – damit ein Admin einem Agenten
|
||
* (per benutzerdefinierter Rolle, siehe staff/roles.php) gezielt einzelne Admin-Funktionen
|
||
* freigeben kann, ohne ihm die volle Administrator-Rolle zu geben ("Co-Admin").
|
||
* Admins haben über has_permission() ohnehin immer automatisch alle Rechte.
|
||
*/
|
||
function admin_section_permissions(): array
|
||
{
|
||
return [
|
||
'manage_departments' => 'Abteilungen verwalten',
|
||
'manage_priorities' => 'Prioritäten / SLA verwalten',
|
||
'manage_help_topics' => 'Hilfe-Themen verwalten',
|
||
'manage_kb' => 'Wissensdatenbank verwalten',
|
||
'manage_email_addresses' => 'E-Mail-Adressen verwalten',
|
||
'manage_email_templates' => 'E-Mail-Vorlagen verwalten',
|
||
'manage_organizations' => 'Organisationen verwalten',
|
||
'manage_teams' => 'Teams verwalten',
|
||
'manage_roles' => 'Rollen verwalten',
|
||
'manage_staff' => 'Mitarbeiter verwalten',
|
||
'view_audit_log' => 'Admin-Protokoll einsehen',
|
||
'manage_escalations' => 'Eskalationsregeln verwalten',
|
||
'view_time_entries' => 'Zeiterfassung einsehen',
|
||
'manage_business_hours' => 'Geschäftszeiten verwalten',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Prüft ob ein Mitarbeiter eine bestimmte Berechtigung hat.
|
||
* Admins haben immer alle Rechte. Bei Agenten greift ggf. eine benutzerdefinierte Rolle.
|
||
*/
|
||
function has_permission(array $staff, string $permission, ?int $departmentId = null): bool
|
||
{
|
||
if ($staff['role'] === 'admin') return true;
|
||
|
||
$roleId = !empty($staff['role_id']) ? (int) $staff['role_id'] : 0;
|
||
// Rolle pro Abteilung (erweiterter Zugriff): überschreibt die Hauptrolle nur für diese Abteilung.
|
||
// Ohne eigene Rolle an der Zugriffszeile gilt weiter die Hauptrolle.
|
||
if ($departmentId !== null && $departmentId !== (int) ($staff['department_id'] ?? 0)) {
|
||
static $deptRoleCache = [];
|
||
$key = $staff['id'] . ':' . $departmentId;
|
||
if (!array_key_exists($key, $deptRoleCache)) {
|
||
$stmt = get_db()->prepare('SELECT role_id FROM staff_department WHERE staff_id = ? AND department_id = ?');
|
||
$stmt->execute([$staff['id'], $departmentId]);
|
||
$deptRoleCache[$key] = $stmt->fetchColumn() ?: null;
|
||
}
|
||
if ($deptRoleCache[$key] !== null) $roleId = (int) $deptRoleCache[$key];
|
||
}
|
||
|
||
if ($roleId > 0) {
|
||
static $roleCache = [];
|
||
$rid = $roleId;
|
||
if (!array_key_exists($rid, $roleCache)) {
|
||
$stmt = get_db()->prepare('SELECT permissions, is_active FROM roles WHERE id = ?');
|
||
$stmt->execute([$rid]);
|
||
$row = $stmt->fetch();
|
||
// Deaktivierte oder gelöschte Rolle: es gelten wieder die Standard-Mitarbeiterrechte
|
||
$roleCache[$rid] = ($row && ($row['is_active'] ?? 1)) ? (json_decode((string) $row['permissions'], true) ?? []) : null;
|
||
}
|
||
$perms = $roleCache[$rid];
|
||
if ($perms !== null) return (bool) ($perms[$permission] ?? false);
|
||
}
|
||
|
||
$defaults = default_permissions('agent');
|
||
return (bool) ($defaults[$permission] ?? false);
|
||
}
|
||
|
||
// ── @Erwähnungen ────────────────────────────────────────────────
|
||
|
||
/** Parst @Name-Erwähnungen aus einer Nachricht und gibt Staff-IDs zurück */
|
||
function parse_mentions(PDO $pdo, string $message): array
|
||
{
|
||
preg_match_all('/@([^\s<,;]+)/u', strip_tags($message), $matches);
|
||
if (empty($matches[1])) return [];
|
||
$found = [];
|
||
foreach ($matches[1] as $name) {
|
||
$stmt = $pdo->prepare("SELECT id FROM staff WHERE is_active=1 AND (name=? OR name LIKE ?) LIMIT 1");
|
||
$stmt->execute([$name, $name . ' %']);
|
||
$id = $stmt->fetchColumn();
|
||
if ($id) $found[(int)$id] = true;
|
||
}
|
||
return array_keys($found);
|
||
}
|
||
|
||
/** Speichert Erwähnungen und benachrichtigt betroffene Mitarbeiter */
|
||
function save_mentions(PDO $pdo, int $ticketId, int $threadId, int $byStaffId, string $message, array $ticket): void
|
||
{
|
||
$ids = parse_mentions($pdo, $message);
|
||
foreach ($ids as $mentionedId) {
|
||
if ($mentionedId === $byStaffId) continue;
|
||
$pdo->prepare('INSERT INTO staff_mentions (ticket_id,thread_id,mentioned_staff_id,mentioned_by_staff_id,created_at) VALUES (?,?,?,?,?)')
|
||
->execute([$ticketId, $threadId, $mentionedId, $byStaffId, db_now()]);
|
||
// E-Mail an erwähnten Mitarbeiter
|
||
$stmt = $pdo->prepare('SELECT email, name FROM staff WHERE id=?');
|
||
$stmt->execute([$mentionedId]);
|
||
$mentioned = $stmt->fetch();
|
||
if ($mentioned) {
|
||
$by = $pdo->prepare('SELECT name FROM staff WHERE id=?');
|
||
$by->execute([$byStaffId]);
|
||
$byName = $by->fetchColumn() ?: 'Ein Mitarbeiter';
|
||
$link = rtrim(config()['site']['url'], '/') . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']);
|
||
$inner = email_p("Hallo " . e($mentioned['name']) . ",")
|
||
. email_p("<strong>" . e($byName) . "</strong> hat Sie in Ticket <strong>#{$ticket['ticket_number']}: " . e($ticket['subject']) . "</strong> erwähnt.", '24px')
|
||
. email_button('Zum Ticket', e($link));
|
||
email_send_and_log(
|
||
$mentioned['email'],
|
||
"Sie wurden in Ticket #{$ticket['ticket_number']} erwähnt",
|
||
email_wrap($inner),
|
||
null,
|
||
'Erwähnung-Mail',
|
||
$ticket['ticket_number']
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Zeiterfassung ────────────────────────────────────────────────
|
||
|
||
/** Gibt die Summe der erfassten Minuten für ein Ticket zurück */
|
||
function ticket_total_minutes(PDO $pdo, int $ticketId): int
|
||
{
|
||
$stmt = $pdo->prepare('SELECT COALESCE(SUM(minutes),0) FROM ticket_time_entries WHERE ticket_id=?');
|
||
$stmt->execute([$ticketId]);
|
||
return (int) $stmt->fetchColumn();
|
||
}
|
||
|
||
/**
|
||
* Eigenes Ticket-Formular einer Abteilung. Gibt null zurück, wenn die Abteilung das Standardformular nutzt.
|
||
* Sonst die Texte/Optionen (mit Standardwerten ergänzt) und die Felder der Abteilung.
|
||
*
|
||
* @return array{title:string,intro:string,subject_show:bool,subject_label:string,message_label:string,message_hint:string,attachment_show:bool,submit_label:string,fields:array}|null
|
||
*/
|
||
function department_form(PDO $pdo, ?int $departmentId): ?array
|
||
{
|
||
if (!$departmentId) return null;
|
||
$stmt = $pdo->prepare('SELECT name, own_form, form_config FROM departments WHERE id = ?');
|
||
$stmt->execute([$departmentId]);
|
||
$d = $stmt->fetch();
|
||
if (!$d || empty($d['own_form'])) return null;
|
||
$cfg = json_decode((string) ($d['form_config'] ?? ''), true);
|
||
$cfg = is_array($cfg) ? $cfg : [];
|
||
$str = fn(string $k, string $def) => (isset($cfg[$k]) && is_string($cfg[$k]) && trim($cfg[$k]) !== '') ? trim($cfg[$k]) : $def;
|
||
$fields = $pdo->prepare('SELECT * FROM custom_fields WHERE department_id = ? AND is_active = 1 ORDER BY sort_order, label');
|
||
$fields->execute([$departmentId]);
|
||
$out = [
|
||
'title' => $str('title', ''),
|
||
'intro' => $str('intro', ''),
|
||
'subject_show' => !array_key_exists('subject_show', $cfg) || !empty($cfg['subject_show']),
|
||
'subject_label' => $str('subject_label', 'Betreff'),
|
||
'message_label' => $str('message_label', 'Beschreibung'),
|
||
'message_show' => !array_key_exists('message_show', $cfg) || !empty($cfg['message_show']),
|
||
'subject_width' => in_array($cfg['subject_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $cfg['subject_width'] : 'full',
|
||
'message_width' => in_array($cfg['message_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $cfg['message_width'] : 'full',
|
||
'message_hint' => $str('message_hint', 'Beschreiben Sie Ihr Anliegen so detailliert wie möglich…'),
|
||
'attachment_show' => !array_key_exists('attachment_show', $cfg) || !empty($cfg['attachment_show']),
|
||
'submit_label' => $str('submit_label', 'Ticket erstellen'),
|
||
'department_name' => (string) $d['name'],
|
||
'contact_title' => $str('contact_title', 'Ihre Kontaktdaten'),
|
||
'issue_title' => $str('issue_title', 'Ihr Anliegen'),
|
||
'details_title' => $str('details_title', 'Ihr Anliegen'),
|
||
'fields' => [],
|
||
];
|
||
$form = $out;
|
||
foreach ($fields->fetchAll() as $f) {
|
||
$sec = trim((string) ($f['form_section'] ?? ''));
|
||
if ($sec === '') $f['_section'] = 'extra';
|
||
elseif ($sec === '@topic') $f['_section'] = 'topic';
|
||
elseif ($sec === '@topic_top') $f['_section'] = 'topic_top';
|
||
elseif ($sec === '@contact_top') $f['_section'] = 'contact_top';
|
||
elseif ($sec === '@contact') $f['_section'] = 'contact';
|
||
elseif ($sec === '@details') $f['_section'] = 'issue';
|
||
elseif ($sec === '@details_top') $f['_section'] = 'issue_top';
|
||
elseif ($sec === '@details_mid') $f['_section'] = 'issue_mid';
|
||
elseif (mb_strtolower($sec) === mb_strtolower($form['contact_title'])) $f['_section'] = 'contact';
|
||
elseif (in_array(mb_strtolower($sec), [mb_strtolower($form['issue_title']), mb_strtolower($form['details_title'])], true)) $f['_section'] = 'issue';
|
||
else $f['_section'] = $sec;
|
||
$f['_section_title'] = in_array($f['_section'], ['topic', 'topic_top'], true) ? $form['issue_title'] : ($f['_section'] === 'extra' ? 'Zusätzliche Angaben' : (in_array($f['_section'], ['contact', 'contact_top'], true) ? $form['contact_title'] : (in_array($f['_section'], ['issue', 'issue_top', 'issue_mid'], true) ? $form['details_title'] : $sec)));
|
||
$form['fields'][] = $f;
|
||
}
|
||
return $form;
|
||
}
|
||
|
||
/**
|
||
* Hinweistext eines Formularfeldes als sicheres HTML: Text wird maskiert, Links schreibt man als [Text](https://adresse)
|
||
* (nur http, https oder Adressen auf dieser Seite, die mit / beginnen). Zeilenumbrüche bleiben erhalten.
|
||
*/
|
||
function form_help_html(?string $text, string $linkLabel = ''): string
|
||
{
|
||
// Steht nur eine Adresse im Text, wird die Bezeichnung des Feldes zum Link (statt der nackten Adresse)
|
||
if ($linkLabel !== '' && preg_match('#^\s*(https?://|/)[^\s)"\'<>]{1,500}\s*$#', (string) $text)) {
|
||
$text = '[' . $linkLabel . '](' . trim((string) $text) . ')';
|
||
}
|
||
$html = e((string) $text);
|
||
$html = preg_replace_callback('/\[([^\]\r\n]{1,120})\]\(((?:https?:\/\/|\/)[^\s)"\'<>]{1,500})\)/u', function ($m) {
|
||
return '<a href="' . $m[2] . '" target="_blank" rel="noopener noreferrer">' . $m[1] . '</a>';
|
||
}, $html);
|
||
// Adressen ohne [Text](…) werden ebenfalls anklickbar (nicht, wenn sie schon in einem Link stehen)
|
||
$html = preg_replace_callback('~(<a [^>]*>.*?</a>)|(https?://[^\s<>"\']+)~u', function ($m) {
|
||
if (!empty($m[1])) return $m[1];
|
||
$url = rtrim($m[2], '.,;:!?)');
|
||
$rest = substr($m[2], strlen($url));
|
||
return '<a href="' . $url . '" target="_blank" rel="noopener noreferrer">' . $url . '</a>' . $rest;
|
||
}, $html);
|
||
return nl2br($html, false);
|
||
}
|
||
|
||
/** Setzt Hinweistext, Link-Adresse und verlinkten Textteil (Eingabefelder im Admin) zu gespeichertem Text mit [Wort](Adresse) zusammen. */
|
||
function form_help_compose(string $text, string $url, string $word, string $fallbackLabel = ''): string
|
||
{
|
||
$text = trim($text);
|
||
$url = trim($url);
|
||
$word = trim($word);
|
||
if (preg_match('#^(https?://|/)[^\s)"\'<>]{1,500}$#', $text) && $url === '') return '[' . ($word !== '' ? $word : ($fallbackLabel !== '' ? $fallbackLabel : 'Mehr erfahren')) . '](' . $text . ')';
|
||
if ($url === '' || !preg_match('#^(https?://|/)[^\s)"\'<>]{1,500}$#', $url)) return mb_substr($text, 0, 1000);
|
||
if ($text === '') return '[' . ($word !== '' ? $word : ($fallbackLabel !== '' ? $fallbackLabel : 'Mehr erfahren')) . '](' . $url . ')';
|
||
if ($word !== '' && ($pos = mb_stripos($text, $word)) !== false) {
|
||
$found = mb_substr($text, $pos, mb_strlen($word));
|
||
return mb_substr(mb_substr($text, 0, $pos) . '[' . $found . '](' . $url . ')' . mb_substr($text, $pos + mb_strlen($word)), 0, 1000);
|
||
}
|
||
return mb_substr($text . ' [' . ($word !== '' ? $word : 'Mehr erfahren') . '](' . $url . ')', 0, 1000);
|
||
}
|
||
|
||
/** Gegenstück zu form_help_compose(): zerlegt gespeicherten Text in [Text ohne Link-Syntax, Adresse, verlinkter Teil]. */
|
||
function form_help_split(?string $stored): array
|
||
{
|
||
$stored = (string) $stored;
|
||
if (preg_match('/\[([^\]\r\n]{1,120})\]\(((?:https?:\/\/|\/)[^\s)"\'<>]{1,500})\)/u', $stored, $m, PREG_OFFSET_CAPTURE)) {
|
||
$plain = substr($stored, 0, $m[0][1]) . $m[1][0] . substr($stored, $m[0][1] + strlen($m[0][0]));
|
||
return [$plain, $m[2][0], $m[1][0]];
|
||
}
|
||
return [$stored, '', ''];
|
||
}
|