Files
nexus/modules/mining-checker/src/Infrastructure/SchemaManager.php
Lars Gebhardt-Kusche fc95898a9d
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped
Miner-Upgrade
2026-05-09 00:58:48 +02:00

1483 lines
68 KiB
PHP

<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Infrastructure;
use App\SqlDataImporter;
use App\UploadedSqlFile;
use Modules\MiningChecker\Support\ApiException;
use PDO;
final class SchemaManager
{
private PDO $pdo;
private string $prefix;
private string $moduleBasePath;
private string $driver;
private SqlDataImporter $sqlImporter;
public function __construct(PDO $pdo, string $prefix, string $moduleBasePath)
{
$this->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',
];
}
}