Upload via GUI (258 Dateien)
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user