Upload via GUI (258 Dateien)

This commit is contained in:
2026-10-16 19:12:15 +00:00
parent 491b87f468
commit 56deade877
50 changed files with 23552 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
# Zusätzlicher Schutz (falls die Rewrite-Regel im Root-.htaccess nicht greift)
Require all denied
Order deny,allow
Deny from all
+90
View File
@@ -0,0 +1,90 @@
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
[skip] Automatische Anonymisierung ist deaktiviert.
Fertig: 0 Ticket(s) anonymisiert.
+177
View File
@@ -0,0 +1,177 @@
<?php
/**
* cron/anonymize_data.php
*
* Anonymisiert personenbezogene Daten aus geschlossenen/gelösten Tickets
* nach der in den Einstellungen konfigurierten Aufbewahrungsdauer.
*
* Crontab: 0 3 * * * php /pfad/zu/deskhub/cron/anonymize_data.php
*
* Was wird anonymisiert:
* - tickets.requester_name → "Anonymisiert"
* - tickets.requester_email → "anon_[hash]@deleted.local"
* - tickets.requester_ip → "0.0.0.0"
* - users.name + email → wenn der User keine anderen offenen Tickets hat
*
* Was bleibt erhalten:
* - Ticket-Nummer, Betreff, Status, Priorität, Abteilung
* - Alle Nachrichten (ticket_threads) inkl. Zeitstempel
* - Statistiken / Auswertungen
*/
// Direkter Web-Zugriff (Browser/URL) wird geblockt. Ausnahmen: echter CLI-Cronjob
// sowie Einbindung per require_once aus dem authentifizierten Backend (staff/settings.php),
// das vor dem require_once die Konstante DESKHUB_CRON_RUNNER definiert.
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) {
http_response_code(403);
exit('Kein Web-Zugriff.');
}
require_once __DIR__ . '/../includes/db.php';
if (php_sapi_name() === 'cli') {
$pdo = get_db();
$result = run_anonymization($pdo, true);
foreach ($result['log'] as $line) {
echo $line . PHP_EOL;
}
echo "Fertig: {$result['count']} Ticket(s) anonymisiert." . PHP_EOL;
}
// ─────────────────────────────────────────────────────────────────────────────
/**
* Führt die Anonymisierung aus.
* Kann von settings.php (manuell) und vom Cron aufgerufen werden.
*
* @return array{count: int, log: string[]}
*/
function run_anonymization(PDO $pdo, bool $verbose = false): array
{
$result = ['count' => 0, 'log' => []];
set_setting_local($pdo, 'cron_anonymize_data_last_run', date('Y-m-d H:i:s'));
// Einstellungen aus DB laden
$enabled = get_setting_local($pdo, 'retention_enabled', '0');
if ($enabled !== '1') {
$result['log'][] = '[skip] Automatische Anonymisierung ist deaktiviert.';
return $result;
}
$months = max(1, (int) get_setting_local($pdo, 'retention_months', '24'));
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
// Cutoff-Datum berechnen
$cutoff = date('Y-m-d H:i:s', strtotime("-{$months} months"));
if ($verbose) {
$result['log'][] = "[info] Anonymisiere Tickets geschlossen/gelöst vor: $cutoff";
}
// Betroffene Tickets finden (noch nicht anonymisiert = email enthält kein 'anon_')
$stmt = $pdo->prepare(
"SELECT id, requester_email, user_id
FROM tickets
WHERE status IN ('closed', 'resolved')
AND updated_at < ?
AND requester_email NOT LIKE 'anon\\_%' ESCAPE '\\\\'
LIMIT 500"
);
$stmt->execute([$cutoff]);
$tickets = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($tickets)) {
$result['log'][] = '[info] Keine Tickets zur Anonymisierung gefunden.';
return $result;
}
$updTicket = $pdo->prepare(
"UPDATE tickets
SET requester_name = 'Anonymisiert',
requester_email = ?,
requester_ip = '0.0.0.0'
WHERE id = ?"
);
// User anonymisieren wenn er keine anderen offenen Tickets mehr hat
$checkUser = $pdo->prepare(
"SELECT COUNT(*) FROM tickets
WHERE user_id = ?
AND status NOT IN ('closed', 'resolved')
AND id != ?"
);
$updUser = $pdo->prepare(
"UPDATE users
SET name = 'Anonymisiert',
email = ?,
phone = NULL
WHERE id = ? AND email NOT LIKE 'anon\\_%' ESCAPE '\\\\'"
);
foreach ($tickets as $ticket) {
$anonEmail = 'anon_' . substr(hash('sha256', $ticket['requester_email'] . 'deskhub-anon'), 0, 16) . '@deleted.local';
try {
$pdo->beginTransaction();
$updTicket->execute([$anonEmail, $ticket['id']]);
// User-Account anonymisieren wenn keine anderen offenen Tickets
if ($ticket['user_id']) {
$checkUser->execute([$ticket['user_id'], $ticket['id']]);
if ((int) $checkUser->fetchColumn() === 0) {
$updUser->execute([$anonEmail, $ticket['user_id']]);
}
}
$pdo->commit();
$result['count']++;
if ($verbose) {
$result['log'][] = "[ok] Ticket #{$ticket['id']} anonymisiert";
}
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
$result['log'][] = "[err] Ticket #{$ticket['id']}: " . $e->getMessage();
}
}
return $result;
}
/**
* Liest eine Einstellung direkt aus der DB (ohne Bootstrap-Abhängigkeit).
*/
function get_setting_local(PDO $pdo, string $key, string $default = ''): string
{
try {
$stmt = $pdo->prepare('SELECT setting_value FROM settings WHERE setting_key = ?');
$stmt->execute([$key]);
$val = $stmt->fetchColumn();
return $val !== false ? (string) $val : $default;
} catch (Throwable $e) {
return $default;
}
}
/**
* Speichert eine Einstellung direkt in der DB (ohne Bootstrap-Abhängigkeit, treiber-unabhängiges Upsert).
*/
function set_setting_local(PDO $pdo, string $key, string $value): void
{
try {
$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]);
}
} catch (Throwable $e) {
// Best effort – Anonymisierung soll nicht daran scheitern.
}
}
@@ -0,0 +1,73 @@
<?php
// Direkter Web-Zugriff (Browser/URL) wird geblockt. Ausnahmen: echter CLI-Cronjob
// sowie Einbindung per require_once aus dem authentifizierten Backend (staff/settings.php),
// das vor dem require_once die Konstante DESKHUB_CRON_RUNNER definiert.
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* cron/auto_close_tickets.php
*
* Schließt Tickets automatisch, die seit "Mitarbeiter hat geantwortet, Kunde
* hat sich nicht mehr gemeldet" (Status "answered") länger als die
* eingestellte Anzahl Tage nicht mehr aktualisiert wurden. Abschaltbar
* (Standard: aus) und die Anzahl Tage einstellbar unter Einstellungen →
* Allgemein → "Tickets automatisch schließen".
*
* Einrichtung: Per Cronjob z.B. einmal täglich aufrufen (Crontab-Beispiel siehe README.md):
* /usr/bin/php /pfad/zu/deskhub/cron/auto_close_tickets.php >> /pfad/zu/deskhub/cron/auto_close_tickets.log 2>&1
*
* Kann außerdem manuell aus dem Backend (Einstellungen → Cron-Jobs) ausgeführt werden.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/**
* Führt das automatische Schließen aus. Kann vom Cron und manuell aus settings.php aufgerufen werden.
*
* @return array{closed: int, log: string[]}
*/
function run_auto_close_tickets(PDO $pdo, bool $verbose = false): array
{
$log = [];
if (get_setting('auto_close_enabled', '0') !== '1') {
$log[] = '[' . date('Y-m-d H:i:s') . '] Automatisches Schließen ist deaktiviert – nichts zu tun.';
return ['closed' => 0, 'log' => $log];
}
$days = max(1, (int) get_setting('auto_close_days', '7'));
$threshold = date('Y-m-d H:i:s', time() - $days * 86400);
// Nur Tickets, bei denen der Mitarbeiter zuletzt geantwortet hat (status='answered')
// und der Kunde sich seitdem nicht mehr gemeldet hat (sonst wäre der Status längst
// wieder 'open') – exakt der in der Anfrage beschriebene Fall "keine Reaktion".
$stmt = $pdo->prepare(
"SELECT id, ticket_number FROM tickets
WHERE status = 'answered' AND is_spam = 0 AND updated_at < ?"
);
$stmt->execute([$threshold]);
$tickets = $stmt->fetchAll();
$now = db_now();
$closed = 0;
foreach ($tickets as $t) {
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")
->execute([$now, $now, $t['id']]);
log_activity($pdo, (int) $t['id'], null, "Automatisch geschlossen (keine Kundenreaktion seit {$days} Tagen)");
$closed++;
if ($verbose) {
$log[] = "[ok] Ticket #{$t['ticket_number']} automatisch geschlossen";
}
}
set_setting('cron_auto_close_tickets_last_run', $now);
$log[] = '[' . date('Y-m-d H:i:s') . "] Automatisches Schließen abgeschlossen. {$closed} Ticket(s) geschlossen (Schwelle: {$days} Tage).";
return ['closed' => $closed, 'log' => $log];
}
if (php_sapi_name() === 'cli') {
$result = run_auto_close_tickets(get_db(), true);
foreach ($result['log'] as $line) {
echo $line . "\n";
}
}
File diff suppressed because it is too large Load Diff
+233
View File
@@ -0,0 +1,233 @@
<?php
// Direkter Web-Zugriff (Browser/URL) wird geblockt. Ausnahmen: echter CLI-Cronjob
// sowie Einbindung per require_once aus dem authentifizierten Backend (staff/settings.php),
// das vor dem require_once die Konstante DESKHUB_CRON_RUNNER definiert.
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* cron/check_escalations.php
*
* Prüft alle aktiven Eskalationsregeln und führt definierte Aktionen aus.
*
* Trigger-Typen:
* no_reply – Ticket seit X Stunden ohne Mitarbeiter-Antwort
* no_update – Ticket seit X Stunden nicht aktualisiert
* overdue – Ticket ist überfällig (resolution_due_at überschritten)
* open_too_long – Ticket seit X Stunden offen
*
* Aktionen:
* - Priorität erhöhen
* - Mitarbeiter neu zuweisen
* - Status ändern
* - Admin benachrichtigen
* - Zugewiesenen Mitarbeiter benachrichtigen
*
* Crontab: 0 * * * * php /pfad/zu/deskhub/cron/check_escalations.php
*
* Kann außerdem manuell aus dem Backend (Einstellungen → Cron-Jobs) ausgeführt werden.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/**
* Führt die Eskalationsprüfung aus. Kann vom Cron und manuell aus settings.php aufgerufen werden.
*
* @return array{applied: int, skipped: int, log: string[]}
*/
function run_check_escalations(PDO $pdo, bool $verbose = false): array
{
$now = db_now();
$applied = 0;
$skipped = 0;
$log = [];
// Alle aktiven Eskalationsregeln laden
try {
$rules = $pdo->query(
'SELECT er.*, p.name AS prio_name, s.name AS staff_name
FROM escalation_rules er
LEFT JOIN priorities p ON p.id = er.action_set_priority_id
LEFT JOIN staff s ON s.id = er.action_assign_staff_id
WHERE er.is_active = 1
ORDER BY er.sort_order, er.id'
)->fetchAll();
} catch (Throwable $e) {
$log[] = "[" . date('Y-m-d H:i:s') . "] Tabelle escalation_rules nicht gefunden – Migration ausstehend.";
return ['applied' => 0, 'skipped' => 0, 'log' => $log];
}
if (!$rules) {
$log[] = "[" . date('Y-m-d H:i:s') . "] Keine aktiven Eskalationsregeln.";
return ['applied' => 0, 'skipped' => 0, 'log' => $log];
}
// Bereits eskalierte Ticket-Regel-Paare laden (verhindert Doppel-Eskalation)
$alreadyDone = [];
$logRows = $pdo->query('SELECT rule_id, ticket_id FROM escalation_log')->fetchAll();
foreach ($logRows as $lr) {
$alreadyDone[$lr['rule_id'] . '_' . $lr['ticket_id']] = true;
}
foreach ($rules as $rule) {
// ── Betroffene Tickets finden ─────────────────────────────────────────────
$where = ["t.status NOT IN ('resolved','closed')"];
$params = [];
$hours = (int)$rule['trigger_hours'];
// Bedingungen aus der Regel
if ($rule['condition_status']) {
$statusList = array_map('trim', explode(',', $rule['condition_status']));
$ph = implode(',', array_fill(0, count($statusList), '?'));
$where[] = "t.status IN ($ph)";
$params = array_merge($params, $statusList);
}
if ($rule['condition_priority_id']) {
$where[] = 't.priority_id = ?';
$params[] = (int)$rule['condition_priority_id'];
}
if ($rule['condition_dept_id']) {
$where[] = 't.department_id = ?';
$params[] = (int)$rule['condition_dept_id'];
}
// Trigger-spezifische Bedingung
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$hoursDiff = $driver === 'sqlite'
? "((julianday('now', 'localtime') - julianday(%s)) * 24)"
: "TIMESTAMPDIFF(HOUR, %s, NOW())";
switch ($rule['trigger_type']) {
case 'no_reply':
// Kein Mitarbeiter-Thread seit X Stunden
$where[] = sprintf(
"NOT EXISTS (
SELECT 1 FROM ticket_threads tt
WHERE tt.ticket_id = t.id AND tt.author_type = 'staff'
AND " . str_replace('%s', 'tt.created_at', $hoursDiff) . " < ?
)",
''
);
// Simpler: letzter Staff-Thread ist älter als X Stunden ODER kein Staff-Thread vorhanden
// Rebuild properly:
$where[count($where)-1] = $driver === 'sqlite'
? "((julianday('now', 'localtime') - julianday(t.created_at)) * 24) >= ?
AND NOT EXISTS (
SELECT 1 FROM ticket_threads tt
WHERE tt.ticket_id = t.id AND tt.author_type = 'staff'
AND ((julianday('now', 'localtime') - julianday(tt.created_at)) * 24) < ?
)"
: "TIMESTAMPDIFF(HOUR, t.created_at, NOW()) >= ?
AND NOT EXISTS (
SELECT 1 FROM ticket_threads tt
WHERE tt.ticket_id = t.id AND tt.author_type = 'staff'
AND TIMESTAMPDIFF(HOUR, tt.created_at, NOW()) < ?
)";
$params[] = $hours;
$params[] = $hours;
break;
case 'no_update':
$where[] = $driver === 'sqlite'
? "((julianday('now', 'localtime') - julianday(t.updated_at)) * 24) >= ?"
: "TIMESTAMPDIFF(HOUR, t.updated_at, NOW()) >= ?";
$params[] = $hours;
break;
case 'overdue':
$where[] = 't.is_overdue = 1';
$where[] = 't.resolution_due_at IS NOT NULL';
break;
case 'open_too_long':
$where[] = $driver === 'sqlite'
? "((julianday('now', 'localtime') - julianday(t.created_at)) * 24) >= ?"
: "TIMESTAMPDIFF(HOUR, t.created_at, NOW()) >= ?";
$params[] = $hours;
break;
}
$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
FROM tickets t
JOIN departments d ON d.id = t.department_id
JOIN priorities p ON p.id = t.priority_id
LEFT JOIN staff s ON s.id = t.assigned_staff_id
WHERE ' . implode(' AND ', $where);
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$tickets = $stmt->fetchAll();
foreach ($tickets as $ticket) {
$key = $rule['id'] . '_' . $ticket['id'];
if (isset($alreadyDone[$key])) {
$skipped++;
continue;
}
// ── Aktionen ausführen ────────────────────────────────────────────────
$changes = [];
if ($rule['action_set_priority_id']) {
$pdo->prepare('UPDATE tickets SET priority_id = ?, updated_at = ? WHERE id = ?')
->execute([$rule['action_set_priority_id'], $now, $ticket['id']]);
$changes[] = 'Priorität gesetzt auf: ' . ($rule['prio_name'] ?? '?');
}
if ($rule['action_assign_staff_id']) {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ?, updated_at = ? WHERE id = ?')
->execute([$rule['action_assign_staff_id'], $now, $ticket['id']]);
$changes[] = 'Zugewiesen an: ' . ($rule['staff_name'] ?? '?');
}
if ($rule['action_set_status']) {
$pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?')
->execute([$rule['action_set_status'], $now, $ticket['id']]);
$changes[] = 'Status gesetzt auf: ' . $rule['action_set_status'];
}
// System-Notiz im Ticket
$noteText = "Eskalationsregel „{$rule['name']}“ ausgelöst: " . implode(', ', $changes ?: ['Benachrichtigung']);
$pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,1,?)'
)->execute([$ticket['id'], 'system', $noteText, $now]);
log_activity($pdo, $ticket['id'], null, $noteText);
// Admin benachrichtigen
if ($rule['action_notify_admin']) {
notify_admins_overdue_ticket(array_merge($ticket, [
'subject' => "[Eskalation: {$rule['name']}] " . $ticket['subject'],
]));
}
// Zugewiesenen Mitarbeiter benachrichtigen
if ($rule['action_notify_staff'] && !empty($ticket['staff_email'])) {
$subj = "Eskalation: Ticket #{$ticket['ticket_number']} – {$ticket['subject']}";
$inner = email_p('Die Eskalationsregel <strong>' . e($rule['name']) . '</strong> wurde für folgendes Ticket ausgelöst:')
. email_badge('Ticket', '#' . $ticket['ticket_number'], '#b91c1c', '#fee2e2')
. email_p('<strong>Aktionen:</strong> ' . e(implode(', ', $changes ?: ['Benachrichtigung'])), '24px')
. email_button('Ticket öffnen', e(base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number'])), '#dc2626');
email_send_and_log($ticket['staff_email'], $subj, email_wrap($inner), null, 'Eskalations-Mail', $ticket['ticket_number']);
}
// Als erledigt markieren
$pdo->prepare(
'INSERT INTO escalation_log (rule_id, ticket_id, applied_at) VALUES (?,?,?)'
)->execute([$rule['id'], $ticket['id'], $now]);
$alreadyDone[$key] = true;
$applied++;
}
}
set_setting('cron_check_escalations_last_run', $now);
$log[] = "[" . date('Y-m-d H:i:s') . "] Eskalations-Check abgeschlossen. $applied Regel(n) angewendet, $skipped bereits erledigt.";
return ['applied' => $applied, 'skipped' => $skipped, 'log' => $log];
}
if (php_sapi_name() === 'cli') {
$result = run_check_escalations(get_db(), true);
foreach ($result['log'] as $line) {
echo $line . "\n";
}
}
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
<?php
// Direkter Web-Zugriff (Browser/URL) wird geblockt. Ausnahmen: echter CLI-Cronjob
// sowie Einbindung per require_once aus dem authentifizierten Backend (staff/settings.php),
// das vor dem require_once die Konstante DESKHUB_CRON_RUNNER definiert.
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* cron/check_sla.php
*
* Prüft alle offenen Tickets auf überschrittene SLA-Fristen (Erstreaktion
* und Lösung) und markiert sie als überfällig (is_overdue). Optional kann
* bei neu erkannter Überfälligkeit eine Eskalations-E-Mail an die
* zuständige Abteilung gesendet werden.
*
* Einrichtung: Per Cronjob alle 15 Minuten aufrufen (Crontab-Beispiel siehe README.md):
* /usr/bin/php /pfad/zu/deskhub/cron/check_sla.php >> /pfad/zu/deskhub/cron/check_sla.log 2>&1
*
* Kann außerdem manuell aus dem Backend (Einstellungen → Cron-Jobs) ausgeführt werden.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/**
* Führt die SLA-Prüfung aus. Kann vom Cron und manuell aus settings.php aufgerufen werden.
*
* @return array{overdueTotal: int, newlyOverdue: int, log: string[]}
*/
function run_check_sla(PDO $pdo, bool $verbose = false): array
{
$now = db_now();
$stmt = $pdo->prepare(
"SELECT t.*, d.email AS department_email, d.name AS department_name FROM tickets t
JOIN departments d ON d.id = t.department_id
WHERE t.status NOT IN ('resolved','closed')
AND t.resolution_due_at IS NOT NULL
AND t.resolution_due_at < ?"
);
$stmt->execute([$now]);
$overdue = $stmt->fetchAll();
$newlyOverdue = 0;
$log = [];
foreach ($overdue as $t) {
if (!$t['is_overdue']) {
$pdo->prepare('UPDATE tickets SET is_overdue = 1 WHERE id = ?')->execute([$t['id']]);
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('<strong>Betreff:</strong> ' . 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);
$newlyOverdue++;
if ($verbose) {
$log[] = "[ok] Ticket #{$t['ticket_number']} als überfällig markiert";
}
}
}
// Tickets, die nicht mehr überfällig sind (z.B. weil sie gelöst wurden), zurücksetzen
$pdo->exec("UPDATE tickets SET is_overdue = 0 WHERE status IN ('resolved','closed') AND is_overdue = 1");
set_setting('cron_check_sla_last_run', $now);
$log[] = "[" . date('Y-m-d H:i:s') . "] SLA-Prüfung abgeschlossen. " . count($overdue) . " überfällige Tickets insgesamt, davon {$newlyOverdue} neu erkannt.";
return ['overdueTotal' => count($overdue), 'newlyOverdue' => $newlyOverdue, 'log' => $log];
}
if (php_sapi_name() === 'cli') {
$result = run_check_sla(get_db(), true);
foreach ($result['log'] as $line) {
echo $line . "\n";
}
}
File diff suppressed because it is too large Load Diff
+353
View File
@@ -0,0 +1,353 @@
<?php
// Direkter Web-Zugriff (Browser/URL) wird geblockt. Ausnahmen: echter CLI-Cronjob
// sowie Einbindung per require_once aus dem authentifizierten Backend (staff/settings.php),
// das vor dem require_once die Konstante DESKHUB_CRON_RUNNER definiert.
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* cron/fetch_emails.php
*
* Ruft per IMAP neue E-Mails aus allen aktiven Postfächern ab (siehe Tabelle
* imap_accounts, verwaltet unter staff/imap_accounts.php) und erstellt Tickets
* oder hängt Antworten an. Nach dem Verarbeiten wird jede Mail je nach
* Postfach-Einstellung gelöscht, archiviert oder nur als gelesen markiert.
*
* Abteilungs-Zuordnung, in dieser Reihenfolge:
* 1. Postfach hat eine feste Standardabteilung hinterlegt → diese wird verwendet.
* 2. Sonst: Ist in der Tabelle email_addresses eine Zeile mit der Empfängeradresse
* der eingehenden Mail hinterlegt, wird das Ticket in deren Abteilung eingeordnet.
* 3. Sonst: globale Standardabteilung (Einstellungen → Allgemein).
* Ticket-Regeln können Abteilung/Priorität in jedem Fall zusätzlich überschreiben.
*
* Automatische Zuweisung:
* Wenn der Absender bereits ein offenes Ticket hat das einem Mitarbeiter
* zugewiesen ist, wird das neue Ticket demselben Mitarbeiter zugewiesen,
* sofern keine Ticket-Regel eine explizite Zuweisung vorgibt.
*
* Kann außerdem manuell aus dem Backend (Einstellungen → Cron-Jobs) ausgeführt werden.
*/
require_once __DIR__ . '/../includes/bootstrap.php';
/** Verarbeitet eine Mail nach dem Einlesen gemäß dem gewählten Modus */
function handle_after_fetch(mixed $mbox, int $emailNum, string $mode, string $folder): void
{
if ($mode === 'delete') {
imap_setflag_full($mbox, $emailNum, "\\Deleted");
imap_expunge($mbox);
} elseif ($mode === 'archive') {
imap_setflag_full($mbox, $emailNum, "\\Seen");
@imap_mail_move($mbox, (string) $emailNum, $folder);
imap_expunge($mbox);
} else {
imap_setflag_full($mbox, $emailNum, "\\Seen");
}
}
/** Ermittelt Abteilungs-ID (und optional Priorität) anhand der Empfänger-E-Mail */
function resolve_dept_from_recipient(PDO $pdo, mixed $header): ?array
{
// "To:" und "Cc:" auswerten
$recipients = array_merge(
(array) ($header->to ?? []),
(array) ($header->cc ?? []),
);
foreach ($recipients as $r) {
$addr = strtolower(trim(($r->mailbox ?? '') . '@' . ($r->host ?? '')));
if ($addr === '@') continue;
$stmt = $pdo->prepare(
'SELECT department_id, priority_id, help_topic_id FROM email_addresses WHERE LOWER(email) = ? AND is_active = 1 LIMIT 1'
);
$stmt->execute([$addr]);
$row = $stmt->fetch();
if ($row) return $row;
}
return null;
}
/** Sucht den zuletzt aktiven Bearbeiter des gleichen Kunden (Auto-Zuweisung) */
function find_previous_assignee(PDO $pdo, string $requesterEmail): ?int
{
$stmt = $pdo->prepare(
"SELECT assigned_staff_id FROM tickets
WHERE LOWER(requester_email) = LOWER(?)
AND assigned_staff_id IS NOT NULL
AND status NOT IN ('closed','resolved')
ORDER BY updated_at DESC
LIMIT 1"
);
$stmt->execute([$requesterEmail]);
$id = $stmt->fetchColumn();
if (!$id) return null;
// Mitarbeiter im Urlaub nicht zuweisen
$vac = $pdo->prepare('SELECT is_on_vacation, vacation_substitute_id FROM staff WHERE id = ? AND is_active = 1');
$vac->execute([(int)$id]);
$row = $vac->fetch();
if (!$row) return null;
if ($row['is_on_vacation']) {
// Vertretung zurückgeben falls gesetzt
return $row['vacation_substitute_id'] ? (int)$row['vacation_substitute_id'] : null;
}
return (int) $id;
}
/**
* Ruft neue E-Mails aus ALLEN aktiven Postfächern ab. Kann vom Cron und
* manuell aus settings.php aufgerufen werden.
*
* @return array{processed: int, log: string[]}
*/
function run_fetch_emails(PDO $pdo, bool $verbose = false): array
{
$log = [];
$accounts = $pdo->query('SELECT * FROM imap_accounts WHERE is_active = 1 ORDER BY id')->fetchAll();
if (!$accounts) {
$log[] = "[" . date('Y-m-d H:i:s') . "] Kein aktives Postfach eingerichtet. Siehe Einstellungen → E-Mail-Adressen → Postfächer (Abruf).";
return ['processed' => 0, 'log' => $log];
}
if (!function_exists('imap_open')) {
$log[] = 'PHP-IMAP-Erweiterung nicht installiert (php-imap). Abbruch.';
return ['processed' => 0, 'log' => $log];
}
$totalProcessed = 0;
foreach ($accounts as $account) {
$result = fetch_emails_for_account($pdo, $account, $verbose);
$log = array_merge($log, $result['log']);
$totalProcessed += $result['processed'];
}
set_setting('cron_fetch_emails_last_run', db_now());
if ($verbose) {
$log[] = "[" . date('Y-m-d H:i:s') . "] Fertig. Insgesamt {$totalProcessed} E-Mail(s) über " . count($accounts) . " Postfach/Postfächer verarbeitet.";
}
return ['processed' => $totalProcessed, 'log' => $log];
}
/** Ruft ein einzelnes Postfach ab und verarbeitet dessen neue Mails. */
function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): array
{
$log = [];
$label = $account['label'] ?: $account['username'];
$sslFlag = match ($account['encryption']) {
'ssl' => '/imap/ssl',
'tls' => '/imap/tls',
default => '/imap/notls',
};
$mailbox = '{' . $account['host'] . ':' . $account['port'] . $sslFlag . '}' . $account['folder'];
$mbox = @imap_open($mailbox, $account['username'], decrypt_secret($account['password_enc']));
if (!$mbox) {
$imapError = imap_last_error() ?: 'unbekannter Fehler';
$log[] = "[$label] IMAP-Verbindung fehlgeschlagen: " . $imapError;
$pdo->prepare('UPDATE imap_accounts SET last_run_at = ? WHERE id = ?')->execute([db_now(), $account['id']]);
health_record_mail_result($pdo, $account, $imapError);
return ['processed' => 0, 'log' => $log];
}
$pdo->prepare('UPDATE imap_accounts SET last_run_at = ? WHERE id = ?')->execute([db_now(), $account['id']]);
health_record_mail_result($pdo, $account, null);
$afterFetch = in_array($account['after_fetch'] ?? 'seen', ['seen','archive','delete'], true) ? $account['after_fetch'] : 'seen';
$archiveFolder = $account['archive_folder'] ?: 'Processed';
$emails = imap_search($mbox, 'UNSEEN');
if (!$emails) {
$log[] = "[$label] Keine neuen E-Mails.";
imap_close($mbox);
return ['processed' => 0, 'log' => $log];
}
$processed = 0;
foreach ($emails as $emailNum) {
$header = imap_headerinfo($mbox, $emailNum);
$fromEmail = $header->from[0]->mailbox . '@' . $header->from[0]->host;
$fromName = $header->from[0]->personal ?? $fromEmail;
$subject = $header->subject ? imap_utf8($header->subject) : '(kein Betreff)';
$messageUid = (int) @imap_uid($mbox, $emailNum);
$messageId = trim((string)($header->message_id ?? ''));
// Bei "archive" liegt die Mail nach dem Abruf im Archivordner; bei "seen" bleibt sie im Quellordner.
// Bei "delete" bleibt die Zuordnung zwar erhalten, die Originalmail ist danach jedoch nicht mehr verschiebbar.
$sourceFolderAfterFetch = $afterFetch === 'archive' ? $archiveFolder : $account['folder'];
// Nachrichtentext
$body = imap_fetchbody($mbox, $emailNum, '1');
$body = quoted_printable_decode($body);
if (trim($body) === '') {
$body = imap_fetchbody($mbox, $emailNum, '1.1') ?: '(kein Inhalt)';
}
$body = trim(strip_tags($body));
// ── Antwort auf bestehendes Ticket? ──────────────────────────────────
if (preg_match('/\[#([0-9]{6}-[0-9]{4})\]/', $subject, $m)) {
$ticketNumber = $m[1];
$stmt = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?');
$stmt->execute([$ticketNumber]);
$ticket = $stmt->fetch();
if ($ticket) {
$now = db_now();
$pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)'
)->execute([$ticket['id'], 'customer', $body, $now]);
$newStatus = in_array($ticket['status'], ['answered', 'resolved']) ? 'open' : $ticket['status'];
$pdo->prepare('UPDATE tickets SET status=?, updated_at=? WHERE id=?')->execute([$newStatus, $now, $ticket['id']]);
log_activity($pdo, $ticket['id'], null, 'Antwort per E-Mail empfangen');
notify_new_reply_channels($ticket, $body);
webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'customer']);
$pdo->prepare(
'INSERT INTO ticket_email_sources (ticket_id, imap_account_id, message_uid, message_id, current_folder, created_at) VALUES (?,?,?,?,?,?)'
)->execute([$ticket['id'], $account['id'], $messageUid ?: null, $messageId ?: null, $sourceFolderAfterFetch, $now]);
$log[] = "[$label] Antwort zu Ticket #{$ticketNumber} hinzugefügt (von {$fromEmail}).";
handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder);
$processed++;
continue;
}
}
// ── Neues Ticket erstellen ────────────────────────────────────────────
// Sperrliste prüfen
if (is_banned($fromEmail)) {
$log[] = "[$label] Neues Ticket von {$fromEmail} verworfen (Sperrliste).";
handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder);
$processed++;
continue;
}
// Abteilung/Priorität: 1) feste Postfach-Zuordnung, 2) Empfängeradresse (Routing-Tabelle), 3) globale Standardwerte
$cfgBase = config()['imap'];
if ($account['department_id']) {
$defaultDeptId = (int) $account['department_id'];
} else {
$defaultDeptId = (int) get_setting('default_department_id', (string) ($cfgBase['default_department_id'] ?? 1));
}
$defaultPrioId = $account['priority_id'] ? (int) $account['priority_id'] : (int) ($cfgBase['default_priority_id'] ?? 2);
$defaultTopicId = null;
// Empfängeradresse kann die Postfach-Standardabteilung zusätzlich überschreiben
// (z.B. Sammelpostfach mit mehreren Aliassen, die auf verschiedene Abteilungen zeigen).
$recipientMatch = resolve_dept_from_recipient($pdo, $header);
if ($recipientMatch) {
$defaultDeptId = (int) $recipientMatch['department_id'];
if ($recipientMatch['priority_id']) $defaultPrioId = (int) $recipientMatch['priority_id'];
if (!empty($recipientMatch['help_topic_id'])) $defaultTopicId = (int) $recipientMatch['help_topic_id'];
$log[] = "[$label] → Abteilung aus Empfängeradresse: Dept-ID {$defaultDeptId}";
}
// Ticket-Regeln (können Abteilung/Priorität nochmal überschreiben)
$ruleResult = evaluate_ticket_rules($pdo, [
'subject' => $subject,
'message' => $body,
'requester_email' => $fromEmail,
'requester_name' => $fromName,
'source' => 'email',
]);
if ($ruleResult['reject']) {
$log[] = "[$label] Neues Ticket von {$fromEmail} verworfen (Ticket-Regel: " . implode(', ', $ruleResult['matched_rules']) . ").";
handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder);
$processed++;
continue;
}
if ($ruleResult['set_department'] !== null) $defaultDeptId = $ruleResult['set_department'];
if ($ruleResult['set_priority'] !== null) $defaultPrioId = $ruleResult['set_priority'];
if ($ruleResult['set_help_topic'] !== null) $defaultTopicId = $ruleResult['set_help_topic'];
// Deaktivierte Abteilungen bekommen keine neuen Tickets: auf aktive Standardabteilung ausweichen
$activeDeptId = active_department_id($pdo, $defaultDeptId);
if ($activeDeptId !== null && $activeDeptId !== $defaultDeptId) {
$log[] = "[$label] → Abteilung {$defaultDeptId} ist deaktiviert, verwende Dept-ID {$activeDeptId}";
$defaultDeptId = $activeDeptId;
}
$stmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
$stmt->execute([$defaultPrioId]);
$priority = $stmt->fetch();
$sla = calc_sla_dates($priority, null, $defaultDeptId, $defaultTopicId);
$ticketNumber = generate_ticket_number($pdo);
$now = db_now();
$pdo->prepare(
'INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, help_topic_id, priority_id,
status, source, first_response_due_at, resolution_due_at, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$ticketNumber, $subject, $fromName, $fromEmail, $defaultDeptId, $defaultTopicId, $defaultPrioId,
'open', 'email', $sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now,
]);
$ticketId = (int) $pdo->lastInsertId();
$pdo->prepare(
'INSERT INTO ticket_email_sources (ticket_id, imap_account_id, message_uid, message_id, current_folder, created_at) VALUES (?,?,?,?,?,?)'
)->execute([$ticketId, $account['id'], $messageUid ?: null, $messageId ?: null, $sourceFolderAfterFetch, db_now()]);
$pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)'
)->execute([$ticketId, 'customer', $body, $now]);
log_activity($pdo, $ticketId, null, 'Ticket per E-Mail erstellt');
$log[] = "[$label] Neues Ticket #{$ticketNumber} erstellt (von {$fromEmail}).";
// Zuweisung: Ticket-Regel hat Vorrang, danach Auto-Zuweisung an letzten Bearbeiter
$assignTo = $ruleResult['assign_staff'];
if ($assignTo === null) {
$assignTo = find_previous_assignee($pdo, $fromEmail);
if ($assignTo !== null) {
log_activity($pdo, $ticketId, null, "Automatisch zugewiesen (letzter Bearbeiter: Staff-ID {$assignTo})");
}
} else {
log_activity($pdo, $ticketId, null, 'Automatisch zugewiesen (Ticket-Regel)');
}
if ($assignTo !== null) {
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$assignTo, $ticketId]);
}
foreach ($ruleResult['add_tags'] as $tagName) {
apply_tag_to_ticket($pdo, $ticketId, $tagName);
}
if ($ruleResult['matched_rules']) {
log_activity($pdo, $ticketId, null, 'Ticket-Regel angewendet: ' . implode(', ', $ruleResult['matched_rules']));
}
apply_auto_reply($pdo, $ticketId, $ruleResult['auto_reply']);
$deptStmt = $pdo->prepare('SELECT name, email FROM departments WHERE id = ?');
$deptStmt->execute([$defaultDeptId]);
$dept = $deptStmt->fetch();
$ticketForNotify = [
'ticket_number' => $ticketNumber,
'subject' => $subject,
'requester_name' => $fromName,
'requester_email' => $fromEmail,
'department_id' => $defaultDeptId,
'department_name' => $dept['name'] ?? null,
];
notify_new_ticket_channels($ticketForNotify, $dept['email'] ?? null);
notify_staff_new_ticket($ticketForNotify);
webhook_dispatch($pdo, 'ticket.created', $ticketId);
handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder);
$processed++;
}
imap_close($mbox);
if ($verbose) {
$log[] = "[$label] {$processed} E-Mail(s) verarbeitet.";
}
return ['processed' => $processed, 'log' => $log];
}
if (php_sapi_name() === 'cli') {
$result = run_fetch_emails(get_db(), true);
foreach ($result['log'] as $line) {
echo $line . "\n";
}
}
+45
View File
@@ -0,0 +1,45 @@
2026-08-09 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-10 02:00:02 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-11 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-12 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-13 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-14 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-15 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-16 02:00:02 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-17 02:00:02 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-18 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-19 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-20 02:00:02 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-21 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-22 02:00:02 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-23 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-24 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-25 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-26 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-27 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-28 02:00:02 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-29 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-30 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-08-31 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-01 02:00:02 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-02 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-03 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-04 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-05 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-06 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-07 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-08 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-09 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-10 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-11 02:00:02 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-12 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-13 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-14 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-15 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-16 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-17 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-18 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-19 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-20 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-21 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
2026-09-22 02:00:01 – Automatische Backups sind deaktiviert (Zeitplan: disabled).
+157
View File
@@ -0,0 +1,157 @@
<?php
// Direkter Web-Zugriff (Browser/URL) wird geblockt. Ausnahmen: echter CLI-Cronjob
// sowie Einbindung per require_once aus dem authentifizierten Backend (staff/settings.php),
// das vor dem require_once die Konstante DESKHUB_CRON_RUNNER definiert.
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* Automatischer Backup-Cron
* Crontab-Beispiele:
* Täglich 02:00: 0 2 * * * php /pfad/zu/deskhub/cron/process_backups.php
* Wöchentlich: 0 2 * * 1 php /pfad/zu/deskhub/cron/process_backups.php
* Monatlich: 0 2 1 * * php /pfad/zu/deskhub/cron/process_backups.php
*
* Kann außerdem manuell aus dem Backend (Einstellungen → Cron-Jobs) ausgeführt werden
* (dort wird der Zeitplan ignoriert und immer sofort ein Backup erstellt, $force=true).
*/
require_once __DIR__ . '/../includes/bootstrap.php';
require_once __DIR__ . '/../includes/backup.php';
function bset(PDO $pdo, string $key): ?string {
$s = $pdo->prepare('SELECT value FROM backup_settings WHERE key_name=?');
$s->execute([$key]);
$r = $s->fetch();
return $r ? $r['value'] : null;
}
/**
* Führt das automatische Backup aus (falls laut Zeitplan fällig, oder immer bei $force=true).
* Kann vom Cron und manuell aus settings.php aufgerufen werden.
*
* @return array{ran: bool, ok: bool, log: string[]}
*/
function run_process_backups(PDO $pdo, bool $verbose = false, bool $force = false): array
{
$log = [];
// Zeitplan prüfen
$schedule = bset($pdo, 'schedule') ?: 'disabled';
$lastBackup = bset($pdo, 'last_auto_backup') ?: '1970-01-01';
$now = time();
$shouldRun = false;
switch ($schedule) {
case 'daily': $shouldRun = (strtotime($lastBackup) < strtotime('-1 day', $now)); break;
case 'weekly': $shouldRun = (strtotime($lastBackup) < strtotime('-7 days', $now)); break;
case 'monthly': $shouldRun = (strtotime($lastBackup) < strtotime('-30 days', $now)); break;
default: $shouldRun = false; // disabled
}
if ($force) {
$shouldRun = true;
}
if (!$shouldRun) {
if ($schedule === 'disabled' && !$force) {
$log[] = date('Y-m-d H:i:s') . ' – Automatische Backups sind deaktiviert (Zeitplan: disabled).';
} else {
$log[] = date('Y-m-d H:i:s') . " – Kein Backup nötig (Zeitplan: $schedule, letztes: $lastBackup)";
}
return ['ran' => false, 'ok' => true, 'log' => $log];
}
$log[] = date('Y-m-d H:i:s') . ' – Starte Backup...';
$result = create_backup();
if (!$result['ok']) {
$log[] = date('Y-m-d H:i:s') . " – FEHLER: {$result['error']}";
return ['ran' => true, 'ok' => false, 'log' => $log];
}
$log[] = date('Y-m-d H:i:s') . " – Backup erstellt: {$result['filename']} ({$result['size']} MB)";
// Sofort prüfen, ob sich das Backup wiederherstellen lässt (ohne etwas zu verändern)
require_once __DIR__ . '/../includes/backup_restore.php';
$check = backup_verify_and_record($result['path']);
$log[] = date('Y-m-d H:i:s') . ($check['ok'] ? ' – Prüfung: Backup ist vollständig und wiederherstellbar.' : ' – WARNUNG, Backup nicht wiederherstellbar: ' . implode(' ', $check['errors']));
// Letztes Backup-Datum speichern
$backupTime = date('Y-m-d H:i:s');
$backupSettingSql = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite'
? "INSERT INTO backup_settings (key_name, value, updated_at) VALUES ('last_auto_backup', ?, ?)
ON CONFLICT(key_name) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at"
: "INSERT INTO backup_settings (key_name, value, updated_at) VALUES ('last_auto_backup', ?, ?)
ON DUPLICATE KEY UPDATE value=VALUES(value), updated_at=VALUES(updated_at)";
$pdo->prepare($backupSettingSql)->execute([$backupTime, $backupTime]);
// ── FTP-Upload (optional) ────────────────────────────────────────────────────
$ftpHost = bset($pdo, 'ftp_host');
$ftpUser = bset($pdo, 'ftp_user');
$ftpPass = bset($pdo, 'ftp_pass');
$ftpPath = bset($pdo, 'ftp_path') ?: '/';
$ftpPort = (int)(bset($pdo, 'ftp_port') ?: 21);
$ftpTls = bset($pdo, 'ftp_tls') === '1';
if ($ftpHost && $ftpUser) {
$log[] = date('Y-m-d H:i:s') . " – FTP-Upload nach $ftpHost...";
if (!function_exists('ftp_connect')) {
$log[] = date('Y-m-d H:i:s') . ' – WARNUNG: PHP FTP-Erweiterung nicht verfügbar.';
} else {
$ftp = $ftpTls ? @ftp_ssl_connect($ftpHost, $ftpPort, 10) : @ftp_connect($ftpHost, $ftpPort, 10);
if ($ftp && @ftp_login($ftp, $ftpUser, $ftpPass)) {
ftp_pasv($ftp, true);
$remotePath = rtrim($ftpPath, '/') . '/' . $result['filename'];
if (ftp_put($ftp, $remotePath, $result['path'], FTP_BINARY)) {
$log[] = date('Y-m-d H:i:s') . " – FTP-Upload erfolgreich: $remotePath";
// FTP-Rotation
$ftpKeep = (int)(bset($pdo, 'ftp_keep') ?: 5);
$files = @ftp_nlist($ftp, $ftpPath);
if ($files) {
$backupFiles = array_filter($files, fn($f) => str_contains($f, 'backup_'));
rsort($backupFiles);
foreach (array_slice($backupFiles, $ftpKeep) as $old) {
@ftp_delete($ftp, $old);
$log[] = date('Y-m-d H:i:s') . " – FTP-Rotation: $old gelöscht";
}
}
} else {
$log[] = date('Y-m-d H:i:s') . ' – FTP-Upload fehlgeschlagen.';
}
ftp_close($ftp);
} else {
$log[] = date('Y-m-d H:i:s') . ' – FTP-Verbindung fehlgeschlagen.';
}
}
}
// ── Lokale Rotation ──────────────────────────────────────────────────────────
$keepLocal = (int)(bset($pdo, 'rotate_keep') ?: 0);
if ($keepLocal > 0) {
$backupDir = realpath(__DIR__ . '/../backups');
$files = array_merge(
glob($backupDir . '/backup_*.zip') ?: [],
glob($backupDir . '/backup_*.tar.gz') ?: []
);
usort($files, fn($a, $b) => filemtime($b) - filemtime($a));
foreach (array_slice($files, $keepLocal) as $old) {
unlink($old);
$log[] = date('Y-m-d H:i:s') . ' – Rotation: ' . basename($old) . ' gelöscht';
}
}
set_setting('cron_process_backups_last_run', date('Y-m-d H:i:s'));
$log[] = date('Y-m-d H:i:s') . ' – Fertig.';
return ['ran' => true, 'ok' => true, 'log' => $log];
}
if (php_sapi_name() === 'cli') {
$result = run_process_backups(get_db(), true, false);
foreach ($result['log'] as $line) {
echo $line . "\n";
}
if (!$result['ok']) {
exit(1);
}
}
@@ -0,0 +1,45 @@
[2026-08-09 06:00:02] Keine fälligen wiederkehrenden Tickets.
[2026-08-10 06:00:02] Keine fälligen wiederkehrenden Tickets.
[2026-08-11 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-12 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-13 06:00:02] Keine fälligen wiederkehrenden Tickets.
[2026-08-14 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-15 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-16 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-17 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-18 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-19 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-20 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-21 06:00:02] Keine fälligen wiederkehrenden Tickets.
[2026-08-22 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-23 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-24 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-25 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-26 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-27 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-28 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-29 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-30 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-08-31 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-01 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-02 06:00:02] Keine fälligen wiederkehrenden Tickets.
[2026-09-03 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-04 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-05 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-06 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-07 06:00:02] Keine fälligen wiederkehrenden Tickets.
[2026-09-08 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-09 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-10 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-11 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-12 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-13 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-14 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-15 06:00:02] Keine fälligen wiederkehrenden Tickets.
[2026-09-16 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-17 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-18 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-19 06:00:01] Keine fälligen wiederkehrenden Tickets.
[2026-09-20 06:00:02] Keine fälligen wiederkehrenden Tickets.
[2026-09-21 06:00:02] Keine fälligen wiederkehrenden Tickets.
[2026-09-22 06:00:01] Keine fälligen wiederkehrenden Tickets.
@@ -0,0 +1,98 @@
<?php
// Direkter Web-Zugriff (Browser/URL) wird geblockt. Ausnahmen: echter CLI-Cronjob
// sowie Einbindung per require_once aus dem authentifizierten Backend (staff/settings.php),
// das vor dem require_once die Konstante DESKHUB_CRON_RUNNER definiert.
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
/**
* DeskHub – Cron: Wiederkehrende Tickets erstellen
* Aufruf: php cron/process_recurring.php
* Empfehlung: täglich einmal, z.B. um 06:00 Uhr
* 0 6 * * * /usr/bin/php /pfad/zu/deskhub/cron/process_recurring.php >> /pfad/zu/deskhub/cron/recurring.log 2>&1
*
* Kann außerdem manuell aus dem Backend (Einstellungen → Cron-Jobs) ausgeführt werden.
*/
define('CRON_CONTEXT', true);
require_once __DIR__ . '/../includes/bootstrap.php';
/**
* Erstellt fällige wiederkehrende Tickets. Kann vom Cron und manuell aus settings.php aufgerufen werden.
*
* @return array{created: int, log: string[]}
*/
function run_process_recurring(PDO $pdo, bool $verbose = false): array
{
$today = date('Y-m-d');
$log = [];
$stmt = $pdo->query(
"SELECT rt.*, d.email AS dept_email
FROM recurring_tickets rt
JOIN departments d ON d.id = rt.department_id
WHERE rt.is_active = 1 AND rt.next_run <= '{$today}'"
);
$due = $stmt->fetchAll();
if (!$due) {
$log[] = date('[Y-m-d H:i:s]') . ' Keine fälligen wiederkehrenden Tickets.';
return ['created' => 0, 'log' => $log];
}
$created = 0;
foreach ($due as $rt) {
$tn = generate_ticket_number();
$now = db_now();
$nextRun = (new DateTimeImmutable($rt['next_run']))
->modify('+' . (int) $rt['interval_days'] . ' days')
->format('Y-m-d');
try {
$pdo->beginTransaction();
$pdo->prepare(
'INSERT INTO tickets (ticket_number,subject,requester_name,requester_email,department_id,priority_id,assigned_staff_id,status,source,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,\'open\',\'staff\',?,?)'
)->execute([
$tn,
$rt['subject'],
'System (Wiederkehrend)',
'system@recurring',
$rt['department_id'],
$rt['priority_id'],
$rt['assigned_staff_id'],
$now, $now
]);
$ticketId = (int) $pdo->lastInsertId();
$pdo->prepare(
'INSERT INTO ticket_threads (ticket_id,author_type,staff_id,message,is_internal_note,created_at) VALUES (?,\'staff\',?,?,0,?)'
)->execute([$ticketId, $rt['created_by'], $rt['message'], $now]);
$pdo->prepare('UPDATE recurring_tickets SET next_run = ? WHERE id = ?')
->execute([$nextRun, $rt['id']]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$log[] = date('[Y-m-d H:i:s]') . " Fehler bei Muster \"{$rt['subject']}\": " . $e->getMessage();
continue;
}
set_setting('cron_process_recurring_last_run', $now);
$created++;
if ($verbose) {
$log[] = date('[Y-m-d H:i:s]') . " Ticket #{$tn} erstellt für Muster: {$rt['subject']}";
}
}
$log[] = date('[Y-m-d H:i:s]') . " Fertig. {$created} Ticket(s) erstellt.";
return ['created' => $created, 'log' => $log];
}
if (php_sapi_name() === 'cli') {
$result = run_process_recurring(get_db(), true);
foreach ($result['log'] as $line) {
echo $line . "\n";
}
}