Files
deskhub/DeskHub-Webseite/database/migrate.php
T
2026-10-26 21:19:00 +00:00

1943 lines
109 KiB
PHP
Raw Blame History

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