Upload via GUI (258 Dateien)
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Verwandelt ein <textarea data-richtext> in einen einfachen Rich-Text-Editor
|
||||
* (Fett/Kursiv/Unterstrichen/Listen/Link) ohne externe Bibliotheken. Der HTML-Inhalt
|
||||
* wird laufend in das ursprüngliche (versteckte) Textfeld zurückgeschrieben, damit
|
||||
* normale Formular-Submits unverändert funktionieren. Serverseitig wird der Wert
|
||||
* zusätzlich über sanitize_staff_html() bereinigt – dieses Skript ist nur für Komfort,
|
||||
* keine Sicherheitsgrenze.
|
||||
*/
|
||||
(function () {
|
||||
function initRichText(textarea) {
|
||||
if (!textarea || textarea.dataset.richtextReady) return;
|
||||
textarea.dataset.richtextReady = '1';
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'richtext-wrap';
|
||||
|
||||
const toolbar = document.createElement('div');
|
||||
toolbar.className = 'richtext-toolbar';
|
||||
|
||||
const editable = document.createElement('div');
|
||||
editable.className = 'richtext-editable';
|
||||
editable.contentEditable = 'true';
|
||||
editable.innerHTML = textarea.value;
|
||||
|
||||
const buttons = [
|
||||
['Fett', 'bold', 'B'],
|
||||
['Kursiv', 'italic', 'I'],
|
||||
['Unterstrichen', 'underline', 'U'],
|
||||
['Aufzählung', 'insertUnorderedList', '•'],
|
||||
['Nummerierte Liste', 'insertOrderedList', '1.'],
|
||||
];
|
||||
buttons.forEach(([title, cmd, label]) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.title = title;
|
||||
btn.textContent = label;
|
||||
btn.addEventListener('click', () => {
|
||||
editable.focus();
|
||||
document.execCommand(cmd, false, null);
|
||||
textarea.value = editable.innerHTML;
|
||||
});
|
||||
toolbar.appendChild(btn);
|
||||
});
|
||||
|
||||
const linkBtn = document.createElement('button');
|
||||
linkBtn.type = 'button';
|
||||
linkBtn.title = 'Link einfügen';
|
||||
linkBtn.textContent = '🔗';
|
||||
linkBtn.addEventListener('click', () => {
|
||||
const url = window.prompt('Link-Adresse (https://...)');
|
||||
if (url) {
|
||||
editable.focus();
|
||||
document.execCommand('createLink', false, url);
|
||||
textarea.value = editable.innerHTML;
|
||||
}
|
||||
});
|
||||
toolbar.appendChild(linkBtn);
|
||||
|
||||
// Bild einfügen: nur sichtbar, wenn ein Upload-Endpunkt angegeben ist
|
||||
// (per data-image-upload-url am textarea, aktuell für Signaturen genutzt).
|
||||
const uploadUrl = textarea.dataset.imageUploadUrl;
|
||||
if (uploadUrl) {
|
||||
const imgBtn = document.createElement('button');
|
||||
imgBtn.type = 'button';
|
||||
imgBtn.title = 'Bild einfügen';
|
||||
imgBtn.textContent = '🖼️';
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = 'image/png,image/jpeg,image/gif';
|
||||
fileInput.style.display = 'none';
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
fileInput.value = '';
|
||||
if (!file) return;
|
||||
|
||||
const metaToken = document.querySelector('meta[name="csrf"]');
|
||||
const csrfToken = metaToken ? metaToken.content : '';
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('csrf_token', csrfToken);
|
||||
|
||||
imgBtn.disabled = true;
|
||||
imgBtn.textContent = '⏳';
|
||||
|
||||
fetch(uploadUrl, { method: 'POST', body: formData })
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
window.alert(data.error);
|
||||
return;
|
||||
}
|
||||
// Rotierten CSRF-Token überall auf der Seite aktualisieren, sonst schlägt
|
||||
// das nachfolgende Absenden des umgebenden Formulars fehl.
|
||||
if (data.csrfToken) {
|
||||
document.querySelectorAll('input[name="csrf_token"]').forEach((el) => { el.value = data.csrfToken; });
|
||||
if (metaToken) metaToken.content = data.csrfToken;
|
||||
}
|
||||
editable.focus();
|
||||
document.execCommand('insertHTML', false, '<img src="' + data.url + '" alt="" style="max-width:100%">');
|
||||
textarea.value = editable.innerHTML;
|
||||
})
|
||||
.catch(() => window.alert('Der Bild-Upload ist fehlgeschlagen.'))
|
||||
.finally(() => {
|
||||
imgBtn.disabled = false;
|
||||
imgBtn.textContent = '🖼️';
|
||||
});
|
||||
});
|
||||
|
||||
imgBtn.addEventListener('click', () => fileInput.click());
|
||||
toolbar.appendChild(imgBtn);
|
||||
toolbar.appendChild(fileInput);
|
||||
}
|
||||
|
||||
editable.addEventListener('input', () => { textarea.value = editable.innerHTML; });
|
||||
editable.addEventListener('blur', () => { textarea.value = editable.innerHTML; });
|
||||
|
||||
const form = textarea.closest('form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', () => { textarea.value = editable.innerHTML; });
|
||||
}
|
||||
|
||||
textarea.style.display = 'none';
|
||||
textarea.parentNode.insertBefore(wrap, textarea);
|
||||
wrap.appendChild(toolbar);
|
||||
wrap.appendChild(editable);
|
||||
wrap.appendChild(textarea);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('textarea[data-richtext]').forEach(initRichText);
|
||||
});
|
||||
|
||||
// Hilfsfunktion für andere Skripte (z.B. Textbaustein-Auswahl), um Inhalt in ein
|
||||
// ggf. durch initRichText() ersetztes Feld einzufügen – funktioniert mit und ohne Rich-Text-Editor.
|
||||
window.insertIntoRichField = function (textareaId, html) {
|
||||
const textarea = document.getElementById(textareaId);
|
||||
if (!textarea) return;
|
||||
const editable = textarea.previousElementSibling;
|
||||
if (editable && editable.classList && editable.classList.contains('richtext-editable')) {
|
||||
editable.innerHTML += (editable.innerHTML && editable.innerHTML !== '<br>' ? '<br><br>' : '') + html;
|
||||
textarea.value = editable.innerHTML;
|
||||
} else {
|
||||
textarea.value += (textarea.value ? '\n\n' : '') + html;
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,4 @@
|
||||
# Kein direkter Web-Zugriff auf Backups
|
||||
Require all denied
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
@@ -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,83 @@
|
||||
<?php
|
||||
/**
|
||||
* DeskHub – Konfiguration
|
||||
* Vor dem Produktiveinsatz alle Werte anpassen und diese Datei
|
||||
* außerhalb des öffentlichen Web-Verzeichnisses sichern bzw. per
|
||||
* .htaccess schützen (siehe mitgelieferte .htaccess).
|
||||
*/
|
||||
|
||||
// Optional: App-Schlüssel für Verschlüsselung sensibler Einstellungen (SMTP/IMAP-Passwörter).
|
||||
// Einmal setzen, danach NICHT mehr ändern (sonst sind gespeicherte Passwörter nicht mehr lesbar).
|
||||
// Empfehlung: php -r "echo bin2hex(random_bytes(32));"
|
||||
// define('APP_KEY', 'ihr-zufaelliger-schluessel-hier');
|
||||
|
||||
return [
|
||||
|
||||
// Verschlüsselungsschlüssel für sensible Einstellungen (SMTP/IMAP/Discord/Telegram-Passwörter)
|
||||
// Generieren: php -r "echo bin2hex(random_bytes(32));"
|
||||
// PFLICHT für den Produktivbetrieb! Ohne diesen Key ist die Verschlüsselung nur schwach.
|
||||
// Einmal setzen und NIEMALS mehr ändern – sonst sind gespeicherte Passwörter unlesbar.
|
||||
'app_key' => '86f1c83be78378ecb8ddc0696cb365bbceaa70a022365a6158d31e6da911aeb9',
|
||||
|
||||
// Browser Push Notifications (VAPID)
|
||||
// Keys werden beim ersten Start automatisch generiert und in der DB gespeichert.
|
||||
// Optional: eigene Keys eintragen (php includes/push.php --generate-keys)
|
||||
'push' => [
|
||||
'public_key' => '', // leer lassen = automatisch
|
||||
'private_key' => '', // leer lassen = automatisch
|
||||
],
|
||||
|
||||
// Datenbankverbindung
|
||||
'db' => [
|
||||
// 'mysql' für den Produktivbetrieb, 'sqlite' NUR für lokale Tests
|
||||
'driver' => 'sqlite',
|
||||
|
||||
// --- MySQL-Einstellungen (nur relevant wenn driver = mysql) ---
|
||||
'host' => 'localhost',
|
||||
'name' => 'deskhub',
|
||||
'user' => 'deskhub_user',
|
||||
'pass' => '', // ← bitte MySQL-Passwort eintragen
|
||||
|
||||
// --- SQLite-Einstellungen (nur relevant wenn driver = sqlite) ---
|
||||
'sqlite_path' => getenv('DESKHUB_SQLITE_PATH') ?: __DIR__ . '/../database/deskhub.sqlite',
|
||||
],
|
||||
|
||||
// Allgemeine Website-Einstellungen
|
||||
'site' => [
|
||||
'name' => 'DeskHub Support',
|
||||
'url' => 'http://localhost:8000',
|
||||
'admin_email' => 'support@example.com',
|
||||
'timezone' => 'Europe/Berlin',
|
||||
],
|
||||
|
||||
// Ausgehende E-Mails (Benachrichtigungen an Kunden/Mitarbeiter)
|
||||
// Hinweis: nutzt die PHP-Funktion mail(). Auf den meisten Shared-Hosting-
|
||||
// Paketen funktioniert das direkt. Für zuverlässigeren Versand kann hier
|
||||
// später ein SMTP-Mailer (z.B. PHPMailer) eingebaut werden – siehe
|
||||
// includes/mailer.php.
|
||||
'mail' => [
|
||||
'enabled' => false, // auf true setzen, sobald mail() auf dem Server funktioniert
|
||||
'from_email' => 'support@example.com',
|
||||
'from_name' => 'DeskHub Support',
|
||||
],
|
||||
|
||||
// Eingehende E-Mails -> automatische Ticketerstellung (cron/fetch_emails.php)
|
||||
// Zugangsdaten werden über Einstellungen → E-Mail in der DB gespeichert (verschlüsselt).
|
||||
// Diese config.php-Werte dienen nur als Fallback falls kein DB-Wert gesetzt ist.
|
||||
'imap' => [
|
||||
'enabled' => false,
|
||||
'mailbox' => '{imap.example.com:993/imap/ssl}INBOX',
|
||||
'user' => '',
|
||||
'pass' => '', // Leer lassen – über Admin-Einstellungen setzen
|
||||
'default_department_id' => 1,
|
||||
'default_priority_id' => 2,
|
||||
],
|
||||
|
||||
// Datei-Uploads
|
||||
'uploads' => [
|
||||
'max_size_bytes' => 10 * 1024 * 1024, // 10 MB
|
||||
'allowed_extensions' => ['png','jpg','jpeg','gif','pdf','txt','log','docx','xlsx','zip'],
|
||||
'path' => __DIR__ . '/../uploads',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/bootstrap.php';
|
||||
$user = require_customer_login();
|
||||
$claimError = null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$ticketNumber = trim($_POST['ticket_number'] ?? '');
|
||||
if (!rate_limit_check('customer_ticket_claim', client_ip(), 10, 5)) {
|
||||
$claimError = 'Zu viele Versuche. Bitte versuchen Sie es in einigen Minuten erneut.';
|
||||
} elseif ($ticketNumber === '' || strlen($ticketNumber) > 32) {
|
||||
$claimError = 'Bitte geben Sie eine gültige Ticketnummer ein.';
|
||||
} elseif (empty($_SESSION['guest_ticket_access'][$ticketNumber])) {
|
||||
$claimError = 'Öffnen Sie zuerst den persönlichen Ticket-Link aus der E-Mail in diesem Browser und versuchen Sie es danach erneut.';
|
||||
} else {
|
||||
$ticketStmt = get_db()->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
|
||||
$ticketStmt->execute([$ticketNumber]);
|
||||
$ticketId = (int) $ticketStmt->fetchColumn();
|
||||
if ($ticketId > 0 && claim_ticket_for_customer(get_db(), $ticketId, $user)) {
|
||||
flash('success', 'Das Ticket wurde Ihrem Konto hinzugefügt.');
|
||||
redirect('/dashboard.php');
|
||||
}
|
||||
$claimError = 'Ticketnummer nicht gefunden oder die Ticket-E-Mail-Adresse stimmt nicht mit Ihrem Konto überein.';
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = get_db()->prepare(
|
||||
"SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name,
|
||||
(SELECT tt.message FROM ticket_threads tt
|
||||
WHERE tt.ticket_id = t.id AND tt.is_internal_note = 0
|
||||
ORDER BY tt.created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT tt.created_at FROM ticket_threads tt
|
||||
WHERE tt.ticket_id = t.id AND tt.is_internal_note = 0
|
||||
ORDER BY tt.created_at DESC LIMIT 1) AS last_message_at
|
||||
FROM tickets t
|
||||
JOIN priorities p ON p.id = t.priority_id
|
||||
JOIN departments d ON d.id = t.department_id
|
||||
WHERE t.user_id = ?
|
||||
ORDER BY COALESCE(last_message_at, t.created_at) DESC"
|
||||
);
|
||||
$stmt->execute([$user['id']]);
|
||||
$tickets = $stmt->fetchAll();
|
||||
|
||||
$pageTitle = 'Meine Tickets';
|
||||
$activePage = 'dashboard';
|
||||
require __DIR__ . '/includes/header_customer.php';
|
||||
?>
|
||||
|
||||
<div class="topbar">
|
||||
<div>
|
||||
<h1>Meine Tickets</h1>
|
||||
<p class="muted" style="margin:4px 0 0">Verlauf und Status Ihrer aktuellen und bisherigen Supportanfragen.</p>
|
||||
</div>
|
||||
<a href="/ticket_new.php" class="btn">+ Neues Ticket</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:18px">
|
||||
<h3>Gast-Ticket zum Konto hinzufügen</h3>
|
||||
<p class="muted">Öffnen Sie zuerst den persönlichen Ticket-Link aus der Ticket-E-Mail in diesem Browser. Anschließend können Sie das Ticket hier Ihrem Konto hinzufügen, wenn die Ticket-E-Mail-Adresse mit Ihrer Konto-E-Mail-Adresse übereinstimmt.</p>
|
||||
<?php if ($claimError): ?><div class="flash error"><?= e($claimError) ?></div><?php endif; ?>
|
||||
<form method="post" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap">
|
||||
<?= csrf_field() ?>
|
||||
<div style="flex:1;min-width:220px">
|
||||
<label for="ticket_number">Ticketnummer</label>
|
||||
<input type="text" id="ticket_number" name="ticket_number" value="<?= e($_POST['ticket_number'] ?? '') ?>" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Ticket hinzufügen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php if (!$tickets): ?>
|
||||
<div class="card empty-state" style="padding:48px 24px">
|
||||
<div style="width:56px;height:56px;border-radius:50%;background:var(--primary-lt);display:flex;align-items:center;justify-content:center;margin:0 auto 16px">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/></svg>
|
||||
</div>
|
||||
<p style="font-weight:600;color:var(--ink);margin:0 0 4px">Noch keine Tickets</p>
|
||||
<p style="margin:0 0 18px">Sie haben bisher keine Supportanfragen erstellt.</p>
|
||||
<a href="/ticket_new.php" class="btn">Erstes Ticket erstellen</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket</th>
|
||||
<th>Betreff & letzte Nachricht</th>
|
||||
<th>Status</th>
|
||||
<th>Zuletzt aktiv</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($tickets as $t): [$label, $color] = status_label($t['status']); ?>
|
||||
<tr>
|
||||
<td><a href="/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>" class="ticket-no">#<?= e($t['ticket_number']) ?></a></td>
|
||||
<td>
|
||||
<a href="/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>" style="font-weight:600"><?= e($t['subject']) ?></a>
|
||||
<?php if ($t['last_message']): ?>
|
||||
<div class="muted" style="font-size:.78rem; margin-top:3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:420px">
|
||||
<?= e(mb_substr(strip_tags($t['last_message']), 0, 100)) ?><?= mb_strlen(strip_tags($t['last_message'])) > 100 ? '…' : '' ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><span class="badge" style="background:<?= $color ?>"><?= e($label) ?></span></td>
|
||||
<td class="muted"><?= format_dt($t['last_message_at'] ?? $t['created_at']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
|
||||
@@ -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,5 @@
|
||||
-- Migration 0001: Baseline
|
||||
-- Legt die schema_migrations-Tabelle an (wird vom Migrator selbst erstellt,
|
||||
-- diese Datei markiert nur den Ausgangszustand des Schemas).
|
||||
-- Neue Spalten, Tabellen oder Umbenennungen kommen in höher nummerierten Dateien.
|
||||
-- no-op, baseline is handled by migrate.php
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Migration 0002: SLA-Zeiten für importierte Tickets nachberechnen
|
||||
-- Wird vom migrate.php treiberspezifisch ausgeführt (siehe migrate.php Sonderbehandlung).
|
||||
-- Platzhalter – die eigentliche Logik liegt in migrate.php::apply_migration_0002()
|
||||
-- no-op
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Migration 0003: Datenschutz-Standardwerte
|
||||
-- Wird von migrate.php ausgeführt (cross-DB via PHP, siehe apply_migration_0003)
|
||||
-- no-op
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Migration 0004: Interne Kommentare zu Thread-Nachrichten
|
||||
-- Wird von migrate.php treiberspezifisch ausgeführt.
|
||||
-- no-op
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Migration 0005: Sensible Einstellungen absichern
|
||||
--
|
||||
-- Fügt eine Markierungsspalte hinzu, die anzeigt ob ein settings-Wert
|
||||
-- verschlüsselt gespeichert ist (für zukünftige UI-Validierung).
|
||||
-- Die eigentliche Verschlüsselung bestehender Discord/Telegram-Werte
|
||||
-- erfolgt beim nächsten Speichern über das Einstellungsformular
|
||||
-- (see includes/notifications.php, staff/settings.php).
|
||||
--
|
||||
-- Außerdem: Index auf setting_key für schnellere Lookups (bei MySQL).
|
||||
|
||||
-- Keine Schema-Änderung an der settings-Tabelle nötig (VARCHAR PRIMARY KEY
|
||||
-- ist bereits ausreichend indexiert). Diese Migration dient als Marker,
|
||||
-- dass das System auf verschlüsselte Notification-Credentials umgestellt wurde.
|
||||
-- no-op
|
||||
@@ -0,0 +1,677 @@
|
||||
-- DeskHub Helpdesk – Datenbankschema (MySQL/MariaDB)
|
||||
-- Zeichensatz: utf8mb4
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Abteilungen
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS departments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(150) NOT NULL,
|
||||
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||
signature TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Prioritäten inkl. SLA-Zeiten (in Stunden)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS priorities (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
color VARCHAR(7) NOT NULL DEFAULT '#888888',
|
||||
response_hours INT NOT NULL DEFAULT 24,
|
||||
resolution_hours INT NOT NULL DEFAULT 72,
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Mitarbeiter (Agenten/Admins)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS staff (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
email VARCHAR(150) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role ENUM('admin','agent') NOT NULL DEFAULT 'agent',
|
||||
role_id INT NULL,
|
||||
department_id INT NULL,
|
||||
signature TEXT NULL,
|
||||
phone VARCHAR(60) NULL,
|
||||
avatar_path VARCHAR(255) NULL,
|
||||
is_on_vacation TINYINT(1) NOT NULL DEFAULT 0,
|
||||
vacation_substitute_id INT NULL,
|
||||
totp_secret VARCHAR(64) NULL,
|
||||
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Mitarbeiter <-> weitere Abteilungen (n:m, zusätzlich zur Hauptabteilung)
|
||||
CREATE TABLE IF NOT EXISTS staff_department (
|
||||
staff_id INT NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
notify TINYINT(1) NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (staff_id, department_id),
|
||||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Widerrufbare Bearer-Tokens fuer native Mitarbeiter-Apps.
|
||||
-- Es wird ausschliesslich der SHA-256-Hash des Tokens gespeichert.
|
||||
CREATE TABLE IF NOT EXISTS mobile_api_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
staff_id INT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
device_name VARCHAR(120) NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
last_used_at DATETIME NULL,
|
||||
revoked_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Kunden-Organisationen (Firmen, denen mehrere Kontakte zugeordnet werden)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Kunden (Endnutzer im Kundenportal)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
email VARCHAR(150) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(40) NULL,
|
||||
organization_id INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Hilfe-Themen (Dropdown bei Ticket-Erstellung, setzt Abteilung+Priorität)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS help_topics (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
priority_id INT NOT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (department_id) REFERENCES departments(id),
|
||||
FOREIGN KEY (priority_id) REFERENCES priorities(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Individuelle Ticket-Felder (zusätzlich zu Name/E-Mail/Betreff/Beschreibung)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS custom_fields (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
label VARCHAR(150) NOT NULL,
|
||||
field_type ENUM('text','textarea','select','checkbox','number','date','list') NOT NULL DEFAULT 'text',
|
||||
options TEXT NULL,
|
||||
list_id INT NULL,
|
||||
help_topic_id INT NULL,
|
||||
is_required TINYINT(1) NOT NULL DEFAULT 0,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (help_topic_id) REFERENCES help_topics(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_field_values (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id INT NOT NULL,
|
||||
custom_field_id INT NOT NULL,
|
||||
value TEXT NULL,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (custom_field_id) REFERENCES custom_fields(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Tickets
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS tickets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_number VARCHAR(20) NOT NULL UNIQUE,
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
user_id INT NULL,
|
||||
requester_name VARCHAR(120) NOT NULL,
|
||||
requester_email VARCHAR(150) NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
help_topic_id INT NULL,
|
||||
assigned_staff_id INT NULL,
|
||||
team_id INT NULL,
|
||||
priority_id INT NOT NULL,
|
||||
status ENUM('open','pending','answered','on_hold','resolved','closed') NOT NULL DEFAULT 'open',
|
||||
source ENUM('web','email','phone','staff') NOT NULL DEFAULT 'web',
|
||||
is_spam TINYINT(1) NOT NULL DEFAULT 0,
|
||||
requester_ip VARCHAR(45) NULL,
|
||||
first_response_due_at DATETIME NULL,
|
||||
first_responded_at DATETIME NULL,
|
||||
resolution_due_at DATETIME NULL,
|
||||
resolved_at DATETIME NULL,
|
||||
closed_at DATETIME NULL,
|
||||
is_overdue TINYINT(1) NOT NULL DEFAULT 0,
|
||||
merged_into INT NULL,
|
||||
locked_by INT NULL,
|
||||
locked_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (department_id) REFERENCES departments(id),
|
||||
FOREIGN KEY (help_topic_id) REFERENCES help_topics(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (assigned_staff_id) REFERENCES staff(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (priority_id) REFERENCES priorities(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_access_tokens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id INT NOT NULL,
|
||||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||
token_enc TEXT NOT NULL,
|
||||
revoked_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Ticket-Verlauf (Nachrichten / interne Notizen)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS ticket_threads (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id INT NOT NULL,
|
||||
author_type ENUM('customer','staff','system') NOT NULL,
|
||||
staff_id INT NULL,
|
||||
user_id INT NULL,
|
||||
message MEDIUMTEXT NOT NULL,
|
||||
is_internal_note TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Dateianhänge
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS ticket_attachments (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
thread_id INT NOT NULL,
|
||||
original_name VARCHAR(255) NOT NULL,
|
||||
stored_name VARCHAR(255) NOT NULL,
|
||||
filesize INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (thread_id) REFERENCES ticket_threads(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Wissensdatenbank
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS kb_categories (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
slug VARCHAR(140) NOT NULL UNIQUE,
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb_articles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
category_id INT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(280) NOT NULL UNIQUE,
|
||||
body MEDIUMTEXT NOT NULL,
|
||||
is_published TINYINT(1) NOT NULL DEFAULT 1,
|
||||
views INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES kb_categories(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Aktivitätsprotokoll (Audit-Trail je Ticket)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS activity_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id INT NOT NULL,
|
||||
staff_id INT NULL,
|
||||
action VARCHAR(255) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Allgemeine Einstellungen (Key-Value), z.B. Firmenlogo, Anzeigename
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
setting_key VARCHAR(100) PRIMARY KEY,
|
||||
setting_value TEXT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Vorlagen/Textbausteine für Mitarbeiter-Antworten
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS canned_responses (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(150) NOT NULL,
|
||||
body MEDIUMTEXT NOT NULL,
|
||||
department_id INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- E-Mail-Adressen (mehrere je Abteilung möglich, z.B. für unterschiedliche
|
||||
-- Postfächer/Absenderadressen je Priorität)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS email_addresses (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
email VARCHAR(150) NOT NULL,
|
||||
label VARCHAR(150) NULL,
|
||||
department_id INT NOT NULL,
|
||||
priority_id INT NULL,
|
||||
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (priority_id) REFERENCES priorities(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Protokoll gelöschter Tickets (für Berichte, da das Ticket selbst beim
|
||||
-- Löschen samt Verlauf entfernt wird)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS deleted_tickets_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_number VARCHAR(20) NOT NULL,
|
||||
department_id INT NULL,
|
||||
priority_id INT NULL,
|
||||
help_topic_id INT NULL,
|
||||
assigned_staff_id INT NULL,
|
||||
deleted_by INT NULL,
|
||||
deleted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Passwort-Reset-Tokens (Kunden und Mitarbeiter gemeinsam, per account_type unterschieden)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
account_type ENUM('customer','staff') NOT NULL,
|
||||
email VARCHAR(150) NOT NULL,
|
||||
token_hash VARCHAR(255) NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
used_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Einfache Rate-Begrenzung für das öffentliche Ticket-Formular
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS rate_limit_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
bucket VARCHAR(50) NOT NULL,
|
||||
identifier VARCHAR(100) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Admin-Aktionsprotokoll (Einstellungen, Mitarbeiter- und Stammdatenänderungen)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS admin_audit_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
staff_id INT NULL,
|
||||
action VARCHAR(255) NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Tags / Labels für Tickets
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(60) NOT NULL UNIQUE,
|
||||
color VARCHAR(7) NOT NULL DEFAULT '#64748b'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_tags (
|
||||
ticket_id INT NOT NULL,
|
||||
tag_id INT NOT NULL,
|
||||
PRIMARY KEY (ticket_id, tag_id),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Sperrliste: blockiert Ticket-Erstellung per E-Mail-Adresse oder Domain
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS banlist (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
match_type ENUM('email','domain') NOT NULL DEFAULT 'email',
|
||||
value VARCHAR(190) NOT NULL,
|
||||
reason VARCHAR(255) NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES staff(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Ticket-Regeln: automatische Aktionen bei neuen Tickets anhand von Bedingungen
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS ticket_rules (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
match_type ENUM('all','any') NOT NULL DEFAULT 'all',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_rule_conditions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_id INT NOT NULL,
|
||||
field ENUM('subject','message','requester_email','requester_name','source') NOT NULL,
|
||||
operator ENUM('contains','not_contains','equals','starts_with','ends_with') NOT NULL DEFAULT 'contains',
|
||||
value VARCHAR(255) NOT NULL,
|
||||
FOREIGN KEY (rule_id) REFERENCES ticket_rules(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_rule_actions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
rule_id INT NOT NULL,
|
||||
action_type ENUM('set_department','set_priority','set_help_topic','assign_staff','add_tag','reject') NOT NULL,
|
||||
action_value VARCHAR(255) NULL,
|
||||
FOREIGN KEY (rule_id) REFERENCES ticket_rules(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Kollaboratoren: zusätzliche, per Mail informierte Personen auf einem Ticket
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS ticket_collaborators (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id INT NOT NULL,
|
||||
email VARCHAR(150) NOT NULL,
|
||||
name VARCHAR(120) NULL,
|
||||
added_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (added_by) REFERENCES staff(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Geschäftszeiten für SLA-Berechnung (optional, siehe settings.business_hours_enabled)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS business_hours (
|
||||
weekday TINYINT NOT NULL PRIMARY KEY,
|
||||
is_open TINYINT(1) NOT NULL DEFAULT 1,
|
||||
open_time TIME NOT NULL DEFAULT '09:00:00',
|
||||
close_time TIME NOT NULL DEFAULT '17:00:00'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS business_holidays (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
holiday_date DATE NOT NULL,
|
||||
name VARCHAR(150) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ==============================================================
|
||||
-- Teams: abteilungsübergreifende Mitarbeiter-Gruppen
|
||||
-- ==============================================================
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
lead_id INT NULL,
|
||||
notes TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (lead_id) REFERENCES staff(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
team_id INT NOT NULL,
|
||||
staff_id INT NOT NULL,
|
||||
PRIMARY KEY (team_id, staff_id),
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ==============================================================
|
||||
-- Rollen: feinere Rechtevergabe für Agenten
|
||||
-- ==============================================================
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
permissions TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ==============================================================
|
||||
-- Benutzerdefinierte Listen (für Custom Fields)
|
||||
-- ==============================================================
|
||||
CREATE TABLE IF NOT EXISTS custom_lists (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS custom_list_items (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
list_id INT NOT NULL,
|
||||
value VARCHAR(255) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (list_id) REFERENCES custom_lists(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ==============================================================
|
||||
-- Seiten-Verwaltung (Kundenportal-Inhaltsseiten)
|
||||
-- ==============================================================
|
||||
CREATE TABLE IF NOT EXISTS site_pages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT NULL,
|
||||
show_in_nav TINYINT(1) NOT NULL DEFAULT 1,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ==============================================================
|
||||
-- Aufgaben (Tasks) – an Tickets oder Mitarbeiter gebunden
|
||||
-- ==============================================================
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id INT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
assigned_to INT NULL,
|
||||
status ENUM('open','in_progress','done') NOT NULL DEFAULT 'open',
|
||||
due_at DATETIME NULL,
|
||||
created_by INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (assigned_to) REFERENCES staff(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (created_by) REFERENCES staff(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ==============================================================
|
||||
-- Gespeicherte Queues (persönliche Ticket-Filter)
|
||||
-- ==============================================================
|
||||
CREATE TABLE IF NOT EXISTS saved_queues (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
staff_id INT NOT NULL,
|
||||
name VARCHAR(150) NOT NULL,
|
||||
filters TEXT NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ==============================================================
|
||||
-- Seed-/Beispieldaten
|
||||
-- ==============================================================
|
||||
|
||||
INSERT INTO departments (name, email, is_default) VALUES
|
||||
('Allgemeiner Support', 'support@example.com', 1),
|
||||
('Technik', 'technik@example.com', 0),
|
||||
('Buchhaltung', 'buchhaltung@example.com', 0);
|
||||
|
||||
INSERT INTO priorities (name, color, response_hours, resolution_hours, sort_order) VALUES
|
||||
('Niedrig', '#6b7280', 48, 168, 1),
|
||||
('Normal', '#2563eb', 24, 72, 2),
|
||||
('Hoch', '#d97706', 8, 24, 3),
|
||||
('Dringend', '#dc2626', 2, 8, 4);
|
||||
|
||||
-- Standard-Admin-Konto: admin@example.com / admin123
|
||||
-- WICHTIG: Passwort nach der ersten Anmeldung sofort ändern!
|
||||
INSERT INTO staff (name, email, password_hash, role, department_id, is_active) VALUES
|
||||
('Administrator', 'admin@example.com', '$2y$10$g9LizLZd7enmFOefHqwJP.FSosxEEKjmhrT5Rc63W4ZA8oIfNxO3a', 'admin', 1, 1);
|
||||
|
||||
INSERT INTO kb_categories (name, slug, sort_order) VALUES
|
||||
('Erste Schritte', 'erste-schritte', 1),
|
||||
('Häufige Fragen', 'haeufige-fragen', 2);
|
||||
|
||||
INSERT INTO kb_articles (category_id, title, slug, body, is_published) VALUES
|
||||
(1, 'Wie erstelle ich ein Ticket?', 'wie-erstelle-ich-ein-ticket',
|
||||
'<p>Klicken Sie im Kundenportal auf "Neues Ticket", wählen Sie eine Abteilung und beschreiben Sie Ihr Anliegen so genau wie möglich. Sie erhalten anschließend eine Ticketnummer, mit der Sie den Status jederzeit verfolgen können.</p>', 1),
|
||||
(2, 'Wie lange dauert die Bearbeitung?', 'wie-lange-dauert-die-bearbeitung',
|
||||
'<p>Die Bearbeitungszeit richtet sich nach der Priorität Ihres Tickets. Die genauen Reaktions- und Lösungszeiten finden Sie in der Ticketübersicht.</p>', 1);
|
||||
|
||||
-- Hilfe-Themen: Dropdown bei der Ticket-Erstellung, setzt Abteilung+Priorität automatisch
|
||||
INSERT INTO help_topics (name, department_id, priority_id, is_active, sort_order) VALUES
|
||||
('Allgemeine Anfrage', 1, 2, 1, 1),
|
||||
('Technisches Problem', 2, 3, 1, 2),
|
||||
('Rechnungsfrage', 3, 2, 1, 3);
|
||||
|
||||
-- Beispiel-Textbaustein für Mitarbeiter-Antworten
|
||||
INSERT INTO canned_responses (title, body, department_id) VALUES
|
||||
('Begrüßung', 'Vielen Dank für Ihre Nachricht. Wir haben Ihr Anliegen erhalten und kümmern uns schnellstmöglich darum.', NULL),
|
||||
('Weitere Informationen benötigt', 'Um Ihnen weiterhelfen zu können, benötigen wir noch ein paar zusätzliche Informationen. Könnten Sie uns bitte mitteilen, ...', NULL);
|
||||
|
||||
-- Standard-E-Mail-Adressen je Abteilung
|
||||
INSERT INTO email_addresses (email, label, department_id, priority_id, is_default) VALUES
|
||||
('support@example.com', 'Allgemeiner Support', 1, NULL, 1),
|
||||
('technik@example.com', 'Technik', 2, NULL, 1),
|
||||
('buchhaltung@example.com', 'Buchhaltung', 3, NULL, 1);
|
||||
|
||||
-- Standard-Geschäftszeiten: Mo-Fr 9-17 Uhr, Wochenende geschlossen (nur wirksam, wenn unter
|
||||
-- Einstellungen "Geschäftszeiten für SLA verwenden" aktiviert wird)
|
||||
INSERT INTO business_hours (weekday, is_open, open_time, close_time) VALUES
|
||||
(0, 0, '09:00:00', '17:00:00'), -- Sonntag
|
||||
(1, 1, '09:00:00', '17:00:00'), -- Montag
|
||||
(2, 1, '09:00:00', '17:00:00'), -- Dienstag
|
||||
(3, 1, '09:00:00', '17:00:00'), -- Mittwoch
|
||||
(4, 1, '09:00:00', '17:00:00'), -- Donnerstag
|
||||
(5, 1, '09:00:00', '17:00:00'), -- Freitag
|
||||
(6, 0, '09:00:00', '17:00:00'); -- Samstag
|
||||
|
||||
-- Neue Tabellen für erweiterte Features (ALTER für bestehende Installationen am Ende)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_mentions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id INT NOT NULL,
|
||||
thread_id INT NOT NULL,
|
||||
mentioned_staff_id INT NOT NULL,
|
||||
mentioned_by_staff_id INT NOT NULL,
|
||||
is_read TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (mentioned_staff_id) REFERENCES staff(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (mentioned_by_staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ticket_time_entries (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id INT NOT NULL,
|
||||
staff_id INT NOT NULL,
|
||||
minutes INT NOT NULL,
|
||||
note VARCHAR(255) NULL,
|
||||
logged_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recurring_tickets (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
subject VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
department_id INT NOT NULL,
|
||||
priority_id INT NOT NULL,
|
||||
assigned_staff_id INT NULL,
|
||||
interval_days INT NOT NULL DEFAULT 30,
|
||||
next_run DATE NOT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_by INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (priority_id) REFERENCES priorities(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (assigned_staff_id) REFERENCES staff(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (created_by) REFERENCES staff(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Interner Chat ─────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS staff_chat_messages (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
from_staff_id INT NOT NULL,
|
||||
to_staff_id INT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
is_read TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (from_staff_id) REFERENCES staff(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (to_staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Kunden-Notizen ────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS customer_notes (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
staff_id INT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Backup-Einstellungen ──────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS backup_settings (
|
||||
key_name VARCHAR(80) PRIMARY KEY,
|
||||
value TEXT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Ticket-Bewertungen ────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS ticket_ratings (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
ticket_id INT NOT NULL UNIQUE,
|
||||
user_id INT NULL,
|
||||
rating TINYINT NOT NULL CHECK(rating BETWEEN 1 AND 5),
|
||||
comment TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Ticket-Präsenz (wer schaut gerade auf welches Ticket) ─────────────────
|
||||
CREATE TABLE IF NOT EXISTS ticket_presence (
|
||||
ticket_id INT NOT NULL,
|
||||
staff_id INT NOT NULL,
|
||||
last_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (ticket_id, staff_id),
|
||||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
/**
|
||||
* database/setup_sqlite.php
|
||||
*
|
||||
* Hilfsskript für den schnellen lokalen Test ohne MySQL-Server:
|
||||
* erstellt database/deskhub.sqlite aus schema.sql (inkl. Seed-Daten).
|
||||
*
|
||||
* Aufruf: php database/setup_sqlite.php
|
||||
*
|
||||
* Voraussetzung: config/config.php -> db.driver = 'sqlite'
|
||||
*
|
||||
* Für den Produktivbetrieb stattdessen schema.sql direkt in eine
|
||||
* MySQL/MariaDB-Datenbank importieren, siehe README.md.
|
||||
*/
|
||||
require_once __DIR__ . '/sqlite_schema.php';
|
||||
|
||||
$sqlitePath = __DIR__ . '/deskhub.sqlite';
|
||||
|
||||
if (file_exists($sqlitePath)) {
|
||||
echo "Es existiert bereits eine Datenbank unter {$sqlitePath}.\n";
|
||||
echo "Zum Zurücksetzen die Datei löschen und dieses Skript erneut ausführen.\n";
|
||||
exit;
|
||||
}
|
||||
|
||||
$sql = file_get_contents(__DIR__ . '/schema.sql');
|
||||
|
||||
$pdo = new PDO('sqlite:' . $sqlitePath);
|
||||
$pdo->exec('PRAGMA foreign_keys = ON;');
|
||||
$pdo->exec(convert_schema_to_sqlite($sql));
|
||||
|
||||
echo "SQLite-Testdatenbank wurde erstellt: {$sqlitePath}\n";
|
||||
echo "Standard-Login Mitarbeiterbereich: admin@example.com / admin123\n";
|
||||
echo "WICHTIG: Dieses Passwort vor dem Produktiveinsatz unbedingt ändern!\n";
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
function convert_schema_to_sqlite(string $sql): string
|
||||
{
|
||||
$sql = preg_replace('/ENGINE=InnoDB DEFAULT CHARSET=utf8mb4/', '', $sql);
|
||||
$sql = preg_replace('/INT AUTO_INCREMENT PRIMARY KEY/', 'INTEGER PRIMARY KEY AUTOINCREMENT', $sql);
|
||||
$sql = preg_replace('/ENUM\([^)]*\)/', 'VARCHAR(30)', $sql);
|
||||
$sql = preg_replace(
|
||||
'/DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP/',
|
||||
'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
|
||||
$sql
|
||||
);
|
||||
$sql = preg_replace('/SET NAMES utf8mb4;/', '', $sql);
|
||||
$sql = preg_replace('/SET FOREIGN_KEY_CHECKS = [01];/', '', $sql);
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/bootstrap.php';
|
||||
|
||||
$pdo = get_db();
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT a.*, tt.ticket_id, t.ticket_number, t.user_id, t.department_id
|
||||
FROM ticket_attachments a
|
||||
JOIN ticket_threads tt ON tt.id = a.thread_id
|
||||
JOIN tickets t ON t.id = tt.ticket_id
|
||||
WHERE a.id = ?'
|
||||
);
|
||||
$stmt->execute([$id]);
|
||||
$att = $stmt->fetch();
|
||||
|
||||
if (!$att) {
|
||||
http_response_code(404);
|
||||
die('Datei nicht gefunden.');
|
||||
}
|
||||
|
||||
$user = current_user();
|
||||
$staff = current_staff();
|
||||
|
||||
$allowed = ($staff !== null && staff_can_access_department($staff, (int) $att['department_id']))
|
||||
|| ($user && $att['user_id'] == $user['id'])
|
||||
|| !empty($_SESSION['guest_ticket_access'][$att['ticket_number']]);
|
||||
|
||||
if (!$allowed) {
|
||||
http_response_code(403);
|
||||
die('Kein Zugriff auf diese Datei.');
|
||||
}
|
||||
|
||||
$uploadsDir = realpath(rtrim(config()['uploads']['path'], '/'));
|
||||
$path = $uploadsDir . '/' . basename($att['stored_name']); // basename verhindert Path-Traversal
|
||||
|
||||
// Sicherstellen dass Datei im Upload-Verzeichnis liegt
|
||||
if (!$uploadsDir || strpos(realpath($path) ?: '', $uploadsDir) !== 0) {
|
||||
http_response_code(403);
|
||||
die('Ungültiger Dateipfad.');
|
||||
}
|
||||
|
||||
if (!is_file($path)) {
|
||||
http_response_code(404);
|
||||
die('Datei nicht gefunden.');
|
||||
}
|
||||
|
||||
$safeName = preg_replace('/[^a-zA-Z0-9._\-]/', '_', basename($att['original_name']));
|
||||
$ext = strtolower(pathinfo($att['original_name'], PATHINFO_EXTENSION));
|
||||
$preview = !empty($_GET['preview']);
|
||||
|
||||
// Inline-fähige Typen für Vorschau
|
||||
$inlineMime = [
|
||||
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png', 'gif' => 'image/gif',
|
||||
'webp' => 'image/webp', 'svg' => 'image/svg+xml',
|
||||
'pdf' => 'application/pdf',
|
||||
];
|
||||
|
||||
if ($preview && isset($inlineMime[$ext])) {
|
||||
// Vorschau: Datei inline im Browser anzeigen
|
||||
$mime = $inlineMime[$ext];
|
||||
// SVG extra absichern
|
||||
if ($ext === 'svg') {
|
||||
header("Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'");
|
||||
} else {
|
||||
header("Content-Security-Policy: default-src 'none'");
|
||||
}
|
||||
header('Content-Type: ' . $mime);
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Content-Disposition: inline; filename="' . $safeName . '"');
|
||||
header('Content-Length: ' . filesize($path));
|
||||
header('Cache-Control: private, max-age=3600');
|
||||
readfile($path);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Standard: Datei herunterladen
|
||||
header('Content-Type: application/octet-stream');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Content-Disposition: attachment; filename="' . $safeName . '"');
|
||||
header('Content-Length: ' . filesize($path));
|
||||
header('Cache-Control: private, no-store, no-cache');
|
||||
header("Content-Security-Policy: default-src 'none'");
|
||||
header('X-Frame-Options: DENY');
|
||||
readfile($path);
|
||||
exit;
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/bootstrap.php';
|
||||
|
||||
if (current_user()) {
|
||||
redirect('/home.php');
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$sent = false;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$errors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.';
|
||||
} elseif (!rate_limit_check('password_reset_request', client_ip(), 3, 15)) {
|
||||
$errors[] = 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten erneut.';
|
||||
} elseif (!rate_limit_check('password_reset_request_email', $email, 2, 60)) {
|
||||
// Gleiche Fehlermeldung – verrät nicht ob die E-Mail bekannt ist
|
||||
$errors[] = 'Zu viele Anfragen. Bitte versuchen Sie es in einigen Minuten erneut.';
|
||||
} else {
|
||||
$stmt = get_db()->prepare('SELECT * FROM users WHERE email = ?');
|
||||
$stmt->execute([$email]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
// Immer dieselbe Erfolgsmeldung anzeigen, unabhängig davon, ob das Konto existiert
|
||||
// (verhindert, dass Angreifer registrierte E-Mail-Adressen erraten können).
|
||||
if ($user) {
|
||||
$token = create_password_reset('customer', $email);
|
||||
$link = base_url() . '/reset_password.php?token=' . urlencode($token);
|
||||
email_send_and_log($email, 'Passwort zurücksetzen', email_password_reset_body($link), null, 'Passwort-zurücksetzen-Mail (Kunde)');
|
||||
}
|
||||
$sent = true;
|
||||
}
|
||||
}
|
||||
|
||||
$pageTitle = 'Passwort vergessen';
|
||||
$activePage = 'login';
|
||||
require __DIR__ . '/includes/header_customer.php';
|
||||
?>
|
||||
<style>.page-content{padding:0!important;margin:0!important}</style>
|
||||
</div><!-- page-content -->
|
||||
<?php $__banner = get_setting('banner_path'); ?>
|
||||
<div class="auth-wrap<?= $__banner ? ' has-image' : '' ?>"<?= $__banner ? ' style="background-image:url(' . e($__banner) . ')"' : '' ?>>
|
||||
<div class="auth-card">
|
||||
<?php require __DIR__ . '/includes/brand_block.php'; ?>
|
||||
<h1>Passwort vergessen</h1>
|
||||
<div class="sub">Wir senden Ihnen einen Link zum Zurücksetzen.</div>
|
||||
<?php foreach ($errors as $err): ?>
|
||||
<div class="flash error"><?= e($err) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php if ($sent): ?>
|
||||
<div class="flash success">Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde soeben ein Link zum Zurücksetzen versendet.</div>
|
||||
<p style="margin-top:18px;font-size:.85rem"><a href="/login.php">← Zur Anmeldung</a></p>
|
||||
<?php else: ?>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input type="email" id="email" name="email" required autofocus>
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn" style="width:100%">Link anfordern</button>
|
||||
</div>
|
||||
</form>
|
||||
<p style="margin-top:18px;font-size:.85rem"><a href="/login.php">← Zur Anmeldung</a></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="public-footer">Copyright © <?= date('Y') ?> <?= e(get_setting('display_name', config()['site']['name'])) ?> – Alle Rechte vorbehalten.</footer>
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/includes/bootstrap.php';
|
||||
|
||||
$user = current_user();
|
||||
$pdo = get_db();
|
||||
|
||||
$pageTitle = 'Supportcenter-Startseite';
|
||||
$activePage = 'home';
|
||||
$showHeroBanner = true;
|
||||
$showHeroImage = true;
|
||||
require __DIR__ . '/includes/header_customer.php';
|
||||
|
||||
$showPopularArticles = get_setting('homepage_popular_articles_enabled', '1') === '1';
|
||||
$popularArticles = $showPopularArticles
|
||||
? $pdo->query(
|
||||
"SELECT title, slug, views FROM kb_articles WHERE is_published = 1 ORDER BY views DESC, title ASC LIMIT 4"
|
||||
)->fetchAll()
|
||||
: [];
|
||||
?>
|
||||
|
||||
<div class="cta-grid">
|
||||
<div class="cta-card primary">
|
||||
<div class="icon-badge">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||
</div>
|
||||
<h3>Neues Ticket eröffnen</h3>
|
||||
<p>Bitte schildern Sie uns so viele Details wie möglich, damit wir Ihnen bestmöglich helfen können.</p>
|
||||
<a href="/ticket_new.php" class="btn">Neues Ticket eröffnen</a>
|
||||
</div>
|
||||
|
||||
<div class="cta-card secondary">
|
||||
<div class="icon-badge">
|
||||
<?php if ($user): ?>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/></svg>
|
||||
<?php else: ?>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($user): ?>
|
||||
<h3>Meine Tickets</h3>
|
||||
<p>Hier finden Sie den Verlauf und Status Ihrer aktuellen und bisherigen Supportanfragen.</p>
|
||||
<a href="/dashboard.php" class="btn secondary">Meine Tickets ansehen</a>
|
||||
<?php else: ?>
|
||||
<h3>Ticketstatus überprüfen</h3>
|
||||
<p>Hier finden Sie das Archiv und den Verlauf Ihrer aktuellen und vorherigen Supportanfragen inklusive der Antworten.</p>
|
||||
<a href="/ticket_lookup.php" class="btn secondary">Ticketstatus überprüfen</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($showPopularArticles && $popularArticles): ?>
|
||||
<div class="card popular-articles">
|
||||
<h3>Beliebte Artikel</h3>
|
||||
<p class="muted" style="margin-top:-6px">Die meistgelesenen Antworten aus unserer Wissensdatenbank.</p>
|
||||
<?php foreach ($popularArticles as $i => $a): ?>
|
||||
<div class="article-row">
|
||||
<span class="rank"><?= $i + 1 ?></span>
|
||||
<div>
|
||||
<a class="article-title" href="/kb_article.php?slug=<?= urlencode($a['slug']) ?>"><?= e($a['title']) ?></a>
|
||||
<div class="article-meta"><?= (int) $a['views'] ?> Aufrufe</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div class="btn-row" style="margin-top:18px">
|
||||
<a href="/kb.php" class="btn secondary">Alle Artikel durchsuchen</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif ($showPopularArticles): ?>
|
||||
<div class="card">
|
||||
<h3>Wissensdatenbank durchsuchen</h3>
|
||||
<p class="muted">Antworten auf häufige Fragen – oft schneller als der Weg über ein Ticket.</p>
|
||||
<a href="/kb.php" class="btn secondary">Zur Wissensdatenbank</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php require __DIR__ . '/includes/footer_customer.php'; ?>
|
||||
@@ -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,320 @@
|
||||
<?php
|
||||
/**
|
||||
* Gemeinsame Verwaltungslogik für Abteilungen, Hilfethemen, Teams und SLA-Pläne.
|
||||
* Wird von den Weboberflächen (staff/*.php) und der API (api/staff/v1/*.php) genutzt,
|
||||
* damit beide exakt dieselben Regeln anwenden.
|
||||
*/
|
||||
|
||||
class AdminModelException extends RuntimeException
|
||||
{
|
||||
public function __construct(string $message, private string $errorCode = 'INVALID', private int $httpStatus = 422)
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
public function errorCode(): string { return $this->errorCode; }
|
||||
public function httpStatus(): int { return $this->httpStatus; }
|
||||
}
|
||||
|
||||
/** Wert aus $in als int oder null (leer/nicht gesetzt = null). */
|
||||
function am_int_or_null(array $in, string $key): ?int
|
||||
{
|
||||
return isset($in[$key]) && $in[$key] !== '' && $in[$key] !== null ? (int) $in[$key] : null;
|
||||
}
|
||||
|
||||
/** Wirft, wenn in $table keine Zeile mit der id existiert. */
|
||||
function am_require_row(PDO $pdo, string $table, ?int $id, string $message, string $code): void
|
||||
{
|
||||
if ($id === null) return;
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
if (!(int) $stmt->fetchColumn()) throw new AdminModelException($message, $code);
|
||||
}
|
||||
|
||||
/** Bisheriger Wert einer Spalte (für „nicht angegeben = unverändert“ bei älteren App-Versionen). */
|
||||
function am_current(PDO $pdo, string $table, int $id, string $column, $default)
|
||||
{
|
||||
if (!$id) return $default;
|
||||
$stmt = $pdo->prepare("SELECT {$column} FROM {$table} WHERE id = ?");
|
||||
$stmt->execute([$id]);
|
||||
$v = $stmt->fetchColumn();
|
||||
return $v === false || $v === null ? $default : $v;
|
||||
}
|
||||
|
||||
// ── Abteilungen ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Legt eine Abteilung an oder aktualisiert sie.
|
||||
* Schlüssel in $in: name, email, is_default, is_active, manager_id, sla_plan_id, notes, signature.
|
||||
* Fehlt is_active, bleibt der bisherige Status erhalten.
|
||||
*/
|
||||
function department_save(PDO $pdo, int $id, array $in): int
|
||||
{
|
||||
$name = trim((string) ($in['name'] ?? ''));
|
||||
$email = trim((string) ($in['email'] ?? ''));
|
||||
$isDefault = !empty($in['is_default']) ? 1 : 0;
|
||||
$isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : (int) am_current($pdo, 'departments', $id, 'is_active', 1);
|
||||
$managerId = am_int_or_null($in, 'manager_id');
|
||||
$slaPlanId = array_key_exists('sla_plan_id', $in) ? am_int_or_null($in, 'sla_plan_id') : ((int) am_current($pdo, 'departments', $id, 'sla_plan_id', 0) ?: null);
|
||||
$notes = trim((string) ($in['notes'] ?? ''));
|
||||
$signature = sanitize_staff_html((string) ($in['signature'] ?? ''));
|
||||
|
||||
if ($name === '') throw new AdminModelException('Bitte einen Namen angeben.', 'NAME_REQUIRED');
|
||||
if ($managerId !== null) {
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1');
|
||||
$stmt->execute([$managerId]);
|
||||
if (!(int) $stmt->fetchColumn()) throw new AdminModelException('Der gewählte Abteilungsleiter existiert nicht oder ist deaktiviert.', 'INVALID_MANAGER');
|
||||
}
|
||||
am_require_row($pdo, 'sla_plans', $slaPlanId, 'Der gewählte SLA-Plan existiert nicht.', 'INVALID_SLA_PLAN');
|
||||
if ($isDefault && !$isActive) throw new AdminModelException('Die Standardabteilung muss aktiv sein.', 'DEFAULT_INACTIVE');
|
||||
|
||||
// E-Mail ist optional: ohne Angabe die Standardadresse der Abteilung (bzw. erste zugeordnete Adresse)
|
||||
if ($email === '') {
|
||||
$fb = $pdo->prepare('SELECT email FROM email_addresses WHERE department_id = ? ORDER BY is_default DESC, id LIMIT 1');
|
||||
$fb->execute([$id]);
|
||||
$email = (string) ($fb->fetchColumn() ?: '');
|
||||
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new AdminModelException('Bitte eine gültige E-Mail-Adresse wählen.', 'INVALID_EMAIL');
|
||||
}
|
||||
|
||||
if ($isDefault) $pdo->exec('UPDATE departments SET is_default = 0');
|
||||
$now = db_now();
|
||||
if ($id === 0) {
|
||||
$pdo->prepare('INSERT INTO departments (name, email, is_default, is_active, manager_id, sla_plan_id, notes, signature, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute([$name, $email, $isDefault, $isActive, $managerId, $slaPlanId, $notes ?: null, $signature ?: null, $now, $now]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
} else {
|
||||
$pdo->prepare('UPDATE departments SET name=?, email=?, is_default=?, is_active=?, manager_id=?, sla_plan_id=?, notes=?, signature=?, updated_at=? WHERE id=?')
|
||||
->execute([$name, $email, $isDefault, $isActive, $managerId, $slaPlanId, $notes ?: null, $signature ?: null, $now, $id]);
|
||||
}
|
||||
// Eingehende Mails an die Abteilungs-E-Mail landen ohne weiteren Schritt in dieser Abteilung
|
||||
if ($email !== '') sync_department_email_route($pdo, $id, $email);
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** Löscht eine Abteilung (nur ohne zugeordnete Tickets). Gibt den Namen zurück. */
|
||||
/**
|
||||
* Anzahl der Datensätze in $table, die über $column auf $id verweisen (0, wenn Tabelle oder Spalte in dieser Datenbank fehlen).
|
||||
*/
|
||||
function am_reference_count(PDO $pdo, string $table, string $column, int $id): int
|
||||
{
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE {$column} = ?");
|
||||
$stmt->execute([$id]);
|
||||
return (int) $stmt->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht eine Abteilung. Wird sie noch gebraucht, gibt es eine verständliche Meldung statt eines Absturzes:
|
||||
* Standardabteilung, letzte Abteilung und Abteilungen mit Tickets, Hilfethemen, E-Mail-Adressen, Postfächern, wiederkehrenden
|
||||
* Tickets, Hauptabteilungs-Mitarbeitern oder Eskalationsregeln lassen sich nicht löschen. Reine Zuordnungen (zusätzliche
|
||||
* Mitgliedschaften, Textbausteine) werden aufgeräumt.
|
||||
*/
|
||||
function department_delete(PDO $pdo, int $id): string
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM departments WHERE id = ?');
|
||||
$stmt->execute([$id]);
|
||||
$dept = $stmt->fetch();
|
||||
if (!$dept) throw new AdminModelException('Die Abteilung existiert nicht (mehr).', 'NOT_FOUND', 404);
|
||||
$name = (string) $dept['name'];
|
||||
if (!empty($dept['is_default'])) throw new AdminModelException('Die Standardabteilung kann nicht gelöscht werden. Bitte zuerst eine andere Abteilung als Standard festlegen.', 'DEPARTMENT_IS_DEFAULT');
|
||||
if ((int) $pdo->query('SELECT COUNT(*) FROM departments')->fetchColumn() <= 1) throw new AdminModelException('Die letzte Abteilung kann nicht gelöscht werden.', 'LAST_DEPARTMENT');
|
||||
|
||||
$inUse = [];
|
||||
$labels = [
|
||||
['tickets', 'department_id', 'Tickets'], ['help_topics', 'department_id', 'Hilfethemen'], ['email_addresses', 'department_id', 'E-Mail-Adressen'],
|
||||
['imap_accounts', 'department_id', 'Postfächer (Abruf)'], ['recurring_tickets', 'department_id', 'wiederkehrende Tickets'],
|
||||
['staff', 'department_id', 'Mitarbeiter mit dieser Hauptabteilung'], ['escalation_rules', 'condition_dept_id', 'Eskalationsregeln'],
|
||||
];
|
||||
foreach ($labels as [$table, $column, $label]) {
|
||||
$n = am_reference_count($pdo, $table, $column, $id);
|
||||
if ($n > 0) $inUse[] = "{$n} {$label}";
|
||||
}
|
||||
if ($inUse) {
|
||||
throw new AdminModelException('Diese Abteilung kann nicht gelöscht werden, sie wird noch verwendet von: ' . implode(', ', $inUse) . '. Bitte zuerst anderen Abteilungen zuordnen oder entfernen.', 'DEPARTMENT_IN_USE');
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo->prepare('DELETE FROM staff_department WHERE department_id = ?')->execute([$id]);
|
||||
$pdo->prepare('UPDATE canned_responses SET department_id = NULL WHERE department_id = ?')->execute([$id]);
|
||||
$pdo->prepare('DELETE FROM departments WHERE id = ?')->execute([$id]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('department_delete: ' . $e->getMessage());
|
||||
throw new AdminModelException('Die Abteilung wird noch verwendet und kann nicht gelöscht werden.', 'DEPARTMENT_IN_USE');
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
// ── Hilfethemen ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Alle Nachfahren eines Hilfethemas (um Zyklen beim Elternthema zu verhindern). */
|
||||
function help_topic_descendants(PDO $pdo, int $id): array
|
||||
{
|
||||
$found = [];
|
||||
$queue = [$id];
|
||||
$stmt = $pdo->prepare('SELECT id FROM help_topics WHERE parent_id = ?');
|
||||
while ($queue) {
|
||||
$stmt->execute([array_shift($queue)]);
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_COLUMN) as $child) {
|
||||
if (!in_array((int) $child, $found, true)) { $found[] = (int) $child; $queue[] = (int) $child; }
|
||||
}
|
||||
}
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt ein Hilfethema an oder aktualisiert es.
|
||||
* Schlüssel in $in: name, department_id, priority_id, is_active, is_public, parent_id, sla_plan_id, notes, sort_order.
|
||||
*/
|
||||
function help_topic_save(PDO $pdo, int $id, array $in): int
|
||||
{
|
||||
$name = trim((string) ($in['name'] ?? ''));
|
||||
$deptId = (int) ($in['department_id'] ?? 0);
|
||||
$prioId = (int) ($in['priority_id'] ?? 0);
|
||||
$isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : (int) am_current($pdo, 'help_topics', $id, 'is_active', 1);
|
||||
$isPublic = array_key_exists('is_public', $in) ? (!empty($in['is_public']) ? 1 : 0) : (int) am_current($pdo, 'help_topics', $id, 'is_public', 1);
|
||||
$parentId = array_key_exists('parent_id', $in) ? am_int_or_null($in, 'parent_id') : ((int) am_current($pdo, 'help_topics', $id, 'parent_id', 0) ?: null);
|
||||
$slaPlanId = array_key_exists('sla_plan_id', $in) ? am_int_or_null($in, 'sla_plan_id') : ((int) am_current($pdo, 'help_topics', $id, 'sla_plan_id', 0) ?: null);
|
||||
$notes = array_key_exists('notes', $in) ? trim((string) $in['notes']) : (string) am_current($pdo, 'help_topics', $id, 'notes', '');
|
||||
$sort = (int) ($in['sort_order'] ?? 0);
|
||||
|
||||
if ($name === '') throw new AdminModelException('Bitte ein Thema angeben.', 'NAME_REQUIRED');
|
||||
am_require_row($pdo, 'departments', $deptId ?: null, 'Bitte eine gültige Abteilung wählen.', 'INVALID_DEPARTMENT');
|
||||
if (!$deptId) throw new AdminModelException('Bitte eine gültige Abteilung wählen.', 'INVALID_DEPARTMENT');
|
||||
am_require_row($pdo, 'priorities', $prioId ?: null, 'Bitte eine gültige Priorität wählen.', 'INVALID_PRIORITY');
|
||||
if (!$prioId) throw new AdminModelException('Bitte eine gültige Priorität wählen.', 'INVALID_PRIORITY');
|
||||
am_require_row($pdo, 'sla_plans', $slaPlanId, 'Der gewählte SLA-Plan existiert nicht.', 'INVALID_SLA_PLAN');
|
||||
if ($parentId !== null) {
|
||||
am_require_row($pdo, 'help_topics', $parentId, 'Das übergeordnete Thema existiert nicht.', 'INVALID_PARENT');
|
||||
if ($id && ($parentId === $id || in_array($parentId, help_topic_descendants($pdo, $id), true))) {
|
||||
throw new AdminModelException('Ein Thema kann nicht sich selbst oder einem eigenen Unterthema untergeordnet werden.', 'INVALID_PARENT');
|
||||
}
|
||||
}
|
||||
|
||||
$now = db_now();
|
||||
if ($id === 0) {
|
||||
$pdo->prepare('INSERT INTO help_topics (name, department_id, priority_id, is_active, is_public, parent_id, sla_plan_id, notes, sort_order, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute([$name, $deptId, $prioId, $isActive, $isPublic, $parentId, $slaPlanId, $notes ?: null, $sort, $now, $now]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
$pdo->prepare('UPDATE help_topics SET name=?, department_id=?, priority_id=?, is_active=?, is_public=?, parent_id=?, sla_plan_id=?, notes=?, sort_order=?, updated_at=? WHERE id=?')
|
||||
->execute([$name, $deptId, $prioId, $isActive, $isPublic, $parentId, $slaPlanId, $notes ?: null, $sort, $now, $id]);
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** Löscht ein Hilfethema; Tickets verlieren die Zuordnung, Unterthemen rücken eine Ebene nach oben. Gibt den Namen zurück. */
|
||||
function help_topic_delete(PDO $pdo, int $id): string
|
||||
{
|
||||
$name = (string) am_current($pdo, 'help_topics', $id, 'name', (string) $id);
|
||||
$pdo->prepare('UPDATE tickets SET help_topic_id = NULL WHERE help_topic_id = ?')->execute([$id]);
|
||||
$parent = am_current($pdo, 'help_topics', $id, 'parent_id', null);
|
||||
$pdo->prepare('UPDATE help_topics SET parent_id = ? WHERE parent_id = ?')->execute([$parent ?: null, $id]);
|
||||
try {
|
||||
$pdo->prepare('DELETE FROM help_topics WHERE id = ?')->execute([$id]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('help_topic_delete: ' . $e->getMessage());
|
||||
throw new AdminModelException('Das Hilfethema wird noch verwendet und kann nicht gelöscht werden.', 'TOPIC_IN_USE');
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
// ── Teams ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Legt ein Team an oder aktualisiert es.
|
||||
* Schlüssel in $in: name, lead_id, notes, is_active, no_alerts, members (Array von Mitarbeiter-IDs).
|
||||
* Fehlen is_active/no_alerts, bleiben die bisherigen Werte erhalten.
|
||||
*/
|
||||
function team_save(PDO $pdo, int $id, array $in): int
|
||||
{
|
||||
$name = trim((string) ($in['name'] ?? ''));
|
||||
$leadId = am_int_or_null($in, 'lead_id');
|
||||
$notes = trim((string) ($in['notes'] ?? ''));
|
||||
$isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : (int) am_current($pdo, 'teams', $id, 'is_active', 1);
|
||||
$noAlerts = array_key_exists('no_alerts', $in) ? (!empty($in['no_alerts']) ? 1 : 0) : (int) am_current($pdo, 'teams', $id, 'no_alerts', 0);
|
||||
$members = array_values(array_unique(array_filter(array_map('intval', (array) ($in['members'] ?? [])))));
|
||||
|
||||
if ($name === '') throw new AdminModelException('Bitte einen Teamnamen angeben.', 'NAME_REQUIRED');
|
||||
if ($leadId !== null) {
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1');
|
||||
$stmt->execute([$leadId]);
|
||||
if (!(int) $stmt->fetchColumn()) throw new AdminModelException('Der gewählte Teamleiter existiert nicht oder ist deaktiviert.', 'INVALID_LEAD');
|
||||
}
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM teams WHERE LOWER(name) = LOWER(?) AND id <> ?');
|
||||
$dup->execute([$name, $id]);
|
||||
if ((int) $dup->fetchColumn() > 0) throw new AdminModelException('Ein Team mit diesem Namen existiert bereits.', 'NAME_TAKEN');
|
||||
|
||||
$now = db_now();
|
||||
if ($id === 0) {
|
||||
$pdo->prepare('INSERT INTO teams (name, lead_id, notes, is_active, no_alerts, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
|
||||
->execute([$name, $leadId, $notes ?: null, $isActive, $noAlerts, $now, $now]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
} else {
|
||||
$pdo->prepare('UPDATE teams SET name=?, lead_id=?, notes=?, is_active=?, no_alerts=?, updated_at=? WHERE id=?')
|
||||
->execute([$name, $leadId, $notes ?: null, $isActive, $noAlerts, $now, $id]);
|
||||
}
|
||||
$pdo->prepare('DELETE FROM team_members WHERE team_id = ?')->execute([$id]);
|
||||
$ins = $pdo->prepare('INSERT INTO team_members (team_id, staff_id) VALUES (?,?)');
|
||||
foreach ($members as $sid) $ins->execute([$id, $sid]);
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** Löscht ein Team; zugewiesene Tickets verlieren die Team-Zuordnung. Gibt den Namen zurück. */
|
||||
function team_delete(PDO $pdo, int $id): string
|
||||
{
|
||||
$name = (string) am_current($pdo, 'teams', $id, 'name', (string) $id);
|
||||
$pdo->prepare('UPDATE tickets SET team_id = NULL WHERE team_id = ?')->execute([$id]);
|
||||
try {
|
||||
$pdo->prepare('DELETE FROM teams WHERE id = ?')->execute([$id]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('team_delete: ' . $e->getMessage());
|
||||
throw new AdminModelException('Das Team wird noch verwendet und kann nicht gelöscht werden.', 'TEAM_IN_USE');
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
|
||||
// ── SLA-Pläne ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Legt einen SLA-Plan an oder aktualisiert ihn. Schlüssel: name, response_hours, resolution_hours, is_active, notes. */
|
||||
function sla_plan_save(PDO $pdo, int $id, array $in): int
|
||||
{
|
||||
$name = trim((string) ($in['name'] ?? ''));
|
||||
$response = (int) ($in['response_hours'] ?? 0);
|
||||
$resolution = (int) ($in['resolution_hours'] ?? 0);
|
||||
$isActive = array_key_exists('is_active', $in) ? (!empty($in['is_active']) ? 1 : 0) : (int) am_current($pdo, 'sla_plans', $id, 'is_active', 1);
|
||||
$notes = trim((string) ($in['notes'] ?? ''));
|
||||
|
||||
if ($name === '') throw new AdminModelException('Bitte einen Namen angeben.', 'NAME_REQUIRED');
|
||||
if ($response <= 0 || $resolution <= 0) throw new AdminModelException('Bitte gültige Stunden angeben (größer als 0).', 'INVALID_HOURS');
|
||||
if ($response > $resolution) throw new AdminModelException('Die Reaktionszeit darf nicht länger als die Lösungszeit sein.', 'INVALID_HOURS');
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM sla_plans WHERE LOWER(name) = LOWER(?) AND id <> ?');
|
||||
$dup->execute([$name, $id]);
|
||||
if ((int) $dup->fetchColumn() > 0) throw new AdminModelException('Ein SLA-Plan mit diesem Namen existiert bereits.', 'NAME_TAKEN');
|
||||
|
||||
$now = db_now();
|
||||
if ($id === 0) {
|
||||
$pdo->prepare('INSERT INTO sla_plans (name, response_hours, resolution_hours, is_active, notes, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
|
||||
->execute([$name, $response, $resolution, $isActive, $notes ?: null, $now, $now]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
$pdo->prepare('UPDATE sla_plans SET name=?, response_hours=?, resolution_hours=?, is_active=?, notes=?, updated_at=? WHERE id=?')
|
||||
->execute([$name, $response, $resolution, $isActive, $notes ?: null, $now, $id]);
|
||||
return $id;
|
||||
}
|
||||
|
||||
/** Löscht einen SLA-Plan; Abteilungen und Themen fallen auf die Zeiten der Priorität zurück. Gibt den Namen zurück. */
|
||||
function sla_plan_delete(PDO $pdo, int $id): string
|
||||
{
|
||||
$name = (string) am_current($pdo, 'sla_plans', $id, 'name', (string) $id);
|
||||
$pdo->prepare('UPDATE departments SET sla_plan_id = NULL WHERE sla_plan_id = ?')->execute([$id]);
|
||||
$pdo->prepare('UPDATE help_topics SET sla_plan_id = NULL WHERE sla_plan_id = ?')->execute([$id]);
|
||||
try {
|
||||
$pdo->prepare('DELETE FROM sla_plans WHERE id = ?')->execute([$id]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('sla_plan_delete: ' . $e->getMessage());
|
||||
throw new AdminModelException('Der SLA-Plan wird noch verwendet und kann nicht gelöscht werden.', 'SLA_PLAN_IN_USE');
|
||||
}
|
||||
return $name;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Download der Windows- und Android-App für Mitarbeiter.
|
||||
*
|
||||
* Der Admin legt die Dateien per FTP in app_downloads/ (per .htaccess nicht direkt aufrufbar) ab:
|
||||
* DeskHub-Setup.exe (Windows) und DeskHub.apk (Android). Ausgeliefert werden sie nur über staff/app_download.php
|
||||
* an angemeldete Mitarbeiter.
|
||||
*/
|
||||
|
||||
/** Unterstützte Plattformen: Dateiendung, feste Dateinamen auf dem Server, Dateikopf-Prüfung, Anzeigename. */
|
||||
function app_platforms(): array
|
||||
{
|
||||
return [
|
||||
'windows' => ['label' => 'Windows', 'ext' => 'exe', 'stored' => 'DeskHub-Setup.exe', 'download' => 'DeskHub-Setup.exe', 'magic' => 'MZ', 'mime' => 'application/vnd.microsoft.portable-executable'],
|
||||
'android' => ['label' => 'Android', 'ext' => 'apk', 'stored' => 'DeskHub.apk', 'download' => 'DeskHub.apk', 'magic' => 'PK', 'mime' => 'application/vnd.android.package-archive'],
|
||||
];
|
||||
}
|
||||
|
||||
function app_downloads_dir(): string
|
||||
{
|
||||
return rtrim((string) (getenv('DESKHUB_APPS_DIR') ?: dirname(__DIR__) . '/app_downloads'), '/\\');
|
||||
}
|
||||
|
||||
/** Legt den Ordner an und sperrt den Direktzugriff. */
|
||||
function app_downloads_prepare_dir(): bool
|
||||
{
|
||||
$dir = app_downloads_dir();
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return false;
|
||||
$htaccess = $dir . '/.htaccess';
|
||||
if (!is_file($htaccess)) {
|
||||
@file_put_contents($htaccess, "# Nur über staff/app_download.php (mit Anmeldung) abrufbar\n<IfModule mod_authz_core.c>\n Require all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\n Order deny,allow\n Deny from all\n</IfModule>\nOptions -Indexes\n");
|
||||
}
|
||||
if (!is_file($dir . '/index.html')) @file_put_contents($dir . '/index.html', '');
|
||||
return is_writable($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Angaben zu einer App oder null, wenn die Datei nicht im Ordner liegt. Version gibt es nicht: Die Dateien werden
|
||||
* per FTP abgelegt, angezeigt werden Datum, Größe und eine berechnete SHA-256-Prüfsumme (zwischengespeichert,
|
||||
* solange sich Änderungsdatum und Größe nicht ändern).
|
||||
*/
|
||||
function app_download_info(string $platform): ?array
|
||||
{
|
||||
$platforms = app_platforms();
|
||||
if (!isset($platforms[$platform])) return null;
|
||||
$p = $platforms[$platform];
|
||||
$path = app_downloads_dir() . '/' . $p['stored'];
|
||||
clearstatcache(true, $path);
|
||||
if (!is_file($path) || (int) filesize($path) < 1024) return null;
|
||||
$size = (int) filesize($path);
|
||||
$mtime = (int) filemtime($path);
|
||||
|
||||
$cacheKey = "app_{$platform}_hash_cache";
|
||||
$cached = explode(':', (string) get_setting($cacheKey, ''), 3);
|
||||
if (count($cached) === 3 && (int) $cached[0] === $mtime && (int) $cached[1] === $size) {
|
||||
$sha = $cached[2];
|
||||
} else {
|
||||
$sha = (string) hash_file('sha256', $path);
|
||||
set_setting($cacheKey, "{$mtime}:{$size}:{$sha}");
|
||||
}
|
||||
return [
|
||||
'platform' => $platform,
|
||||
'label' => $p['label'],
|
||||
'size' => $size,
|
||||
'sha256' => $sha,
|
||||
'modified_at' => date('Y-m-d H:i:s', $mtime),
|
||||
'path' => $path,
|
||||
'filename' => $p['download'],
|
||||
'mime' => $p['mime'],
|
||||
];
|
||||
}
|
||||
|
||||
function app_format_size(int $bytes): string
|
||||
{
|
||||
if ($bytes >= 1048576) return number_format($bytes / 1048576, 1, ',', '.') . ' MB';
|
||||
return number_format(max(1, $bytes / 1024), 0, ',', '.') . ' KB';
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
/**
|
||||
* Authentifizierung – getrennte Sessions für Kunden (users) und Mitarbeiter (staff)
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Kunden-Auth
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
function customer_login(array $user): void
|
||||
{
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
}
|
||||
|
||||
function customer_logout(): void
|
||||
{
|
||||
unset($_SESSION['user_id']);
|
||||
}
|
||||
|
||||
function current_user(): ?array
|
||||
{
|
||||
if (empty($_SESSION['user_id'])) {
|
||||
return null;
|
||||
}
|
||||
static $cache = null;
|
||||
if ($cache !== null) {
|
||||
return $cache;
|
||||
}
|
||||
$stmt = get_db()->prepare('SELECT * FROM users WHERE id = ?');
|
||||
$stmt->execute([$_SESSION['user_id']]);
|
||||
$user = $stmt->fetch();
|
||||
return $cache = ($user ?: null);
|
||||
}
|
||||
|
||||
function require_customer_login(): array
|
||||
{
|
||||
$user = current_user();
|
||||
if (!$user) {
|
||||
flash('error', 'Bitte melden Sie sich an, um fortzufahren.');
|
||||
redirect(base_path() . '/login.php');
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Mitarbeiter-Auth
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
function staff_login(array $staff): void
|
||||
{
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['staff_id'] = $staff['id'];
|
||||
$_SESSION['staff_last_activity'] = time();
|
||||
}
|
||||
|
||||
function staff_logout(): void
|
||||
{
|
||||
// Automatische Zeiterfassung: offene Arbeits-Sitzungen beim Logout sofort
|
||||
// abschließen, statt bis zum nächsten Stale-Sweep (Auto-Cron) zu warten.
|
||||
if (!empty($_SESSION['staff_id'])) {
|
||||
finalize_all_sessions_for_staff(get_db(), (int) $_SESSION['staff_id']);
|
||||
}
|
||||
unset($_SESSION['staff_id']);
|
||||
}
|
||||
|
||||
function current_staff(): ?array
|
||||
{
|
||||
if (empty($_SESSION['staff_id'])) {
|
||||
return null;
|
||||
}
|
||||
static $cache = null;
|
||||
if ($cache !== null) {
|
||||
return $cache;
|
||||
}
|
||||
$stmt = get_db()->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
|
||||
$stmt->execute([$_SESSION['staff_id']]);
|
||||
$staff = $stmt->fetch();
|
||||
return $cache = ($staff ?: null);
|
||||
}
|
||||
|
||||
function require_staff_login(): array
|
||||
{
|
||||
$staff = current_staff();
|
||||
if (!$staff) {
|
||||
flash('error', 'Bitte melden Sie sich an, um fortzufahren.');
|
||||
redirect(staff_base_path() . '/login.php');
|
||||
}
|
||||
// Inaktivitäts-Timeout
|
||||
$timeout = (int)(get_setting('staff_session_timeout', '30') ?: 30); // Minuten
|
||||
$last = $_SESSION['staff_last_activity'] ?? 0;
|
||||
if ($last && (time() - $last) > ($timeout * 60)) {
|
||||
staff_logout();
|
||||
session_start();
|
||||
$_SESSION['flash'] = [['type'=>'error','message'=>'Sie wurden wegen Inaktivität abgemeldet.']];
|
||||
redirect('/staff/login.php');
|
||||
}
|
||||
$_SESSION['staff_last_activity'] = time();
|
||||
|
||||
// "Eingebauter" Ersatz für echte Server-Cronjobs (siehe includes/pseudo_cron.php):
|
||||
// bei jeder authentifizierten Backend-Anfrage werden fällige Jobs im Hintergrund geprüft.
|
||||
pseudo_cron_schedule_background();
|
||||
|
||||
return $staff;
|
||||
}
|
||||
|
||||
function require_admin(): array
|
||||
{
|
||||
$staff = require_staff_login();
|
||||
if ($staff['role'] !== 'admin') {
|
||||
flash('error', 'Diese Funktion ist nur für Administratoren verfügbar.');
|
||||
redirect(staff_base_path() . '/reports.php');
|
||||
}
|
||||
return $staff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie require_admin(), lässt aber zusätzlich Agenten mit einer benutzerdefinierten Rolle
|
||||
* durch, die genau dieses Adminportal-Recht zugewiesen bekommen haben ("Co-Admin" für
|
||||
* einzelne Bereiche, siehe admin_section_permissions() / staff/roles.php).
|
||||
*/
|
||||
function require_admin_or_permission(string $permission): array
|
||||
{
|
||||
$staff = require_staff_login();
|
||||
if ($staff['role'] !== 'admin' && !has_permission($staff, $permission)) {
|
||||
flash('error', 'Für diese Funktion fehlt die erforderliche Berechtigung.');
|
||||
redirect(staff_base_path() . '/reports.php');
|
||||
}
|
||||
return $staff;
|
||||
}
|
||||
|
||||
/** Pfad-Helfer, damit Includes unabhängig vom Aufrufort funktionieren */
|
||||
function base_path(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
function staff_base_path(): string
|
||||
{
|
||||
return '/staff';
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
/**
|
||||
* Backup-Hilfsfunktion – wird von update.php, backup.php und den Cron-Jobs genutzt.
|
||||
*
|
||||
* Ein Backup ist eine ZIP-Datei mit
|
||||
* - allen Programm- und Datendateien (ohne Backups, Entwickler-Artefakte),
|
||||
* - einem konsistenten Datenbank-Abzug: database_backup.sqlite (SQLite, per VACUUM INTO) oder database_backup.sql (MySQL),
|
||||
* - backup_manifest.json (Zeitpunkt, Datenbanktyp, Programmversion, Anzahl Tabellen).
|
||||
* Wiederherstellen und Prüfen: includes/backup_restore.php.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/simple_zip.php';
|
||||
|
||||
/** Pfade, die nie ins Backup gehören (Entwickler-Artefakte und Zwischenspeicher). */
|
||||
function backup_excluded_fragments(): array
|
||||
{
|
||||
return ['/node_modules/', '/.git/', '/desktop/dist/', '/mobile/build/', '/mobile/artifacts/', '/plugin-webseite/storage/', '/plugin-webseite/schluessel/'];
|
||||
}
|
||||
|
||||
// ── Backup erstellen ───────────────────────────────────────────────�
|
||||
/**
|
||||
* @param string|null $rootDir Ordner, dessen Dateien gesichert werden (Standard: DeskHub-Hauptordner)
|
||||
* @param string|null $backupDir Zielordner (Standard: backups/ im Hauptordner)
|
||||
* @param PDO|null $pdo Datenbankverbindung (Standard: get_db())
|
||||
*/
|
||||
function create_backup(?string $rootDir = null, ?string $backupDir = null, ?PDO $pdo = null): array
|
||||
{
|
||||
$rootDir = realpath($rootDir ?? __DIR__ . '/..');
|
||||
$backupDir = $backupDir ?? $rootDir . '/backups';
|
||||
$pdo = $pdo ?? get_db();
|
||||
|
||||
if (!is_dir($backupDir)) {
|
||||
mkdir($backupDir, 0755, true);
|
||||
}
|
||||
|
||||
// .htaccess zum Schutz des Backup-Ordners
|
||||
$htaccess = $backupDir . '/.htaccess';
|
||||
if (!file_exists($htaccess)) {
|
||||
file_put_contents($htaccess, "Require all denied
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
");
|
||||
}
|
||||
|
||||
$backupName = 'backup_' . date('Y-m-d_H-i-s') . '.zip';
|
||||
$backupPath = $backupDir . '/' . $backupName;
|
||||
if (file_exists($backupPath)) { // zwei Backups in derselben Sekunde
|
||||
$backupName = 'backup_' . date('Y-m-d_H-i-s') . '_' . substr(bin2hex(random_bytes(2)), 0, 3) . '.zip';
|
||||
$backupPath = $backupDir . '/' . $backupName;
|
||||
}
|
||||
|
||||
// Zeitlimit erhöhen für große Backups
|
||||
@set_time_limit(300);
|
||||
@ini_set('memory_limit', '256M');
|
||||
|
||||
// php_zip automatisch aktivieren falls nicht geladen
|
||||
if (!class_exists('ZipArchive')) {
|
||||
$activated = false;
|
||||
|
||||
// 1. Dynamisch laden (funktioniert wenn dl() erlaubt)
|
||||
if (function_exists('dl') && ini_get('enable_dl')) {
|
||||
$activated = @dl('php_zip.dll') || @dl('zip.so') || @dl('zip');
|
||||
}
|
||||
|
||||
// 2. php.ini dauerhaft anpassen (für nächsten Start)
|
||||
if (!$activated) {
|
||||
$phpIni = php_ini_loaded_file();
|
||||
if ($phpIni && is_writable($phpIni)) {
|
||||
$ini = file_get_contents($phpIni);
|
||||
$patched = preg_replace('/^[ \t]*;[ \t]*(extension[ \t]*=[ \t]*(?:php_)?zip(?:\.dll|\.so)?)/mi', '$1', $ini);
|
||||
if ($patched !== $ini) {
|
||||
file_put_contents($phpIni, $patched);
|
||||
// Hinweis speichern dass Neustart nötig ist
|
||||
file_put_contents($backupDir . '/.restart_required', '1');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Datenbank-Abzug: SQLite-Datei wird NICHT als laufende Datei kopiert, sondern konsistent per VACUUM INTO gesichert
|
||||
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$liveDb = null;
|
||||
if ($driver === 'sqlite') {
|
||||
$row = $pdo->query('PRAGMA database_list')->fetch(PDO::FETCH_ASSOC);
|
||||
$liveDb = !empty($row['file']) ? realpath($row['file']) : null;
|
||||
}
|
||||
|
||||
// Alle Dateien sammeln
|
||||
$exclude = [realpath($backupDir) ?: $backupDir];
|
||||
$iter = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($rootDir, RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
$files = [];
|
||||
foreach ($iter as $file) {
|
||||
if (!$file->isFile()) continue;
|
||||
$filePath = $file->getRealPath();
|
||||
$norm = str_replace('\\', '/', $filePath);
|
||||
$skip = false;
|
||||
foreach ($exclude as $ex) {
|
||||
if ($ex && strpos($filePath, $ex) === 0) { $skip = true; break; }
|
||||
}
|
||||
foreach (backup_excluded_fragments() as $frag) {
|
||||
if (str_contains($norm, $frag)) { $skip = true; break; }
|
||||
}
|
||||
if ($liveDb && ($filePath === $liveDb || $filePath === $liveDb . '-wal' || $filePath === $liveDb . '-shm' || $filePath === $liveDb . '-journal')) $skip = true;
|
||||
if ($skip) continue;
|
||||
$files[$filePath] = str_replace(chr(92), '/', substr($filePath, strlen($rootDir) + 1)); // im ZIP immer mit /
|
||||
}
|
||||
|
||||
// Abzug erzeugen
|
||||
$sqlDump = null; // MySQL: Text
|
||||
$sqlFile = null; // MySQL: Dateiname im ZIP
|
||||
$sqliteSnapshot = null;
|
||||
$tableCount = 0;
|
||||
$dbError = null;
|
||||
|
||||
if ($driver === 'sqlite') {
|
||||
$snapshot = tempnam(sys_get_temp_dir(), 'dhbk');
|
||||
@unlink($snapshot);
|
||||
try {
|
||||
$pdo->exec('VACUUM INTO ' . $pdo->quote($snapshot));
|
||||
$sqliteSnapshot = $snapshot;
|
||||
} catch (Throwable $e) {
|
||||
// Ältere SQLite-Version ohne VACUUM INTO: Datei kopieren (weniger sicher, aber besser als nichts)
|
||||
if ($liveDb && @copy($liveDb, $snapshot)) $sqliteSnapshot = $snapshot;
|
||||
else $dbError = 'SQLite-Abzug fehlgeschlagen: ' . $e->getMessage();
|
||||
}
|
||||
if ($sqliteSnapshot) {
|
||||
$tableCount = (int) $pdo->query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchColumn();
|
||||
}
|
||||
} elseif ($driver === 'mysql') {
|
||||
try {
|
||||
$dbName = (string) $pdo->query('SELECT DATABASE()')->fetchColumn();
|
||||
$dump = "-- DeskHub MySQL Backup\n-- Erstellt: " . date('Y-m-d H:i:s') . "\n-- Datenbank: " . $dbName . "\n\nSET FOREIGN_KEY_CHECKS=0;\n\n";
|
||||
|
||||
// Alle Tabellen auslesen
|
||||
$tables = $pdo->query("SHOW TABLES")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$tableCount = count($tables);
|
||||
|
||||
foreach ($tables as $table) {
|
||||
// CREATE TABLE
|
||||
$create = $pdo->query("SHOW CREATE TABLE `$table`")->fetch();
|
||||
$dump .= "\n-- Tabelle: $table\n";
|
||||
$dump .= "DROP TABLE IF EXISTS `$table`;\n";
|
||||
$dump .= $create['Create Table'] . ";\n\n";
|
||||
|
||||
// Daten
|
||||
$rows = $pdo->query("SELECT * FROM `$table`")->fetchAll(PDO::FETCH_ASSOC);
|
||||
if ($rows) {
|
||||
$cols = '`' . implode('`, `', array_keys($rows[0])) . '`';
|
||||
$dump .= "INSERT INTO `$table` ($cols) VALUES\n";
|
||||
$vals = [];
|
||||
foreach ($rows as $row) {
|
||||
$escaped = array_map(fn($v) => $v === null ? 'NULL' : $pdo->quote((string)$v), $row);
|
||||
$vals[] = '(' . implode(', ', $escaped) . ')';
|
||||
}
|
||||
$dump .= implode(",\n", $vals) . ";\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
$dump .= "SET FOREIGN_KEY_CHECKS=1;\n";
|
||||
$sqlFile = 'database_backup.sql';
|
||||
$sqlDump = $dump;
|
||||
} catch (Throwable $e) {
|
||||
$dbError = 'MySQL-Abzug fehlgeschlagen: ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
// Ohne Datenbank-Abzug ist es kein brauchbares Backup: lieber Fehler melden als ein Backup vortäuschen
|
||||
if ($dbError !== null) {
|
||||
if ($sqliteSnapshot) @unlink($sqliteSnapshot);
|
||||
return ['ok' => false, 'error' => $dbError];
|
||||
}
|
||||
|
||||
$manifest = json_encode([
|
||||
'created' => date('Y-m-d H:i:s'), 'driver' => $driver, 'version' => defined('DESKHUB_VERSION') ? DESKHUB_VERSION : null,
|
||||
'database' => $sqliteSnapshot ? 'database_backup.sqlite' : $sqlFile, 'tables' => $tableCount, 'files' => count($files),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||
|
||||
// ZIP erstellen – ZipArchive wenn verfügbar, sonst eigene Implementierung
|
||||
try {
|
||||
if (class_exists('ZipArchive')) {
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($backupPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||
return ['ok' => false, 'error' => 'Backup-ZIP konnte nicht erstellt werden.'];
|
||||
}
|
||||
foreach ($files as $filePath => $relativePath) {
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump);
|
||||
if ($sqliteSnapshot) $zip->addFile($sqliteSnapshot, 'database_backup.sqlite');
|
||||
$zip->addFromString('backup_manifest.json', $manifest);
|
||||
$zip->close();
|
||||
} else {
|
||||
// Eigene ZIP-Implementierung (kein php_zip nötig)
|
||||
$zip = new SimpleZip($backupPath);
|
||||
foreach ($files as $filePath => $relativePath) {
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
if ($sqlDump && $sqlFile) $zip->addFromString($sqlFile, $sqlDump);
|
||||
if ($sqliteSnapshot) $zip->addFromString('database_backup.sqlite', (string) file_get_contents($sqliteSnapshot));
|
||||
$zip->addFromString('backup_manifest.json', $manifest);
|
||||
$zip->close();
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
if ($sqliteSnapshot) @unlink($sqliteSnapshot);
|
||||
return ['ok' => false, 'error' => 'Backup fehlgeschlagen: ' . $e->getMessage()];
|
||||
}
|
||||
if ($sqliteSnapshot) @unlink($sqliteSnapshot);
|
||||
|
||||
if (!file_exists($backupPath)) {
|
||||
return ['ok' => false, 'error' => 'Backup-Datei wurde nicht erstellt.'];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'filename' => $backupName,
|
||||
'path' => $backupPath,
|
||||
'files' => count($files),
|
||||
'size' => round(filesize($backupPath) / 1024 / 1024, 2),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Backups prüfen und wiederherstellen (Gegenstück zu create_backup in includes/backup.php).
|
||||
*
|
||||
* backup_verify($zip) prüft, ob ein Backup vollständig und lesbar ist, ohne etwas zu verändern
|
||||
* backup_restore($pdo, $zip, …) stellt Datenbank und (wählbar) Dateien wieder her; vorher wird ein Sicherheits-Backup erstellt
|
||||
*
|
||||
* Programmdateien (includes/, staff/, config/ …) werden nie überschrieben: Dafür gibt es das System-Update. Wiederhergestellt werden
|
||||
* die Datenbank sowie die Ordner uploads/, plugins/ und app_downloads/.
|
||||
*/
|
||||
require_once __DIR__ . '/backup.php';
|
||||
|
||||
/** Ordner im Backup, die beim Wiederherstellen zurückgespielt werden dürfen. */
|
||||
function backup_restorable_dirs(): array
|
||||
{
|
||||
return ['uploads' => 'Hochgeladene Dateien (Anhänge)', 'plugins' => 'Plugins', 'app_downloads' => 'App-Dateien'];
|
||||
}
|
||||
|
||||
/** Ist der Eintragsname im ZIP unbedenklich (keine .., keine absoluten Pfade)? */
|
||||
function backup_entry_name_ok(string $name): bool
|
||||
{
|
||||
$n = str_replace('\\', '/', $name);
|
||||
return $n !== '' && $n[0] !== '/' && !str_contains($n, "\0") && !preg_match('#(^|/)\.\.(/|$)#', $n) && !preg_match('#^[A-Za-z]:#', $n);
|
||||
}
|
||||
|
||||
/** Zerlegt einen SQL-Dump in Anweisungen (Zeilenweise, mit Beachtung von Zeichenketten). */
|
||||
function backup_sql_statements(string $file): Generator
|
||||
{
|
||||
$fh = fopen($file, 'rb');
|
||||
$buf = '';
|
||||
$inQuote = false;
|
||||
while (($line = fgets($fh)) !== false) {
|
||||
if (!$inQuote && $buf === '' && (trim($line) === '' || str_starts_with($line, '--'))) continue;
|
||||
$buf .= $line;
|
||||
// Anführungszeichen-Zustand über die Zeile fortschreiben
|
||||
$n = strlen($line);
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$c = $line[$i];
|
||||
if ($inQuote) {
|
||||
if ($c === '\\') { $i++; continue; }
|
||||
if ($c === "'") $inQuote = false;
|
||||
} elseif ($c === "'") {
|
||||
$inQuote = true;
|
||||
}
|
||||
}
|
||||
if (!$inQuote && substr(rtrim($line), -1) === ';') {
|
||||
yield trim($buf);
|
||||
$buf = '';
|
||||
}
|
||||
}
|
||||
if (trim($buf) !== '') yield trim($buf);
|
||||
fclose($fh);
|
||||
}
|
||||
|
||||
/** Entpackt einen Eintrag in eine temporäre Datei (Streaming, auch für große Dateien). */
|
||||
function backup_extract_entry(ZipArchive $zip, string $name): ?string
|
||||
{
|
||||
$in = $zip->getStream($name);
|
||||
if (!$in) return null;
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'dhrs');
|
||||
$out = fopen($tmp, 'wb');
|
||||
stream_copy_to_stream($in, $out);
|
||||
fclose($in);
|
||||
fclose($out);
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein Backup, ohne etwas zu verändern.
|
||||
*
|
||||
* @return array{ok:bool, errors:string[], warnings:string[], info:array}
|
||||
*/
|
||||
function backup_verify(string $zipPath): array
|
||||
{
|
||||
$r = ['ok' => false, 'errors' => [], 'warnings' => [], 'info' => []];
|
||||
if (!class_exists('ZipArchive')) { $r['errors'][] = 'Die PHP-Erweiterung zip fehlt.'; return $r; }
|
||||
if (!is_file($zipPath) || filesize($zipPath) < 100) { $r['errors'][] = 'Die Backup-Datei fehlt oder ist leer.'; return $r; }
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CHECKCONS) !== true) { $r['errors'][] = 'Die ZIP-Datei ist beschädigt oder unvollständig.'; return $r; }
|
||||
|
||||
$names = [];
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$n = (string) $zip->getNameIndex($i);
|
||||
if (!backup_entry_name_ok($n)) { $zip->close(); $r['errors'][] = "Unzulässiger Pfad im Backup: {$n}"; return $r; }
|
||||
$names[$n] = true;
|
||||
}
|
||||
$r['info']['files'] = count($names);
|
||||
|
||||
$manifest = null;
|
||||
if (isset($names['backup_manifest.json'])) {
|
||||
$manifest = json_decode((string) $zip->getFromName('backup_manifest.json'), true);
|
||||
if (!is_array($manifest)) { $r['errors'][] = 'Das Manifest im Backup ist unlesbar.'; $manifest = null; }
|
||||
}
|
||||
$r['info']['created'] = $manifest['created'] ?? null;
|
||||
$r['info']['version'] = $manifest['version'] ?? null;
|
||||
|
||||
$key = ['tickets', 'users', 'staff', 'settings'];
|
||||
if (isset($names['database_backup.sqlite'])) {
|
||||
$r['info']['driver'] = 'sqlite';
|
||||
$tmp = backup_extract_entry($zip, 'database_backup.sqlite');
|
||||
if (!$tmp || (string) @file_get_contents($tmp, false, null, 0, 15) !== 'SQLite format 3') {
|
||||
$r['errors'][] = 'Der Datenbank-Abzug im Backup ist keine gültige SQLite-Datenbank.';
|
||||
} else {
|
||||
try {
|
||||
$db = new PDO('sqlite:' . $tmp);
|
||||
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
$integrity = $db->query('PRAGMA integrity_check')->fetchColumn();
|
||||
if ($integrity !== 'ok') $r['errors'][] = 'Die Datenbank im Backup ist beschädigt: ' . $integrity;
|
||||
$tables = $db->query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$r['info']['tables'] = count($tables);
|
||||
foreach ($key as $t) if (!in_array($t, $tables, true)) $r['errors'][] = "In der Datenbank des Backups fehlt die Tabelle \"{$t}\".";
|
||||
foreach (['tickets', 'users', 'staff'] as $t) if (in_array($t, $tables, true)) $r['info'][$t] = (int) $db->query("SELECT COUNT(*) FROM {$t}")->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
$r['errors'][] = 'Die Datenbank im Backup lässt sich nicht lesen: ' . $e->getMessage();
|
||||
}
|
||||
$db = null;
|
||||
}
|
||||
if ($tmp) @unlink($tmp);
|
||||
} elseif (isset($names['database_backup.sql'])) {
|
||||
$r['info']['driver'] = 'mysql';
|
||||
$tmp = backup_extract_entry($zip, 'database_backup.sql');
|
||||
if (!$tmp) {
|
||||
$r['errors'][] = 'Der Datenbank-Abzug im Backup ist nicht lesbar.';
|
||||
} else {
|
||||
$creates = [];
|
||||
$count = 0;
|
||||
$last = '';
|
||||
foreach (backup_sql_statements($tmp) as $stmt) {
|
||||
$count++;
|
||||
$last = $stmt;
|
||||
if (preg_match('/^CREATE TABLE `?([A-Za-z0-9_]+)`?/i', $stmt, $m)) $creates[$m[1]] = true;
|
||||
}
|
||||
$r['info']['tables'] = count($creates);
|
||||
$r['info']['statements'] = $count;
|
||||
foreach ($key as $t) if (!isset($creates[$t])) $r['errors'][] = "Im Datenbank-Abzug fehlt die Tabelle \"{$t}\".";
|
||||
if (stripos($last, 'FOREIGN_KEY_CHECKS=1') === false) $r['errors'][] = 'Der Datenbank-Abzug ist unvollständig (das Ende fehlt).';
|
||||
@unlink($tmp);
|
||||
}
|
||||
} elseif (isset($names['database/deskhub.sqlite'])) {
|
||||
// Ältere Backups enthalten die laufende SQLite-Datei als Kopie
|
||||
$r['info']['driver'] = 'sqlite';
|
||||
$r['warnings'][] = 'Dieses ältere Backup enthält eine Kopie der laufenden Datenbankdatei. Sie lässt sich nicht garantiert fehlerfrei wiederherstellen.';
|
||||
$tmp = backup_extract_entry($zip, 'database/deskhub.sqlite');
|
||||
if (!$tmp || (string) @file_get_contents($tmp, false, null, 0, 15) !== 'SQLite format 3') $r['errors'][] = 'Die Datenbankkopie im Backup ist keine gültige SQLite-Datenbank.';
|
||||
else $r['errors'][] = 'Backups im alten Format können nicht automatisch wiederhergestellt werden. Bitte ein neues Backup erstellen.';
|
||||
if ($tmp) @unlink($tmp);
|
||||
} else {
|
||||
$r['errors'][] = 'Im Backup fehlt der Datenbank-Abzug.';
|
||||
}
|
||||
$zip->close();
|
||||
$r['ok'] = !$r['errors'];
|
||||
return $r;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stellt ein Backup wieder her.
|
||||
*
|
||||
* Optionen: database (Standard true), dirs (Liste aus backup_restorable_dirs(), Standard alle),
|
||||
* root (Zielordner für Dateien), backup_dir (Zielordner des Sicherheits-Backups), safety_backup (Standard true).
|
||||
*
|
||||
* @return array{ok:bool, error?:string, tables?:int, rows?:int, files?:int, safety?:string, notes?:string[]}
|
||||
*/
|
||||
function backup_restore(PDO $pdo, string $zipPath, array $options = []): array
|
||||
{
|
||||
@set_time_limit(600);
|
||||
$check = backup_verify($zipPath);
|
||||
if (!$check['ok']) return ['ok' => false, 'error' => 'Das Backup ist nicht wiederherstellbar: ' . implode(' ', $check['errors'])];
|
||||
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
if (($check['info']['driver'] ?? '') !== $driver) {
|
||||
return ['ok' => false, 'error' => 'Das Backup stammt aus einer ' . strtoupper((string) $check['info']['driver']) . '-Datenbank, dieses System nutzt ' . strtoupper($driver) . '. Eine Wiederherstellung ist nur auf demselben Datenbanktyp möglich.'];
|
||||
}
|
||||
$root = realpath($options['root'] ?? __DIR__ . '/..');
|
||||
$result = ['ok' => true, 'tables' => 0, 'rows' => 0, 'files' => 0, 'notes' => []];
|
||||
|
||||
if (($options['safety_backup'] ?? true)) {
|
||||
$safety = create_backup($root, $options['backup_dir'] ?? null, $pdo);
|
||||
if (!$safety['ok']) return ['ok' => false, 'error' => 'Das Sicherheits-Backup vor der Wiederherstellung ist fehlgeschlagen, es wurde nichts verändert: ' . $safety['error']];
|
||||
$result['safety'] = $safety['filename'];
|
||||
}
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($zipPath);
|
||||
|
||||
try {
|
||||
if ($options['database'] ?? true) {
|
||||
$db = $driver === 'sqlite' ? backup_restore_sqlite($pdo, $zip) : backup_restore_mysql($pdo, $zip);
|
||||
$result['tables'] = $db['tables'];
|
||||
$result['rows'] = $db['rows'];
|
||||
$result['notes'] = array_merge($result['notes'], $db['notes']);
|
||||
}
|
||||
$dirs = array_values(array_intersect(array_keys(backup_restorable_dirs()), (array) ($options['dirs'] ?? array_keys(backup_restorable_dirs()))));
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = str_replace('\\', '/', (string) $zip->getNameIndex($i));
|
||||
if (substr($name, -1) === '/' || !backup_entry_name_ok($name)) continue;
|
||||
$top = explode('/', $name, 2)[0];
|
||||
if (!in_array($top, $dirs, true)) continue;
|
||||
$dest = $root . '/' . $name;
|
||||
if (!is_dir(dirname($dest))) @mkdir(dirname($dest), 0755, true);
|
||||
$in = $zip->getStream($zip->getNameIndex($i));
|
||||
if (!$in) continue;
|
||||
$out = fopen($dest, 'wb');
|
||||
if (!$out) { fclose($in); $result['notes'][] = "Datei nicht beschreibbar: {$name}"; continue; }
|
||||
stream_copy_to_stream($in, $out);
|
||||
fclose($in);
|
||||
fclose($out);
|
||||
$result['files']++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$zip->close();
|
||||
return ['ok' => false, 'error' => 'Wiederherstellung abgebrochen: ' . $e->getMessage() . (isset($result['safety']) ? ' Der Stand von vorher steht im Sicherheits-Backup ' . $result['safety'] . '.' : '')];
|
||||
}
|
||||
$zip->close();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** SQLite: Inhalte aller Tabellen des Backups in die laufende Datenbank übernehmen (Spalten, die es in beiden gibt). */
|
||||
function backup_restore_sqlite(PDO $pdo, ZipArchive $zip): array
|
||||
{
|
||||
$tmp = backup_extract_entry($zip, 'database_backup.sqlite');
|
||||
if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.');
|
||||
$out = ['tables' => 0, 'rows' => 0, 'notes' => []];
|
||||
$pdo->exec('PRAGMA foreign_keys = OFF'); // außerhalb einer Transaktion
|
||||
try {
|
||||
$pdo->exec('ATTACH DATABASE ' . $pdo->quote($tmp) . ' AS bk');
|
||||
$pdo->beginTransaction();
|
||||
$bkTables = $pdo->query("SELECT name FROM bk.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$liveTables = $pdo->query("SELECT name FROM main.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$cols = fn(string $schema, string $t) => array_column($pdo->query("PRAGMA {$schema}.table_info(\"{$t}\")")->fetchAll(PDO::FETCH_ASSOC), 'name');
|
||||
$missing = [];
|
||||
foreach ($liveTables as $t) {
|
||||
if (!in_array($t, $bkTables, true)) { $missing[] = $t; continue; }
|
||||
$common = array_values(array_intersect($cols('main', $t), $cols('bk', $t)));
|
||||
$pdo->exec("DELETE FROM main.\"{$t}\"");
|
||||
if ($common) {
|
||||
$list = '"' . implode('","', $common) . '"';
|
||||
$pdo->exec("INSERT INTO main.\"{$t}\" ({$list}) SELECT {$list} FROM bk.\"{$t}\"");
|
||||
}
|
||||
$out['tables']++;
|
||||
$out['rows'] += (int) $pdo->query("SELECT COUNT(*) FROM main.\"{$t}\"")->fetchColumn();
|
||||
}
|
||||
// Zähler der automatischen Nummern übernehmen, damit neue Datensätze nicht alte Nummern wiederverwenden
|
||||
$hasSeq = (bool) $pdo->query("SELECT 1 FROM bk.sqlite_master WHERE name = 'sqlite_sequence'")->fetchColumn();
|
||||
if ($hasSeq) {
|
||||
$pdo->exec('DELETE FROM main.sqlite_sequence');
|
||||
$pdo->exec('INSERT INTO main.sqlite_sequence (name, seq) SELECT name, seq FROM bk.sqlite_sequence');
|
||||
}
|
||||
$pdo->commit();
|
||||
if ($missing) $out['notes'][] = 'Tabellen, die es im Backup noch nicht gab, blieben unverändert: ' . implode(', ', $missing) . '. Danach unter System-Update „Datenbank aktualisieren“ ausführen.';
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $e;
|
||||
} finally {
|
||||
try { $pdo->exec('DETACH DATABASE bk'); } catch (Throwable $e) {}
|
||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||
@unlink($tmp);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** MySQL: den SQL-Abzug Anweisung für Anweisung einspielen. */
|
||||
function backup_restore_mysql(PDO $pdo, ZipArchive $zip): array
|
||||
{
|
||||
$tmp = backup_extract_entry($zip, 'database_backup.sql');
|
||||
if (!$tmp) throw new RuntimeException('Der Datenbank-Abzug konnte nicht entpackt werden.');
|
||||
$out = ['tables' => 0, 'rows' => 0, 'notes' => []];
|
||||
try {
|
||||
foreach (backup_sql_statements($tmp) as $stmt) {
|
||||
$pdo->exec($stmt);
|
||||
if (preg_match('/^CREATE TABLE/i', $stmt)) $out['tables']++;
|
||||
if (preg_match('/^INSERT INTO/i', $stmt)) $out['rows'] += max(1, substr_count($stmt, "),\n(") + 1);
|
||||
}
|
||||
} finally {
|
||||
@unlink($tmp);
|
||||
}
|
||||
$out['notes'][] = 'Danach unter System-Update „Datenbank aktualisieren“ ausführen, falls das Backup von einer älteren Version stammt.';
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Pfad eines Backups im backups-Ordner anhand des Dateinamens (nur echte Backups, sonst null). */
|
||||
function backup_path_for(string $filename, ?string $backupDir = null): ?string
|
||||
{
|
||||
$dir = realpath($backupDir ?? __DIR__ . '/../backups');
|
||||
$file = basename($filename);
|
||||
if (!$dir || !preg_match('/^backup_[\d\-_a-f]+\.zip$/', $file) || !is_file($dir . '/' . $file)) return null;
|
||||
return $dir . '/' . $file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ein Backup und merkt sich das Ergebnis (für die Anzeige und den Hinweis im System). Ein Backup, das sich nicht
|
||||
* wiederherstellen lässt, ist wertlos: Das soll auffallen, bevor man es braucht.
|
||||
*/
|
||||
function backup_verify_and_record(string $zipPath): array
|
||||
{
|
||||
$r = backup_verify($zipPath);
|
||||
set_setting('backup_last_check', json_encode(['file' => basename($zipPath), 'at' => date('Y-m-d H:i:s'), 'ok' => $r['ok'], 'errors' => $r['errors'], 'info' => $r['info']], JSON_UNESCAPED_UNICODE));
|
||||
return $r;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
/**
|
||||
* Wird von jeder Seite als erstes eingebunden.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../version.php';
|
||||
|
||||
require_once __DIR__ . '/db.php';
|
||||
require_once __DIR__ . '/functions.php';
|
||||
require_once __DIR__ . '/themes.php';
|
||||
require_once __DIR__ . '/time_tracking.php';
|
||||
require_once __DIR__ . '/pseudo_cron.php';
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/smtp_mailer.php';
|
||||
require_once __DIR__ . '/mailer.php';
|
||||
require_once __DIR__ . '/email_addresses.php';
|
||||
require_once __DIR__ . '/health.php';
|
||||
require_once __DIR__ . '/admin_models.php';
|
||||
require_once __DIR__ . '/queues.php';
|
||||
require_once __DIR__ . '/integrations.php';
|
||||
require_once __DIR__ . '/reports.php';
|
||||
require_once __DIR__ . '/notifications.php';
|
||||
require_once __DIR__ . '/plugins.php';
|
||||
require_once __DIR__ . '/app_downloads.php';
|
||||
require_once __DIR__ . '/theme_catalog.php';
|
||||
require_once __DIR__ . '/security_check.php';
|
||||
require_once __DIR__ . '/update_security.php';
|
||||
|
||||
date_default_timezone_set(get_setting('timezone', config()['site']['timezone'] ?? 'UTC'));
|
||||
|
||||
// Sichere Session-Cookie-Einstellungen (vor session_start)
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/',
|
||||
'secure' => request_is_https(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
|
||||
// Sicherheits-Header
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('X-Frame-Options: DENY');
|
||||
header('Referrer-Policy: strict-origin-when-cross-origin');
|
||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://api.qrserver.com; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';");
|
||||
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()');
|
||||
// HSTS nur bei echtem HTTPS senden
|
||||
if (request_is_https()) {
|
||||
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
// PHP-Fehler nie im Browser ausgeben (Produktion)
|
||||
@ini_set('display_errors', '0');
|
||||
@ini_set('log_errors', '1');
|
||||
|
||||
// "Eingebauter" Ersatz für echte Server-Cronjobs (siehe includes/pseudo_cron.php).
|
||||
// Bewusst auf JEDER Seite (nicht nur im Mitarbeiterbereich): auch Kundenportal-Besuche
|
||||
// (Ticket einreichen, Status prüfen, Login, KB) halten die Hintergrund-Jobs am Laufen –
|
||||
// genau wie bei WordPress' wp-cron.php reicht dafür jeder Seitenaufruf, nicht nur ein
|
||||
// eingeloggter Mitarbeiter. Läuft NACH dem Senden der Antwort, verlangsamt also nichts.
|
||||
pseudo_cron_schedule_background();
|
||||
|
||||
// Aktive Plugins laden (Fehler in Plugins werden isoliert, siehe includes/plugins.php).
|
||||
plugins_boot();
|
||||
|
||||
// DB-kompatibler SQL-Ausdruck für Domain-Teil einer E-Mail
|
||||
function sql_email_domain(string $col): string {
|
||||
$driver = config()['db']['driver'] ?? 'sqlite';
|
||||
return $driver === 'mysql'
|
||||
? "SUBSTRING_INDEX($col,'@',-1)"
|
||||
: "SUBSTR($col, INSTR($col,'@')+1)";
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
/** @var string|null $logo */
|
||||
$logo = get_setting('logo_path');
|
||||
?>
|
||||
<div class="brand-block">
|
||||
<?php if ($logo): ?>
|
||||
<img src="<?= e($logo) ?>" alt="<?= e(get_setting('display_name', 'DeskHub')) ?>">
|
||||
<?php else: ?>
|
||||
<span class="brand-block-text">Desk<span>Hub</span></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
/**
|
||||
* Offizieller Plugin- und Theme-Katalog dieser DeskHub-Version (siehe plugin-webseite/README.md).
|
||||
* Mit diesen Werten gilt der geprüfte Modus: Plugins nur aus diesem Katalog, nur mit Signatur des Herausgebers.
|
||||
* Ändern: Webseite → Verwaltung → „Einstellungen für DeskHub“, dann Release neu bauen.
|
||||
*/
|
||||
return [
|
||||
'plugins_url' => 'https://plugin-deskhub.viper.ipv64.net/catalog.php?type=plugins',
|
||||
'themes_url' => 'https://plugin-deskhub.viper.ipv64.net/catalog.php?type=themes',
|
||||
'access_key' => '', // Zugangsschlüssel des Katalogs (docker-build gibt ihn aus); leer = Katalog ohne Zugangsschutz
|
||||
'publisher_key' => '-----BEGIN PUBLIC KEY-----
|
||||
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEApuCBEnEYk4uVtP4cijWN
|
||||
LBgzjGU9TKb8Zjygk6DIeKKiR3Li7MUjkSPzP4JIZolxdzN147yw4gKG08WUSx4B
|
||||
QbtvfgD40CxjRC83D3L+/8K2FaWmYGsQefhZjtsZzY9xtJjUEEVe0eRYlyi2ZDgW
|
||||
nDcz0++KTnRmPwd2qz2MNsgx1gOciRRawqOXzzlx7u5aKaVHzpxTIMaFA/7i/Tpd
|
||||
Eu8fEuTP88LDbwKuYVo8Aid5s3y171uRCDDu+91Bwu0sIxzAXH+Z/yXqHppmEGxX
|
||||
HCYXGn/L/sEAKRsH9aIO8muE8wpOF5QhDW4NtWD5QFPxbkvLzvRBp5Q3jyvRjHXy
|
||||
S1i4h8VujBNJXC5AsVjXgO/4Aq4q/j1HhOfhK6Njv0gwt54CnK+SVsniwro0fpZr
|
||||
XGlAo9JY6X4WIJ5etRMzwrhwTG7BTz1ClrE51628qO4q9ymFLiEoakQhYevfTnRa
|
||||
hrQtwtRCAjN86vPD3dT4cXtjZnYQ+9/DEHNy2CBieIVXAgMBAAE=
|
||||
-----END PUBLIC KEY-----
|
||||
',
|
||||
];
|
||||