diff --git a/DeskHub-Webseite/cron/check_sla.php b/DeskHub-Webseite/cron/check_sla.php index 36f9a01d..98335d7c 100644 --- a/DeskHub-Webseite/cron/check_sla.php +++ b/DeskHub-Webseite/cron/check_sla.php @@ -55,6 +55,7 @@ function run_check_sla(PDO $pdo, bool $verbose = false): array email_send_and_log($t['department_email'], $subject, email_wrap($inner), null, 'SLA-überschritten-Mail (Abteilung)', $t['ticket_number']); } notify_admins_overdue_ticket($t); + fcm_notify_overdue($t); // Push auf die Android-App $newlyOverdue++; if ($verbose) { $log[] = "[ok] Ticket #{$t['ticket_number']} als überfällig markiert"; diff --git a/DeskHub-Webseite/database/migrate.php b/DeskHub-Webseite/database/migrate.php index d6959305..9381f885 100644 --- a/DeskHub-Webseite/database/migrate.php +++ b/DeskHub-Webseite/database/migrate.php @@ -1,1265 +1,1331 @@ -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(); - } - } - - 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']]); - } -} +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']]); + } +} diff --git a/DeskHub-Webseite/includes/auth.php b/DeskHub-Webseite/includes/auth.php index 2c629320..089fd5e3 100644 --- a/DeskHub-Webseite/includes/auth.php +++ b/DeskHub-Webseite/includes/auth.php @@ -52,6 +52,7 @@ function staff_login(array $staff): void session_regenerate_id(true); $_SESSION['staff_id'] = $staff['id']; $_SESSION['staff_last_activity'] = time(); + staff_touch_activity(get_db(), (int) $staff['id']); } function staff_logout(): void @@ -96,6 +97,8 @@ function require_staff_login(): array redirect('/staff/login.php'); } $_SESSION['staff_last_activity'] = time(); + // Echte Seitenaufrufe zählen als Aktivität (Hintergrund-Abrufe per fetch nicht) + if (staff_is_user_navigation()) staff_touch_activity(get_db(), (int) $staff['id']); // "Eingebauter" Ersatz für echte Server-Cronjobs (siehe includes/pseudo_cron.php): // bei jeder authentifizierten Backend-Anfrage werden fällige Jobs im Hintergrund geprüft. diff --git a/DeskHub-Webseite/includes/bootstrap.php b/DeskHub-Webseite/includes/bootstrap.php index bc819c75..fcf33ceb 100644 --- a/DeskHub-Webseite/includes/bootstrap.php +++ b/DeskHub-Webseite/includes/bootstrap.php @@ -19,6 +19,8 @@ require_once __DIR__ . '/admin_models.php'; require_once __DIR__ . '/queues.php'; require_once __DIR__ . '/integrations.php'; require_once __DIR__ . '/reports.php'; +require_once __DIR__ . '/fcm.php'; +require_once __DIR__ . '/staff_activity.php'; require_once __DIR__ . '/notifications.php'; require_once __DIR__ . '/plugins.php'; require_once __DIR__ . '/app_downloads.php'; diff --git a/DeskHub-Webseite/includes/catalog_defaults.php b/DeskHub-Webseite/includes/catalog_defaults.php index e5889576..da69fcba 100644 --- a/DeskHub-Webseite/includes/catalog_defaults.php +++ b/DeskHub-Webseite/includes/catalog_defaults.php @@ -1,23 +1,18 @@ - 'https://plugin-deskhub.viper.ipv64.net/catalog.php?type=plugins', - 'themes_url' => 'https://plugin-deskhub.viper.ipv64.net/catalog.php?type=themes', - 'access_key' => '', // Zugangsschlüssel des Katalogs (docker-build gibt ihn aus); leer = Katalog ohne Zugangsschutz - 'publisher_key' => '-----BEGIN PUBLIC KEY----- -MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEApuCBEnEYk4uVtP4cijWN -LBgzjGU9TKb8Zjygk6DIeKKiR3Li7MUjkSPzP4JIZolxdzN147yw4gKG08WUSx4B -QbtvfgD40CxjRC83D3L+/8K2FaWmYGsQefhZjtsZzY9xtJjUEEVe0eRYlyi2ZDgW -nDcz0++KTnRmPwd2qz2MNsgx1gOciRRawqOXzzlx7u5aKaVHzpxTIMaFA/7i/Tpd -Eu8fEuTP88LDbwKuYVo8Aid5s3y171uRCDDu+91Bwu0sIxzAXH+Z/yXqHppmEGxX -HCYXGn/L/sEAKRsH9aIO8muE8wpOF5QhDW4NtWD5QFPxbkvLzvRBp5Q3jyvRjHXy -S1i4h8VujBNJXC5AsVjXgO/4Aq4q/j1HhOfhK6Njv0gwt54CnK+SVsniwro0fpZr -XGlAo9JY6X4WIJ5etRMzwrhwTG7BTz1ClrE51628qO4q9ymFLiEoakQhYevfTnRa -hrQtwtRCAjN86vPD3dT4cXtjZnYQ+9/DEHNy2CBieIVXAgMBAAE= ------END PUBLIC KEY----- -', -]; + 'https://plugin-deskhub.viper.ipv64.net/catalog.php?type=plugins', + 'themes_url' => 'https://plugin-deskhub.viper.ipv64.net/catalog.php?type=themes', + 'publisher_key' => '-----BEGIN PUBLIC KEY----- +MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEApuCBEnEYk4uVtP4cijWN +LBgzjGU9TKb8Zjygk6DIeKKiR3Li7MUjkSPzP4JIZolxdzN147yw4gKG08WUSx4B +QbtvfgD40CxjRC83D3L+/8K2FaWmYGsQefhZjtsZzY9xtJjUEEVe0eRYlyi2ZDgW +nDcz0++KTnRmPwd2qz2MNsgx1gOciRRawqOXzzlx7u5aKaVHzpxTIMaFA/7i/Tpd +Eu8fEuTP88LDbwKuYVo8Aid5s3y171uRCDDu+91Bwu0sIxzAXH+Z/yXqHppmEGxX +HCYXGn/L/sEAKRsH9aIO8muE8wpOF5QhDW4NtWD5QFPxbkvLzvRBp5Q3jyvRjHXy +S1i4h8VujBNJXC5AsVjXgO/4Aq4q/j1HhOfhK6Njv0gwt54CnK+SVsniwro0fpZr +XGlAo9JY6X4WIJ5etRMzwrhwTG7BTz1ClrE51628qO4q9ymFLiEoakQhYevfTnRa +hrQtwtRCAjN86vPD3dT4cXtjZnYQ+9/DEHNy2CBieIVXAgMBAAE= +-----END PUBLIC KEY----- +', + 'access_key' => '64f141c43fc64dbfd1391ac8bfad81933bb59bb5c4d791d2', +]; \ No newline at end of file diff --git a/DeskHub-Webseite/includes/catalog_defaults.php.bak b/DeskHub-Webseite/includes/catalog_defaults.php.bak new file mode 100644 index 00000000..e5889576 --- /dev/null +++ b/DeskHub-Webseite/includes/catalog_defaults.php.bak @@ -0,0 +1,23 @@ + 'https://plugin-deskhub.viper.ipv64.net/catalog.php?type=plugins', + 'themes_url' => 'https://plugin-deskhub.viper.ipv64.net/catalog.php?type=themes', + 'access_key' => '', // Zugangsschlüssel des Katalogs (docker-build gibt ihn aus); leer = Katalog ohne Zugangsschutz + 'publisher_key' => '-----BEGIN PUBLIC KEY----- +MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEApuCBEnEYk4uVtP4cijWN +LBgzjGU9TKb8Zjygk6DIeKKiR3Li7MUjkSPzP4JIZolxdzN147yw4gKG08WUSx4B +QbtvfgD40CxjRC83D3L+/8K2FaWmYGsQefhZjtsZzY9xtJjUEEVe0eRYlyi2ZDgW +nDcz0++KTnRmPwd2qz2MNsgx1gOciRRawqOXzzlx7u5aKaVHzpxTIMaFA/7i/Tpd +Eu8fEuTP88LDbwKuYVo8Aid5s3y171uRCDDu+91Bwu0sIxzAXH+Z/yXqHppmEGxX +HCYXGn/L/sEAKRsH9aIO8muE8wpOF5QhDW4NtWD5QFPxbkvLzvRBp5Q3jyvRjHXy +S1i4h8VujBNJXC5AsVjXgO/4Aq4q/j1HhOfhK6Njv0gwt54CnK+SVsniwro0fpZr +XGlAo9JY6X4WIJ5etRMzwrhwTG7BTz1ClrE51628qO4q9ymFLiEoakQhYevfTnRa +hrQtwtRCAjN86vPD3dT4cXtjZnYQ+9/DEHNy2CBieIVXAgMBAAE= +-----END PUBLIC KEY----- +', +];