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(); } } 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']]); } }