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