2103 lines
119 KiB
PHP
2103 lines
119 KiB
PHP
<?php
|
||
/**
|
||
* database/migrate.php – Schema-Migrator
|
||
*
|
||
* Führt alle noch nicht eingespielten Migrationsdateien aus
|
||
* database/migrations/*.sql aus. Bereits angewendete Migrationen werden
|
||
* in der Tabelle schema_migrations protokolliert und NIEMALS erneut
|
||
* ausgeführt – Einstellungen und Nutzerdaten bleiben dadurch immer erhalten.
|
||
*
|
||
* Aufruf (CLI): php database/migrate.php
|
||
* Aufruf (intern): run_migrations($pdo) nach require_once
|
||
*/
|
||
|
||
// Kann direkt per CLI aufgerufen oder per require eingebunden werden
|
||
if (php_sapi_name() === 'cli' && realpath(__FILE__) === realpath($_SERVER['SCRIPT_FILENAME'] ?? '')) {
|
||
require_once __DIR__ . '/../includes/db.php';
|
||
$pdo = get_db();
|
||
$result = run_migrations($pdo, true);
|
||
foreach ($result['log'] as $line) {
|
||
echo $line . PHP_EOL;
|
||
}
|
||
exit($result['errors'] ? 1 : 0);
|
||
}
|
||
|
||
/**
|
||
* Alle dem Code bekannten Migrationen (SQL-Dateien + php_00NN-Schritte in dieser Datei).
|
||
*
|
||
* @return string[]
|
||
*/
|
||
function migrations_known(): array
|
||
{
|
||
$known = [];
|
||
foreach (glob(__DIR__ . '/migrations/*.sql') ?: [] as $file) $known[basename($file)] = true;
|
||
if (preg_match_all("/'(php_\d{4}_[a-z0-9_]+)'/", (string) file_get_contents(__FILE__), $m)) {
|
||
foreach ($m[1] as $key) $known[$key] = true;
|
||
}
|
||
$names = array_keys($known);
|
||
natsort($names);
|
||
return array_values($names);
|
||
}
|
||
|
||
/**
|
||
* Migrationen, die der Code kennt, die in dieser Datenbank aber noch fehlen.
|
||
* Eine Datenbank ohne schema_migrations-Tabelle gilt als komplett veraltet.
|
||
*
|
||
* @return string[]
|
||
*/
|
||
function migrations_pending(PDO $pdo): array
|
||
{
|
||
try {
|
||
$done = $pdo->query('SELECT migration FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
|
||
} catch (Throwable $e) {
|
||
$done = [];
|
||
}
|
||
return array_values(array_diff(migrations_known(), $done));
|
||
}
|
||
|
||
/**
|
||
* Spielt alle ausstehenden Migrationen ein.
|
||
*
|
||
* @param PDO $pdo Aktive Datenbankverbindung
|
||
* @param bool $verbose true = gibt Meldungen zurück
|
||
* @return array{applied: int, skipped: int, errors: string[], log: string[]}
|
||
*/
|
||
function run_migrations(PDO $pdo, bool $verbose = false): array
|
||
{
|
||
$result = ['applied' => 0, 'skipped' => 0, 'errors' => [], 'log' => []];
|
||
|
||
// ── 1. schema_migrations-Tabelle sicherstellen ──────────────────────────
|
||
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
|
||
if ($driver === 'sqlite') {
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS schema_migrations (
|
||
migration VARCHAR(255) PRIMARY KEY,
|
||
applied_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||
)");
|
||
} else {
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS schema_migrations (
|
||
migration VARCHAR(255) PRIMARY KEY,
|
||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||
}
|
||
|
||
// ── 2. Bereits eingespielten Migrationen laden ──────────────────────────
|
||
$done = [];
|
||
foreach ($pdo->query("SELECT migration FROM schema_migrations ORDER BY migration") as $row) {
|
||
$done[$row['migration']] = true;
|
||
}
|
||
|
||
// ── 3. Migrationsdateien einlesen und sortieren ─────────────────────────
|
||
$dir = __DIR__ . '/migrations';
|
||
$files = glob($dir . '/*.sql') ?: [];
|
||
natsort($files);
|
||
|
||
foreach ($files as $file) {
|
||
$name = basename($file);
|
||
|
||
if (isset($done[$name])) {
|
||
$result['skipped']++;
|
||
if ($verbose) $result['log'][] = " [skip] $name";
|
||
continue;
|
||
}
|
||
|
||
$sql = file_get_contents($file);
|
||
if ($sql === false) {
|
||
$result['errors'][] = "Datei nicht lesbar: $name";
|
||
continue;
|
||
}
|
||
|
||
// Vollzeilige Kommentare vor dem Split entfernen, damit ein vorangestellter
|
||
// Kommentar nicht zusammen mit dem ersten Statement verworfen wird.
|
||
$sql = preg_replace('/^\s*--.*$/m', '', $sql);
|
||
$statements = array_filter(
|
||
array_map('trim', explode(';', $sql)),
|
||
fn($s) => $s !== '' && !preg_match('/^--/', $s)
|
||
);
|
||
|
||
try {
|
||
$pdo->beginTransaction();
|
||
foreach ($statements as $stmt) {
|
||
if (trim($stmt) === '') continue;
|
||
$pdo->exec($stmt);
|
||
}
|
||
// Als erledigt markieren
|
||
$pdo->prepare("INSERT INTO schema_migrations (migration) VALUES (?)")->execute([$name]);
|
||
$pdo->commit();
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = " [OK] $name";
|
||
} catch (Throwable $e) {
|
||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||
$result['errors'][] = "$name: " . $e->getMessage();
|
||
if ($verbose) $result['log'][] = " [FAIL] $name – " . $e->getMessage();
|
||
// Fehler in einer Migration → restliche überspringen (Reihenfolge!)
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Sondermigration: 0002 wird per PHP ausgeführt (cross-DB-kompatibel)
|
||
if (!isset($done['php_0002_fix_sla_dates'])) {
|
||
try {
|
||
apply_migration_0002($pdo);
|
||
$pdo->prepare("INSERT INTO schema_migrations (migration) VALUES (?)")
|
||
->execute(['php_0002_fix_sla_dates']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0002_fix_sla_dates';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0002_fix_sla_dates: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0002_fix_sla_dates - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Sondermigration 0003
|
||
if (!isset($done['php_0003_privacy_settings'])) {
|
||
try {
|
||
apply_migration_0003($pdo);
|
||
$pdo->prepare("INSERT INTO schema_migrations (migration) VALUES (?)")->execute(['php_0003_privacy_settings']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0003_privacy_settings';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0003_privacy_settings: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0003_privacy_settings - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0007: escalation_rules
|
||
if (!isset($done['php_0007_escalation_rules'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS escalation_rules (
|
||
id $ai,
|
||
name VARCHAR(150) NOT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||
trigger_type VARCHAR(30) NOT NULL DEFAULT 'no_reply',
|
||
trigger_hours INT NOT NULL DEFAULT 24,
|
||
condition_status VARCHAR(50) NULL,
|
||
condition_priority_id INT NULL,
|
||
condition_dept_id INT NULL,
|
||
action_set_priority_id INT NULL,
|
||
action_assign_staff_id INT NULL,
|
||
action_set_status VARCHAR(20) NULL,
|
||
action_notify_admin TINYINT(1) NOT NULL DEFAULT 1,
|
||
action_notify_staff TINYINT(1) NOT NULL DEFAULT 0,
|
||
sort_order INT NOT NULL DEFAULT 0,
|
||
created_at DATETIME NOT NULL
|
||
)$eng");
|
||
// Protokoll: wann wurde welche Regel auf welches Ticket angewendet
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS escalation_log (
|
||
id $ai,
|
||
rule_id INT NOT NULL,
|
||
ticket_id INT NOT NULL,
|
||
applied_at DATETIME NOT NULL
|
||
)$eng");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0007_escalation_rules']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0007_escalation_rules';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0007_escalation_rules: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0007_escalation_rules - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0006: staff_availability + staff_shifts
|
||
if (!isset($done['php_0006_availability'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$now = $drv === 'sqlite' ? "(datetime('now'))" : 'CURRENT_TIMESTAMP';
|
||
|
||
// Echtzeit-Status pro Mitarbeiter
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS staff_availability (
|
||
id $ai,
|
||
staff_id INT NOT NULL UNIQUE,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'available',
|
||
note VARCHAR(120) NULL,
|
||
updated_at DATETIME NOT NULL DEFAULT $now
|
||
)$eng");
|
||
|
||
// Wochenschicht-Plan pro Mitarbeiter
|
||
$timeType = $drv === 'sqlite' ? 'TEXT' : 'TIME';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS staff_shifts (
|
||
id $ai,
|
||
staff_id INT NOT NULL,
|
||
weekday TINYINT NOT NULL,
|
||
start_time $timeType NOT NULL,
|
||
end_time $timeType NOT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||
)$eng");
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0006_availability']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0006_availability';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0006_availability: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0006_availability - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0005: push_subscriptions
|
||
if (!isset($done['php_0005_push_subscriptions'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
if ($drv === 'sqlite') {
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
staff_id INT NOT NULL,
|
||
endpoint TEXT NOT NULL,
|
||
p256dh TEXT NOT NULL,
|
||
auth TEXT NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT (datetime(\'now\')),
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
)');
|
||
} else {
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
staff_id INT NOT NULL,
|
||
endpoint TEXT NOT NULL,
|
||
p256dh TEXT NOT NULL,
|
||
auth TEXT NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0005_push_subscriptions']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0005_push_subscriptions';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0005_push_subscriptions: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0005_push_subscriptions - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0004: ticket_thread_comments
|
||
if (!isset($done['php_0004_thread_comments'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
if ($drv === 'sqlite') {
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS ticket_thread_comments (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
thread_id INT NOT NULL,
|
||
staff_id INT NOT NULL,
|
||
message TEXT NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT (datetime(\'now\')),
|
||
FOREIGN KEY (thread_id) REFERENCES ticket_threads(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
)');
|
||
} else {
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS ticket_thread_comments (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
thread_id INT NOT NULL,
|
||
staff_id INT NOT NULL,
|
||
message TEXT NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (thread_id) REFERENCES ticket_threads(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0004_thread_comments']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0004_thread_comments';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0004_thread_comments: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0004_thread_comments - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// ── php_0008_seed_app_key ──────────────────────────────────────────────────
|
||
// Überträgt den app_key aus config.php in die settings-Tabelle,
|
||
// damit er Updates überlebt (config.php ist zwar geschützt, aber die DB
|
||
// ist die zuverlässigere Quelle). Läuft einmalig auf bestehenden Installationen.
|
||
if (!isset($done['php_0008_seed_app_key'])) {
|
||
try {
|
||
$cfgKey = config()['app_key'] ?? '';
|
||
if ($cfgKey !== '') {
|
||
// Nur schreiben wenn in der DB noch kein Key steht
|
||
$existing = $pdo->prepare('SELECT setting_value FROM settings WHERE setting_key = ?');
|
||
$existing->execute(['app_key']);
|
||
if ($existing->fetchColumn() === false) {
|
||
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
if ($driver === 'sqlite') {
|
||
$pdo->prepare('INSERT OR IGNORE INTO settings (setting_key, setting_value) VALUES (?,?)')->execute(['app_key', $cfgKey]);
|
||
} else {
|
||
$pdo->prepare('INSERT IGNORE INTO settings (setting_key, setting_value) VALUES (?,?)')->execute(['app_key', $cfgKey]);
|
||
}
|
||
}
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0008_seed_app_key']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0008_seed_app_key';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0008_seed_app_key: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0008_seed_app_key - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// php_0009_mobile_api_tokens: widerrufbare Tokens fuer die native Staff-App
|
||
if (!isset($done['php_0009_mobile_api_tokens'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
if ($drv === 'sqlite') {
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS mobile_api_tokens (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
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 (datetime('now')),
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
)");
|
||
} else {
|
||
$pdo->exec("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");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')
|
||
->execute(['php_0009_mobile_api_tokens']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0009_mobile_api_tokens';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0009_mobile_api_tokens: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0009_mobile_api_tokens - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Bestehende SQLite-Tabellen aus älteren Migrationen um fehlende FKs ergänzen.
|
||
if (!isset($done['php_0010_sqlite_foreign_keys'])) {
|
||
try {
|
||
repair_sqlite_foreign_keys($pdo);
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')
|
||
->execute(['php_0010_sqlite_foreign_keys']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0010_sqlite_foreign_keys';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0010_sqlite_foreign_keys: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0010_sqlite_foreign_keys - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0011: imap_accounts – mehrere IMAP-Postfächer statt einem globalen Konto
|
||
if (!isset($done['php_0011_imap_accounts'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$now = $drv === 'sqlite' ? "(datetime('now'))" : 'CURRENT_TIMESTAMP';
|
||
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS imap_accounts (
|
||
id $ai,
|
||
label VARCHAR(150) NOT NULL,
|
||
host VARCHAR(190) NOT NULL,
|
||
port INT NOT NULL DEFAULT 993,
|
||
encryption VARCHAR(10) NOT NULL DEFAULT 'ssl',
|
||
username VARCHAR(190) NOT NULL,
|
||
password_enc TEXT NOT NULL,
|
||
folder VARCHAR(190) NOT NULL DEFAULT 'INBOX',
|
||
after_fetch VARCHAR(10) NOT NULL DEFAULT 'seen',
|
||
archive_folder VARCHAR(190) NOT NULL DEFAULT 'Processed',
|
||
department_id INT NULL,
|
||
priority_id INT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||
last_run_at DATETIME NULL,
|
||
created_at DATETIME NOT NULL DEFAULT $now
|
||
)$eng");
|
||
|
||
// Bestehendes globales IMAP-Konto (aus den alten Einzel-Settings) automatisch als
|
||
// ersten Eintrag übernehmen, damit der E-Mail-Abruf nach dem Update nahtlos weiterläuft.
|
||
$existingCount = (int) $pdo->query('SELECT COUNT(*) FROM imap_accounts')->fetchColumn();
|
||
if ($existingCount === 0) {
|
||
$readSetting = function (string $key, string $default = '') use ($pdo): string {
|
||
$s = $pdo->prepare('SELECT setting_value FROM settings WHERE setting_key = ?');
|
||
$s->execute([$key]);
|
||
$v = $s->fetchColumn();
|
||
return ($v !== false && $v !== null) ? (string) $v : $default;
|
||
};
|
||
$legacyHost = $readSetting('imap_host');
|
||
if ($legacyHost !== '') {
|
||
$pdo->prepare(
|
||
"INSERT INTO imap_accounts
|
||
(label, host, port, encryption, username, password_enc, folder, after_fetch, archive_folder, department_id, priority_id, is_active, created_at)
|
||
VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL,1,$now)"
|
||
)->execute([
|
||
'Hauptpostfach',
|
||
$legacyHost,
|
||
(int) ($readSetting('imap_port', '993') ?: 993),
|
||
$readSetting('imap_ssl', 'ssl'),
|
||
$readSetting('imap_user'),
|
||
$readSetting('imap_pass'), // bereits verschlüsselt (encrypt_secret) – unverändert übernehmen
|
||
$readSetting('imap_folder', 'INBOX'),
|
||
$readSetting('imap_after_fetch', 'seen'),
|
||
$readSetting('imap_archive_folder', 'Processed'),
|
||
]);
|
||
}
|
||
}
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0011_imap_accounts']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0011_imap_accounts';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0011_imap_accounts: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0011_imap_accounts - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0012: E-Mail-Quellen pro Ticket + Spam-Kennzeichnung
|
||
if (!isset($done['php_0012_email_spam'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$now = $drv === 'sqlite' ? "(datetime('now'))" : 'CURRENT_TIMESTAMP';
|
||
|
||
// Bestehende Installationen erhalten die Spam-Kennzeichnung ohne Datenverlust.
|
||
$ticketCols = $pdo->query($drv === 'sqlite' ? "PRAGMA table_info(tickets)" : "SHOW COLUMNS FROM tickets")->fetchAll();
|
||
$hasSpam = false;
|
||
foreach ($ticketCols as $col) {
|
||
$name = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
if ($name === 'is_spam') { $hasSpam = true; break; }
|
||
}
|
||
if (!$hasSpam) {
|
||
$pdo->exec("ALTER TABLE tickets ADD COLUMN is_spam TINYINT(1) NOT NULL DEFAULT 0");
|
||
}
|
||
|
||
// Spam-/Junk-Zielordner pro IMAP-Konto.
|
||
$imapCols = $pdo->query($drv === 'sqlite' ? "PRAGMA table_info(imap_accounts)" : "SHOW COLUMNS FROM imap_accounts")->fetchAll();
|
||
$hasSpamFolder = false;
|
||
foreach ($imapCols as $col) {
|
||
$name = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
if ($name === 'spam_folder') { $hasSpamFolder = true; break; }
|
||
}
|
||
if (!$hasSpamFolder) {
|
||
$pdo->exec("ALTER TABLE imap_accounts ADD COLUMN spam_folder VARCHAR(190) NOT NULL DEFAULT 'Junk'");
|
||
}
|
||
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_email_sources (
|
||
id $ai,
|
||
ticket_id INT NOT NULL,
|
||
imap_account_id INT NOT NULL,
|
||
message_uid BIGINT NULL,
|
||
message_id VARCHAR(998) NULL,
|
||
current_folder VARCHAR(190) NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT $now
|
||
)$eng");
|
||
|
||
$pdo->prepare("INSERT INTO schema_migrations (migration) VALUES (?)")->execute(['php_0012_email_spam']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0012_email_spam';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0012_email_spam: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0012_email_spam - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0013: automatische Zeiterfassung pro Ticket (offene Arbeits-Sitzungen)
|
||
if (!isset($done['php_0013_ticket_work_sessions'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_work_sessions (
|
||
id $ai,
|
||
ticket_id INT NOT NULL,
|
||
staff_id INT NOT NULL,
|
||
started_at DATETIME NOT NULL,
|
||
last_seen_at DATETIME NOT NULL,
|
||
UNIQUE (ticket_id, staff_id)
|
||
)$eng");
|
||
|
||
// Standardmäßig aktiv, aber über Einstellungen abschaltbar.
|
||
$check = $pdo->prepare('SELECT COUNT(*) FROM settings WHERE setting_key = ?');
|
||
$check->execute(['auto_time_tracking_enabled']);
|
||
if ((int) $check->fetchColumn() === 0) {
|
||
$pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?,?)')
|
||
->execute(['auto_time_tracking_enabled', '1']);
|
||
}
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0013_ticket_work_sessions']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0013_ticket_work_sessions';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0013_ticket_work_sessions: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0013_ticket_work_sessions - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0014: 'chat' als gültige tickets.source ergänzen (Live-Chat-Widget).
|
||
// Auf MySQL ist source ein ENUM ohne 'chat' – bestehende SQLite-Installationen kennen
|
||
// keine ENUMs und akzeptieren den Wert ohnehin bereits, dort ist nichts zu tun.
|
||
if (!isset($done['php_0014_ticket_source_chat'])) {
|
||
try {
|
||
if ($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||
$pdo->exec("ALTER TABLE tickets MODIFY COLUMN source ENUM('web','email','phone','staff','chat') NOT NULL DEFAULT 'web'");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0014_ticket_source_chat']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0014_ticket_source_chat';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0014_ticket_source_chat: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0014_ticket_source_chat - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0015: Tippt-gerade-Indikator für den Live-Chat (Kunden-Widget +
|
||
// Mitarbeiter-Postfach) – zwei einfache Zeitstempel-Spalten statt einer eigenen
|
||
// Tabelle, da nur "gilt bis wann" pro Ticket gebraucht wird (siehe live_chat.php
|
||
// Aktion "typing").
|
||
if (!isset($done['php_0015_ticket_typing'])) {
|
||
try {
|
||
$pdo->exec('ALTER TABLE tickets ADD COLUMN typing_staff_until DATETIME NULL');
|
||
$pdo->exec('ALTER TABLE tickets ADD COLUMN typing_customer_until DATETIME NULL');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0015_ticket_typing']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0015_ticket_typing';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0015_ticket_typing: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0015_ticket_typing - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0016: 'auto_reply' als gültige ticket_rule_actions.action_type ergänzen
|
||
// (Ticket-Regeln → automatisch einen Textbaustein als Antwort senden). Auf MySQL ist
|
||
// action_type ein ENUM ohne 'auto_reply' – SQLite kennt keine ENUMs, dort ist nichts zu tun.
|
||
if (!isset($done['php_0016_rule_auto_reply'])) {
|
||
try {
|
||
if ($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||
$pdo->exec("ALTER TABLE ticket_rule_actions MODIFY COLUMN action_type
|
||
ENUM('set_department','set_priority','set_help_topic','assign_staff','add_tag','reject','auto_reply') NOT NULL");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0016_rule_auto_reply']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0016_rule_auto_reply';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0016_rule_auto_reply: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0016_rule_auto_reply - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0017: verschlüsselte, widerrufbare Tokens für direkte Ticket-Links in Kunden-E-Mails.
|
||
if (!isset($done['php_0017_ticket_access_tokens'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$now = $drv === 'sqlite' ? "(datetime('now'))" : 'CURRENT_TIMESTAMP';
|
||
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_access_tokens (
|
||
id $ai,
|
||
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 $now,
|
||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
|
||
)$eng");
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')
|
||
->execute(['php_0017_ticket_access_tokens']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0017_ticket_access_tokens';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0017_ticket_access_tokens: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0017_ticket_access_tokens - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0018: IMAP-Postfach direkt an eine E-Mail-Adresse knüpfen (osTicket-Stil:
|
||
// Konto + entferntes Postfach in einem Formular). Bestehende Postfächer werden, falls der
|
||
// Benutzername einer hinterlegten Adresse entspricht, automatisch verknüpft.
|
||
if (!isset($done['php_0018_imap_email_link'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$imapCols = $pdo->query($drv === 'sqlite' ? "PRAGMA table_info(imap_accounts)" : "SHOW COLUMNS FROM imap_accounts")->fetchAll();
|
||
$hasLink = false;
|
||
foreach ($imapCols as $col) {
|
||
$name = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
if ($name === 'email_address_id') { $hasLink = true; break; }
|
||
}
|
||
if (!$hasLink) {
|
||
$pdo->exec('ALTER TABLE imap_accounts ADD COLUMN email_address_id INT NULL');
|
||
}
|
||
$pdo->exec(
|
||
'UPDATE imap_accounts SET email_address_id = (
|
||
SELECT ea.id FROM email_addresses ea WHERE LOWER(ea.email) = LOWER(imap_accounts.username) LIMIT 1
|
||
) WHERE email_address_id IS NULL'
|
||
);
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0018_imap_email_link']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0018_imap_email_link';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0018_imap_email_link: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0018_imap_email_link - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0019: Hilfethemen im osTicket-Stil – Typ (öffentlich/intern), übergeordnetes Thema,
|
||
// interne Notizen sowie Erstellt-/Aktualisiert-Zeitpunkt.
|
||
if (!isset($done['php_0019_help_topic_options'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(help_topics)" : "SHOW COLUMNS FROM help_topics")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('is_public', $cols, true)) $pdo->exec("ALTER TABLE help_topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1");
|
||
if (!in_array('parent_id', $cols, true)) $pdo->exec("ALTER TABLE help_topics ADD COLUMN parent_id INT NULL");
|
||
if (!in_array('notes', $cols, true)) $pdo->exec("ALTER TABLE help_topics ADD COLUMN notes TEXT NULL");
|
||
if (!in_array('created_at', $cols, true)) $pdo->exec("ALTER TABLE help_topics ADD COLUMN created_at DATETIME NULL");
|
||
if (!in_array('updated_at', $cols, true)) $pdo->exec("ALTER TABLE help_topics ADD COLUMN updated_at DATETIME NULL");
|
||
$now = db_now();
|
||
$pdo->prepare('UPDATE help_topics SET created_at = ? WHERE created_at IS NULL')->execute([$now]);
|
||
$pdo->prepare('UPDATE help_topics SET updated_at = ? WHERE updated_at IS NULL')->execute([$now]);
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0019_help_topic_options']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0019_help_topic_options';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0019_help_topic_options: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0019_help_topic_options - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0020: Abteilungen im osTicket-Stil – Status, Abteilungsleiter, Admin-Notizen, Änderungszeitpunkt.
|
||
if (!isset($done['php_0020_department_options'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(departments)" : "SHOW COLUMNS FROM departments")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('is_active', $cols, true)) $pdo->exec("ALTER TABLE departments ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1");
|
||
if (!in_array('manager_id', $cols, true)) $pdo->exec("ALTER TABLE departments ADD COLUMN manager_id INT NULL");
|
||
if (!in_array('notes', $cols, true)) $pdo->exec("ALTER TABLE departments ADD COLUMN notes TEXT NULL");
|
||
if (!in_array('updated_at', $cols, true)) $pdo->exec("ALTER TABLE departments ADD COLUMN updated_at DATETIME NULL");
|
||
$pdo->prepare('UPDATE departments SET updated_at = COALESCE(created_at, ?) WHERE updated_at IS NULL')->execute([db_now()]);
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0020_department_options']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0020_department_options';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0020_department_options: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0020_department_options - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0021: E-Mail-Adressen im osTicket-Stil – Status, Notizen, Hilfethema,
|
||
// eigener SMTP-Server pro Adresse (Reiter "Abgehend") und Änderungszeitpunkt.
|
||
if (!isset($done['php_0021_email_address_options'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(email_addresses)" : "SHOW COLUMNS FROM email_addresses")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
$add = [
|
||
'is_active' => "TINYINT(1) NOT NULL DEFAULT 1",
|
||
'notes' => "TEXT NULL",
|
||
'help_topic_id' => "INT NULL",
|
||
'smtp_enabled' => "TINYINT(1) NOT NULL DEFAULT 0",
|
||
'smtp_host' => "VARCHAR(190) NULL",
|
||
'smtp_port' => "INT NOT NULL DEFAULT 587",
|
||
'smtp_encryption' => "VARCHAR(10) NOT NULL DEFAULT 'tls'",
|
||
'smtp_username' => "VARCHAR(190) NULL",
|
||
'smtp_password_enc' => "TEXT NULL",
|
||
'updated_at' => "DATETIME NULL",
|
||
];
|
||
foreach ($add as $name => $def) {
|
||
if (!in_array($name, $cols, true)) $pdo->exec("ALTER TABLE email_addresses ADD COLUMN {$name} {$def}");
|
||
}
|
||
$pdo->prepare('UPDATE email_addresses SET updated_at = COALESCE(created_at, ?) WHERE updated_at IS NULL')->execute([db_now()]);
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0021_email_address_options']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0021_email_address_options';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0021_email_address_options: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0021_email_address_options - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0022: Teams im osTicket-Stil – Änderungszeitpunkt und "Benachrichtigung bei Ticketzuweisung deaktivieren".
|
||
if (!isset($done['php_0022_team_options'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(teams)" : "SHOW COLUMNS FROM teams")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('no_alerts', $cols, true)) $pdo->exec("ALTER TABLE teams ADD COLUMN no_alerts TINYINT(1) NOT NULL DEFAULT 0");
|
||
if (!in_array('updated_at', $cols, true)) $pdo->exec("ALTER TABLE teams ADD COLUMN updated_at DATETIME NULL");
|
||
$pdo->prepare('UPDATE teams SET updated_at = COALESCE(created_at, ?) WHERE updated_at IS NULL')->execute([db_now()]);
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0022_team_options']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0022_team_options';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0022_team_options: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0022_team_options - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0023: Rolle pro Abteilung (erweiterter Zugriff). NULL = Hauptrolle des Mitarbeiters.
|
||
if (!isset($done['php_0023_department_roles'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(staff_department)" : "SHOW COLUMNS FROM staff_department")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('role_id', $cols, true)) $pdo->exec("ALTER TABLE staff_department ADD COLUMN role_id INT NULL");
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0023_department_roles']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0023_department_roles';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0023_department_roles: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0023_department_roles - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0024: Rollen, Prioritäten/SLA, Listen und Seiten im osTicket-Stil – Status, Notizen, Zeitstempel.
|
||
if (!isset($done['php_0024_admin_list_options'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$colsOf = function (string $table) use ($pdo, $drv): array {
|
||
$out = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info($table)" : "SHOW COLUMNS FROM $table")->fetchAll() as $col) {
|
||
$out[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
return $out;
|
||
};
|
||
$plan = [
|
||
'roles' => ['is_active' => 'TINYINT(1) NOT NULL DEFAULT 1', 'notes' => 'TEXT NULL', 'updated_at' => 'DATETIME NULL'],
|
||
'priorities' => ['notes' => 'TEXT NULL', 'created_at' => 'DATETIME NULL', 'updated_at' => 'DATETIME NULL'],
|
||
'custom_lists' => ['notes' => 'TEXT NULL', 'updated_at' => 'DATETIME NULL'],
|
||
'site_pages' => ['updated_at' => 'DATETIME NULL'],
|
||
];
|
||
$now = db_now();
|
||
foreach ($plan as $table => $adds) {
|
||
$have = $colsOf($table);
|
||
foreach ($adds as $name => $def) {
|
||
if (!in_array($name, $have, true)) $pdo->exec("ALTER TABLE {$table} ADD COLUMN {$name} {$def}");
|
||
}
|
||
if (isset($adds['updated_at'])) {
|
||
$createdCol = in_array('created_at', $colsOf($table), true) ? 'COALESCE(created_at, ?)' : '?';
|
||
$pdo->prepare("UPDATE {$table} SET updated_at = {$createdCol} WHERE updated_at IS NULL")->execute([$now]);
|
||
}
|
||
if (isset($adds['created_at'])) $pdo->prepare("UPDATE {$table} SET created_at = ? WHERE created_at IS NULL")->execute([$now]);
|
||
}
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0024_admin_list_options']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0024_admin_list_options';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0024_admin_list_options: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0024_admin_list_options - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0025: Ticket-Regeln ("Filter") – Administratornotizen und Änderungszeitpunkt.
|
||
if (!isset($done['php_0025_rule_options'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(ticket_rules)" : "SHOW COLUMNS FROM ticket_rules")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('notes', $cols, true)) $pdo->exec("ALTER TABLE ticket_rules ADD COLUMN notes TEXT NULL");
|
||
if (!in_array('updated_at', $cols, true)) $pdo->exec("ALTER TABLE ticket_rules ADD COLUMN updated_at DATETIME NULL");
|
||
$pdo->prepare('UPDATE ticket_rules SET updated_at = COALESCE(created_at, ?) WHERE updated_at IS NULL')->execute([db_now()]);
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0025_rule_options']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0025_rule_options';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0025_rule_options: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0025_rule_options - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0026: Überwachung des Mail-Abrufs – Status je Postfach (letzter Erfolg, Fehler, Fehlerzähler, Alarmzeit).
|
||
if (!isset($done['php_0026_mailbox_health'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(imap_accounts)" : "SHOW COLUMNS FROM imap_accounts")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
$add = ['last_success_at' => 'DATETIME NULL', 'last_error' => 'TEXT NULL', 'fail_count' => 'INT NOT NULL DEFAULT 0', 'alert_sent_at' => 'DATETIME NULL'];
|
||
foreach ($add as $name => $def) {
|
||
if (!in_array($name, $cols, true)) $pdo->exec("ALTER TABLE imap_accounts ADD COLUMN {$name} {$def}");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0026_mailbox_health']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0026_mailbox_health';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0026_mailbox_health: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0026_mailbox_health - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0027: benannte SLA-Pläne, zuordenbar je Abteilung und Hilfethema.
|
||
if (!isset($done['php_0027_sla_plans'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS sla_plans (
|
||
id $ai,
|
||
name VARCHAR(120) NOT NULL,
|
||
response_hours INT NOT NULL DEFAULT 24,
|
||
resolution_hours INT NOT NULL DEFAULT 72,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||
notes TEXT NULL,
|
||
created_at DATETIME NULL,
|
||
updated_at DATETIME NULL
|
||
)$eng");
|
||
foreach (['departments', 'help_topics'] as $table) {
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info($table)" : "SHOW COLUMNS FROM $table")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('sla_plan_id', $cols, true)) $pdo->exec("ALTER TABLE {$table} ADD COLUMN sla_plan_id INT NULL");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0027_sla_plans']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0027_sla_plans';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0027_sla_plans: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0027_sla_plans - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0028: gespeicherte Ansichten – teilbar mit allen Mitarbeitern, Änderungszeitpunkt.
|
||
if (!isset($done['php_0028_shared_queues'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(saved_queues)" : "SHOW COLUMNS FROM saved_queues")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('is_shared', $cols, true)) $pdo->exec("ALTER TABLE saved_queues ADD COLUMN is_shared TINYINT(1) NOT NULL DEFAULT 0");
|
||
if (!in_array('updated_at', $cols, true)) $pdo->exec("ALTER TABLE saved_queues ADD COLUMN updated_at DATETIME NULL");
|
||
$pdo->prepare('UPDATE saved_queues SET updated_at = COALESCE(created_at, ?) WHERE updated_at IS NULL')->execute([db_now()]);
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0028_shared_queues']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0028_shared_queues';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0028_shared_queues: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0028_shared_queues - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0029: Schnittstellen – API-Schlüssel für die öffentliche Ticket-API und Webhooks.
|
||
if (!isset($done['php_0029_integrations'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
// MySQL: tickets.source ist ein ENUM – Quelle "api" ergänzen (SQLite kennt keine ENUMs)
|
||
if ($drv !== 'sqlite') {
|
||
$pdo->exec("ALTER TABLE tickets MODIFY COLUMN source ENUM('web','email','phone','staff','chat','api') NOT NULL DEFAULT 'web'");
|
||
}
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS api_keys (
|
||
id $ai,
|
||
name VARCHAR(120) NOT NULL,
|
||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||
key_prefix VARCHAR(16) NOT NULL,
|
||
can_create_tickets TINYINT(1) NOT NULL DEFAULT 1,
|
||
can_read_tickets TINYINT(1) NOT NULL DEFAULT 0,
|
||
ip_whitelist TEXT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||
last_used_at DATETIME NULL,
|
||
created_by INT NULL,
|
||
created_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS webhooks (
|
||
id $ai,
|
||
name VARCHAR(120) NOT NULL,
|
||
url VARCHAR(500) NOT NULL,
|
||
secret_enc TEXT NOT NULL,
|
||
events VARCHAR(255) NOT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||
last_status INT NULL,
|
||
last_error VARCHAR(300) NULL,
|
||
last_delivery_at DATETIME NULL,
|
||
fail_count INT NOT NULL DEFAULT 0,
|
||
created_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0029_integrations']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0029_integrations';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0029_integrations: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0029_integrations - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0030: gespeicherte Berichte (Bericht-Baukasten).
|
||
if (!isset($done['php_0030_saved_reports'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS saved_reports (
|
||
id $ai,
|
||
staff_id INT NOT NULL,
|
||
name VARCHAR(120) NOT NULL,
|
||
config TEXT NOT NULL,
|
||
is_shared TINYINT(1) NOT NULL DEFAULT 0,
|
||
created_at DATETIME NULL,
|
||
updated_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0030_saved_reports']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0030_saved_reports';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0030_saved_reports: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0030_saved_reports - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0031: Plugin-Verwaltung (installierte Plugins und ihr Zustand).
|
||
if (!isset($done['php_0031_plugins'])) {
|
||
try {
|
||
$eng = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS plugins (
|
||
slug VARCHAR(60) PRIMARY KEY,
|
||
name VARCHAR(120) NOT NULL,
|
||
version VARCHAR(30) NOT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 0,
|
||
fail_count INT NOT NULL DEFAULT 0,
|
||
last_error VARCHAR(500) NULL,
|
||
package_hash VARCHAR(64) NULL,
|
||
installed_at DATETIME NULL,
|
||
updated_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0031_plugins']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0031_plugins';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0031_plugins: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0031_plugins - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0032: Plugins – bestätigte Berechtigungen, Signaturstatus und Herkunft.
|
||
if (!isset($done['php_0032_plugin_trust'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(plugins)" : "SHOW COLUMNS FROM plugins")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('approved_permissions', $cols, true)) $pdo->exec("ALTER TABLE plugins ADD COLUMN approved_permissions TEXT NULL");
|
||
if (!in_array('signature_ok', $cols, true)) $pdo->exec("ALTER TABLE plugins ADD COLUMN signature_ok TINYINT(1) NOT NULL DEFAULT 0");
|
||
if (!in_array('source', $cols, true)) $pdo->exec("ALTER TABLE plugins ADD COLUMN source VARCHAR(20) NULL");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0032_plugin_trust']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0032_plugin_trust';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0032_plugin_trust: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0032_plugin_trust - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0033: Eigenes Ticket-Formular pro Abteilung (Felder + Texte, unabhängig vom Standardformular).
|
||
if (!isset($done['php_0033_department_forms'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$colsOf = function (string $table) use ($pdo, $drv): array {
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info($table)" : "SHOW COLUMNS FROM $table")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
return $cols;
|
||
};
|
||
if (!in_array('department_id', $colsOf('custom_fields'), true)) $pdo->exec("ALTER TABLE custom_fields ADD COLUMN department_id INT NULL");
|
||
$dc = $colsOf('departments');
|
||
if (!in_array('own_form', $dc, true)) $pdo->exec("ALTER TABLE departments ADD COLUMN own_form TINYINT(1) NOT NULL DEFAULT 0");
|
||
if (!in_array('form_config', $dc, true)) $pdo->exec("ALTER TABLE departments ADD COLUMN form_config TEXT NULL");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0033_department_forms']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0033_department_forms';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0033_department_forms: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0033_department_forms - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0034: Abschnitt eines Abteilungsfeldes im Ticket-Formular (Kontaktdaten, Anliegen, eigene Abschnitte).
|
||
if (!isset($done['php_0034_field_sections'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(custom_fields)" : "SHOW COLUMNS FROM custom_fields")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('form_section', $cols, true)) $pdo->exec("ALTER TABLE custom_fields ADD COLUMN form_section VARCHAR(100) NULL");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0034_field_sections']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0034_field_sections';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0034_field_sections: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0034_field_sections - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0035: Feldtyp "Radio-Auswahl" (MySQL: ENUM erweitern; SQLite kennt keine ENUMs).
|
||
if (!isset($done['php_0035_field_type_radio'])) {
|
||
try {
|
||
if ($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql') {
|
||
$pdo->exec("ALTER TABLE custom_fields MODIFY COLUMN field_type ENUM('text','textarea','select','checkbox','number','date','list','radio') NOT NULL DEFAULT 'text'");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0035_field_type_radio']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0035_field_type_radio';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0035_field_type_radio: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0035_field_type_radio - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0036: Breite eines Formularfeldes (volle Breite, Hälfte, Drittel, zwei Drittel).
|
||
if (!isset($done['php_0036_field_width'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(custom_fields)" : "SHOW COLUMNS FROM custom_fields")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('form_width', $cols, true)) $pdo->exec("ALTER TABLE custom_fields ADD COLUMN form_width VARCHAR(10) NULL");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0036_field_width']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0036_field_width';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0036_field_width: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0036_field_width - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0037: Anordnung der Radio-Optionen (nebeneinander oder untereinander).
|
||
if (!isset($done['php_0037_radio_inline'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(custom_fields)" : "SHOW COLUMNS FROM custom_fields")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('radio_inline', $cols, true)) $pdo->exec("ALTER TABLE custom_fields ADD COLUMN radio_inline TINYINT(1) NOT NULL DEFAULT 0");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0037_radio_inline']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0037_radio_inline';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0037_radio_inline: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0037_radio_inline - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0038: Hinweistext (mit Links) zu einem Formularfeld, z. B. für die Zustimmung zu Nutzerbedingungen.
|
||
if (!isset($done['php_0038_field_help_text'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(custom_fields)" : "SHOW COLUMNS FROM custom_fields")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('help_text', $cols, true)) $pdo->exec("ALTER TABLE custom_fields ADD COLUMN help_text TEXT NULL");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0038_field_help_text']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0038_field_help_text';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0038_field_help_text: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0038_field_help_text - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0039: Regel-Aktion "Ticket schließen" (MySQL: ENUM erweitern; SQLite kennt keine ENUMs).
|
||
if (!isset($done['php_0039_rule_close'])) {
|
||
try {
|
||
if ($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql') {
|
||
$pdo->exec("ALTER TABLE ticket_rule_actions MODIFY COLUMN action_type
|
||
ENUM('set_department','set_priority','set_help_topic','assign_staff','add_tag','reject','auto_reply','close') NOT NULL");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0039_rule_close']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0039_rule_close';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0039_rule_close: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0039_rule_close - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0040: Geräte der Android-App für Push-Benachrichtigungen (Firebase Cloud Messaging).
|
||
if (!isset($done['php_0040_push_devices'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS push_devices (
|
||
id $ai,
|
||
staff_id INT NOT NULL,
|
||
token TEXT NOT NULL,
|
||
token_hash CHAR(64) NOT NULL,
|
||
platform VARCHAR(20) NOT NULL DEFAULT 'android',
|
||
device_name VARCHAR(120) NULL,
|
||
app_version VARCHAR(30) NULL,
|
||
created_at DATETIME NULL,
|
||
last_seen_at DATETIME NULL,
|
||
UNIQUE (token_hash)
|
||
)$eng");
|
||
$pdo->exec('CREATE INDEX idx_push_devices_staff ON push_devices (staff_id)');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0040_push_devices']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0040_push_devices';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0040_push_devices: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0040_push_devices - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0041: Push-Einstellungen je Mitarbeiter (welche Popups, stille Zeiten).
|
||
if (!isset($done['php_0041_push_prefs'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS push_prefs (
|
||
staff_id INT NOT NULL PRIMARY KEY,
|
||
events VARCHAR(300) NOT NULL,
|
||
quiet_from CHAR(5) NULL,
|
||
quiet_to CHAR(5) NULL
|
||
)$eng");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0041_push_prefs']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0041_push_prefs';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0041_push_prefs: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0041_push_prefs - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0042: Aktivität der Mitarbeiter + automatischer Offline-Status.
|
||
if (!isset($done['php_0042_staff_activity'])) {
|
||
try {
|
||
$has = function (string $table, string $col) use ($pdo): bool {
|
||
try { $pdo->query("SELECT $col FROM $table LIMIT 1"); return true; } catch (Throwable $e) { return false; }
|
||
};
|
||
if (!$has('staff', 'last_active_at')) $pdo->exec('ALTER TABLE staff ADD COLUMN last_active_at DATETIME NULL');
|
||
if (!$has('staff_availability', 'auto_offline')) $pdo->exec('ALTER TABLE staff_availability ADD COLUMN auto_offline TINYINT NOT NULL DEFAULT 0');
|
||
if (!$has('staff_availability', 'prev_status')) $pdo->exec('ALTER TABLE staff_availability ADD COLUMN prev_status VARCHAR(20) NULL');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0042_staff_activity']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0042_staff_activity';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0042_staff_activity: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0042_staff_activity - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0043: Abteilungen können neue Tickets automatisch zuweisen (an einen Mitarbeiter oder reihum).
|
||
if (!isset($done['php_0043_department_auto_assign'])) {
|
||
try {
|
||
$has = function (string $col) use ($pdo): bool {
|
||
try { $pdo->query("SELECT $col FROM departments LIMIT 1"); return true; } catch (Throwable $e) { return false; }
|
||
};
|
||
if (!$has('auto_assign_mode')) $pdo->exec("ALTER TABLE departments ADD COLUMN auto_assign_mode VARCHAR(20) NOT NULL DEFAULT 'none'");
|
||
if (!$has('auto_assign_staff_id')) $pdo->exec('ALTER TABLE departments ADD COLUMN auto_assign_staff_id INT NULL');
|
||
if (!$has('auto_assign_last_id')) $pdo->exec('ALTER TABLE departments ADD COLUMN auto_assign_last_id INT NULL');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0043_department_auto_assign']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0043_department_auto_assign';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0043_department_auto_assign: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0043_department_auto_assign - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0044: Antworten des digitalen Assistenten (zusätzlich zur Wissensdatenbank).
|
||
if (!isset($done['php_0044_assistant_answers'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS assistant_answers (
|
||
id $ai,
|
||
title VARCHAR(255) NOT NULL,
|
||
keywords TEXT NULL,
|
||
answer TEXT NOT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||
hits INT NOT NULL DEFAULT 0,
|
||
created_at DATETIME NULL,
|
||
updated_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0044_assistant_answers']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0044_assistant_answers';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0044_assistant_answers: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0044_assistant_answers - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0045: Protokoll der Fragen an den digitalen Assistenten.
|
||
if (!isset($done['php_0045_assistant_log'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS assistant_log (
|
||
id $ai,
|
||
question VARCHAR(300) NOT NULL,
|
||
result VARCHAR(20) NOT NULL,
|
||
source VARCHAR(10) NULL,
|
||
matched_id INT NULL,
|
||
matched_title VARCHAR(255) NULL,
|
||
ticket_number VARCHAR(40) NULL,
|
||
created_at DATETIME NOT NULL
|
||
)$eng");
|
||
$pdo->exec('CREATE INDEX idx_assistant_log_created ON assistant_log (created_at)');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0045_assistant_log']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0045_assistant_log';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0045_assistant_log: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0045_assistant_log - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0046: Wiedervorlage, Beobachter, Kundenerinnerung, Rückmeldung zu Antworten des Assistenten.
|
||
if (!isset($done['php_0046_ticket_extras'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$has = function (string $table, string $col) use ($pdo): bool {
|
||
try { $pdo->query("SELECT $col FROM $table LIMIT 1"); return true; } catch (Throwable $e) { return false; }
|
||
};
|
||
if (!$has('tickets', 'snoozed_until')) $pdo->exec('ALTER TABLE tickets ADD COLUMN snoozed_until DATETIME NULL');
|
||
if (!$has('tickets', 'snoozed_by')) $pdo->exec('ALTER TABLE tickets ADD COLUMN snoozed_by INT NULL');
|
||
if (!$has('tickets', 'reminder_sent_at')) $pdo->exec('ALTER TABLE tickets ADD COLUMN reminder_sent_at DATETIME NULL');
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_followers (
|
||
ticket_id INT NOT NULL,
|
||
staff_id INT NOT NULL,
|
||
created_at DATETIME NOT NULL,
|
||
PRIMARY KEY (ticket_id, staff_id)
|
||
)$eng");
|
||
if (!$has('assistant_log', 'helpful')) $pdo->exec('ALTER TABLE assistant_log ADD COLUMN helpful TINYINT NULL');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0046_ticket_extras']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0046_ticket_extras';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0046_ticket_extras: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0046_ticket_extras - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0047: einmalige Anmelde-Adressen für die Übergabe von der App an die Weboberfläche.
|
||
if (!isset($done['php_0047_web_login_tokens'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS web_login_tokens (
|
||
id $ai,
|
||
staff_id INT NOT NULL,
|
||
token_hash VARCHAR(64) NOT NULL,
|
||
path VARCHAR(255) NOT NULL,
|
||
created_at DATETIME NOT NULL,
|
||
expires_at DATETIME NOT NULL,
|
||
used_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->exec('CREATE UNIQUE INDEX idx_web_login_tokens_hash ON web_login_tokens (token_hash)');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0047_web_login_tokens']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0047_web_login_tokens';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0047_web_login_tokens: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0047_web_login_tokens - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0048: Zufriedenheitsabfrage (CSAT), Statusseite für Kunden und Ticket-Vorlagen.
|
||
if (!isset($done['php_0048_csat_status_templates'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_csat (
|
||
id $ai,
|
||
ticket_id INT NOT NULL,
|
||
token VARCHAR(64) NOT NULL,
|
||
staff_id INT NULL,
|
||
department_id INT NULL,
|
||
rating TINYINT NULL,
|
||
comment TEXT NULL,
|
||
sent_at DATETIME NOT NULL,
|
||
answered_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->exec('CREATE UNIQUE INDEX idx_ticket_csat_ticket ON ticket_csat (ticket_id)');
|
||
$pdo->exec('CREATE UNIQUE INDEX idx_ticket_csat_token ON ticket_csat (token)');
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS status_components (
|
||
id $ai,
|
||
name VARCHAR(120) NOT NULL,
|
||
description VARCHAR(255) NULL,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'operational',
|
||
sort_order INT NOT NULL DEFAULT 0,
|
||
created_at DATETIME NOT NULL
|
||
)$eng");
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS status_incidents (
|
||
id $ai,
|
||
title VARCHAR(200) NOT NULL,
|
||
body TEXT NULL,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'investigating',
|
||
impact VARCHAR(20) NOT NULL DEFAULT 'minor',
|
||
component_id INT NULL,
|
||
created_by INT NULL,
|
||
created_at DATETIME NOT NULL,
|
||
updated_at DATETIME NOT NULL,
|
||
resolved_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->exec('CREATE INDEX idx_status_incidents_created ON status_incidents (created_at)');
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS status_incident_updates (
|
||
id $ai,
|
||
incident_id INT NOT NULL,
|
||
status VARCHAR(20) NOT NULL,
|
||
body TEXT NULL,
|
||
created_by INT NULL,
|
||
created_at DATETIME NOT NULL
|
||
)$eng");
|
||
$pdo->exec('CREATE INDEX idx_status_incident_updates_incident ON status_incident_updates (incident_id)');
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_templates (
|
||
id $ai,
|
||
name VARCHAR(120) NOT NULL,
|
||
description VARCHAR(255) NULL,
|
||
subject VARCHAR(200) NOT NULL DEFAULT '',
|
||
body TEXT NULL,
|
||
department_id INT NULL,
|
||
help_topic_id INT NULL,
|
||
priority_id INT NULL,
|
||
is_public TINYINT NOT NULL DEFAULT 1,
|
||
is_active TINYINT NOT NULL DEFAULT 1,
|
||
sort_order INT NOT NULL DEFAULT 0,
|
||
created_at DATETIME NOT NULL
|
||
)$eng");
|
||
$defaults = ['csat_enabled' => '0', 'csat_delay_minutes' => '30', 'status_page_enabled' => '0', 'status_page_title' => 'Systemstatus'];
|
||
$chk = $pdo->prepare('SELECT COUNT(*) FROM settings WHERE setting_key = ?');
|
||
$ins = $pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?,?)');
|
||
foreach ($defaults as $k => $v) {
|
||
$chk->execute([$k]);
|
||
if ((int) $chk->fetchColumn() === 0) $ins->execute([$k, $v]);
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0048_csat_status_templates']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0048_csat_status_templates';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0048_csat_status_templates: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0048_csat_status_templates - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0050: Kategorien für Dienste auf der Statusseite.
|
||
if (!isset($done['php_0050_status_categories'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
|
||
$hasCategory = false;
|
||
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'category') $hasCategory = true;
|
||
if (!$hasCategory) $pdo->exec('ALTER TABLE status_components ADD COLUMN category VARCHAR(80) NULL');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0050_status_categories']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0050_status_categories';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0050_status_categories: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0050_status_categories - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0051: Kategorien als eigene Datensätze mit Dropdown-Zuordnung.
|
||
if (!isset($done['php_0051_status_category_records'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS status_categories (
|
||
id $ai,
|
||
name VARCHAR(80) NOT NULL,
|
||
sort_order INT NOT NULL DEFAULT 0
|
||
)$eng");
|
||
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
|
||
$hasCategoryId = false;
|
||
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'category_id') $hasCategoryId = true;
|
||
if (!$hasCategoryId) $pdo->exec('ALTER TABLE status_components ADD COLUMN category_id INT NULL');
|
||
$legacy = $pdo->query("SELECT DISTINCT category FROM status_components WHERE category IS NOT NULL AND TRIM(category) <> ''")->fetchAll(PDO::FETCH_COLUMN);
|
||
$find = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
|
||
$add = $pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)');
|
||
$assign = $pdo->prepare('UPDATE status_components SET category_id = ? WHERE category = ? AND category_id IS NULL');
|
||
foreach ($legacy as $name) {
|
||
$find->execute([$name]);
|
||
$categoryId = $find->fetchColumn();
|
||
if (!$categoryId) {
|
||
$add->execute([$name, 0]);
|
||
$categoryId = (int) $pdo->lastInsertId();
|
||
}
|
||
$assign->execute([$categoryId, $name]);
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0051_status_category_records']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0051_status_category_records';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0051_status_category_records: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0051_status_category_records - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0052: Statuswechsel der Dienste für eine ehrliche Verlaufsanzeige speichern.
|
||
if (!isset($done['php_0052_status_component_history'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS status_component_events (
|
||
id $ai,
|
||
component_id INT NOT NULL,
|
||
status VARCHAR(20) NOT NULL,
|
||
created_at DATETIME NOT NULL
|
||
)$eng");
|
||
// Der erste Messpunkt ist ab jetzt; frühere Zustände werden nicht geraten.
|
||
$existing = $pdo->prepare('SELECT COUNT(*) FROM status_component_events WHERE component_id = ?');
|
||
$seed = $pdo->prepare('INSERT INTO status_component_events (component_id, status, created_at) VALUES (?,?,?)');
|
||
foreach ($pdo->query('SELECT id, status FROM status_components')->fetchAll() as $component) {
|
||
$existing->execute([(int) $component['id']]);
|
||
if ((int) $existing->fetchColumn() === 0) $seed->execute([(int) $component['id'], (string) $component['status'], date('Y-m-d H:i:s')]);
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0052_status_component_history']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0052_status_component_history';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0052_status_component_history: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0052_status_component_history - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0053: automatische Statusprüfungen für Dienste konfigurieren.
|
||
if (!isset($done['php_0053_status_auto_checks'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
|
||
$existing = [];
|
||
foreach ($cols as $col) $existing[] = (string) ($col['name'] ?? $col['Field'] ?? '');
|
||
$add = [
|
||
'monitor_type' => "VARCHAR(20) NOT NULL DEFAULT 'auto'",
|
||
'monitor_target' => 'VARCHAR(500) NULL',
|
||
'last_checked_at' => 'DATETIME NULL',
|
||
'last_check_ms' => 'INT NULL',
|
||
'last_check_error' => 'VARCHAR(255) NULL',
|
||
'consecutive_failures' => 'INT NOT NULL DEFAULT 0',
|
||
];
|
||
foreach ($add as $column => $definition) {
|
||
if (!in_array($column, $existing, true)) $pdo->exec("ALTER TABLE status_components ADD COLUMN $column $definition");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0053_status_auto_checks']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0053_status_auto_checks';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0053_status_auto_checks: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0053_status_auto_checks - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0054: einzelne Prüfergebnisse für die Heartbeat-Leiste speichern.
|
||
if (!isset($done['php_0054_status_check_samples'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS status_component_checks (
|
||
id $ai,
|
||
component_id INT NOT NULL,
|
||
status VARCHAR(20) NOT NULL,
|
||
checked_at DATETIME NOT NULL,
|
||
response_ms INT NULL
|
||
)$eng");
|
||
$pdo->exec('CREATE INDEX idx_status_component_checks_lookup ON status_component_checks (component_id, checked_at, id)');
|
||
$pdo->exec('CREATE INDEX idx_status_component_checks_checked_at ON status_component_checks (checked_at)');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0054_status_check_samples']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0054_status_check_samples';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0054_status_check_samples: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0054_status_check_samples - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0055: Status-Komponenten lassen sich für Anzeige und Prüfungen aktivieren/deaktivieren.
|
||
if (!isset($done['php_0055_status_component_enabled'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
|
||
$hasEnabled = false;
|
||
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'is_enabled') $hasEnabled = true;
|
||
if (!$hasEnabled) $pdo->exec('ALTER TABLE status_components ADD COLUMN is_enabled TINYINT NOT NULL DEFAULT 1');
|
||
$categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
|
||
$categoryStmt->execute(['Apps']);
|
||
$gameCategoryId = $categoryStmt->fetchColumn();
|
||
if (!$gameCategoryId) {
|
||
$categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
|
||
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Apps', $categoryOrder]);
|
||
$gameCategoryId = (int) $pdo->lastInsertId();
|
||
}
|
||
$minecraftStmt = $pdo->query("SELECT id, category_id, category FROM status_components WHERE LOWER(name) LIKE '%minecraft%' ORDER BY id LIMIT 1");
|
||
$minecraft = $minecraftStmt->fetch(PDO::FETCH_ASSOC);
|
||
if (!$minecraft) {
|
||
$sortOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn() + 10;
|
||
$insert = $pdo->prepare('INSERT INTO status_components (name, description, status, sort_order, created_at, category_id, monitor_type, is_enabled) VALUES (?,?,?,?,?,?,?,0)');
|
||
$insert->execute(['Minecraft-Server', 'Minecraft Java – IP-Adresse oder Domain und Port eintragen.', 'operational', $sortOrder, db_now(), (int) $gameCategoryId, 'tcp']);
|
||
} elseif (empty($minecraft['category_id']) && trim((string) ($minecraft['category'] ?? '')) === '') {
|
||
$pdo->prepare('UPDATE status_components SET category_id = ? WHERE id = ?')->execute([(int) $gameCategoryId, (int) $minecraft['id']]);
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0055_status_component_enabled']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0055_status_component_enabled';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0055_status_component_enabled: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0055_status_component_enabled - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0056: Minecraft-Statusdienst der Kategorie „Apps“ zuordnen.
|
||
if (!isset($done['php_0056_minecraft_status_category'])) {
|
||
try {
|
||
$categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
|
||
$categoryStmt->execute(['Apps']);
|
||
$gameCategoryId = $categoryStmt->fetchColumn();
|
||
if (!$gameCategoryId) {
|
||
$categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
|
||
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Apps', $categoryOrder]);
|
||
$gameCategoryId = (int) $pdo->lastInsertId();
|
||
}
|
||
$pdo->prepare("UPDATE status_components SET category_id = ? WHERE LOWER(name) LIKE '%minecraft%' AND category_id IS NULL AND (category IS NULL OR TRIM(category) = '')")
|
||
->execute([(int) $gameCategoryId]);
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0056_minecraft_status_category']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0056_minecraft_status_category';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0056_minecraft_status_category: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0056_minecraft_status_category - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0057: Minecraft-Dienst unter „Apps“ einsortieren.
|
||
if (!isset($done['php_0057_minecraft_status_apps_category'])) {
|
||
try {
|
||
$categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
|
||
$categoryStmt->execute(['Apps']);
|
||
$appCategoryId = $categoryStmt->fetchColumn();
|
||
if (!$appCategoryId) {
|
||
$categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
|
||
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Apps', $categoryOrder]);
|
||
$appCategoryId = (int) $pdo->lastInsertId();
|
||
}
|
||
$pdo->prepare("UPDATE status_components SET category_id = ?, category = NULL WHERE LOWER(name) LIKE '%minecraft%'")
|
||
->execute([(int) $appCategoryId]);
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0057_minecraft_status_apps_category']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0057_minecraft_status_apps_category';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0057_minecraft_status_apps_category: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0057_minecraft_status_apps_category - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0059: Optionalen Backup-Server für die Statusseite anlegen.
|
||
if (!isset($done['php_0059_backup_server_status_component'])) {
|
||
try {
|
||
$categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
|
||
$categoryStmt->execute(['Server & Datenbank']);
|
||
$categoryId = $categoryStmt->fetchColumn();
|
||
if (!$categoryId) {
|
||
$categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
|
||
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Server & Datenbank', $categoryOrder]);
|
||
$categoryId = (int) $pdo->lastInsertId();
|
||
}
|
||
$existing = $pdo->query("SELECT id FROM status_components WHERE LOWER(name) IN ('backup-server', 'backup server', 'ftp-backup-server') LIMIT 1")->fetchColumn();
|
||
if (!$existing) {
|
||
$sortOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn() + 10;
|
||
$insert = $pdo->prepare('INSERT INTO status_components (name, description, status, sort_order, created_at, category_id, monitor_type, is_enabled) VALUES (?,?,?,?,?,?,?,0)');
|
||
$insert->execute(['Backup-Server', 'Optionaler externer Backup-Server; Prüfung mit Host und Port.', 'operational', $sortOrder, db_now(), (int) $categoryId, 'tcp']);
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0059_backup_server_status_component']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0059_backup_server_status_component';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0059_backup_server_status_component: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0059_backup_server_status_component - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0060: Geplante Wartungsfenster der Statusseite.
|
||
if (!isset($done['php_0060_status_maintenance_windows'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS status_maintenance_windows (
|
||
id $ai,
|
||
title VARCHAR(200) NOT NULL,
|
||
body TEXT NULL,
|
||
component_id INT NULL,
|
||
starts_at DATETIME NOT NULL,
|
||
ends_at DATETIME NOT NULL,
|
||
created_by INT NULL,
|
||
created_at DATETIME NOT NULL,
|
||
cancelled_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->exec('CREATE INDEX idx_status_maintenance_window_time ON status_maintenance_windows (starts_at, ends_at)');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0060_status_maintenance_windows']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0060_status_maintenance_windows';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0060_status_maintenance_windows: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0060_status_maintenance_windows - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0061: Prüfintervall je Statusdienst konfigurierbar machen.
|
||
if (!isset($done['php_0061_status_check_intervals'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
|
||
$hasInterval = false;
|
||
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'check_interval_minutes') $hasInterval = true;
|
||
if (!$hasInterval) $pdo->exec('ALTER TABLE status_components ADD COLUMN check_interval_minutes INT NOT NULL DEFAULT 1');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0061_status_check_intervals']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0061_status_check_intervals';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0061_status_check_intervals: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0061_status_check_intervals - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0063: SLA-Wartepausen und Ereignisverlauf.
|
||
if (!isset($done['php_0063_ticket_sla_pause_history'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(tickets)' : 'SHOW COLUMNS FROM tickets')->fetchAll();
|
||
$hasPauseColumn = false;
|
||
foreach ($cols as $col) if ((string) ($col['name'] ?? $col['Field'] ?? '') === 'sla_paused_at') $hasPauseColumn = true;
|
||
if (!$hasPauseColumn) $pdo->exec('ALTER TABLE tickets ADD COLUMN sla_paused_at DATETIME NULL');
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_sla_history (
|
||
id $ai, ticket_id INT NOT NULL, event_type VARCHAR(24) NOT NULL,
|
||
deadline_type VARCHAR(24) NULL, details TEXT NULL, staff_id INT NULL, created_at DATETIME NOT NULL
|
||
)$eng");
|
||
$indexes = $pdo->query($drv === 'sqlite' ? 'PRAGMA index_list(ticket_sla_history)' : 'SHOW INDEX FROM ticket_sla_history')->fetchAll();
|
||
$hasIndex = false;
|
||
foreach ($indexes as $index) if ((string) ($index['name'] ?? $index['Key_name'] ?? '') === 'idx_ticket_sla_history_ticket_time') $hasIndex = true;
|
||
if (!$hasIndex) $pdo->exec('CREATE INDEX idx_ticket_sla_history_ticket_time ON ticket_sla_history (ticket_id, created_at)');
|
||
foreach ([['first_response_due_at', 'first_response'], ['resolution_due_at', 'resolution']] as [$dueColumn, $type]) {
|
||
$sql = "INSERT INTO ticket_sla_history (ticket_id, event_type, deadline_type, details, created_at)
|
||
SELECT t.id, 'started', ?, ?, t.created_at FROM tickets t
|
||
WHERE t.$dueColumn IS NOT NULL AND NOT EXISTS (
|
||
SELECT 1 FROM ticket_sla_history h WHERE h.ticket_id = t.id AND h.event_type = 'started' AND h.deadline_type = ?
|
||
)";
|
||
$pdo->prepare($sql)->execute([$type, 'SLA gestartet; Fristfeld: ' . $dueColumn, $type]);
|
||
}
|
||
$now = db_now();
|
||
$pdo->prepare("UPDATE tickets SET sla_paused_at = ? WHERE sla_paused_at IS NULL AND status IN ('pending','on_hold')")->execute([$now]);
|
||
$pdo->prepare("INSERT INTO ticket_sla_history (ticket_id, event_type, details, created_at)
|
||
SELECT id, 'paused', 'SLA-Pause bei Aktivierung uebernommen', ? FROM tickets
|
||
WHERE status IN ('pending','on_hold') AND sla_paused_at = ?")->execute([$now, $now]);
|
||
$pdo->exec("INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,created_at)
|
||
SELECT t.id,'overdue','resolution',NULL,t.resolution_due_at FROM tickets t
|
||
WHERE t.is_overdue = 1 AND t.resolution_due_at IS NOT NULL AND NOT EXISTS (
|
||
SELECT 1 FROM ticket_sla_history h WHERE h.ticket_id=t.id AND h.event_type='overdue' AND h.deadline_type='resolution'
|
||
)");
|
||
$backfillResponse = $pdo->prepare("INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,created_at)
|
||
SELECT t.id,'overdue','first_response',NULL,t.first_response_due_at FROM tickets t
|
||
WHERE t.first_responded_at IS NULL AND t.first_response_due_at IS NOT NULL AND t.first_response_due_at < ?
|
||
AND t.status NOT IN ('pending','on_hold','resolved','closed') AND NOT EXISTS (
|
||
SELECT 1 FROM ticket_sla_history h WHERE h.ticket_id=t.id AND h.event_type='overdue' AND h.deadline_type='first_response'
|
||
)");
|
||
$backfillResponse->execute([$now]);
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0063_ticket_sla_pause_history']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0063_ticket_sla_pause_history';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0063_ticket_sla_pause_history: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0063_ticket_sla_pause_history - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0062: Problemmanagement und dokumentierte Ticket-Abschlussgründe.
|
||
if (!isset($done['php_0062_problems_and_resolution_codes'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$ticketCols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(tickets)' : 'SHOW COLUMNS FROM tickets')->fetchAll();
|
||
$has = [];
|
||
foreach ($ticketCols as $col) $has[] = (string) ($col['name'] ?? $col['Field'] ?? '');
|
||
if (!in_array('resolution_code', $has, true)) $pdo->exec('ALTER TABLE tickets ADD COLUMN resolution_code VARCHAR(40) NULL');
|
||
if (!in_array('resolution_note', $has, true)) $pdo->exec('ALTER TABLE tickets ADD COLUMN resolution_note TEXT NULL');
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS problems (
|
||
id $ai,
|
||
title VARCHAR(200) NOT NULL,
|
||
description TEXT NULL,
|
||
root_cause TEXT NULL,
|
||
workaround TEXT NULL,
|
||
resolution TEXT NULL,
|
||
status VARCHAR(24) NOT NULL DEFAULT 'investigating',
|
||
created_by INT NULL,
|
||
updated_by INT NULL,
|
||
created_at DATETIME NOT NULL,
|
||
updated_at DATETIME NOT NULL,
|
||
resolved_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS problem_tickets (
|
||
problem_id INT NOT NULL,
|
||
ticket_id INT NOT NULL,
|
||
linked_by INT NULL,
|
||
linked_at DATETIME NOT NULL,
|
||
PRIMARY KEY (problem_id, ticket_id)
|
||
)$eng");
|
||
$indexes = $pdo->query($drv === 'sqlite' ? 'PRAGMA index_list(problem_tickets)' : 'SHOW INDEX FROM problem_tickets')->fetchAll();
|
||
$ticketIndexExists = false;
|
||
foreach ($indexes as $index) if ((string) ($index['name'] ?? $index['Key_name'] ?? '') === 'idx_problem_tickets_ticket') $ticketIndexExists = true;
|
||
if (!$ticketIndexExists) $pdo->exec('CREATE INDEX idx_problem_tickets_ticket ON problem_tickets (ticket_id)');
|
||
$indexes = $pdo->query($drv === 'sqlite' ? 'PRAGMA index_list(problems)' : 'SHOW INDEX FROM problems')->fetchAll();
|
||
$problemIndexExists = false;
|
||
foreach ($indexes as $index) if ((string) ($index['name'] ?? $index['Key_name'] ?? '') === 'idx_problems_status_updated') $problemIndexExists = true;
|
||
if (!$problemIndexExists) $pdo->exec('CREATE INDEX idx_problems_status_updated ON problems (status, updated_at)');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0062_problems_and_resolution_codes']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0062_problems_and_resolution_codes';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0062_problems_and_resolution_codes: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0062_problems_and_resolution_codes - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0049: gesammelte, noch nicht übersetzte Texte (Sammelmodus der Mehrsprachigkeit).
|
||
if (!isset($done['php_0049_i18n_missing'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS i18n_missing (
|
||
id $ai,
|
||
text_hash CHAR(32) NOT NULL,
|
||
text VARCHAR(400) NOT NULL,
|
||
hits INT NOT NULL DEFAULT 1,
|
||
last_seen DATETIME NOT NULL
|
||
)$eng");
|
||
$pdo->exec('CREATE UNIQUE INDEX idx_i18n_missing_hash ON i18n_missing (text_hash)');
|
||
$chk = $pdo->prepare('SELECT COUNT(*) FROM settings WHERE setting_key = ?');
|
||
$ins = $pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?,?)');
|
||
foreach (['default_language' => 'de', 'i18n_collect' => '0'] as $k => $v) {
|
||
$chk->execute([$k]);
|
||
if ((int) $chk->fetchColumn() === 0) $ins->execute([$k, $v]);
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0049_i18n_missing']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0049_i18n_missing';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0049_i18n_missing: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0049_i18n_missing - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Warteschlange für ausgehende Mails: Fehlgeschlagene Mails werden später erneut versucht
|
||
if (!isset($done['php_0064_mail_queue'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$long = $drv === 'sqlite' ? 'TEXT' : 'MEDIUMTEXT';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS mail_queue (
|
||
id $ai,
|
||
to_email VARCHAR(255) NOT NULL,
|
||
from_header VARCHAR(255) NULL,
|
||
subject VARCHAR(500) NOT NULL,
|
||
body $long NOT NULL,
|
||
status VARCHAR(10) NOT NULL DEFAULT 'pending',
|
||
attempts INT NOT NULL DEFAULT 0,
|
||
next_attempt_at DATETIME NOT NULL,
|
||
last_error VARCHAR(400) NULL,
|
||
created_at DATETIME NOT NULL,
|
||
sent_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->exec('CREATE INDEX idx_mail_queue_due ON mail_queue (status, next_attempt_at)');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0064_mail_queue']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0064_mail_queue';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0064_mail_queue: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0064_mail_queue - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Anmelde-Wächter: Protokoll der Anmeldungen sowie gemerkte Geräte und Orte je Mitarbeiter
|
||
if (!isset($done['php_0065_login_guard'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS login_events (
|
||
id $ai,
|
||
staff_id INT NULL,
|
||
email VARCHAR(255) NOT NULL DEFAULT '',
|
||
event VARCHAR(20) NOT NULL,
|
||
channel VARCHAR(10) NOT NULL DEFAULT 'web',
|
||
ip VARCHAR(64) NOT NULL DEFAULT '',
|
||
place VARCHAR(100) NOT NULL DEFAULT '',
|
||
device VARCHAR(160) NOT NULL DEFAULT '',
|
||
detail VARCHAR(255) NOT NULL DEFAULT '',
|
||
token_hash VARCHAR(64) NULL,
|
||
token_used TINYINT NOT NULL DEFAULT 0,
|
||
created_at DATETIME NOT NULL
|
||
)$eng");
|
||
$pdo->exec('CREATE INDEX idx_login_events_created ON login_events (created_at)');
|
||
$pdo->exec('CREATE INDEX idx_login_events_staff ON login_events (staff_id, created_at)');
|
||
$pdo->exec('CREATE INDEX idx_login_events_token ON login_events (token_hash)');
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS login_devices (
|
||
id $ai,
|
||
staff_id INT NOT NULL,
|
||
device_hash VARCHAR(64) NOT NULL,
|
||
label VARCHAR(160) NOT NULL DEFAULT '',
|
||
channel VARCHAR(10) NOT NULL DEFAULT 'web',
|
||
first_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||
last_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||
place VARCHAR(100) NOT NULL DEFAULT '',
|
||
first_seen DATETIME NOT NULL,
|
||
last_seen DATETIME NOT NULL,
|
||
UNIQUE (staff_id, device_hash)
|
||
)$eng");
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS login_places (
|
||
id $ai,
|
||
staff_id INT NOT NULL,
|
||
place_key VARCHAR(80) NOT NULL,
|
||
label VARCHAR(100) NOT NULL DEFAULT '',
|
||
first_seen DATETIME NOT NULL,
|
||
last_seen DATETIME NOT NULL,
|
||
UNIQUE (staff_id, place_key)
|
||
)$eng");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0065_login_guard']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0065_login_guard';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0065_login_guard: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0065_login_guard - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Abwesenheit im Profil: Welche Tickets an die Vertretung weitergegeben wurden (für die Rückgabe nach dem Urlaub)
|
||
if (!isset($done['php_0066_vacation_moves'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS vacation_moves (
|
||
id $ai,
|
||
ticket_id INT NOT NULL,
|
||
from_staff_id INT NOT NULL,
|
||
to_staff_id INT NOT NULL,
|
||
created_at DATETIME NOT NULL,
|
||
returned_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->exec('CREATE INDEX idx_vacation_moves_from ON vacation_moves (from_staff_id, returned_at)');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0066_vacation_moves']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0066_vacation_moves';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0066_vacation_moves: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0066_vacation_moves - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Reparatur: Entwarnungs-Notizen der öffentlichen API waren doppelt maskiert (" statt ")
|
||
if (!isset($done['php_0067_fix_api_resolve_notes'])) {
|
||
try {
|
||
$rows = $pdo->query("SELECT t.id, t.message FROM ticket_threads t JOIN tickets k ON k.id = t.ticket_id
|
||
WHERE k.source = 'api' AND t.author_type = 'system' AND t.is_internal_note = 1
|
||
AND (t.message LIKE '%"%' OR t.message LIKE '%&%' OR t.message LIKE '%'%')")->fetchAll();
|
||
$upd = $pdo->prepare('UPDATE ticket_threads SET message = ? WHERE id = ?');
|
||
foreach ($rows as $r) $upd->execute([html_entity_decode((string) $r['message'], ENT_QUOTES | ENT_HTML5, 'UTF-8'), $r['id']]);
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0067_fix_api_resolve_notes']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0067_fix_api_resolve_notes (' . count($rows) . ' Notizen repariert)';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0067_fix_api_resolve_notes: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0067_fix_api_resolve_notes - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Indizes für die Übersicht (letzte Aktivität) und die Ticketliste (Sortierung nach letzter Bearbeitung)
|
||
if (!isset($done['php_0068_overview_indexes'])) {
|
||
try {
|
||
$hasIndex = function (string $table, string $name) use ($pdo): bool {
|
||
$rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? "PRAGMA index_list($table)" : "SHOW INDEX FROM $table")->fetchAll();
|
||
foreach ($rows as $r) if ((string) ($r['name'] ?? $r['Key_name'] ?? '') === $name) return true;
|
||
return false;
|
||
};
|
||
if (!$hasIndex('activity_log', 'idx_activity_log_created')) $pdo->exec('CREATE INDEX idx_activity_log_created ON activity_log (created_at)');
|
||
if (!$hasIndex('tickets', 'idx_tickets_updated')) $pdo->exec('CREATE INDEX idx_tickets_updated ON tickets (updated_at)');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0068_overview_indexes']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0068_overview_indexes';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0068_overview_indexes: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0068_overview_indexes - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Statusseite: Dienste lassen sich als „intern“ markieren (werden geprüft, erscheinen aber nicht auf der öffentlichen Statusseite)
|
||
if (!isset($done['php_0069_status_component_internal'])) {
|
||
try {
|
||
$cols = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
|
||
$has = false;
|
||
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'is_internal') $has = true;
|
||
if (!$has) $pdo->exec('ALTER TABLE status_components ADD COLUMN is_internal TINYINT NOT NULL DEFAULT 0');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0069_status_component_internal']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0069_status_component_internal';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0069_status_component_internal: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0069_status_component_internal - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
function repair_sqlite_foreign_keys(PDO $pdo): void
|
||
{
|
||
if ($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||
return;
|
||
}
|
||
|
||
$missingPushFk = !sqlite_has_foreign_key($pdo, 'push_subscriptions', 'staff_id', 'staff');
|
||
$missingCommentThreadFk = !sqlite_has_foreign_key($pdo, 'ticket_thread_comments', 'thread_id', 'ticket_threads');
|
||
$missingCommentStaffFk = !sqlite_has_foreign_key($pdo, 'ticket_thread_comments', 'staff_id', 'staff');
|
||
if (!$missingPushFk && !$missingCommentThreadFk && !$missingCommentStaffFk) {
|
||
return;
|
||
}
|
||
|
||
$pdo->exec('PRAGMA foreign_keys = OFF');
|
||
try {
|
||
$pdo->beginTransaction();
|
||
if ($missingPushFk) {
|
||
$pdo->exec("CREATE TABLE push_subscriptions_fk_new (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
staff_id INT NOT NULL,
|
||
endpoint TEXT NOT NULL,
|
||
p256dh TEXT NOT NULL,
|
||
auth TEXT NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
)");
|
||
$pdo->exec('INSERT INTO push_subscriptions_fk_new (id, staff_id, endpoint, p256dh, auth, created_at)
|
||
SELECT id, staff_id, endpoint, p256dh, auth, created_at FROM push_subscriptions');
|
||
$pdo->exec('DROP TABLE push_subscriptions');
|
||
$pdo->exec('ALTER TABLE push_subscriptions_fk_new RENAME TO push_subscriptions');
|
||
}
|
||
|
||
if ($missingCommentThreadFk || $missingCommentStaffFk) {
|
||
$pdo->exec("CREATE TABLE ticket_thread_comments_fk_new (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
thread_id INT NOT NULL,
|
||
staff_id INT NOT NULL,
|
||
message TEXT NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||
FOREIGN KEY (thread_id) REFERENCES ticket_threads(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
)");
|
||
$pdo->exec('INSERT INTO ticket_thread_comments_fk_new (id, thread_id, staff_id, message, created_at)
|
||
SELECT id, thread_id, staff_id, message, created_at FROM ticket_thread_comments');
|
||
$pdo->exec('DROP TABLE ticket_thread_comments');
|
||
$pdo->exec('ALTER TABLE ticket_thread_comments_fk_new RENAME TO ticket_thread_comments');
|
||
}
|
||
$pdo->commit();
|
||
} catch (Throwable $e) {
|
||
if ($pdo->inTransaction()) {
|
||
$pdo->rollBack();
|
||
}
|
||
throw $e;
|
||
} finally {
|
||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||
}
|
||
}
|
||
|
||
function sqlite_has_foreign_key(PDO $pdo, string $table, string $column, string $parentTable): bool
|
||
{
|
||
foreach ($pdo->query("PRAGMA foreign_key_list({$table})") as $foreignKey) {
|
||
if ($foreignKey['from'] === $column && $foreignKey['table'] === $parentTable) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** Legt Datenschutz-Standardwerte an falls noch nicht vorhanden */
|
||
function apply_migration_0003(PDO $pdo): void
|
||
{
|
||
$defaults = [
|
||
'retention_enabled' => '0',
|
||
'retention_months' => '24',
|
||
'ip_anonymize' => '0',
|
||
'staff_session_timeout' => '30',
|
||
];
|
||
$check = $pdo->prepare('SELECT COUNT(*) FROM settings WHERE setting_key = ?');
|
||
$ins = $pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?,?)');
|
||
foreach ($defaults as $key => $val) {
|
||
$check->execute([$key]);
|
||
if ((int)$check->fetchColumn() === 0) {
|
||
$ins->execute([$key, $val]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* SLA-Zeiten für Tickets nachberechnen, bei denen sie NULL sind.
|
||
* Läuft auf SQLite und MySQL.
|
||
*/
|
||
function apply_migration_0002(PDO $pdo): void
|
||
{
|
||
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
|
||
// Alle offenen Tickets ohne SLA-Datum laden
|
||
$tickets = $pdo->query(
|
||
"SELECT t.id, t.created_at, p.response_hours, p.resolution_hours
|
||
FROM tickets t
|
||
JOIN priorities p ON p.id = t.priority_id
|
||
WHERE t.status NOT IN ('resolved','closed')
|
||
AND (t.first_response_due_at IS NULL OR t.resolution_due_at IS NULL)"
|
||
)->fetchAll(PDO::FETCH_ASSOC);
|
||
|
||
if (!$tickets) return;
|
||
|
||
$upd = $pdo->prepare(
|
||
'UPDATE tickets SET first_response_due_at = ?, resolution_due_at = ? WHERE id = ?'
|
||
);
|
||
|
||
foreach ($tickets as $t) {
|
||
$base = $t['created_at'];
|
||
$firstDue = date('Y-m-d H:i:s', strtotime($base) + (int)$t['response_hours'] * 3600);
|
||
$resolDue = date('Y-m-d H:i:s', strtotime($base) + (int)$t['resolution_hours'] * 3600);
|
||
$upd->execute([$firstDue, $resolDue, $t['id']]);
|
||
}
|
||
}
|