diff --git a/DeskHub-Webseite/cron/check_escalations.php b/DeskHub-Webseite/cron/check_escalations.php index a5ee8931..59053be6 100644 --- a/DeskHub-Webseite/cron/check_escalations.php +++ b/DeskHub-Webseite/cron/check_escalations.php @@ -147,7 +147,7 @@ function run_check_escalations(PDO $pdo, bool $verbose = false): array } $sql = 'SELECT t.*, d.name AS department_name, d.email AS department_email, - p.name AS priority_name, s.name AS staff_name, s.email AS staff_email + p.name AS priority_name, s.name AS staff_name, s.email AS staff_email, s.is_on_vacation AS staff_on_vacation FROM tickets t JOIN departments d ON d.id = t.department_id JOIN priorities p ON p.id = t.priority_id @@ -202,6 +202,11 @@ function run_check_escalations(PDO $pdo, bool $verbose = false): array } // Zugewiesenen Mitarbeiter benachrichtigen + if ($rule['action_notify_staff'] && !empty($ticket['staff_email'])) { + // Bearbeiter im Urlaub: die Vertretung bekommt die Mail + $escTo = vacation_expand_ids($pdo, [(int) $ticket['assigned_staff_id']]); + $ticket['staff_email'] = $escTo ? (staff_emails_by_ids($pdo, [$escTo[0]])[0] ?? '') : ''; + } if ($rule['action_notify_staff'] && !empty($ticket['staff_email'])) { $subj = "Eskalation: Ticket #{$ticket['ticket_number']} – {$ticket['subject']}"; $inner = email_p('Die Eskalationsregel ' . e($rule['name']) . ' wurde für folgendes Ticket ausgelöst:') diff --git a/DeskHub-Webseite/cron/check_sla.php b/DeskHub-Webseite/cron/check_sla.php index d6c8f721..d0387931 100644 --- a/DeskHub-Webseite/cron/check_sla.php +++ b/DeskHub-Webseite/cron/check_sla.php @@ -48,15 +48,8 @@ function run_check_sla(PDO $pdo, bool $verbose = false): array ticket_sla_log_overdue($pdo, (int) $t['id'], 'resolution', (string) $t['resolution_due_at']); log_activity($pdo, $t['id'], null, 'SLA überschritten – Ticket als überfällig markiert'); - if ($t['department_email']) { - $subject = "SLA überschritten: Ticket #{$t['ticket_number']}"; - $inner = email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:') - . email_badge('Ticket', '#' . $t['ticket_number'], '#b91c1c', '#fee2e2') - . email_p('Betreff: ' . e($t['subject']), '24px') - . email_button('Ticket ansehen', e(base_url() . '/staff/ticket_view.php?n=' . urlencode($t['ticket_number'])), '#dc2626'); - email_send_and_log($t['department_email'], $subject, email_wrap($inner), null, 'SLA-überschritten-Mail (Abteilung)', $t['ticket_number']); - } - notify_admins_overdue_ticket($t); + // Nur die Betroffenen bekommen eine Mail (Bearbeiter bzw. Abteilung), nicht die Postfach-Adresse der Abteilung und nicht alle Admins + notify_overdue_ticket_staff($t); fcm_notify_overdue($t); // Push auf die Android-App $newlyOverdue++; if ($verbose) { diff --git a/DeskHub-Webseite/cron/fetch_emails.php b/DeskHub-Webseite/cron/fetch_emails.php index c262f462..1c854793 100644 --- a/DeskHub-Webseite/cron/fetch_emails.php +++ b/DeskHub-Webseite/cron/fetch_emails.php @@ -175,6 +175,14 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra // Bei "delete" bleibt die Zuordnung zwar erhalten, die Originalmail ist danach jedoch nicht mehr verschiebbar. $sourceFolderAfterFetch = $afterFetch === 'archive' ? $archiveFolder : $account['folder']; + // Mails, die DeskHub selbst verschickt hat (zum Beispiel SLA-Meldungen an eine Postfach-Adresse), dürfen keine Tickets erzeugen + if (mail_is_own_system_mail($pdo, (string) $fromEmail, (string) @imap_fetchheader($mbox, $emailNum))) { + $log[] = "[$label] Mail von {$fromEmail} ignoriert (eigene Systemmail, würde sonst ein Ticket erzeugen): {$subject}"; + handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder); + $processed++; + continue; + } + // Nachrichtentext: Text-Teil bevorzugt, bei reinen HTML-Mails (Newsletter) nur der sichtbare Text ohne CSS und Skripte $structure = @imap_fetchstructure($mbox, $emailNum); $body = $structure ? mail_best_text($structure, fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no)) : ''; diff --git a/DeskHub-Webseite/database/migrate.php b/DeskHub-Webseite/database/migrate.php index c06b1958..b9401364 100644 --- a/DeskHub-Webseite/database/migrate.php +++ b/DeskHub-Webseite/database/migrate.php @@ -1850,6 +1850,101 @@ function run_migrations(PDO $pdo, bool $verbose = false): array } } + // Anmelde-Wächter: Protokoll der Anmeldungen sowie gemerkte Geräte und Orte je Mitarbeiter + if (!isset($done['php_0065_login_guard'])) { + try { + $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY'; + $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; + $pdo->exec("CREATE TABLE IF NOT EXISTS login_events ( + id $ai, + staff_id INT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + event VARCHAR(20) NOT NULL, + channel VARCHAR(10) NOT NULL DEFAULT 'web', + ip VARCHAR(64) NOT NULL DEFAULT '', + place VARCHAR(100) NOT NULL DEFAULT '', + device VARCHAR(160) NOT NULL DEFAULT '', + detail VARCHAR(255) NOT NULL DEFAULT '', + token_hash VARCHAR(64) NULL, + token_used TINYINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL + )$eng"); + $pdo->exec('CREATE INDEX idx_login_events_created ON login_events (created_at)'); + $pdo->exec('CREATE INDEX idx_login_events_staff ON login_events (staff_id, created_at)'); + $pdo->exec('CREATE INDEX idx_login_events_token ON login_events (token_hash)'); + $pdo->exec("CREATE TABLE IF NOT EXISTS login_devices ( + id $ai, + staff_id INT NOT NULL, + device_hash VARCHAR(64) NOT NULL, + label VARCHAR(160) NOT NULL DEFAULT '', + channel VARCHAR(10) NOT NULL DEFAULT 'web', + first_ip VARCHAR(64) NOT NULL DEFAULT '', + last_ip VARCHAR(64) NOT NULL DEFAULT '', + place VARCHAR(100) NOT NULL DEFAULT '', + first_seen DATETIME NOT NULL, + last_seen DATETIME NOT NULL, + UNIQUE (staff_id, device_hash) + )$eng"); + $pdo->exec("CREATE TABLE IF NOT EXISTS login_places ( + id $ai, + staff_id INT NOT NULL, + place_key VARCHAR(80) NOT NULL, + label VARCHAR(100) NOT NULL DEFAULT '', + first_seen DATETIME NOT NULL, + last_seen DATETIME NOT NULL, + UNIQUE (staff_id, place_key) + )$eng"); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0065_login_guard']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0065_login_guard'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0065_login_guard: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0065_login_guard - ' . $e->getMessage(); + } + } + + // Abwesenheit im Profil: Welche Tickets an die Vertretung weitergegeben wurden (für die Rückgabe nach dem Urlaub) + if (!isset($done['php_0066_vacation_moves'])) { + try { + $drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY'; + $eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; + $pdo->exec("CREATE TABLE IF NOT EXISTS vacation_moves ( + id $ai, + ticket_id INT NOT NULL, + from_staff_id INT NOT NULL, + to_staff_id INT NOT NULL, + created_at DATETIME NOT NULL, + returned_at DATETIME NULL + )$eng"); + $pdo->exec('CREATE INDEX idx_vacation_moves_from ON vacation_moves (from_staff_id, returned_at)'); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0066_vacation_moves']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0066_vacation_moves'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0066_vacation_moves: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0066_vacation_moves - ' . $e->getMessage(); + } + } + + // Reparatur: Entwarnungs-Notizen der öffentlichen API waren doppelt maskiert (" statt ") + if (!isset($done['php_0067_fix_api_resolve_notes'])) { + try { + $rows = $pdo->query("SELECT t.id, t.message FROM ticket_threads t JOIN tickets k ON k.id = t.ticket_id + WHERE k.source = 'api' AND t.author_type = 'system' AND t.is_internal_note = 1 + AND (t.message LIKE '%"%' OR t.message LIKE '%&%' OR t.message LIKE '%'%')")->fetchAll(); + $upd = $pdo->prepare('UPDATE ticket_threads SET message = ? WHERE id = ?'); + foreach ($rows as $r) $upd->execute([html_entity_decode((string) $r['message'], ENT_QUOTES | ENT_HTML5, 'UTF-8'), $r['id']]); + $pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0067_fix_api_resolve_notes']); + $result['applied']++; + if ($verbose) $result['log'][] = ' [OK] php_0067_fix_api_resolve_notes (' . count($rows) . ' Notizen repariert)'; + } catch (Throwable $e) { + $result['errors'][] = 'php_0067_fix_api_resolve_notes: ' . $e->getMessage(); + if ($verbose) $result['log'][] = ' [FAIL] php_0067_fix_api_resolve_notes - ' . $e->getMessage(); + } + } + return $result; }