Files
deskhub/DeskHub-Webseite/database/migrate.php
T
2026-10-23 17:22:17 +00:00

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