pdo = $pdo; $this->prefix = $prefix; $this->moduleBasePath = rtrim($moduleBasePath, '/'); $this->driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); $this->sqlImporter = new SqlDataImporter($this->pdo); } public function ensureSchema(): void { $status = $this->schemaStatus(); if ($status['all_present']) { return; } if ($status['present_count'] === 0) { $this->importSchema(); return; } throw new ApiException( 'Mining-Checker Tabellen sind nur teilweise vorhanden. Bitte Migration manuell abschliessen.', 500, [ 'missing_tables' => $status['missing_tables'], 'present_tables' => $status['present_tables'], ] ); } public function initializeSchema(bool $dropExisting = false): array { $before = $this->schemaStatus(); $droppedTables = []; if ($dropExisting && $before['present_count'] > 0) { $droppedTables = $this->dropExistingTables(); } $statusBeforeImport = $this->schemaStatus(); $imported = false; if (!$statusBeforeImport['all_present']) { if ($statusBeforeImport['present_count'] > 0 && !$dropExisting) { throw new ApiException( 'Mining-Checker Tabellen sind nur teilweise vorhanden. Fuer eine Neuinitialisierung bitte Reset aktivieren.', 409, [ 'missing_tables' => $statusBeforeImport['missing_tables'], 'present_tables' => $statusBeforeImport['present_tables'], ] ); } $this->importSchema(); $imported = true; } $after = $this->schemaStatus(); return [ 'dropped_existing' => $dropExisting, 'dropped_tables' => $droppedTables, 'schema_imported' => $imported, 'before' => $before, 'after' => $after, 'message' => $after['all_present'] ? ($imported ? 'Mining-Checker Schema wurde erfolgreich angelegt.' : 'Mining-Checker Tabellen waren bereits vollstaendig vorhanden.') : 'Mining-Checker Schema ist weiterhin unvollstaendig.', ]; } public function rebuildSchemaDirect(): array { $dropped = []; foreach ($this->knownTablesInDropOrder() as $table) { try { if ($this->driver === 'pgsql') { $this->pdo->exec('DROP TABLE IF EXISTS ' . $table . ' CASCADE'); } else { $safeTable = str_replace('`', '``', $table); $this->pdo->exec('DROP TABLE IF EXISTS `' . $safeTable . '`'); } $dropped[] = $table; } catch (\Throwable $exception) { throw new ApiException( 'Mining-Checker Tabellen konnten nicht direkt geloescht werden.', 500, ['message' => $exception->getMessage(), 'table' => $table] ); } } $this->importSchema(); return [ 'dropped_tables' => $dropped, 'message' => 'Mining-Checker Tabellen wurden geloescht und das Schema neu aufgebaut.', ]; } public function schemaStatus(): array { $requiredTables = $this->knownTablesInCreateOrder(); $presentTables = $this->existingTables($requiredTables); $missingTables = array_values(array_diff($requiredTables, $presentTables)); $pendingUpgrades = []; if ($missingTables === []) { $pendingUpgrades = $this->detectPendingUpgrades($presentTables); } return [ 'required_tables' => $requiredTables, 'present_tables' => $presentTables, 'missing_tables' => $missingTables, 'present_count' => count($presentTables), 'missing_count' => count($missingTables), 'pending_upgrades' => $pendingUpgrades, 'pending_upgrade_count' => count($pendingUpgrades), 'all_present' => $missingTables === [], ]; } public function upgradeSchema(): array { $before = $this->lightweightStatus(); if ($before['present_count'] === 0) { $this->importSchema(); $after = $this->lightweightStatus(); return [ 'upgraded' => ['schema_initialized'], 'before' => $before, 'after' => $after, 'message' => 'Mining-Checker Schema war leer und wurde initial angelegt.', ]; } if (!$before['core_present']) { throw new ApiException( 'Schema-Upgrade ist nur moeglich, wenn alle Grundtabellen vorhanden sind. Bitte zuerst initialisieren oder resetten.', 409, [ 'missing_tables' => $before['missing_core_tables'], 'present_tables' => $before['present_tables'], ] ); } $applied = []; if ($this->tableExists($this->prefix . 'cost_plans')) { $requiredColumns = ['mining_speed_value', 'mining_speed_unit', 'bonus_speed_value', 'bonus_speed_unit', 'base_price_amount', 'payment_type']; $existingColumns = $this->existingColumns($this->prefix . 'cost_plans', $requiredColumns); if (count($existingColumns) !== count($requiredColumns)) { $this->upgradeCostPlanColumns(); $applied[] = 'cost_plan_columns'; } } $settingsColumns = $this->existingColumns($this->prefix . 'settings', ['preferred_currencies', 'report_currency', 'crypto_currency', 'display_timezone', 'fx_max_age_hours', 'module_theme_mode', 'module_theme_accent']); if (!in_array('preferred_currencies', $settingsColumns, true) || !in_array('report_currency', $settingsColumns, true) || !in_array('crypto_currency', $settingsColumns, true) || !in_array('display_timezone', $settingsColumns, true) || !in_array('fx_max_age_hours', $settingsColumns, true) || !in_array('module_theme_mode', $settingsColumns, true) || !in_array('module_theme_accent', $settingsColumns, true)) { $this->upgradeSettingsPreferredCurrenciesColumn(); $applied[] = 'settings_preferences'; } if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) { $this->ensureMeasurementFxReferenceColumn(); $applied[] = 'measurement_fx_reference'; } if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'coin_currency')) { $this->ensureMeasurementCoinCurrencyColumn(); $applied[] = 'measurement_coin_currency'; } if (!$this->tableExists($this->prefix . 'measurement_rates')) { $this->ensureMeasurementRatesTable(); $applied[] = 'measurement_rates_table'; } if (!$this->tableExists($this->prefix . 'payouts')) { $this->ensurePayoutsTable(); $applied[] = 'payouts_table'; } if (!$this->tableExists($this->prefix . 'wallet_snapshots')) { $this->ensureWalletSnapshotsTable(); $applied[] = 'wallet_snapshots_table'; } if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) { $this->ensureFxRatesTable(); $applied[] = 'fx_rates_table'; } if (!$this->tableExists($this->prefix . 'miner_offers')) { $this->upgradeMinerOffersTable(); $applied[] = 'miner_offers_table'; } if (!$this->tableExists($this->prefix . 'purchased_miners')) { $this->upgradePurchasedMinersTable(); $applied[] = 'purchased_miners_table'; } if ($this->tableExists($this->prefix . 'targets') && !$this->columnExists($this->prefix . 'targets', 'miner_offer_id')) { $this->upgradeTargetOfferColumn(); $applied[] = 'target_offer_column'; } if ($this->tableExists($this->prefix . 'miner_offers') && ( !$this->columnExists($this->prefix . 'miner_offers', 'base_price_amount') || !$this->columnExists($this->prefix . 'miner_offers', 'base_price_currency') || !$this->columnExists($this->prefix . 'miner_offers', 'payment_type') || !$this->columnExists($this->prefix . 'miner_offers', 'auto_renew') )) { $this->upgradeMinerOfferBasePriceColumns(); $applied[] = 'miner_offer_base_columns'; } if ($this->tableExists($this->prefix . 'purchased_miners') && ( !$this->columnExists($this->prefix . 'purchased_miners', 'reference_price_amount') || !$this->columnExists($this->prefix . 'purchased_miners', 'reference_price_currency') || !$this->columnExists($this->prefix . 'purchased_miners', 'auto_renew') )) { $this->upgradePurchasedMinerReferenceColumns(); $applied[] = 'purchased_miner_reference_columns'; } if ($this->tableExists($this->prefix . 'targets') && $this->tableExists($this->prefix . 'miner_offers')) { $this->ensureTargetOfferForeignKey(); $applied[] = 'target_offer_foreign_key'; } $after = $this->lightweightStatus(); $allApplied = array_values(array_unique($applied)); return [ 'upgraded' => $allApplied, 'before' => $before, 'after' => $after, 'message' => $allApplied === [] ? 'Schema ist bereits auf dem neuesten Stand.' : 'Schema-Upgrade erfolgreich ausgefuehrt.', ]; } public function upgradeSchemaDirect(): array { $coreTables = [ $this->prefix . 'projects', $this->prefix . 'settings', $this->prefix . 'cost_plans', $this->prefix . 'measurements', $this->prefix . 'targets', $this->prefix . 'dashboard_definitions', ]; $presentCoreTables = $this->existingTables($coreTables); if ($presentCoreTables === []) { $this->importSchema(); return [ 'upgraded' => ['schema_initialized'], 'message' => 'Mining-Checker Schema wurde neu angelegt.', ]; } if (count($presentCoreTables) !== count($coreTables)) { throw new ApiException( 'Grundtabellen sind nur teilweise vorhanden. Bitte Schema zuerst sauber initialisieren.', 409, ['missing_core_tables' => array_values(array_diff($coreTables, $presentCoreTables))] ); } $applied = []; $requiredColumns = ['mining_speed_value', 'mining_speed_unit', 'bonus_speed_value', 'bonus_speed_unit', 'base_price_amount', 'payment_type']; $existingColumns = $this->existingColumns($this->prefix . 'cost_plans', $requiredColumns); if (count($existingColumns) !== count($requiredColumns)) { $this->upgradeCostPlanColumns(); $applied[] = 'cost_plan_columns'; } $settingsColumns = $this->existingColumns($this->prefix . 'settings', ['preferred_currencies', 'report_currency', 'crypto_currency', 'display_timezone', 'fx_max_age_hours', 'module_theme_mode', 'module_theme_accent']); if (!in_array('preferred_currencies', $settingsColumns, true) || !in_array('report_currency', $settingsColumns, true) || !in_array('crypto_currency', $settingsColumns, true) || !in_array('display_timezone', $settingsColumns, true) || !in_array('fx_max_age_hours', $settingsColumns, true) || !in_array('module_theme_mode', $settingsColumns, true) || !in_array('module_theme_accent', $settingsColumns, true)) { $this->upgradeSettingsPreferredCurrenciesColumn(); $applied[] = 'settings_preferences'; } if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) { $this->ensureMeasurementFxReferenceColumn(); $applied[] = 'measurement_fx_reference'; } if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'coin_currency')) { $this->ensureMeasurementCoinCurrencyColumn(); $applied[] = 'measurement_coin_currency'; } if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) { $this->ensureFxRatesTable(); $applied[] = 'fx_rates_table'; } if (!$this->tableExists($this->prefix . 'measurement_rates')) { $this->ensureMeasurementRatesTable(); $applied[] = 'measurement_rates_table'; } if (!$this->tableExists($this->prefix . 'payouts')) { $this->ensurePayoutsTable(); $applied[] = 'payouts_table'; } if (!$this->tableExists($this->prefix . 'wallet_snapshots')) { $this->ensureWalletSnapshotsTable(); $applied[] = 'wallet_snapshots_table'; } if (!$this->tableExists($this->prefix . 'miner_offers')) { $this->upgradeMinerOffersTable(); $applied[] = 'miner_offers_table'; } if (!$this->tableExists($this->prefix . 'purchased_miners')) { $this->upgradePurchasedMinersTable(); $applied[] = 'purchased_miners_table'; } if ($this->tableExists($this->prefix . 'targets') && !$this->columnExists($this->prefix . 'targets', 'miner_offer_id')) { $this->upgradeTargetOfferColumn(); $applied[] = 'target_offer_column'; } if ($this->tableExists($this->prefix . 'miner_offers') && ( !$this->columnExists($this->prefix . 'miner_offers', 'base_price_amount') || !$this->columnExists($this->prefix . 'miner_offers', 'base_price_currency') || !$this->columnExists($this->prefix . 'miner_offers', 'payment_type') || !$this->columnExists($this->prefix . 'miner_offers', 'auto_renew') )) { $this->upgradeMinerOfferBasePriceColumns(); $applied[] = 'miner_offer_base_columns'; } if ($this->tableExists($this->prefix . 'purchased_miners') && ( !$this->columnExists($this->prefix . 'purchased_miners', 'reference_price_amount') || !$this->columnExists($this->prefix . 'purchased_miners', 'reference_price_currency') || !$this->columnExists($this->prefix . 'purchased_miners', 'auto_renew') )) { $this->upgradePurchasedMinerReferenceColumns(); $applied[] = 'purchased_miner_reference_columns'; } if ($this->tableExists($this->prefix . 'targets') && $this->tableExists($this->prefix . 'miner_offers')) { $this->ensureTargetOfferForeignKey(); $applied[] = 'target_offer_foreign_key'; } return [ 'upgraded' => $applied, 'message' => $applied === [] ? 'Schema ist bereits auf dem neuesten Stand.' : 'Direktes Schema-Upgrade erfolgreich ausgefuehrt.', ]; } public function importSqlFile(array $uploadedFile): array { try { $file = UploadedSqlFile::read($uploadedFile); } catch (\RuntimeException $exception) { throw new ApiException($exception->getMessage(), 422); } $this->prepareSchemaForSqlImport(); $statementCount = $this->executeSqlContent((string) $file['sql'], (string) $file['file']); return [ 'file' => (string) $file['file'], 'statement_count' => $statementCount, 'message' => 'SQL-Datei wurde erfolgreich eingespielt.', ]; } private function prepareSchemaForSqlImport(): void { $status = $this->schemaStatus(); if (!$status['all_present']) { $this->initializeSchema(false); } $this->upgradeSchemaDirect(); $this->ensureLegacyImportCompatibility(); } private function ensureLegacyImportCompatibility(): void { $this->ensureLegacyMinerOfferImportColumns(); } private function ensureMeasurementFxReferenceColumn(): void { $table = $this->prefix . 'measurements'; if (!$this->tableExists($table)) { return; } $statements = $this->driver === 'pgsql' ? [ 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS fx_fetch_id BIGINT', 'CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch ON ' . $table . ' (fx_fetch_id)', ] : [ 'ALTER TABLE `' . $table . '` ADD COLUMN fx_fetch_id BIGINT UNSIGNED NULL', 'ALTER TABLE `' . $table . '` ADD INDEX idx_miningcheck_measurements_fx_fetch (fx_fetch_id)', ]; foreach ($statements as $statement) { try { $this->executeUpgradeStatements([$statement], 'Messpunkt-FX-Referenz konnte nicht angelegt werden.'); } catch (\Throwable $exception) { $message = strtolower($exception->getMessage()); if ( ($this->driver === 'mysql' && (str_contains($message, 'duplicate column') || str_contains($message, 'duplicate key name'))) || ($this->driver === 'pgsql' && str_contains($message, 'already exists')) ) { continue; } throw $exception; } } } private function ensureMeasurementCoinCurrencyColumn(): void { $table = $this->prefix . 'measurements'; $settingsTable = $this->prefix . 'settings'; if (!$this->tableExists($table)) { return; } $statements = $this->driver === 'pgsql' ? [ 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS coin_currency VARCHAR(10)', 'UPDATE ' . $table . ' AS m SET coin_currency = COALESCE(NULLIF(BTRIM(s.crypto_currency), \'\'), \'DOGE\') FROM ' . $settingsTable . ' AS s WHERE s.project_key = m.project_key AND (m.coin_currency IS NULL OR BTRIM(m.coin_currency) = \'\')', 'UPDATE ' . $table . ' SET coin_currency = \'DOGE\' WHERE coin_currency IS NULL OR BTRIM(coin_currency) = \'\'', 'ALTER TABLE ' . $table . ' ALTER COLUMN coin_currency SET DEFAULT \'DOGE\'', 'ALTER TABLE ' . $table . ' ALTER COLUMN coin_currency SET NOT NULL', ] : [ 'ALTER TABLE `' . $table . '` ADD COLUMN coin_currency VARCHAR(10) NOT NULL DEFAULT \'DOGE\' AFTER coins_total', 'UPDATE `' . $table . '` m LEFT JOIN `' . $settingsTable . '` s ON s.project_key = m.project_key SET m.coin_currency = COALESCE(NULLIF(TRIM(s.crypto_currency), \'\'), \'DOGE\') WHERE m.coin_currency IS NULL OR TRIM(m.coin_currency) = \'\'', ]; foreach ($statements as $index => $statement) { try { if ($this->driver === 'mysql' && $index === 0 && $this->columnExists($table, 'coin_currency')) { continue; } $this->executeUpgradeStatements([$statement], 'Messpunkt-Coin-Waehrung konnte nicht angelegt werden.'); } catch (\Throwable $exception) { $message = strtolower($exception->getMessage()); if ( ($this->driver === 'mysql' && $index === 0 && str_contains($message, 'duplicate column')) || ($this->driver === 'pgsql' && str_contains($message, 'already exists')) ) { continue; } throw $exception; } } } private function ensureLegacyMinerOfferImportColumns(): void { $table = $this->prefix . 'miner_offers'; if (!$this->tableExists($table)) { return; } $statements = $this->driver === 'pgsql' ? [ 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS price_amount NUMERIC(20,8)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS price_currency VARCHAR(10)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS usd_reference_amount NUMERIC(20,8)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS reference_price_amount NUMERIC(20,8)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS reference_price_currency VARCHAR(10)', ] : [ 'ALTER TABLE `' . $table . '` ADD COLUMN price_amount DECIMAL(20,8) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN price_currency VARCHAR(10) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN usd_reference_amount DECIMAL(20,8) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN reference_price_amount DECIMAL(20,8) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN reference_price_currency VARCHAR(10) NULL', ]; foreach ($statements as $statement) { try { $this->executeUpgradeStatements([$statement], 'Import-Kompatibilitaet fuer Miner-Angebote fehlgeschlagen.'); } catch (\Throwable $exception) { if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { continue; } throw $exception; } } } private function tableExists(string $table): bool { return in_array($table, $this->existingTables([$table]), true); } private function importSchema(): void { $schemaFile = $this->resolveSchemaFile(); if (!is_file($schemaFile)) { throw new ApiException('Schema-Datei fuer Mining-Checker fehlt.', 500, ['schema_file' => $schemaFile]); } $sql = (string) file_get_contents($schemaFile); $this->executeSqlContent($sql, $schemaFile, 'Schema-Import fuer Mining-Checker fehlgeschlagen.'); } private function executeSqlContent(string $sql, string $sourceLabel, string $errorMessage = 'SQL-Import fuer Mining-Checker fehlgeschlagen.'): int { try { return $this->sqlImporter->importString($sql); } catch (\RuntimeException $exception) { $previous = $exception->getPrevious() ?? $exception; throw new ApiException( $errorMessage, 500, [ 'message' => $previous->getMessage(), 'source' => $sourceLabel, 'statement' => substr($exception->getMessage(), 0, 1000), ] ); } catch (\Throwable $exception) { throw new ApiException( $errorMessage, 500, [ 'message' => $exception->getMessage(), 'source' => $sourceLabel, 'statement' => null, ] ); } } private function dropExistingTables(): array { $tables = array_reverse($this->schemaStatus()['present_tables']); if ($tables === []) { return []; } try { if ($this->driver !== 'mysql') { $this->pdo->beginTransaction(); } else { $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); } foreach ($tables as $table) { if ($this->driver === 'pgsql') { $this->pdo->exec('DROP TABLE IF EXISTS ' . $table . ' CASCADE'); } else { $safeTable = str_replace('`', '``', $table); $this->pdo->exec('DROP TABLE IF EXISTS `' . $safeTable . '`'); } } if ($this->driver === 'mysql') { $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); } elseif ($this->pdo->inTransaction()) { $this->pdo->commit(); } } catch (\Throwable $exception) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } try { if ($this->driver === 'mysql') { $this->pdo->exec('SET FOREIGN_KEY_CHECKS = 1'); } } catch (\Throwable) { } throw new ApiException( 'Vorhandene Mining-Checker Tabellen konnten nicht geloescht werden.', 500, ['message' => $exception->getMessage()] ); } return $tables; } private function resolveSchemaFile(): string { $specificFile = $this->moduleBasePath . '/sql/schema.' . $this->driver . '.sql'; if (is_file($specificFile)) { return $specificFile; } return $this->moduleBasePath . '/sql/schema.sql'; } private function detectPendingUpgrades(array $presentTables): array { $upgrades = []; if (in_array($this->prefix . 'cost_plans', $presentTables, true)) { $requiredColumns = [ 'mining_speed_value', 'mining_speed_unit', 'bonus_speed_value', 'bonus_speed_unit', 'base_price_amount', 'payment_type', ]; $existingColumns = $this->existingColumns($this->prefix . 'cost_plans', $requiredColumns); foreach ($requiredColumns as $column) { if (!in_array($column, $existingColumns, true)) { $upgrades[] = 'cost_plan_columns'; break; } } } if ($this->tableExists($this->prefix . 'settings')) { $requiredSettingsColumns = ['preferred_currencies', 'report_currency', 'crypto_currency', 'display_timezone', 'fx_max_age_hours', 'module_theme_mode', 'module_theme_accent']; $existingSettingsColumns = $this->existingColumns($this->prefix . 'settings', $requiredSettingsColumns); foreach ($requiredSettingsColumns as $column) { if (!in_array($column, $existingSettingsColumns, true)) { $upgrades[] = 'settings_preferences'; break; } } } if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) { $upgrades[] = 'measurement_fx_reference'; } if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'coin_currency')) { $upgrades[] = 'measurement_coin_currency'; } if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) { $upgrades[] = 'fx_rates_table'; } if (!$this->tableExists($this->prefix . 'measurement_rates')) { $upgrades[] = 'measurement_rates_table'; } if (!$this->tableExists($this->prefix . 'payouts')) { $upgrades[] = 'payouts_table'; } if (!$this->tableExists($this->prefix . 'wallet_snapshots')) { $upgrades[] = 'wallet_snapshots_table'; } if (!$this->tableExists($this->prefix . 'miner_offers')) { $upgrades[] = 'miner_offers_table'; } if (!$this->tableExists($this->prefix . 'purchased_miners')) { $upgrades[] = 'purchased_miners_table'; } if ($this->tableExists($this->prefix . 'targets') && !$this->columnExists($this->prefix . 'targets', 'miner_offer_id')) { $upgrades[] = 'target_offer_column'; } if ( $this->tableExists($this->prefix . 'targets') && $this->tableExists($this->prefix . 'miner_offers') && !$this->foreignKeyExists('fk_mining_targets_offer') ) { $upgrades[] = 'target_offer_foreign_key'; } return array_values(array_unique($upgrades)); } private function columnExists(string $table, string $column): bool { return in_array($column, $this->existingColumns($table, [$column]), true); } private function existingTables(array $tableNames): array { $tableNames = array_values(array_unique(array_filter($tableNames))); if ($tableNames === []) { return []; } $placeholders = []; $params = []; foreach ($tableNames as $index => $tableName) { $placeholder = ':table_' . $index; $placeholders[] = $placeholder; $params['table_' . $index] = $tableName; } $schemaCondition = $this->driver === 'pgsql' ? 'table_schema = current_schema()' : 'table_schema = DATABASE()'; $sql = sprintf( 'SELECT table_name FROM information_schema.tables WHERE %s AND table_name IN (%s)', $schemaCondition, implode(', ', $placeholders) ); $statement = $this->pdo->prepare($sql); $statement->execute($params); $rows = $statement->fetchAll(PDO::FETCH_COLUMN) ?: []; return array_values(array_intersect($tableNames, array_map('strval', $rows))); } private function existingColumns(string $table, array $columnNames): array { $columnNames = array_values(array_unique(array_filter($columnNames))); if ($columnNames === []) { return []; } $placeholders = []; $params = ['table_name' => $table]; foreach ($columnNames as $index => $columnName) { $placeholder = ':column_' . $index; $placeholders[] = $placeholder; $params['column_' . $index] = $columnName; } $schemaCondition = $this->driver === 'pgsql' ? 'table_schema = current_schema()' : 'table_schema = DATABASE()'; $sql = sprintf( 'SELECT column_name FROM information_schema.columns WHERE %s AND table_name = :table_name AND column_name IN (%s)', $schemaCondition, implode(', ', $placeholders) ); $statement = $this->pdo->prepare($sql); $statement->execute($params); $rows = $statement->fetchAll(PDO::FETCH_COLUMN) ?: []; return array_values(array_intersect($columnNames, array_map('strval', $rows))); } private function upgradeCostPlanColumns(): void { $table = $this->prefix . 'cost_plans'; $columns = $this->driver === 'pgsql' ? [ 'mining_speed_value' => 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS mining_speed_value NUMERIC(20,4)', 'mining_speed_unit' => 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS mining_speed_unit VARCHAR(8)', 'bonus_speed_value' => 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS bonus_speed_value NUMERIC(20,4)', 'bonus_speed_unit' => 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS bonus_speed_unit VARCHAR(8)', 'base_price_amount' => 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS base_price_amount NUMERIC(20,8)', 'payment_type' => 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS payment_type VARCHAR(10) NOT NULL DEFAULT \'fiat\'', ] : [ 'mining_speed_value' => 'ALTER TABLE `' . $table . '` ADD COLUMN mining_speed_value DECIMAL(20,4) NULL', 'mining_speed_unit' => 'ALTER TABLE `' . $table . '` ADD COLUMN mining_speed_unit VARCHAR(8) NULL', 'bonus_speed_value' => 'ALTER TABLE `' . $table . '` ADD COLUMN bonus_speed_value DECIMAL(20,4) NULL', 'bonus_speed_unit' => 'ALTER TABLE `' . $table . '` ADD COLUMN bonus_speed_unit VARCHAR(8) NULL', 'base_price_amount' => 'ALTER TABLE `' . $table . '` ADD COLUMN base_price_amount DECIMAL(20,8) NULL', 'payment_type' => 'ALTER TABLE `' . $table . '` ADD COLUMN payment_type VARCHAR(10) NOT NULL DEFAULT \'fiat\'', ]; foreach ($columns as $targetColumn => $statement) { try { if ($this->driver === 'mysql') { if ($this->columnExists($table, $targetColumn)) { continue; } } $this->pdo->exec($statement); } catch (\Throwable $exception) { throw new ApiException( 'Schema-Upgrade fuer Mining-Checker fehlgeschlagen.', 500, ['message' => $exception->getMessage(), 'statement' => $statement] ); } } $backfillStatements = $this->driver === 'pgsql' ? [ 'UPDATE ' . $table . ' SET base_price_amount = total_cost_amount WHERE base_price_amount IS NULL', "UPDATE " . $table . " SET payment_type = CASE WHEN UPPER(currency) IN ('ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP') THEN 'crypto' ELSE 'fiat' END WHERE payment_type IS NULL OR BTRIM(payment_type) = ''", ] : [ 'UPDATE `' . $table . '` SET base_price_amount = total_cost_amount WHERE base_price_amount IS NULL', "UPDATE `" . $table . "` SET payment_type = CASE WHEN UPPER(currency) IN ('ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP') THEN 'crypto' ELSE 'fiat' END WHERE payment_type IS NULL OR TRIM(payment_type) = ''", ]; $this->executeUpgradeStatements($backfillStatements, 'Backfill fuer Kostenplaene fehlgeschlagen.'); } public function ensureFxRatesTable(): void { if ($this->tableExists($this->prefix . 'fx_fetches') && $this->tableExists($this->prefix . 'fx_rates')) { return; } $this->upgradeFxRatesTable(); } public function ensureExtendedTables(): void { $this->ensureFxRatesTable(); $this->ensureMeasurementRatesTable(); $this->ensurePayoutsTable(); $this->ensureMinerTables(); } public function ensureMeasurementRatesTable(): void { if ($this->tableExists($this->prefix . 'measurement_rates')) { return; } $table = $this->prefix . 'measurement_rates'; $measurementTable = $this->prefix . 'measurements'; $statements = $this->driver === 'pgsql' ? [ 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( id BIGSERIAL PRIMARY KEY, measurement_id BIGINT NOT NULL, project_key VARCHAR(64) NOT NULL, base_currency VARCHAR(10) NOT NULL, quote_currency VARCHAR(10) NOT NULL, rate NUMERIC(20,10) NOT NULL, provider VARCHAR(32) NOT NULL DEFAULT \'derived\', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES ' . $measurementTable . '(id) ON DELETE CASCADE, CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency) )', 'CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_measurement ON ' . $table . ' (project_key, measurement_id)', ] : [ 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, measurement_id BIGINT UNSIGNED NOT NULL, project_key VARCHAR(64) NOT NULL, base_currency VARCHAR(10) NOT NULL, quote_currency VARCHAR(10) NOT NULL, rate DECIMAL(20,10) NOT NULL, provider VARCHAR(32) NOT NULL DEFAULT \'derived\', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES `' . $measurementTable . '`(id) ON DELETE CASCADE, CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency), KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id) )', ]; $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Messwert-Waehrungen fehlgeschlagen.'); } public function ensurePayoutsTable(): void { if ($this->tableExists($this->prefix . 'payouts')) { return; } $table = $this->prefix . 'payouts'; $projectTable = $this->prefix . 'projects'; $statements = $this->driver === 'pgsql' ? [ 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( id BIGSERIAL PRIMARY KEY, project_key VARCHAR(64) NOT NULL, payout_at TIMESTAMP NOT NULL, coins_amount NUMERIC(20,6) NOT NULL, payout_currency VARCHAR(10) NOT NULL DEFAULT \'DOGE\', note TEXT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES ' . $projectTable . '(project_key) ON DELETE CASCADE )', 'CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at ON ' . $table . ' (project_key, payout_at)', ] : [ 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, project_key VARCHAR(64) NOT NULL, payout_at TIMESTAMP NOT NULL, coins_amount DECIMAL(20,6) NOT NULL, payout_currency VARCHAR(10) NOT NULL DEFAULT \'DOGE\', note TEXT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES `' . $projectTable . '`(project_key) ON DELETE CASCADE, KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at) )', ]; $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Auszahlungen fehlgeschlagen.'); } public function ensureWalletSnapshotsTable(): void { if ($this->tableExists($this->prefix . 'wallet_snapshots')) { return; } $table = $this->prefix . 'wallet_snapshots'; $projectTable = $this->prefix . 'projects'; $statements = $this->driver === 'pgsql' ? [ 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( id BIGSERIAL PRIMARY KEY, project_key VARCHAR(64) NOT NULL, owner_sub VARCHAR(128) NOT NULL, measured_at TIMESTAMP NOT NULL, total_value_amount NUMERIC(20,8), total_value_currency VARCHAR(10), wallet_balance NUMERIC(28,10), wallet_currency VARCHAR(10) NOT NULL, balances_json JSONB, note TEXT, source VARCHAR(16) NOT NULL, image_path VARCHAR(255), ocr_raw_text TEXT, ocr_confidence NUMERIC(6,4), ocr_flags JSONB, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES ' . $projectTable . '(project_key) ON DELETE CASCADE )', 'CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at ON ' . $table . ' (project_key, owner_sub, measured_at)', ] : [ 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, project_key VARCHAR(64) NOT NULL, owner_sub VARCHAR(128) NOT NULL, measured_at TIMESTAMP NOT NULL, total_value_amount DECIMAL(20,8) NULL, total_value_currency VARCHAR(10) NULL, wallet_balance DECIMAL(28,10) NULL, wallet_currency VARCHAR(10) NOT NULL, balances_json JSON NULL, note TEXT NULL, source ENUM(\'manual\', \'image_ocr\', \'seed_import\') NOT NULL, image_path VARCHAR(255) NULL, ocr_raw_text MEDIUMTEXT NULL, ocr_confidence DECIMAL(6,4) NULL, ocr_flags JSON NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES `' . $projectTable . '`(project_key) ON DELETE CASCADE, KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) )', ]; $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Wallet-Snapshots fehlgeschlagen.'); } public function ensureMinerTables(): void { if (!$this->tableExists($this->prefix . 'miner_offers')) { $this->upgradeMinerOffersTable(); } if (!$this->tableExists($this->prefix . 'purchased_miners')) { $this->upgradePurchasedMinersTable(); } } private function upgradeFxRatesTable(): void { $fetchTable = $this->prefix . 'fx_fetches'; $rateTable = $this->prefix . 'fx_rates'; $statements = $this->driver === 'pgsql' ? [ 'CREATE TABLE IF NOT EXISTS ' . $fetchTable . ' ( id BIGSERIAL PRIMARY KEY, provider VARCHAR(32) NOT NULL DEFAULT \'currencyapi\', base_currency VARCHAR(10) NOT NULL, rate_date DATE NOT NULL, fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )', 'CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_fetches_base_fetched ON ' . $fetchTable . ' (base_currency, fetched_at)', 'CREATE TABLE IF NOT EXISTS ' . $rateTable . ' ( id BIGSERIAL PRIMARY KEY, fetch_id BIGINT NOT NULL, currency_code VARCHAR(10) NOT NULL, current_value NUMERIC(20,10) NOT NULL, CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES ' . $fetchTable . '(id) ON DELETE CASCADE )', 'CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_fetch ON ' . $rateTable . ' (fetch_id)', 'CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_currency ON ' . $rateTable . ' (currency_code)', ] : [ 'CREATE TABLE IF NOT EXISTS `' . $fetchTable . '` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, provider VARCHAR(32) NOT NULL DEFAULT \'currencyapi\', base_currency VARCHAR(10) NOT NULL, rate_date DATE NOT NULL, fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at) )', 'CREATE TABLE IF NOT EXISTS `' . $rateTable . '` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, fetch_id BIGINT UNSIGNED NOT NULL, currency_code VARCHAR(10) NOT NULL, current_value DECIMAL(20,10) NOT NULL, KEY idx_miningcheck_fx_rates_fetch (fetch_id), KEY idx_miningcheck_fx_rates_currency (currency_code), CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES `' . $fetchTable . '`(id) ON DELETE CASCADE )', ]; foreach ($statements as $statement) { $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer FX-Kurse fehlgeschlagen.'); } } private function upgradeSettingsPreferredCurrenciesColumn(): void { $table = $this->prefix . 'settings'; $statements = $this->driver === 'pgsql' ? [ 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS preferred_currencies JSONB', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS report_currency VARCHAR(10)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS crypto_currency VARCHAR(10)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS display_timezone VARCHAR(64)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS fx_max_age_hours NUMERIC(10,2)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS module_theme_mode VARCHAR(16)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS module_theme_accent VARCHAR(16)', ] : [ 'ALTER TABLE `' . $table . '` ADD COLUMN preferred_currencies JSON NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN report_currency VARCHAR(10) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN crypto_currency VARCHAR(10) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN display_timezone VARCHAR(64) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN fx_max_age_hours DECIMAL(10,2) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN module_theme_mode VARCHAR(16) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN module_theme_accent VARCHAR(16) NULL', ]; foreach ($statements as $statement) { try { $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Settings fehlgeschlagen.'); } catch (\Throwable $exception) { if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { continue; } throw $exception; } } } private function upgradeMinerOffersTable(): void { $table = $this->prefix . 'miner_offers'; $projectTable = $this->prefix . 'projects'; $statements = $this->driver === 'pgsql' ? [ 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( id BIGSERIAL PRIMARY KEY, project_key VARCHAR(64) NOT NULL, label VARCHAR(120) NOT NULL, runtime_months INTEGER, mining_speed_value NUMERIC(20,4), mining_speed_unit VARCHAR(8), bonus_speed_value NUMERIC(20,4), bonus_speed_unit VARCHAR(8), base_price_amount NUMERIC(20,8) NOT NULL, base_price_currency VARCHAR(10) NOT NULL, payment_type VARCHAR(10) NOT NULL DEFAULT \'fiat\', auto_renew BOOLEAN NOT NULL DEFAULT FALSE, note TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES ' . $projectTable . '(project_key) ON DELETE CASCADE )', ] : [ 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, project_key VARCHAR(64) NOT NULL, label VARCHAR(120) NOT NULL, runtime_months INT NULL, mining_speed_value DECIMAL(20,4) NULL, mining_speed_unit VARCHAR(8) NULL, bonus_speed_value DECIMAL(20,4) NULL, bonus_speed_unit VARCHAR(8) NULL, base_price_amount DECIMAL(20,8) NOT NULL, base_price_currency VARCHAR(10) NOT NULL, payment_type VARCHAR(10) NOT NULL DEFAULT \'fiat\', auto_renew TINYINT(1) NOT NULL DEFAULT 0, note TEXT, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES `' . $projectTable . '`(project_key) ON DELETE CASCADE )', ]; $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Miner-Angebote fehlgeschlagen.'); } private function upgradePurchasedMinersTable(): void { $table = $this->prefix . 'purchased_miners'; $projectTable = $this->prefix . 'projects'; $offerTable = $this->prefix . 'miner_offers'; $statements = $this->driver === 'pgsql' ? [ 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( id BIGSERIAL PRIMARY KEY, project_key VARCHAR(64) NOT NULL, miner_offer_id BIGINT, purchased_at TIMESTAMP NOT NULL, label VARCHAR(120) NOT NULL, runtime_months INTEGER, mining_speed_value NUMERIC(20,4), mining_speed_unit VARCHAR(8), bonus_speed_value NUMERIC(20,4), bonus_speed_unit VARCHAR(8), total_cost_amount NUMERIC(20,8) NOT NULL, currency VARCHAR(10) NOT NULL, usd_reference_amount NUMERIC(20,8), reference_price_amount NUMERIC(20,8), reference_price_currency VARCHAR(10), auto_renew BOOLEAN NOT NULL DEFAULT FALSE, note TEXT, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES ' . $projectTable . '(project_key) ON DELETE CASCADE, CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES ' . $offerTable . '(id) ON DELETE SET NULL )', ] : [ 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, project_key VARCHAR(64) NOT NULL, miner_offer_id BIGINT UNSIGNED NULL, purchased_at TIMESTAMP NOT NULL, label VARCHAR(120) NOT NULL, runtime_months INT NULL, mining_speed_value DECIMAL(20,4) NULL, mining_speed_unit VARCHAR(8) NULL, bonus_speed_value DECIMAL(20,4) NULL, bonus_speed_unit VARCHAR(8) NULL, total_cost_amount DECIMAL(20,8) NOT NULL, currency VARCHAR(10) NOT NULL, usd_reference_amount DECIMAL(20,8) NULL, reference_price_amount DECIMAL(20,8) NULL, reference_price_currency VARCHAR(10) NULL, auto_renew TINYINT(1) NOT NULL DEFAULT 0, note TEXT, is_active TINYINT(1) NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES `' . $projectTable . '`(project_key) ON DELETE CASCADE, CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES `' . $offerTable . '`(id) ON DELETE SET NULL )', ]; $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer gekaufte Miner fehlgeschlagen.'); } private function ensureCurrencyForeignKeys(): void { $this->seedMissingCurrenciesFromReferences(); $constraints = [ ['table' => $this->prefix . 'settings', 'column' => 'daily_cost_currency', 'name' => 'fk_mining_settings_daily_cost_currency_currency'], ['table' => $this->prefix . 'settings', 'column' => 'report_currency', 'name' => 'fk_mining_settings_report_currency_currency'], ['table' => $this->prefix . 'settings', 'column' => 'crypto_currency', 'name' => 'fk_mining_settings_crypto_currency_currency'], ['table' => $this->prefix . 'cost_plans', 'column' => 'currency', 'name' => 'fk_mining_cost_plans_currency_currency'], ['table' => $this->prefix . 'measurements', 'column' => 'price_currency', 'name' => 'fk_mining_measurements_price_currency_currency'], ['table' => $this->prefix . 'measurement_rates', 'column' => 'base_currency', 'name' => 'fk_mining_measurement_rates_base_currency_currency'], ['table' => $this->prefix . 'measurement_rates', 'column' => 'quote_currency', 'name' => 'fk_mining_measurement_rates_quote_currency_currency'], ['table' => $this->prefix . 'payouts', 'column' => 'payout_currency', 'name' => 'fk_mining_payouts_payout_currency_currency'], ['table' => $this->prefix . 'targets', 'column' => 'currency', 'name' => 'fk_mining_targets_currency_currency'], ['table' => $this->prefix . 'miner_offers', 'column' => 'base_price_currency', 'name' => 'fk_mining_miner_offers_base_price_currency_currency'], ['table' => $this->prefix . 'purchased_miners', 'column' => 'currency', 'name' => 'fk_mining_purchased_miners_currency_currency'], ['table' => $this->prefix . 'purchased_miners', 'column' => 'reference_price_currency', 'name' => 'fk_mining_purchased_miners_reference_price_currency_currency'], ['table' => $this->prefix . 'fx_fetches', 'column' => 'base_currency', 'name' => 'fk_mining_fx_fetches_base_currency_currency'], ['table' => $this->prefix . 'fx_rates', 'column' => 'currency_code', 'name' => 'fk_mining_fx_rates_currency_code_currency'], ]; foreach ($constraints as $constraint) { if ( !$this->tableExists($constraint['table']) || !$this->columnExists($constraint['table'], $constraint['column']) || $this->foreignKeyExists($constraint['name']) ) { continue; } $statement = $this->driver === 'pgsql' ? 'ALTER TABLE ' . $constraint['table'] . ' ADD CONSTRAINT ' . $constraint['name'] . ' FOREIGN KEY (' . $constraint['column'] . ') REFERENCES ' . $this->prefix . 'currencies(code)' : 'ALTER TABLE `' . $constraint['table'] . '` ADD CONSTRAINT ' . $constraint['name'] . ' FOREIGN KEY (`' . $constraint['column'] . '`) REFERENCES `' . $this->prefix . 'currencies`(`code`)'; $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Waehrungs-Referenzen fehlgeschlagen.'); } } private function seedMissingCurrenciesFromReferences(): void { $sources = [ [$this->prefix . 'settings', 'daily_cost_currency'], [$this->prefix . 'settings', 'report_currency'], [$this->prefix . 'settings', 'crypto_currency'], [$this->prefix . 'cost_plans', 'currency'], [$this->prefix . 'measurements', 'price_currency'], [$this->prefix . 'measurement_rates', 'base_currency'], [$this->prefix . 'measurement_rates', 'quote_currency'], [$this->prefix . 'payouts', 'payout_currency'], [$this->prefix . 'targets', 'currency'], [$this->prefix . 'miner_offers', 'base_price_currency'], [$this->prefix . 'purchased_miners', 'currency'], [$this->prefix . 'purchased_miners', 'reference_price_currency'], [$this->prefix . 'fx_fetches', 'base_currency'], [$this->prefix . 'fx_rates', 'currency_code'], ]; foreach ($sources as [$table, $column]) { if (!$this->tableExists($table) || !$this->columnExists($table, $column)) { continue; } $statement = $this->driver === 'pgsql' ? 'INSERT INTO ' . $this->prefix . 'currencies (code, name, symbol, is_active, sort_order) SELECT DISTINCT src.' . $column . ', src.' . $column . ', src.' . $column . ', TRUE, 1000 FROM ' . $table . ' src LEFT JOIN ' . $this->prefix . 'currencies c ON c.code = src.' . $column . ' WHERE src.' . $column . ' IS NOT NULL AND BTRIM(src.' . $column . ') <> \'\' AND c.code IS NULL' : 'INSERT INTO `' . $this->prefix . 'currencies` (code, name, symbol, is_active, sort_order) SELECT DISTINCT src.`' . $column . '`, src.`' . $column . '`, src.`' . $column . '`, 1, 1000 FROM `' . $table . '` src LEFT JOIN `' . $this->prefix . 'currencies` c ON c.code = src.`' . $column . '` WHERE src.`' . $column . '` IS NOT NULL AND TRIM(src.`' . $column . '`) <> \'\' AND c.code IS NULL'; $this->executeUpgradeStatements([$statement], 'Fehlende Waehrungen konnten nicht vorbereitet werden.'); } } private function upgradeCurrenciesClassificationColumns(): void { $table = $this->prefix . 'currencies'; $statements = $this->driver === 'pgsql' ? ['ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS is_crypto BOOLEAN NOT NULL DEFAULT FALSE'] : ['ALTER TABLE `' . $table . '` ADD COLUMN is_crypto TINYINT(1) NOT NULL DEFAULT 0']; foreach ($statements as $statement) { try { $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Waehrungs-Klassifikation fehlgeschlagen.'); } catch (\Throwable $exception) { if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { continue; } throw $exception; } } } private function upgradeMinerOfferBasePriceColumns(): void { $table = $this->prefix . 'miner_offers'; $statements = $this->driver === 'pgsql' ? [ 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS base_price_amount NUMERIC(20,8)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS base_price_currency VARCHAR(10)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS payment_type VARCHAR(10) NOT NULL DEFAULT \'fiat\'', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS auto_renew BOOLEAN NOT NULL DEFAULT FALSE', 'UPDATE ' . $table . ' SET base_price_amount = COALESCE(base_price_amount, reference_price_amount, usd_reference_amount, price_amount)', 'UPDATE ' . $table . ' SET base_price_currency = COALESCE(base_price_currency, reference_price_currency, CASE WHEN usd_reference_amount IS NOT NULL THEN \'USD\' ELSE price_currency END)', "UPDATE " . $table . " SET payment_type = CASE WHEN payment_type IS NULL OR BTRIM(payment_type) = '' THEN CASE WHEN UPPER(COALESCE(price_currency, '')) IN ('ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP') THEN 'crypto' ELSE 'fiat' END ELSE payment_type END", ] : [ 'ALTER TABLE `' . $table . '` ADD COLUMN base_price_amount DECIMAL(20,8) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN base_price_currency VARCHAR(10) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN payment_type VARCHAR(10) NOT NULL DEFAULT \'fiat\'', 'ALTER TABLE `' . $table . '` ADD COLUMN auto_renew TINYINT(1) NOT NULL DEFAULT 0', 'UPDATE `' . $table . '` SET base_price_amount = COALESCE(base_price_amount, reference_price_amount, usd_reference_amount, price_amount)', 'UPDATE `' . $table . '` SET base_price_currency = COALESCE(base_price_currency, reference_price_currency, CASE WHEN usd_reference_amount IS NOT NULL THEN \'USD\' ELSE price_currency END)', "UPDATE `" . $table . "` SET payment_type = CASE WHEN payment_type IS NULL OR TRIM(payment_type) = '' THEN CASE WHEN UPPER(COALESCE(price_currency, '')) IN ('ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP') THEN 'crypto' ELSE 'fiat' END ELSE payment_type END", ]; foreach ($statements as $statement) { try { $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Miner-Angebote fehlgeschlagen.'); } catch (\Throwable $exception) { if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { continue; } throw $exception; } } } private function upgradePurchasedMinerReferenceColumns(): void { $table = $this->prefix . 'purchased_miners'; $statements = $this->driver === 'pgsql' ? [ 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS reference_price_amount NUMERIC(20,8)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS reference_price_currency VARCHAR(10)', 'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS auto_renew BOOLEAN NOT NULL DEFAULT FALSE', ] : [ 'ALTER TABLE `' . $table . '` ADD COLUMN reference_price_amount DECIMAL(20,8) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN reference_price_currency VARCHAR(10) NULL', 'ALTER TABLE `' . $table . '` ADD COLUMN auto_renew TINYINT(1) NOT NULL DEFAULT 0', ]; foreach ($statements as $statement) { try { $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer gekaufte Miner fehlgeschlagen.'); } catch (\Throwable $exception) { if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { continue; } throw $exception; } } } private function upgradeTargetOfferColumn(): void { $table = $this->prefix . 'targets'; $statements = $this->driver === 'pgsql' ? ['ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS miner_offer_id BIGINT'] : ['ALTER TABLE `' . $table . '` ADD COLUMN miner_offer_id BIGINT UNSIGNED NULL']; foreach ($statements as $statement) { try { $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Ziel-Angebots-Verknuepfung fehlgeschlagen.'); } catch (\Throwable $exception) { if ($this->driver === 'mysql' && str_contains(strtolower($exception->getMessage()), 'duplicate column')) { continue; } throw $exception; } } } private function ensureTargetOfferForeignKey(): void { $constraintName = 'fk_mining_targets_offer'; if ($this->foreignKeyExists($constraintName)) { return; } $targetTable = $this->prefix . 'targets'; $offerTable = $this->prefix . 'miner_offers'; $statement = $this->driver === 'pgsql' ? 'ALTER TABLE ' . $targetTable . ' ADD CONSTRAINT ' . $constraintName . ' FOREIGN KEY (miner_offer_id) REFERENCES ' . $offerTable . '(id) ON DELETE SET NULL' : 'ALTER TABLE `' . $targetTable . '` ADD CONSTRAINT ' . $constraintName . ' FOREIGN KEY (miner_offer_id) REFERENCES `' . $offerTable . '`(id) ON DELETE SET NULL'; $this->executeUpgradeStatements([$statement], 'Schema-Upgrade fuer Ziel-Angebots-Fremdschluessel fehlgeschlagen.'); } private function foreignKeyExists(string $constraintName): bool { $schemaCondition = $this->driver === 'pgsql' ? 'constraint_schema = current_schema()' : 'constraint_schema = DATABASE()'; $statement = $this->pdo->prepare( 'SELECT constraint_name FROM information_schema.table_constraints WHERE ' . $schemaCondition . ' AND constraint_type = \'FOREIGN KEY\' AND constraint_name = :constraint_name LIMIT 1' ); $statement->execute(['constraint_name' => $constraintName]); return (bool) $statement->fetchColumn(); } private function executeUpgradeStatements(array $statements, string $message): void { foreach ($statements as $statement) { try { $this->pdo->exec($statement); } catch (\Throwable $exception) { throw new ApiException( $message, 500, ['message' => $exception->getMessage(), 'statement' => $statement] ); } } } private function lightweightStatus(): array { $coreTables = $this->coreTables(); $extraTables = $this->extraTables(); $presentTables = $this->existingTables(array_merge($coreTables, $extraTables)); return [ 'present_tables' => $presentTables, 'present_count' => count($presentTables), 'core_present' => count(array_intersect($coreTables, $presentTables)) === count($coreTables), 'missing_core_tables' => array_values(array_diff($coreTables, $presentTables)), 'missing_extra_tables' => array_values(array_diff($extraTables, $presentTables)), ]; } private function coreTables(): array { return [ $this->prefix . 'projects', $this->prefix . 'settings', $this->prefix . 'cost_plans', $this->prefix . 'measurements', $this->prefix . 'targets', $this->prefix . 'dashboard_definitions', ]; } private function extraTables(): array { return [ $this->prefix . 'fx_fetches', $this->prefix . 'fx_rates', $this->prefix . 'measurement_rates', $this->prefix . 'payouts', $this->prefix . 'wallet_snapshots', $this->prefix . 'miner_offers', $this->prefix . 'purchased_miners', ]; } private function knownTablesInDropOrder(): array { return array_reverse($this->knownTablesInCreateOrder()); } private function knownTablesInCreateOrder(): array { return [ $this->prefix . 'projects', $this->prefix . 'settings', $this->prefix . 'cost_plans', $this->prefix . 'measurements', $this->prefix . 'measurement_rates', $this->prefix . 'payouts', $this->prefix . 'wallet_snapshots', $this->prefix . 'miner_offers', $this->prefix . 'targets', $this->prefix . 'dashboard_definitions', $this->prefix . 'purchased_miners', $this->prefix . 'fx_fetches', $this->prefix . 'fx_rates', ]; } }