1350 lines
72 KiB
PHP
1350 lines
72 KiB
PHP
<?php
|
||
/**
|
||
* database/migrate.php – Schema-Migrator
|
||
*
|
||
* Führt alle noch nicht eingespielten Migrationsdateien aus
|
||
* database/migrations/*.sql aus. Bereits angewendete Migrationen werden
|
||
* in der Tabelle schema_migrations protokolliert und NIEMALS erneut
|
||
* ausgeführt – Einstellungen und Nutzerdaten bleiben dadurch immer erhalten.
|
||
*
|
||
* Aufruf (CLI): php database/migrate.php
|
||
* Aufruf (intern): run_migrations($pdo) nach require_once
|
||
*/
|
||
|
||
// Kann direkt per CLI aufgerufen oder per require eingebunden werden
|
||
if (php_sapi_name() === 'cli' && realpath(__FILE__) === realpath($_SERVER['SCRIPT_FILENAME'] ?? '')) {
|
||
require_once __DIR__ . '/../includes/db.php';
|
||
$pdo = get_db();
|
||
$result = run_migrations($pdo, true);
|
||
foreach ($result['log'] as $line) {
|
||
echo $line . PHP_EOL;
|
||
}
|
||
exit($result['errors'] ? 1 : 0);
|
||
}
|
||
|
||
/**
|
||
* Alle dem Code bekannten Migrationen (SQL-Dateien + php_00NN-Schritte in dieser Datei).
|
||
*
|
||
* @return string[]
|
||
*/
|
||
function migrations_known(): array
|
||
{
|
||
$known = [];
|
||
foreach (glob(__DIR__ . '/migrations/*.sql') ?: [] as $file) $known[basename($file)] = true;
|
||
if (preg_match_all("/'(php_\d{4}_[a-z0-9_]+)'/", (string) file_get_contents(__FILE__), $m)) {
|
||
foreach ($m[1] as $key) $known[$key] = true;
|
||
}
|
||
$names = array_keys($known);
|
||
natsort($names);
|
||
return array_values($names);
|
||
}
|
||
|
||
/**
|
||
* Migrationen, die der Code kennt, die in dieser Datenbank aber noch fehlen.
|
||
* Eine Datenbank ohne schema_migrations-Tabelle gilt als komplett veraltet.
|
||
*
|
||
* @return string[]
|
||
*/
|
||
function migrations_pending(PDO $pdo): array
|
||
{
|
||
try {
|
||
$done = $pdo->query('SELECT migration FROM schema_migrations')->fetchAll(PDO::FETCH_COLUMN);
|
||
} catch (Throwable $e) {
|
||
$done = [];
|
||
}
|
||
return array_values(array_diff(migrations_known(), $done));
|
||
}
|
||
|
||
/**
|
||
* Spielt alle ausstehenden Migrationen ein.
|
||
*
|
||
* @param PDO $pdo Aktive Datenbankverbindung
|
||
* @param bool $verbose true = gibt Meldungen zurück
|
||
* @return array{applied: int, skipped: int, errors: string[], log: string[]}
|
||
*/
|
||
function run_migrations(PDO $pdo, bool $verbose = false): array
|
||
{
|
||
$result = ['applied' => 0, 'skipped' => 0, 'errors' => [], 'log' => []];
|
||
|
||
// ── 1. schema_migrations-Tabelle sicherstellen ──────────────────────────
|
||
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
|
||
if ($driver === 'sqlite') {
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS schema_migrations (
|
||
migration VARCHAR(255) PRIMARY KEY,
|
||
applied_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||
)");
|
||
} else {
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS schema_migrations (
|
||
migration VARCHAR(255) PRIMARY KEY,
|
||
applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||
}
|
||
|
||
// ── 2. Bereits eingespielten Migrationen laden ──────────────────────────
|
||
$done = [];
|
||
foreach ($pdo->query("SELECT migration FROM schema_migrations ORDER BY migration") as $row) {
|
||
$done[$row['migration']] = true;
|
||
}
|
||
|
||
// ── 3. Migrationsdateien einlesen und sortieren ─────────────────────────
|
||
$dir = __DIR__ . '/migrations';
|
||
$files = glob($dir . '/*.sql') ?: [];
|
||
natsort($files);
|
||
|
||
foreach ($files as $file) {
|
||
$name = basename($file);
|
||
|
||
if (isset($done[$name])) {
|
||
$result['skipped']++;
|
||
if ($verbose) $result['log'][] = " [skip] $name";
|
||
continue;
|
||
}
|
||
|
||
$sql = file_get_contents($file);
|
||
if ($sql === false) {
|
||
$result['errors'][] = "Datei nicht lesbar: $name";
|
||
continue;
|
||
}
|
||
|
||
// Vollzeilige Kommentare vor dem Split entfernen, damit ein vorangestellter
|
||
// Kommentar nicht zusammen mit dem ersten Statement verworfen wird.
|
||
$sql = preg_replace('/^\s*--.*$/m', '', $sql);
|
||
$statements = array_filter(
|
||
array_map('trim', explode(';', $sql)),
|
||
fn($s) => $s !== '' && !preg_match('/^--/', $s)
|
||
);
|
||
|
||
try {
|
||
$pdo->beginTransaction();
|
||
foreach ($statements as $stmt) {
|
||
if (trim($stmt) === '') continue;
|
||
$pdo->exec($stmt);
|
||
}
|
||
// Als erledigt markieren
|
||
$pdo->prepare("INSERT INTO schema_migrations (migration) VALUES (?)")->execute([$name]);
|
||
$pdo->commit();
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = " [OK] $name";
|
||
} catch (Throwable $e) {
|
||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||
$result['errors'][] = "$name: " . $e->getMessage();
|
||
if ($verbose) $result['log'][] = " [FAIL] $name – " . $e->getMessage();
|
||
// Fehler in einer Migration → restliche überspringen (Reihenfolge!)
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Sondermigration: 0002 wird per PHP ausgeführt (cross-DB-kompatibel)
|
||
if (!isset($done['php_0002_fix_sla_dates'])) {
|
||
try {
|
||
apply_migration_0002($pdo);
|
||
$pdo->prepare("INSERT INTO schema_migrations (migration) VALUES (?)")
|
||
->execute(['php_0002_fix_sla_dates']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0002_fix_sla_dates';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0002_fix_sla_dates: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0002_fix_sla_dates - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Sondermigration 0003
|
||
if (!isset($done['php_0003_privacy_settings'])) {
|
||
try {
|
||
apply_migration_0003($pdo);
|
||
$pdo->prepare("INSERT INTO schema_migrations (migration) VALUES (?)")->execute(['php_0003_privacy_settings']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0003_privacy_settings';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0003_privacy_settings: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0003_privacy_settings - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0007: escalation_rules
|
||
if (!isset($done['php_0007_escalation_rules'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS escalation_rules (
|
||
id $ai,
|
||
name VARCHAR(150) NOT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||
trigger_type VARCHAR(30) NOT NULL DEFAULT 'no_reply',
|
||
trigger_hours INT NOT NULL DEFAULT 24,
|
||
condition_status VARCHAR(50) NULL,
|
||
condition_priority_id INT NULL,
|
||
condition_dept_id INT NULL,
|
||
action_set_priority_id INT NULL,
|
||
action_assign_staff_id INT NULL,
|
||
action_set_status VARCHAR(20) NULL,
|
||
action_notify_admin TINYINT(1) NOT NULL DEFAULT 1,
|
||
action_notify_staff TINYINT(1) NOT NULL DEFAULT 0,
|
||
sort_order INT NOT NULL DEFAULT 0,
|
||
created_at DATETIME NOT NULL
|
||
)$eng");
|
||
// Protokoll: wann wurde welche Regel auf welches Ticket angewendet
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS escalation_log (
|
||
id $ai,
|
||
rule_id INT NOT NULL,
|
||
ticket_id INT NOT NULL,
|
||
applied_at DATETIME NOT NULL
|
||
)$eng");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0007_escalation_rules']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0007_escalation_rules';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0007_escalation_rules: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0007_escalation_rules - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0006: staff_availability + staff_shifts
|
||
if (!isset($done['php_0006_availability'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$now = $drv === 'sqlite' ? "(datetime('now'))" : 'CURRENT_TIMESTAMP';
|
||
|
||
// Echtzeit-Status pro Mitarbeiter
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS staff_availability (
|
||
id $ai,
|
||
staff_id INT NOT NULL UNIQUE,
|
||
status VARCHAR(20) NOT NULL DEFAULT 'available',
|
||
note VARCHAR(120) NULL,
|
||
updated_at DATETIME NOT NULL DEFAULT $now
|
||
)$eng");
|
||
|
||
// Wochenschicht-Plan pro Mitarbeiter
|
||
$timeType = $drv === 'sqlite' ? 'TEXT' : 'TIME';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS staff_shifts (
|
||
id $ai,
|
||
staff_id INT NOT NULL,
|
||
weekday TINYINT NOT NULL,
|
||
start_time $timeType NOT NULL,
|
||
end_time $timeType NOT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||
)$eng");
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0006_availability']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0006_availability';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0006_availability: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0006_availability - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0005: push_subscriptions
|
||
if (!isset($done['php_0005_push_subscriptions'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
if ($drv === 'sqlite') {
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
staff_id INT NOT NULL,
|
||
endpoint TEXT NOT NULL,
|
||
p256dh TEXT NOT NULL,
|
||
auth TEXT NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT (datetime(\'now\')),
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
)');
|
||
} else {
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
staff_id INT NOT NULL,
|
||
endpoint TEXT NOT NULL,
|
||
p256dh TEXT NOT NULL,
|
||
auth TEXT NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0005_push_subscriptions']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0005_push_subscriptions';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0005_push_subscriptions: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0005_push_subscriptions - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0004: ticket_thread_comments
|
||
if (!isset($done['php_0004_thread_comments'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
if ($drv === 'sqlite') {
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS ticket_thread_comments (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
thread_id INT NOT NULL,
|
||
staff_id INT NOT NULL,
|
||
message TEXT NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT (datetime(\'now\')),
|
||
FOREIGN KEY (thread_id) REFERENCES ticket_threads(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
)');
|
||
} else {
|
||
$pdo->exec('CREATE TABLE IF NOT EXISTS ticket_thread_comments (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
thread_id INT NOT NULL,
|
||
staff_id INT NOT NULL,
|
||
message TEXT NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (thread_id) REFERENCES ticket_threads(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4');
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0004_thread_comments']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0004_thread_comments';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0004_thread_comments: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0004_thread_comments - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// ── php_0008_seed_app_key ──────────────────────────────────────────────────
|
||
// Überträgt den app_key aus config.php in die settings-Tabelle,
|
||
// damit er Updates überlebt (config.php ist zwar geschützt, aber die DB
|
||
// ist die zuverlässigere Quelle). Läuft einmalig auf bestehenden Installationen.
|
||
if (!isset($done['php_0008_seed_app_key'])) {
|
||
try {
|
||
$cfgKey = config()['app_key'] ?? '';
|
||
if ($cfgKey !== '') {
|
||
// Nur schreiben wenn in der DB noch kein Key steht
|
||
$existing = $pdo->prepare('SELECT setting_value FROM settings WHERE setting_key = ?');
|
||
$existing->execute(['app_key']);
|
||
if ($existing->fetchColumn() === false) {
|
||
$driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
if ($driver === 'sqlite') {
|
||
$pdo->prepare('INSERT OR IGNORE INTO settings (setting_key, setting_value) VALUES (?,?)')->execute(['app_key', $cfgKey]);
|
||
} else {
|
||
$pdo->prepare('INSERT IGNORE INTO settings (setting_key, setting_value) VALUES (?,?)')->execute(['app_key', $cfgKey]);
|
||
}
|
||
}
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0008_seed_app_key']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0008_seed_app_key';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0008_seed_app_key: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0008_seed_app_key - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// php_0009_mobile_api_tokens: widerrufbare Tokens fuer die native Staff-App
|
||
if (!isset($done['php_0009_mobile_api_tokens'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
if ($drv === 'sqlite') {
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS mobile_api_tokens (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
staff_id INT NOT NULL,
|
||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||
device_name VARCHAR(120) NULL,
|
||
expires_at DATETIME NOT NULL,
|
||
last_used_at DATETIME NULL,
|
||
revoked_at DATETIME NULL,
|
||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
)");
|
||
} else {
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS mobile_api_tokens (
|
||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||
staff_id INT NOT NULL,
|
||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||
device_name VARCHAR(120) NULL,
|
||
expires_at DATETIME NOT NULL,
|
||
last_used_at DATETIME NULL,
|
||
revoked_at DATETIME NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
FOREIGN KEY (staff_id) REFERENCES staff(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')
|
||
->execute(['php_0009_mobile_api_tokens']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0009_mobile_api_tokens';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0009_mobile_api_tokens: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0009_mobile_api_tokens - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Bestehende SQLite-Tabellen aus älteren Migrationen um fehlende FKs ergänzen.
|
||
if (!isset($done['php_0010_sqlite_foreign_keys'])) {
|
||
try {
|
||
repair_sqlite_foreign_keys($pdo);
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')
|
||
->execute(['php_0010_sqlite_foreign_keys']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0010_sqlite_foreign_keys';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0010_sqlite_foreign_keys: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0010_sqlite_foreign_keys - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0011: imap_accounts – mehrere IMAP-Postfächer statt einem globalen Konto
|
||
if (!isset($done['php_0011_imap_accounts'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$now = $drv === 'sqlite' ? "(datetime('now'))" : 'CURRENT_TIMESTAMP';
|
||
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS imap_accounts (
|
||
id $ai,
|
||
label VARCHAR(150) NOT NULL,
|
||
host VARCHAR(190) NOT NULL,
|
||
port INT NOT NULL DEFAULT 993,
|
||
encryption VARCHAR(10) NOT NULL DEFAULT 'ssl',
|
||
username VARCHAR(190) NOT NULL,
|
||
password_enc TEXT NOT NULL,
|
||
folder VARCHAR(190) NOT NULL DEFAULT 'INBOX',
|
||
after_fetch VARCHAR(10) NOT NULL DEFAULT 'seen',
|
||
archive_folder VARCHAR(190) NOT NULL DEFAULT 'Processed',
|
||
department_id INT NULL,
|
||
priority_id INT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||
last_run_at DATETIME NULL,
|
||
created_at DATETIME NOT NULL DEFAULT $now
|
||
)$eng");
|
||
|
||
// Bestehendes globales IMAP-Konto (aus den alten Einzel-Settings) automatisch als
|
||
// ersten Eintrag übernehmen, damit der E-Mail-Abruf nach dem Update nahtlos weiterläuft.
|
||
$existingCount = (int) $pdo->query('SELECT COUNT(*) FROM imap_accounts')->fetchColumn();
|
||
if ($existingCount === 0) {
|
||
$readSetting = function (string $key, string $default = '') use ($pdo): string {
|
||
$s = $pdo->prepare('SELECT setting_value FROM settings WHERE setting_key = ?');
|
||
$s->execute([$key]);
|
||
$v = $s->fetchColumn();
|
||
return ($v !== false && $v !== null) ? (string) $v : $default;
|
||
};
|
||
$legacyHost = $readSetting('imap_host');
|
||
if ($legacyHost !== '') {
|
||
$pdo->prepare(
|
||
"INSERT INTO imap_accounts
|
||
(label, host, port, encryption, username, password_enc, folder, after_fetch, archive_folder, department_id, priority_id, is_active, created_at)
|
||
VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL,1,$now)"
|
||
)->execute([
|
||
'Hauptpostfach',
|
||
$legacyHost,
|
||
(int) ($readSetting('imap_port', '993') ?: 993),
|
||
$readSetting('imap_ssl', 'ssl'),
|
||
$readSetting('imap_user'),
|
||
$readSetting('imap_pass'), // bereits verschlüsselt (encrypt_secret) – unverändert übernehmen
|
||
$readSetting('imap_folder', 'INBOX'),
|
||
$readSetting('imap_after_fetch', 'seen'),
|
||
$readSetting('imap_archive_folder', 'Processed'),
|
||
]);
|
||
}
|
||
}
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0011_imap_accounts']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0011_imap_accounts';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0011_imap_accounts: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0011_imap_accounts - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0012: E-Mail-Quellen pro Ticket + Spam-Kennzeichnung
|
||
if (!isset($done['php_0012_email_spam'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$now = $drv === 'sqlite' ? "(datetime('now'))" : 'CURRENT_TIMESTAMP';
|
||
|
||
// Bestehende Installationen erhalten die Spam-Kennzeichnung ohne Datenverlust.
|
||
$ticketCols = $pdo->query($drv === 'sqlite' ? "PRAGMA table_info(tickets)" : "SHOW COLUMNS FROM tickets")->fetchAll();
|
||
$hasSpam = false;
|
||
foreach ($ticketCols as $col) {
|
||
$name = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
if ($name === 'is_spam') { $hasSpam = true; break; }
|
||
}
|
||
if (!$hasSpam) {
|
||
$pdo->exec("ALTER TABLE tickets ADD COLUMN is_spam TINYINT(1) NOT NULL DEFAULT 0");
|
||
}
|
||
|
||
// Spam-/Junk-Zielordner pro IMAP-Konto.
|
||
$imapCols = $pdo->query($drv === 'sqlite' ? "PRAGMA table_info(imap_accounts)" : "SHOW COLUMNS FROM imap_accounts")->fetchAll();
|
||
$hasSpamFolder = false;
|
||
foreach ($imapCols as $col) {
|
||
$name = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
if ($name === 'spam_folder') { $hasSpamFolder = true; break; }
|
||
}
|
||
if (!$hasSpamFolder) {
|
||
$pdo->exec("ALTER TABLE imap_accounts ADD COLUMN spam_folder VARCHAR(190) NOT NULL DEFAULT 'Junk'");
|
||
}
|
||
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_email_sources (
|
||
id $ai,
|
||
ticket_id INT NOT NULL,
|
||
imap_account_id INT NOT NULL,
|
||
message_uid BIGINT NULL,
|
||
message_id VARCHAR(998) NULL,
|
||
current_folder VARCHAR(190) NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT $now
|
||
)$eng");
|
||
|
||
$pdo->prepare("INSERT INTO schema_migrations (migration) VALUES (?)")->execute(['php_0012_email_spam']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0012_email_spam';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0012_email_spam: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0012_email_spam - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0013: automatische Zeiterfassung pro Ticket (offene Arbeits-Sitzungen)
|
||
if (!isset($done['php_0013_ticket_work_sessions'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_work_sessions (
|
||
id $ai,
|
||
ticket_id INT NOT NULL,
|
||
staff_id INT NOT NULL,
|
||
started_at DATETIME NOT NULL,
|
||
last_seen_at DATETIME NOT NULL,
|
||
UNIQUE (ticket_id, staff_id)
|
||
)$eng");
|
||
|
||
// Standardmäßig aktiv, aber über Einstellungen abschaltbar.
|
||
$check = $pdo->prepare('SELECT COUNT(*) FROM settings WHERE setting_key = ?');
|
||
$check->execute(['auto_time_tracking_enabled']);
|
||
if ((int) $check->fetchColumn() === 0) {
|
||
$pdo->prepare('INSERT INTO settings (setting_key, setting_value) VALUES (?,?)')
|
||
->execute(['auto_time_tracking_enabled', '1']);
|
||
}
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0013_ticket_work_sessions']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0013_ticket_work_sessions';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0013_ticket_work_sessions: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0013_ticket_work_sessions - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0014: 'chat' als gültige tickets.source ergänzen (Live-Chat-Widget).
|
||
// Auf MySQL ist source ein ENUM ohne 'chat' – bestehende SQLite-Installationen kennen
|
||
// keine ENUMs und akzeptieren den Wert ohnehin bereits, dort ist nichts zu tun.
|
||
if (!isset($done['php_0014_ticket_source_chat'])) {
|
||
try {
|
||
if ($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||
$pdo->exec("ALTER TABLE tickets MODIFY COLUMN source ENUM('web','email','phone','staff','chat') NOT NULL DEFAULT 'web'");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0014_ticket_source_chat']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0014_ticket_source_chat';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0014_ticket_source_chat: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0014_ticket_source_chat - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0015: Tippt-gerade-Indikator für den Live-Chat (Kunden-Widget +
|
||
// Mitarbeiter-Postfach) – zwei einfache Zeitstempel-Spalten statt einer eigenen
|
||
// Tabelle, da nur "gilt bis wann" pro Ticket gebraucht wird (siehe live_chat.php
|
||
// Aktion "typing").
|
||
if (!isset($done['php_0015_ticket_typing'])) {
|
||
try {
|
||
$pdo->exec('ALTER TABLE tickets ADD COLUMN typing_staff_until DATETIME NULL');
|
||
$pdo->exec('ALTER TABLE tickets ADD COLUMN typing_customer_until DATETIME NULL');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0015_ticket_typing']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0015_ticket_typing';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0015_ticket_typing: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0015_ticket_typing - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0016: 'auto_reply' als gültige ticket_rule_actions.action_type ergänzen
|
||
// (Ticket-Regeln → automatisch einen Textbaustein als Antwort senden). Auf MySQL ist
|
||
// action_type ein ENUM ohne 'auto_reply' – SQLite kennt keine ENUMs, dort ist nichts zu tun.
|
||
if (!isset($done['php_0016_rule_auto_reply'])) {
|
||
try {
|
||
if ($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||
$pdo->exec("ALTER TABLE ticket_rule_actions MODIFY COLUMN action_type
|
||
ENUM('set_department','set_priority','set_help_topic','assign_staff','add_tag','reject','auto_reply') NOT NULL");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0016_rule_auto_reply']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0016_rule_auto_reply';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0016_rule_auto_reply: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0016_rule_auto_reply - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0017: verschlüsselte, widerrufbare Tokens für direkte Ticket-Links in Kunden-E-Mails.
|
||
if (!isset($done['php_0017_ticket_access_tokens'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$now = $drv === 'sqlite' ? "(datetime('now'))" : 'CURRENT_TIMESTAMP';
|
||
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_access_tokens (
|
||
id $ai,
|
||
ticket_id INT NOT NULL,
|
||
token_hash VARCHAR(64) NOT NULL UNIQUE,
|
||
token_enc TEXT NOT NULL,
|
||
revoked_at DATETIME NULL,
|
||
created_at DATETIME NOT NULL DEFAULT $now,
|
||
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE
|
||
)$eng");
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')
|
||
->execute(['php_0017_ticket_access_tokens']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0017_ticket_access_tokens';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0017_ticket_access_tokens: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0017_ticket_access_tokens - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0018: IMAP-Postfach direkt an eine E-Mail-Adresse knüpfen (osTicket-Stil:
|
||
// Konto + entferntes Postfach in einem Formular). Bestehende Postfächer werden, falls der
|
||
// Benutzername einer hinterlegten Adresse entspricht, automatisch verknüpft.
|
||
if (!isset($done['php_0018_imap_email_link'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$imapCols = $pdo->query($drv === 'sqlite' ? "PRAGMA table_info(imap_accounts)" : "SHOW COLUMNS FROM imap_accounts")->fetchAll();
|
||
$hasLink = false;
|
||
foreach ($imapCols as $col) {
|
||
$name = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
if ($name === 'email_address_id') { $hasLink = true; break; }
|
||
}
|
||
if (!$hasLink) {
|
||
$pdo->exec('ALTER TABLE imap_accounts ADD COLUMN email_address_id INT NULL');
|
||
}
|
||
$pdo->exec(
|
||
'UPDATE imap_accounts SET email_address_id = (
|
||
SELECT ea.id FROM email_addresses ea WHERE LOWER(ea.email) = LOWER(imap_accounts.username) LIMIT 1
|
||
) WHERE email_address_id IS NULL'
|
||
);
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0018_imap_email_link']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0018_imap_email_link';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0018_imap_email_link: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0018_imap_email_link - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0019: Hilfethemen im osTicket-Stil – Typ (öffentlich/intern), übergeordnetes Thema,
|
||
// interne Notizen sowie Erstellt-/Aktualisiert-Zeitpunkt.
|
||
if (!isset($done['php_0019_help_topic_options'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(help_topics)" : "SHOW COLUMNS FROM help_topics")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('is_public', $cols, true)) $pdo->exec("ALTER TABLE help_topics ADD COLUMN is_public TINYINT(1) NOT NULL DEFAULT 1");
|
||
if (!in_array('parent_id', $cols, true)) $pdo->exec("ALTER TABLE help_topics ADD COLUMN parent_id INT NULL");
|
||
if (!in_array('notes', $cols, true)) $pdo->exec("ALTER TABLE help_topics ADD COLUMN notes TEXT NULL");
|
||
if (!in_array('created_at', $cols, true)) $pdo->exec("ALTER TABLE help_topics ADD COLUMN created_at DATETIME NULL");
|
||
if (!in_array('updated_at', $cols, true)) $pdo->exec("ALTER TABLE help_topics ADD COLUMN updated_at DATETIME NULL");
|
||
$now = db_now();
|
||
$pdo->prepare('UPDATE help_topics SET created_at = ? WHERE created_at IS NULL')->execute([$now]);
|
||
$pdo->prepare('UPDATE help_topics SET updated_at = ? WHERE updated_at IS NULL')->execute([$now]);
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0019_help_topic_options']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0019_help_topic_options';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0019_help_topic_options: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0019_help_topic_options - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0020: Abteilungen im osTicket-Stil – Status, Abteilungsleiter, Admin-Notizen, Änderungszeitpunkt.
|
||
if (!isset($done['php_0020_department_options'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(departments)" : "SHOW COLUMNS FROM departments")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('is_active', $cols, true)) $pdo->exec("ALTER TABLE departments ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1");
|
||
if (!in_array('manager_id', $cols, true)) $pdo->exec("ALTER TABLE departments ADD COLUMN manager_id INT NULL");
|
||
if (!in_array('notes', $cols, true)) $pdo->exec("ALTER TABLE departments ADD COLUMN notes TEXT NULL");
|
||
if (!in_array('updated_at', $cols, true)) $pdo->exec("ALTER TABLE departments ADD COLUMN updated_at DATETIME NULL");
|
||
$pdo->prepare('UPDATE departments SET updated_at = COALESCE(created_at, ?) WHERE updated_at IS NULL')->execute([db_now()]);
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0020_department_options']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0020_department_options';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0020_department_options: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0020_department_options - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0021: E-Mail-Adressen im osTicket-Stil – Status, Notizen, Hilfethema,
|
||
// eigener SMTP-Server pro Adresse (Reiter "Abgehend") und Änderungszeitpunkt.
|
||
if (!isset($done['php_0021_email_address_options'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(email_addresses)" : "SHOW COLUMNS FROM email_addresses")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
$add = [
|
||
'is_active' => "TINYINT(1) NOT NULL DEFAULT 1",
|
||
'notes' => "TEXT NULL",
|
||
'help_topic_id' => "INT NULL",
|
||
'smtp_enabled' => "TINYINT(1) NOT NULL DEFAULT 0",
|
||
'smtp_host' => "VARCHAR(190) NULL",
|
||
'smtp_port' => "INT NOT NULL DEFAULT 587",
|
||
'smtp_encryption' => "VARCHAR(10) NOT NULL DEFAULT 'tls'",
|
||
'smtp_username' => "VARCHAR(190) NULL",
|
||
'smtp_password_enc' => "TEXT NULL",
|
||
'updated_at' => "DATETIME NULL",
|
||
];
|
||
foreach ($add as $name => $def) {
|
||
if (!in_array($name, $cols, true)) $pdo->exec("ALTER TABLE email_addresses ADD COLUMN {$name} {$def}");
|
||
}
|
||
$pdo->prepare('UPDATE email_addresses SET updated_at = COALESCE(created_at, ?) WHERE updated_at IS NULL')->execute([db_now()]);
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0021_email_address_options']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0021_email_address_options';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0021_email_address_options: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0021_email_address_options - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0022: Teams im osTicket-Stil – Änderungszeitpunkt und "Benachrichtigung bei Ticketzuweisung deaktivieren".
|
||
if (!isset($done['php_0022_team_options'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(teams)" : "SHOW COLUMNS FROM teams")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('no_alerts', $cols, true)) $pdo->exec("ALTER TABLE teams ADD COLUMN no_alerts TINYINT(1) NOT NULL DEFAULT 0");
|
||
if (!in_array('updated_at', $cols, true)) $pdo->exec("ALTER TABLE teams ADD COLUMN updated_at DATETIME NULL");
|
||
$pdo->prepare('UPDATE teams SET updated_at = COALESCE(created_at, ?) WHERE updated_at IS NULL')->execute([db_now()]);
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0022_team_options']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0022_team_options';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0022_team_options: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0022_team_options - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0023: Rolle pro Abteilung (erweiterter Zugriff). NULL = Hauptrolle des Mitarbeiters.
|
||
if (!isset($done['php_0023_department_roles'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(staff_department)" : "SHOW COLUMNS FROM staff_department")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('role_id', $cols, true)) $pdo->exec("ALTER TABLE staff_department ADD COLUMN role_id INT NULL");
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0023_department_roles']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0023_department_roles';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0023_department_roles: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0023_department_roles - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0024: Rollen, Prioritäten/SLA, Listen und Seiten im osTicket-Stil – Status, Notizen, Zeitstempel.
|
||
if (!isset($done['php_0024_admin_list_options'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$colsOf = function (string $table) use ($pdo, $drv): array {
|
||
$out = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info($table)" : "SHOW COLUMNS FROM $table")->fetchAll() as $col) {
|
||
$out[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
return $out;
|
||
};
|
||
$plan = [
|
||
'roles' => ['is_active' => 'TINYINT(1) NOT NULL DEFAULT 1', 'notes' => 'TEXT NULL', 'updated_at' => 'DATETIME NULL'],
|
||
'priorities' => ['notes' => 'TEXT NULL', 'created_at' => 'DATETIME NULL', 'updated_at' => 'DATETIME NULL'],
|
||
'custom_lists' => ['notes' => 'TEXT NULL', 'updated_at' => 'DATETIME NULL'],
|
||
'site_pages' => ['updated_at' => 'DATETIME NULL'],
|
||
];
|
||
$now = db_now();
|
||
foreach ($plan as $table => $adds) {
|
||
$have = $colsOf($table);
|
||
foreach ($adds as $name => $def) {
|
||
if (!in_array($name, $have, true)) $pdo->exec("ALTER TABLE {$table} ADD COLUMN {$name} {$def}");
|
||
}
|
||
if (isset($adds['updated_at'])) {
|
||
$createdCol = in_array('created_at', $colsOf($table), true) ? 'COALESCE(created_at, ?)' : '?';
|
||
$pdo->prepare("UPDATE {$table} SET updated_at = {$createdCol} WHERE updated_at IS NULL")->execute([$now]);
|
||
}
|
||
if (isset($adds['created_at'])) $pdo->prepare("UPDATE {$table} SET created_at = ? WHERE created_at IS NULL")->execute([$now]);
|
||
}
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0024_admin_list_options']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0024_admin_list_options';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0024_admin_list_options: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0024_admin_list_options - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0025: Ticket-Regeln ("Filter") – Administratornotizen und Änderungszeitpunkt.
|
||
if (!isset($done['php_0025_rule_options'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(ticket_rules)" : "SHOW COLUMNS FROM ticket_rules")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('notes', $cols, true)) $pdo->exec("ALTER TABLE ticket_rules ADD COLUMN notes TEXT NULL");
|
||
if (!in_array('updated_at', $cols, true)) $pdo->exec("ALTER TABLE ticket_rules ADD COLUMN updated_at DATETIME NULL");
|
||
$pdo->prepare('UPDATE ticket_rules SET updated_at = COALESCE(created_at, ?) WHERE updated_at IS NULL')->execute([db_now()]);
|
||
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0025_rule_options']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0025_rule_options';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0025_rule_options: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0025_rule_options - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0026: Überwachung des Mail-Abrufs – Status je Postfach (letzter Erfolg, Fehler, Fehlerzähler, Alarmzeit).
|
||
if (!isset($done['php_0026_mailbox_health'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(imap_accounts)" : "SHOW COLUMNS FROM imap_accounts")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
$add = ['last_success_at' => 'DATETIME NULL', 'last_error' => 'TEXT NULL', 'fail_count' => 'INT NOT NULL DEFAULT 0', 'alert_sent_at' => 'DATETIME NULL'];
|
||
foreach ($add as $name => $def) {
|
||
if (!in_array($name, $cols, true)) $pdo->exec("ALTER TABLE imap_accounts ADD COLUMN {$name} {$def}");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0026_mailbox_health']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0026_mailbox_health';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0026_mailbox_health: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0026_mailbox_health - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0027: benannte SLA-Pläne, zuordenbar je Abteilung und Hilfethema.
|
||
if (!isset($done['php_0027_sla_plans'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS sla_plans (
|
||
id $ai,
|
||
name VARCHAR(120) NOT NULL,
|
||
response_hours INT NOT NULL DEFAULT 24,
|
||
resolution_hours INT NOT NULL DEFAULT 72,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||
notes TEXT NULL,
|
||
created_at DATETIME NULL,
|
||
updated_at DATETIME NULL
|
||
)$eng");
|
||
foreach (['departments', 'help_topics'] as $table) {
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info($table)" : "SHOW COLUMNS FROM $table")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('sla_plan_id', $cols, true)) $pdo->exec("ALTER TABLE {$table} ADD COLUMN sla_plan_id INT NULL");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0027_sla_plans']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0027_sla_plans';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0027_sla_plans: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0027_sla_plans - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0028: gespeicherte Ansichten – teilbar mit allen Mitarbeitern, Änderungszeitpunkt.
|
||
if (!isset($done['php_0028_shared_queues'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(saved_queues)" : "SHOW COLUMNS FROM saved_queues")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('is_shared', $cols, true)) $pdo->exec("ALTER TABLE saved_queues ADD COLUMN is_shared TINYINT(1) NOT NULL DEFAULT 0");
|
||
if (!in_array('updated_at', $cols, true)) $pdo->exec("ALTER TABLE saved_queues ADD COLUMN updated_at DATETIME NULL");
|
||
$pdo->prepare('UPDATE saved_queues SET updated_at = COALESCE(created_at, ?) WHERE updated_at IS NULL')->execute([db_now()]);
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0028_shared_queues']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0028_shared_queues';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0028_shared_queues: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0028_shared_queues - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0029: Schnittstellen – API-Schlüssel für die öffentliche Ticket-API und Webhooks.
|
||
if (!isset($done['php_0029_integrations'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
// MySQL: tickets.source ist ein ENUM – Quelle "api" ergänzen (SQLite kennt keine ENUMs)
|
||
if ($drv !== 'sqlite') {
|
||
$pdo->exec("ALTER TABLE tickets MODIFY COLUMN source ENUM('web','email','phone','staff','chat','api') NOT NULL DEFAULT 'web'");
|
||
}
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS api_keys (
|
||
id $ai,
|
||
name VARCHAR(120) NOT NULL,
|
||
key_hash VARCHAR(64) NOT NULL UNIQUE,
|
||
key_prefix VARCHAR(16) NOT NULL,
|
||
can_create_tickets TINYINT(1) NOT NULL DEFAULT 1,
|
||
can_read_tickets TINYINT(1) NOT NULL DEFAULT 0,
|
||
ip_whitelist TEXT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||
last_used_at DATETIME NULL,
|
||
created_by INT NULL,
|
||
created_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS webhooks (
|
||
id $ai,
|
||
name VARCHAR(120) NOT NULL,
|
||
url VARCHAR(500) NOT NULL,
|
||
secret_enc TEXT NOT NULL,
|
||
events VARCHAR(255) NOT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||
last_status INT NULL,
|
||
last_error VARCHAR(300) NULL,
|
||
last_delivery_at DATETIME NULL,
|
||
fail_count INT NOT NULL DEFAULT 0,
|
||
created_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0029_integrations']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0029_integrations';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0029_integrations: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0029_integrations - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0030: gespeicherte Berichte (Bericht-Baukasten).
|
||
if (!isset($done['php_0030_saved_reports'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS saved_reports (
|
||
id $ai,
|
||
staff_id INT NOT NULL,
|
||
name VARCHAR(120) NOT NULL,
|
||
config TEXT NOT NULL,
|
||
is_shared TINYINT(1) NOT NULL DEFAULT 0,
|
||
created_at DATETIME NULL,
|
||
updated_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0030_saved_reports']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0030_saved_reports';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0030_saved_reports: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0030_saved_reports - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0031: Plugin-Verwaltung (installierte Plugins und ihr Zustand).
|
||
if (!isset($done['php_0031_plugins'])) {
|
||
try {
|
||
$eng = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS plugins (
|
||
slug VARCHAR(60) PRIMARY KEY,
|
||
name VARCHAR(120) NOT NULL,
|
||
version VARCHAR(30) NOT NULL,
|
||
is_active TINYINT(1) NOT NULL DEFAULT 0,
|
||
fail_count INT NOT NULL DEFAULT 0,
|
||
last_error VARCHAR(500) NULL,
|
||
package_hash VARCHAR(64) NULL,
|
||
installed_at DATETIME NULL,
|
||
updated_at DATETIME NULL
|
||
)$eng");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0031_plugins']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0031_plugins';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0031_plugins: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0031_plugins - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0032: Plugins – bestätigte Berechtigungen, Signaturstatus und Herkunft.
|
||
if (!isset($done['php_0032_plugin_trust'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(plugins)" : "SHOW COLUMNS FROM plugins")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('approved_permissions', $cols, true)) $pdo->exec("ALTER TABLE plugins ADD COLUMN approved_permissions TEXT NULL");
|
||
if (!in_array('signature_ok', $cols, true)) $pdo->exec("ALTER TABLE plugins ADD COLUMN signature_ok TINYINT(1) NOT NULL DEFAULT 0");
|
||
if (!in_array('source', $cols, true)) $pdo->exec("ALTER TABLE plugins ADD COLUMN source VARCHAR(20) NULL");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0032_plugin_trust']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0032_plugin_trust';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0032_plugin_trust: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0032_plugin_trust - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0033: Eigenes Ticket-Formular pro Abteilung (Felder + Texte, unabhängig vom Standardformular).
|
||
if (!isset($done['php_0033_department_forms'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$colsOf = function (string $table) use ($pdo, $drv): array {
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info($table)" : "SHOW COLUMNS FROM $table")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
return $cols;
|
||
};
|
||
if (!in_array('department_id', $colsOf('custom_fields'), true)) $pdo->exec("ALTER TABLE custom_fields ADD COLUMN department_id INT NULL");
|
||
$dc = $colsOf('departments');
|
||
if (!in_array('own_form', $dc, true)) $pdo->exec("ALTER TABLE departments ADD COLUMN own_form TINYINT(1) NOT NULL DEFAULT 0");
|
||
if (!in_array('form_config', $dc, true)) $pdo->exec("ALTER TABLE departments ADD COLUMN form_config TEXT NULL");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0033_department_forms']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0033_department_forms';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0033_department_forms: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0033_department_forms - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0034: Abschnitt eines Abteilungsfeldes im Ticket-Formular (Kontaktdaten, Anliegen, eigene Abschnitte).
|
||
if (!isset($done['php_0034_field_sections'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(custom_fields)" : "SHOW COLUMNS FROM custom_fields")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('form_section', $cols, true)) $pdo->exec("ALTER TABLE custom_fields ADD COLUMN form_section VARCHAR(100) NULL");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0034_field_sections']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0034_field_sections';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0034_field_sections: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0034_field_sections - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0035: Feldtyp "Radio-Auswahl" (MySQL: ENUM erweitern; SQLite kennt keine ENUMs).
|
||
if (!isset($done['php_0035_field_type_radio'])) {
|
||
try {
|
||
if ($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql') {
|
||
$pdo->exec("ALTER TABLE custom_fields MODIFY COLUMN field_type ENUM('text','textarea','select','checkbox','number','date','list','radio') NOT NULL DEFAULT 'text'");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0035_field_type_radio']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0035_field_type_radio';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0035_field_type_radio: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0035_field_type_radio - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0036: Breite eines Formularfeldes (volle Breite, Hälfte, Drittel, zwei Drittel).
|
||
if (!isset($done['php_0036_field_width'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(custom_fields)" : "SHOW COLUMNS FROM custom_fields")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('form_width', $cols, true)) $pdo->exec("ALTER TABLE custom_fields ADD COLUMN form_width VARCHAR(10) NULL");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0036_field_width']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0036_field_width';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0036_field_width: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0036_field_width - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0037: Anordnung der Radio-Optionen (nebeneinander oder untereinander).
|
||
if (!isset($done['php_0037_radio_inline'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(custom_fields)" : "SHOW COLUMNS FROM custom_fields")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('radio_inline', $cols, true)) $pdo->exec("ALTER TABLE custom_fields ADD COLUMN radio_inline TINYINT(1) NOT NULL DEFAULT 0");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0037_radio_inline']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0037_radio_inline';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0037_radio_inline: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0037_radio_inline - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0038: Hinweistext (mit Links) zu einem Formularfeld, z. B. für die Zustimmung zu Nutzerbedingungen.
|
||
if (!isset($done['php_0038_field_help_text'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$cols = [];
|
||
foreach ($pdo->query($drv === 'sqlite' ? "PRAGMA table_info(custom_fields)" : "SHOW COLUMNS FROM custom_fields")->fetchAll() as $col) {
|
||
$cols[] = $drv === 'sqlite' ? ($col['name'] ?? '') : ($col['Field'] ?? '');
|
||
}
|
||
if (!in_array('help_text', $cols, true)) $pdo->exec("ALTER TABLE custom_fields ADD COLUMN help_text TEXT NULL");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0038_field_help_text']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0038_field_help_text';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0038_field_help_text: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0038_field_help_text - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0039: Regel-Aktion "Ticket schließen" (MySQL: ENUM erweitern; SQLite kennt keine ENUMs).
|
||
if (!isset($done['php_0039_rule_close'])) {
|
||
try {
|
||
if ($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'mysql') {
|
||
$pdo->exec("ALTER TABLE ticket_rule_actions MODIFY COLUMN action_type
|
||
ENUM('set_department','set_priority','set_help_topic','assign_staff','add_tag','reject','auto_reply','close') NOT NULL");
|
||
}
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0039_rule_close']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0039_rule_close';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0039_rule_close: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0039_rule_close - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0040: Geräte der Android-App für Push-Benachrichtigungen (Firebase Cloud Messaging).
|
||
if (!isset($done['php_0040_push_devices'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS push_devices (
|
||
id $ai,
|
||
staff_id INT NOT NULL,
|
||
token TEXT NOT NULL,
|
||
token_hash CHAR(64) NOT NULL,
|
||
platform VARCHAR(20) NOT NULL DEFAULT 'android',
|
||
device_name VARCHAR(120) NULL,
|
||
app_version VARCHAR(30) NULL,
|
||
created_at DATETIME NULL,
|
||
last_seen_at DATETIME NULL,
|
||
UNIQUE (token_hash)
|
||
)$eng");
|
||
$pdo->exec('CREATE INDEX idx_push_devices_staff ON push_devices (staff_id)');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0040_push_devices']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0040_push_devices';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0040_push_devices: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0040_push_devices - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0041: Push-Einstellungen je Mitarbeiter (welche Popups, stille Zeiten).
|
||
if (!isset($done['php_0041_push_prefs'])) {
|
||
try {
|
||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||
$pdo->exec("CREATE TABLE IF NOT EXISTS push_prefs (
|
||
staff_id INT NOT NULL PRIMARY KEY,
|
||
events VARCHAR(300) NOT NULL,
|
||
quiet_from CHAR(5) NULL,
|
||
quiet_to CHAR(5) NULL
|
||
)$eng");
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0041_push_prefs']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0041_push_prefs';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0041_push_prefs: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0041_push_prefs - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0042: Aktivität der Mitarbeiter + automatischer Offline-Status.
|
||
if (!isset($done['php_0042_staff_activity'])) {
|
||
try {
|
||
$has = function (string $table, string $col) use ($pdo): bool {
|
||
try { $pdo->query("SELECT $col FROM $table LIMIT 1"); return true; } catch (Throwable $e) { return false; }
|
||
};
|
||
if (!$has('staff', 'last_active_at')) $pdo->exec('ALTER TABLE staff ADD COLUMN last_active_at DATETIME NULL');
|
||
if (!$has('staff_availability', 'auto_offline')) $pdo->exec('ALTER TABLE staff_availability ADD COLUMN auto_offline TINYINT NOT NULL DEFAULT 0');
|
||
if (!$has('staff_availability', 'prev_status')) $pdo->exec('ALTER TABLE staff_availability ADD COLUMN prev_status VARCHAR(20) NULL');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0042_staff_activity']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0042_staff_activity';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0042_staff_activity: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0042_staff_activity - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
// Migration 0043: Abteilungen können neue Tickets automatisch zuweisen (an einen Mitarbeiter oder reihum).
|
||
if (!isset($done['php_0043_department_auto_assign'])) {
|
||
try {
|
||
$has = function (string $col) use ($pdo): bool {
|
||
try { $pdo->query("SELECT $col FROM departments LIMIT 1"); return true; } catch (Throwable $e) { return false; }
|
||
};
|
||
if (!$has('auto_assign_mode')) $pdo->exec("ALTER TABLE departments ADD COLUMN auto_assign_mode VARCHAR(20) NOT NULL DEFAULT 'none'");
|
||
if (!$has('auto_assign_staff_id')) $pdo->exec('ALTER TABLE departments ADD COLUMN auto_assign_staff_id INT NULL');
|
||
if (!$has('auto_assign_last_id')) $pdo->exec('ALTER TABLE departments ADD COLUMN auto_assign_last_id INT NULL');
|
||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0043_department_auto_assign']);
|
||
$result['applied']++;
|
||
if ($verbose) $result['log'][] = ' [OK] php_0043_department_auto_assign';
|
||
} catch (Throwable $e) {
|
||
$result['errors'][] = 'php_0043_department_auto_assign: ' . $e->getMessage();
|
||
if ($verbose) $result['log'][] = ' [FAIL] php_0043_department_auto_assign - ' . $e->getMessage();
|
||
}
|
||
}
|
||
|
||
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']]);
|
||
}
|
||
}
|