diff --git a/README.md b/README.md index c5c856e0..041aa02b 100644 --- a/README.md +++ b/README.md @@ -1 +1,18 @@ # desktop.kusche.berlin + +V1-Scaffold fuer die neue Desktop-Oberflaeche von Kusche.Berlin. + +## Struktur + +- `public/` Web-Root mit Desktop-Shell auf `/` +- `api/desktop.php` JSON-Payload fuer die Shell +- `src/Desktop/` zentrale Desktop-Mechaniken +- `partials/desktop/` Shell-Template +- `modules/` Zielort fuer klassische Module +- `temp/nexus-module-import/` Rohbasis fuer importierte Nexus-Module + +## Hinweise + +- `Old-Nexus/` wird nicht technisch eingebunden. +- Skins `Windows`, `Apple`, `Linux` laufen auf einer gemeinsamen Shell. +- Keycloak bleibt das Auth-System; der Theme-Handoff ist in `docs/keycloak-theme-handoff.md` beschrieben. diff --git a/api/desktop.php b/api/desktop.php new file mode 100644 index 00000000..11795bf7 --- /dev/null +++ b/api/desktop.php @@ -0,0 +1,13 @@ +desktopPayload(), JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); diff --git a/config/apps.php b/config/apps.php new file mode 100644 index 00000000..b14dc8af --- /dev/null +++ b/config/apps.php @@ -0,0 +1,71 @@ + 'welcome-center', + 'title' => 'Welcome Center', + 'icon' => 'WC', + 'entry_route' => '/', + 'window_mode' => 'window', + 'default_width' => 680, + 'default_height' => 420, + 'supports_widget' => false, + 'supports_tray' => false, + 'module_name' => 'system', + 'summary' => 'Startpunkt der Desktop-Shell mit Status zur Architektur, Skin-Umschaltung und naechsten Schritten.', + ], + [ + 'app_id' => 'public-dashboard', + 'title' => 'Public Dashboard', + 'icon' => 'PD', + 'entry_route' => '/public-home', + 'window_mode' => 'window', + 'default_width' => 540, + 'default_height' => 360, + 'supports_widget' => true, + 'supports_tray' => false, + 'module_name' => 'desktop', + 'summary' => 'Oeffentliche Startansicht fuer nicht eingeloggte Nutzer.', + ], + [ + 'app_id' => 'workspace-manager', + 'title' => 'Workspaces', + 'icon' => 'WS', + 'entry_route' => '/workspaces', + 'window_mode' => 'window', + 'default_width' => 520, + 'default_height' => 380, + 'supports_widget' => false, + 'supports_tray' => false, + 'module_name' => 'desktop', + 'summary' => 'Vorbereitung fuer mehrere persoenliche Dashboards und Workspace-Verwaltung.', + ], + [ + 'app_id' => 'admin-apps', + 'title' => 'Admin Apps', + 'icon' => 'AA', + 'entry_route' => '/admin/apps', + 'window_mode' => 'window', + 'default_width' => 560, + 'default_height' => 380, + 'supports_widget' => false, + 'supports_tray' => true, + 'module_name' => 'admin', + 'summary' => 'Platzhalter fuer globale Verwaltung von Apps, Widgets, Integrationen und Suchmaschinen.', + ], + [ + 'app_id' => 'desktop-login', + 'title' => 'Desktop Login', + 'icon' => 'DL', + 'entry_route' => '/auth/login', + 'window_mode' => 'modal', + 'default_width' => 460, + 'default_height' => 320, + 'supports_widget' => false, + 'supports_tray' => false, + 'module_name' => 'auth', + 'summary' => 'Uebergabepunkt fuer das spaetere Keycloak-Theme im Desktop-Look.', + ], +]; diff --git a/docs/keycloak-theme-handoff.md b/docs/keycloak-theme-handoff.md new file mode 100644 index 00000000..5c2b1b4d --- /dev/null +++ b/docs/keycloak-theme-handoff.md @@ -0,0 +1,30 @@ +# Keycloak Theme Handoff + +Diese Datei beschreibt die Uebergabepunkte zwischen der Desktop-Shell und dem spaeteren Keycloak-Login-Theme. + +## Zielbild + +- Keycloak bleibt das Auth-System. +- `desktop.kusche.berlin` bekommt ein eigenes Keycloak-Theme. +- Das Login soll sich wie ein Desktop-Login anfuehlen und visuell zur Shell passen. + +## Design-Bausteine + +- Wallpaper oder Verlauf passend zum aktiven Branding +- zentrierte Login-Kachel +- grosse Uhr oder Statusanzeige +- Logo und Produktname `Kusche.Berlin` +- ruhige Desktop-artige Animationen + +## Technische Schnittstellen + +- Desktop-Shell verweist auf die Login-App `desktop-login`. +- Keycloak-Theme liegt spaeter getrennt im Keycloak-Theme-System. +- Gemeinsame Branding-Werte sollten aus einer zentralen Theme-Definition abgeleitet werden. + +## Zu erledigen ausserhalb dieser V1 + +- Keycloak-Theme erzeugen +- Theme dem passenden Client oder Realm zuweisen +- Login-Templates, CSS und Assets im Theme pflegen +- Ruecksprung aus Keycloak sauber in die Desktop-Shell fuehren diff --git a/modules/README.md b/modules/README.md new file mode 100644 index 00000000..0e42ae10 --- /dev/null +++ b/modules/README.md @@ -0,0 +1,5 @@ +# Module + +Klassische Module bleiben in diesem Projekt unter `modules//`. + +Die neue Desktop-Shell ist nur die UI-Schicht. Modul-Businesslogik wird nicht in den Desktop-Core verschoben. diff --git a/partials/desktop/shell.php b/partials/desktop/shell.php new file mode 100644 index 00000000..2a75b9b3 --- /dev/null +++ b/partials/desktop/shell.php @@ -0,0 +1,83 @@ + + + + + + <?= htmlspecialchars($desktopPayload['meta']['title'], ENT_QUOTES) ?> + + + +
+ + +
+
+ +
+ +
+

+

+
+ +
+ +
+ +
+ +
+
+ + + + +
+
+
+
+ + + + + diff --git a/partials/landingpages/admin/apps.php b/partials/landingpages/admin/apps.php new file mode 100644 index 00000000..980889b4 --- /dev/null +++ b/partials/landingpages/admin/apps.php @@ -0,0 +1,8 @@ + 'Admin Apps', + 'description' => 'Verwaltung von Apps, Widgets, Integrationen und Suchmaschinen als Desktop-App.', +]; diff --git a/partials/landingpages/auth/login.php b/partials/landingpages/auth/login.php new file mode 100644 index 00000000..e26cdcd4 --- /dev/null +++ b/partials/landingpages/auth/login.php @@ -0,0 +1,8 @@ + 'Desktop Login', + 'description' => 'Spaeterer Uebergabepunkt fuer das Keycloak-Theme im Desktop-Look.', +]; diff --git a/partials/landingpages/public/home.php b/partials/landingpages/public/home.php new file mode 100644 index 00000000..ae298ac5 --- /dev/null +++ b/partials/landingpages/public/home.php @@ -0,0 +1,8 @@ + 'Public Home Dashboard', + 'description' => 'Oeffentliche Desktop-Startansicht fuer Gaeste.', +]; diff --git a/partials/structure/layout_end.php b/partials/structure/layout_end.php new file mode 100644 index 00000000..174d7fd7 --- /dev/null +++ b/partials/structure/layout_end.php @@ -0,0 +1,3 @@ + { + if (!clockNode) { + return; + } + + clockNode.textContent = new Intl.DateTimeFormat('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date()); + }; + + const focusWindow = (windowId) => { + windows.forEach((record) => { + record.node.classList.remove('is-focused'); + }); + + const record = windows.get(windowId); + if (!record) { + return; + } + + zIndex += 1; + record.node.style.zIndex = String(zIndex); + record.node.classList.add('is-focused'); + }; + + const syncTaskbar = () => { + taskbarRoot.innerHTML = ''; + + windows.forEach((record) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'taskbar-app-button'; + button.textContent = record.title; + button.addEventListener('click', () => { + record.node.classList.toggle('is-minimized'); + focusWindow(record.id); + }); + taskbarRoot.appendChild(button); + }); + }; + + const wireDragging = (record) => { + const header = record.node.querySelector('.window-header'); + if (!header || window.matchMedia('(max-width: 1100px)').matches) { + return; + } + + let startX = 0; + let startY = 0; + let originX = 0; + let originY = 0; + let dragging = false; + + header.addEventListener('pointerdown', (event) => { + dragging = true; + startX = event.clientX; + startY = event.clientY; + originX = parseInt(record.node.style.left, 10); + originY = parseInt(record.node.style.top, 10); + header.setPointerCapture(event.pointerId); + focusWindow(record.id); + }); + + header.addEventListener('pointermove', (event) => { + if (!dragging) { + return; + } + + record.node.style.left = `${originX + event.clientX - startX}px`; + record.node.style.top = `${originY + event.clientY - startY}px`; + }); + + header.addEventListener('pointerup', () => { + dragging = false; + }); + }; + + const createWindow = (definition) => { + const node = document.createElement('article'); + node.className = 'window'; + node.style.left = `${definition.x}px`; + node.style.top = `${definition.y}px`; + node.style.width = `${definition.width}px`; + node.style.height = `${definition.height}px`; + node.innerHTML = ` +
+ ${definition.title} +
+ + + +
+
+
+

${definition.content}

+

Route: ${definition.app_id}

+
+ `; + + const record = { + id: definition.window_id, + title: definition.title, + node, + definition, + }; + + node.addEventListener('mousedown', () => focusWindow(record.id)); + + node.querySelectorAll('.window-action').forEach((action) => { + action.addEventListener('click', () => { + const actionName = action.dataset.action; + if (actionName === 'close') { + windows.delete(record.id); + node.remove(); + syncTaskbar(); + return; + } + + if (actionName === 'minimize') { + node.classList.toggle('is-minimized'); + syncTaskbar(); + return; + } + + if (actionName === 'maximize') { + node.style.left = '24px'; + node.style.top = '24px'; + node.style.width = 'calc(100% - 48px)'; + node.style.height = 'calc(100% - 48px)'; + focusWindow(record.id); + } + }); + }); + + windows.set(record.id, record); + windowsRoot.appendChild(node); + wireDragging(record); + focusWindow(record.id); + syncTaskbar(); + }; + + payload.apps.forEach((app) => { + const icon = document.createElement('button'); + icon.type = 'button'; + icon.className = 'desktop-icon'; + icon.innerHTML = ` + ${app.icon} + ${app.title} + `; + icon.addEventListener('click', () => { + const existing = Array.from(windows.values()).find((record) => record.definition.app_id === app.app_id); + + if (existing) { + existing.node.classList.remove('is-minimized'); + focusWindow(existing.id); + return; + } + + createWindow({ + window_id: `window-${app.app_id}-${Date.now()}`, + app_id: app.app_id, + title: app.title, + x: 64 + Math.round(Math.random() * 80), + y: 70 + Math.round(Math.random() * 60), + width: app.default_width || 640, + height: app.default_height || 420, + content: app.summary || '', + }); + }); + iconsRoot.appendChild(icon); + }); + + payload.windows.forEach(createWindow); + renderClock(); + window.setInterval(renderClock, 1000); +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 00000000..276425b4 --- /dev/null +++ b/public/index.php @@ -0,0 +1,12 @@ +desktopPayload(); + +require dirname(__DIR__) . '/partials/desktop/shell.php'; diff --git a/src/App/App.php b/src/App/App.php new file mode 100644 index 00000000..a1606532 --- /dev/null +++ b/src/App/App.php @@ -0,0 +1,51 @@ + + */ + public function desktopPayload(): array + { + $registry = new AppRegistry($this->projectRoot . '/config/apps.php'); + $skins = SkinResolver::all(); + $activeSkin = SkinResolver::resolve($_GET['skin'] ?? null); + $windows = (new WindowManager($registry))->defaultWindows(); + + return [ + 'meta' => [ + 'title' => 'Kusche.Berlin Desktop UI', + 'active_skin' => $activeSkin, + 'available_skins' => $skins, + ], + 'desktop' => [ + 'greeting' => 'Neue Desktop-Shell fuer Kusche.Berlin', + 'wallpaper' => SkinResolver::wallpaper($activeSkin), + 'workspace' => DesktopState::defaultWorkspace(), + 'login' => [ + 'authenticated' => false, + 'mode' => 'public-home', + 'keycloak_theme_handoff' => '/docs/keycloak-theme-handoff.md', + ], + ], + 'apps' => $registry->all(), + 'windows' => $windows, + 'widgets' => DesktopState::defaultWidgets(), + 'tray' => DesktopState::trayItems(), + ]; + } +} diff --git a/src/App/bootstrap.php b/src/App/bootstrap.php new file mode 100644 index 00000000..6b495f71 --- /dev/null +++ b/src/App/bootstrap.php @@ -0,0 +1,23 @@ + __DIR__ . '/', + 'Desktop\\' => dirname(__DIR__) . '/Desktop/', + ]; + + foreach ($prefixes as $prefix => $baseDir) { + if (!str_starts_with($class, $prefix)) { + continue; + } + + $relative = substr($class, strlen($prefix)); + $path = $baseDir . str_replace('\\', '/', $relative) . '.php'; + + if (is_file($path)) { + require_once $path; + } + } +}); diff --git a/src/Desktop/AppRegistry.php b/src/Desktop/AppRegistry.php new file mode 100644 index 00000000..63ad83f0 --- /dev/null +++ b/src/Desktop/AppRegistry.php @@ -0,0 +1,40 @@ +> */ + private array $apps; + + public function __construct(string $configPath) + { + /** @var array> $apps */ + $apps = require $configPath; + $this->apps = $apps; + } + + /** + * @return array> + */ + public function all(): array + { + return $this->apps; + } + + /** + * @return array|null + */ + public function find(string $appId): ?array + { + foreach ($this->apps as $app) { + if (($app['app_id'] ?? null) === $appId) { + return $app; + } + } + + return null; + } +} diff --git a/src/Desktop/DesktopState.php b/src/Desktop/DesktopState.php new file mode 100644 index 00000000..e7afaaf2 --- /dev/null +++ b/src/Desktop/DesktopState.php @@ -0,0 +1,52 @@ + + */ + public static function defaultWorkspace(): array + { + return [ + 'workspace_id' => 'default', + 'title' => 'Persoenlicher Desktop', + 'supports_public_home' => true, + 'supports_multi_workspace' => true, + ]; + } + + /** + * @return array> + */ + public static function defaultWidgets(): array + { + return [ + [ + 'widget_id' => 'public-home', + 'title' => 'Public Home', + 'content' => 'Oeffentliches Dashboard als Startzustand fuer nicht eingeloggte Nutzer.', + ], + [ + 'widget_id' => 'personal-links', + 'title' => 'Persoenliche Links', + 'content' => 'Platzhalter fuer spaetere user-spezifische Linklisten und Dashboards.', + ], + ]; + } + + /** + * @return array> + */ + public static function trayItems(): array + { + return [ + ['id' => 'network', 'label' => 'Netzwerk'], + ['id' => 'widgets', 'label' => 'Widgets'], + ['id' => 'account', 'label' => 'Gast'], + ]; + } +} diff --git a/src/Desktop/SkinResolver.php b/src/Desktop/SkinResolver.php new file mode 100644 index 00000000..8c401521 --- /dev/null +++ b/src/Desktop/SkinResolver.php @@ -0,0 +1,32 @@ + + */ + public static function all(): array + { + return ['windows', 'apple', 'linux']; + } + + public static function resolve(?string $requestedSkin): string + { + $skin = strtolower((string) $requestedSkin); + + return in_array($skin, self::all(), true) ? $skin : 'windows'; + } + + public static function wallpaper(string $skin): string + { + return match ($skin) { + 'apple' => 'radial-gradient(circle at top, #ffe7ba 0%, #ff9a62 38%, #531b53 100%)', + 'linux' => 'linear-gradient(140deg, #111827 0%, #1f2937 40%, #ea580c 100%)', + default => 'linear-gradient(160deg, #1d4ed8 0%, #0f172a 100%)', + }; + } +} diff --git a/src/Desktop/WindowManager.php b/src/Desktop/WindowManager.php new file mode 100644 index 00000000..d2ae0b8c --- /dev/null +++ b/src/Desktop/WindowManager.php @@ -0,0 +1,44 @@ +> + */ + public function defaultWindows(): array + { + $defaults = ['welcome-center', 'public-dashboard']; + $windows = []; + + foreach ($defaults as $index => $appId) { + $app = $this->registry->find($appId); + + if ($app === null) { + continue; + } + + $windows[] = [ + 'window_id' => 'window-' . $appId, + 'app_id' => $appId, + 'title' => $app['title'], + 'state' => $index === 0 ? 'focused' : 'open', + 'x' => 72 + ($index * 48), + 'y' => 84 + ($index * 36), + 'width' => $app['default_width'] ?? 640, + 'height' => $app['default_height'] ?? 420, + 'content' => $app['summary'] ?? '', + ]; + } + + return $windows; + } +} diff --git a/temp/nexus-module-import/MODULE_DEVELOPMENT.md b/temp/nexus-module-import/MODULE_DEVELOPMENT.md new file mode 100644 index 00000000..708e8cb8 --- /dev/null +++ b/temp/nexus-module-import/MODULE_DEVELOPMENT.md @@ -0,0 +1,154 @@ +Projektkontext: Modul-Entwicklung + +1) Modul-Konzept +- Jedes klassische Modul lebt unter `/modules//`. +- Pflicht-Dateien sind in der Regel: + - `module.json` + - `bootstrap.php` + - `pages/*.php` + - `assets/*` + +2) Modulspezifische Assets +- Modul-JS und Modul-CSS müssen im Modul-Ordner liegen. +- Laden über Modul-Assets, zum Beispiel: + - `$assets->addStyle('/module/pi_control/asset?file=pi_control.css');` + - `$assets->addScript('/module/pi_control/asset?file=hosts.js', 'footer', true);` +- Keine modulspezifischen Änderungen in `/public/assets/*`. + +3) Scope-Regeln +- Modul-Aufgaben: nur `/modules//` und gegebenenfalls `/tools/` ändern +- globale Layouts: `/partials/structure/` und `/public/assets/css/app.css` +- Konfigurationslogik nur bei echtem Globalbedarf in `/config/` oder `/src/` + +4) UI-Regeln für Module +- Modulseiten sollen diesem Muster folgen: + - Seitenheader-Box + - Submenü-Box + - danach Bereichs-Boxen und/oder Karten-Boxen +- `Setup` gehört in Modulen grundsätzlich in die Submenü-Box +- die Optik von Submenü-Aktionen kommt ausschließlich aus dem globalen CSS +- Module dürfen dort keine eigenen Farb- oder Variantenlogiken einschleusen + +5) Globales Setup-System +- Modul-Setup wird zentral über `partials/landingpages/modules/setup.php` gerendert. +- Die Bereiche + - `Allgemein` + - `Datenbank` + - `Zugriffsrechte` + - `Cron Einstellungen` + müssen für alle Module aus dieser gemeinsamen Setup-Logik kommen. +- Nur `Custom Settings` darf modulspezifischen Inhalt enthalten. +- Modul-spezifische Sonderlayouts für die Bereiche `Allgemein`, `Datenbank`, `Zugriffsrechte` oder `Cron Einstellungen` sind nicht erlaubt. + +Was global im Setup bereits verfügbar ist: +- gemeinsame Setup-Navigation mit festen Unterseiten +- rechte Aktionsseite mit + - `Nexus Übersicht` + - `Zurück zum Modul` +- gemeinsames Speichern pro Bereich +- gemeinsames Rendering von + - Textfeldern + - Zahlenfeldern + - Checkboxen + - Selects + - Multiselects + - Textareas +- globale `Debug aktivieren`-Option pro Modul im Bereich `Allgemein` +- gemeinsame Datenbank-Logik mit + - `Eigene Modul-DB nutzen` + - Anzeige oder Verbergen der Custom-DB-Felder + - optional mehreren DB-Gruppen wie `db.*` und `metadata_db.*` + - `Verbindung testen` + - `Standardwerte laden` +- gemeinsame Auth- und Zugriffslogik mit + - `Login erforderlich` + - erlaubten Benutzern + - erlaubten Gruppen + - bekannten Keycloak-Benutzern und -Gruppen +- gemeinsame Cron- und Scheduler-Logik mit + - Anzeige von Intervall-Tasks + - Anzeige von Cron-Jobs + - Bearbeiten von Cron-Einträgen im Modal + - Cron-Test direkt aus dem Setup + - Mehrfacheinträgen bei `mode = multi` + - Modul-Zeitzonen-Override für Crons + - Vererbung globaler Cron-Zeitzonen-Defaults +- gemeinsame Darstellung von Setup-Aktionen und Statusblöcken +- globale Zeitzonen-Datalist aus `nexus_timezones` + +6) Was ein Modul für das Setup liefern darf +- `module.json` mit `setup.fields` +- optional `setup.sections.database` +- optional `interval_tasks` +- optional `scheduler_jobs` +- optional `auth` +- optional Bootstrap-Funktionen: + - `setup_actions` + - `run_setup_action` + - `setup_status` + - `runtime_settings` + - `save_runtime_settings` + +7) Was ein Modul nicht selbst bauen darf +- eigene Setup-Seitenstruktur für `Allgemein`, `Datenbank`, `Zugriffsrechte`, `Cron Einstellungen` +- eigene DB-Toggle-Logik für Standard und Custom +- eigene Cron-Editor-Grundlogik +- eigene Debug-UI-Grundlogik +- eigene globale Zeitzonen-Defaults + +8) Setup-Navigation +- Setup-Routen laufen zentral über: + - `/modules/setup//general` + - `/modules/setup//database` + - `/modules/setup//access` + - `/modules/setup//cron` + - `/modules/setup//custom` +- `Setup` gehört in der Modulansicht in die rechte Aktionsseite der Submenü-Box + +9) Steuerung per `module.json` +- Ein Modul kann über `setup.sections.database: true|false` steuern, ob der Menüpunkt `Datenbank` angezeigt wird. +- Wenn `setup.sections.database` fehlt, kann die zentrale Setup-Logik den Punkt implizit aktivieren, sobald DB-Felder vorhanden sind. +- Modulfelder für `Allgemein`, `Datenbank`, `Cron` und `Custom Settings` werden zentral nach Feldnamen aufgeteilt: + - `debug_enabled` -> `Allgemein` + - `use_separate_db` und `db.*` oder `metadata_db.*` -> `Datenbank` + - `schedule_timezone` -> `Cron Einstellungen` + - alle übrigen Setup-Felder -> `Custom Settings` + +Weitere anerkannte Setup-Bausteine: +- `interval_tasks` +- `scheduler_jobs` +- `auth` +- `db_defaults` +- `metadata_db_defaults` + +10) Speicherregel +- Beim Speichern eines Setup-Bereichs dürfen nur die in diesem Bereich sichtbaren Felder aktualisiert werden. +- Felder aus anderen Bereichen dürfen nicht mit `null`, `0` oder leeren Strings überschrieben werden. + +11) Datenbankbereich +- `Eigene Modul-DB nutzen` ist der zentrale Standard-Schalter für Module mit optionaler eigener DB. +- Wenn der Schalter deaktiviert ist, dürfen keine Custom-DB-Eingabefelder sichtbar sein. +- Datenbankaktionen und Tabellenstatus gehören in den Menüpunkt `Datenbank`, nicht in `Custom Settings`. + +12) Globale PHP-Helfer für Module +- Neue Module sollen für zentrale Zeit- und Debug-Defaults nach Möglichkeit die globalen Funktionen aus `src/App/functions.php` nutzen: + - `nexus_settings()` + - `nexus_save_settings(array $settings)` + - `nexus_system_timezone_name()` + - `nexus_display_timezone_name()` + - `nexus_cron_timezone_name()` + - `module_debug_enabled(string $module)` + - `module_debug_push(string $module, array $entry)` + - `module_debug_clear(string $module)` + +13) Regeln für neue Module +- Keine Zeitzone wie `Europe/Berlin` hart im Modul als Standard erzwingen, wenn dafür ein globaler Nexus-Default existiert. +- Für Anzeige- und Formatierungslogik nach Möglichkeit `nexus_display_timezone_name()` nutzen. +- Für Cron-Fallbacks nach Möglichkeit `nexus_cron_timezone_name()` nutzen. +- Neue Module dürfen keine lokalen Zeitzonen direkt in Datenbank-Zeitspalten persistieren. + +14) Pi-Control-Besonderheiten +- Worker und Jobs unter `/tools/pi_control/` +- Check-Updates und Cron nutzen die gleichen SSH-Routinen +- Host-Karten, Befehle und Konsole sind UI im Modul +- Update- und Upgrade-Checks liefern Debug-Ausgaben, die als Tooltip oder Debugzeile angezeigt werden diff --git a/temp/nexus-module-import/NEXUS_SYSTEM.md b/temp/nexus-module-import/NEXUS_SYSTEM.md new file mode 100644 index 00000000..ceda0738 --- /dev/null +++ b/temp/nexus-module-import/NEXUS_SYSTEM.md @@ -0,0 +1,119 @@ +Projektkontext: Nexus-System + +1) Projekt-Zweck und Ziel +- Nexus ist ein zentrales, webbasiertes Admin-Interface zur Steuerung einer dynamischen IT-Infrastruktur. +- Module kapseln fachliche Funktionen, das Nexus-System stellt das globale Grundgerüst bereit. +- Das generelle Nexus-System wird unabhängig von den bestehenden Fachmodulen weiterentwickelt. + +2) Umgebungen und Domains +- Live: `nexus.kusche.berlin` +- Staging: `staging.nexus.kusche.berlin` + +Container- und Deploy-Layout: +- `/app/live/` -> Live-Code +- `/app/staging/` -> Staging-Code +- jeweils mit eigenem `config`-Unterordner: + - `/app/live/config/` + - `/app/staging/config/` + +Repo-Layout zu Configs: +- `/config/live/` und `/config/staging/` liegen im Repo +- beim Deployment werden die Dateien daraus nach `/app//config` kopiert +- wichtig: im laufenden Container existiert `/app/config/` nicht, sondern nur `/app/live/config` und `/app/staging/config` + +3) Verzeichnisstruktur +- `/public/` -> Web Root mit globalen Assets +- `/api/` -> Backend- und API-Endpunkte +- `/src/` -> PHP-Kernklassen und Utilities +- `/tools/` -> CLI, Worker und Jobs +- `/config/` -> Umgebungs-Configs +- `/modules//` -> klassische Nexus-Module +- `/partials/structure/` -> Header, Footer, Menüs +- `/partials/landingpages/` -> komplette Seitenlayouts +- `/debug/` -> Custom Logs + +4) Code-Änderungen nach Scope +- globale Layouts: `/partials/structure/` und `/public/assets/css/app.css` +- Konfigurationslogik: nur wenn nötig `/config/` und `/src/` +- Modul-Aufgaben: nur `/modules//` und gegebenenfalls `/tools/` + +5) UI-Naming und Seitenaufbau +- `Seitenheader-Box`: oberste globale Header-Box mit Seitentitel, Login und Farbschema +- `Submenü-Box`: Box direkt unter der Seitenheader-Box für modul- oder seitenbezogene Aktionen +- `Submenü-Aktionen`: rechtsbündige Zusatzbuttons innerhalb der Submenü-Box, z.B. `Setup` oder `Nexus Übersicht` +- `Bereichs-Box`: größere Inhaltsbox für einen zusammenhängenden Seitenbereich +- `Karten-Box`: kleinere Karte auf derselben Ebene wie Bereichs-Boxen, meist in Grids + +Zentrale CSS-Klassen: +- `main-header-box` +- `submenu-box` +- `module-submenu-actions` +- `section-box` +- `card-box` + +Globale Struktur: +- zuerst Seitenheader-Box +- danach Submenü-Box +- danach Bereichs-Boxen und/oder Karten-Boxen je nach Seite + +Layout-Regeln: +- vertikale Abstände zwischen `main-header-box`, `submenu-box` und den ersten Folge-Boxen müssen aus der globalen Shell kommen +- maßgeblich sind `module-page-bg` und `module-page-stack` in `public/assets/css/app.css` +- Top-Level-Wrapper wie Grids, Kartencontainer oder Modul-Listen dürfen keinen zusätzlichen `margin-top` oder Sonder-Gap erzeugen, der den Abstand nach dem Submenü verändert +- bei Layout-Reviews ist explizit zu prüfen, ob `Main-Header -> Submenü -> erste Section/Card` optisch denselben Rhythmus hat wie auf Referenzseiten +- die Optik der Submenü-Aktionsbuttons kommt ausschließlich aus dem globalen CSS + +Beispielstruktur: +- Börsenchecker: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Bereichs-Box +- FX-Rates: Seitenheader-Box, Submenü-Box, danach Bereichs-Boxen +- Mining-Checker: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Karten-Boxen, Bereichs-Box +- Modulverwaltung: Seitenheader-Box, Submenü-Box, danach Karten-Boxen + +6) Nexus-Kerngerüst +- Der aktuelle Ausbauschritt betrifft das generelle Nexus-System, nicht die bestehenden Fachmodule. +- Bestehende Module sind funktional und strukturell zunächst ausgenommen und dürfen durch Arbeiten am Kerngerüst nicht beeinträchtigt werden. +- Neue Kernfunktionen müssen parallel zum bestehenden Modulsystem eingeführt werden. +- Zielbild ist ein Nexus-Grundsystem mit flexiblen Benutzer-Dashboards, Integrationen und datengetriebenen Seitenmodulen. + +Produktprinzip: +- Nexus ist nicht nur Modul-Launcher, sondern ein persönliches und gruppenfähiges Dashboard-System. +- Benutzer sollen eigene Dashboards anlegen, anordnen und konfigurieren können. +- Inhalte auf Dashboards sollen aus drei Quellen kommen können: + - interne Nexus-Funktionen + - externe Integrationen + - einfache Seitenmodule ohne eigene Modul-Implementierung + +Abgrenzung zu bestehenden Modulen: +- das Verzeichnis `/modules//` bleibt das Zuhause klassischer Nexus-Module +- das Dashboard-System ist ein zusätzliches globales Kernsystem +- neue Kernfunktionen müssen ohne Umbau bestehender Module lauffähig sein + +7) Globale Zeitzonen-Logik +- globale Nexus-Einstellungen liegen unter `/settings` +- dort werden zentral gepflegt: + - `Anzeige-Zeitzone` + - `Standard-Zeitzone für Crons` + +Regeln: +- ohne Custom bei der Anzeige-Zeitzone wird die System-Zeitzone verwendet +- die aktive Anzeige-Zeitzone soll angezeigt werden +- die Standard-Zeitzone für Crons ist der globale Default für Modul-Crons +- Module dürfen diese Zeitzone im Setup übersteuern +- einzelne Cron-Einträge dürfen sie ebenfalls übersteuern + +UTC-Speicherregel: +- Zeitwerte sollen projektweit intern immer in `UTC` gespeichert werden +- Anzeige-Zeitzonen dienen nur der Darstellung für Benutzer +- Cron-Zeitzonen dienen nur der lokalen Auswertung von Zeitplänen und Fälligkeiten +- beim Einlesen lokaler Eingaben muss vor dem Speichern nach `UTC` normalisiert werden +- beim Anzeigen gespeicherter Werte muss von `UTC` in die jeweils wirksame Anzeige-Zeitzone umgerechnet werden + +8) Globales Debug-System +- das Debug-Popup ist eine globale Infrastruktur aus dem zentralen Layout +- die Aktivierung bleibt pro Modul über `debug_enabled` im Modul-Setup steuerbar +- das Debug-Symbol darf nur sichtbar sein, wenn für das aktuelle Modul Debug aktiv ist +- Module sollen keine eigene separate Debug-Oberfläche bauen, wenn der globale Debug-Stream genutzt werden kann + +9) Sicherheits- und Netzwerk-Constraints +- Zugriff im Heimnetz `192.168.178.0/24` per Nginx begrenzt +- SSH-Hosts nur Heimnetz diff --git a/temp/nexus-module-import/README-Old-Nexus.md b/temp/nexus-module-import/README-Old-Nexus.md new file mode 100644 index 00000000..a0cfb97c --- /dev/null +++ b/temp/nexus-module-import/README-Old-Nexus.md @@ -0,0 +1,151 @@ +# Nexus +# Comment by Lars + +## UI-Naming + +Für die Oberfläche gilt projektweit dieses Naming: + +- `Seitenheader-Box`: globaler Header mit Seitentitel, Login und Farbschema +- `Submenü-Box`: zusätzliche modul- oder seitenbezogene Aktionen direkt unter dem Seitenheader; `Setup` soll in Modulen immer vorhanden sein +- `Submenü-Aktionen`: rechtsbündige Zusatzbuttons innerhalb der Submenü-Box, z.B. `Setup` oder `Nexus Übersicht` +- `Bereichs-Box`: größere Inhaltsbox für einen zusammenhängenden Bereich; davon können beliebig viele untereinander folgen +- `Karten-Box`: kleinere Inhaltskarte auf derselben Ebene wie Bereichs-Boxen, typischerweise innerhalb eines Grids für Kennzahlen, Statistiken oder Modulübersichten + +Zentrale CSS-Klassen für dieses Layout: + +- `main-header-box` +- `submenu-box` +- `module-submenu-actions` +- `section-box` +- `card-box` + +Beispiele: + +- `Börsenchecker`: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Bereichs-Box +- `FX-Rates`: Seitenheader-Box, Submenü-Box, danach Bereichs-Boxen +- `Mining-Checker`: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Karten-Boxen, Bereichs-Box +- `Modulverwaltung`: Seitenheader-Box, Submenü-Box, danach Karten-Boxen + +Technisch: + +- globale Shell und Header in `partials/structure/` und `public/assets/css/app.css` +- modulbezogene Inhalte und Assets ausschließlich unter `modules//` +- vertikale Abstände zwischen `main-header-box`, `submenu-box` und den ersten Folge-Boxen müssen aus der globalen Shell kommen; Top-Level-Grids oder Wrapper dürfen dort keinen zusätzlichen `margin-top` oder Sonderabstand einführen +- bei Layout-Checks ist ausdrücklich zu prüfen, ob `Main-Header -> Submenü -> erste Section/Card` denselben Rhythmus hat wie auf Referenzseiten +- die Optik von Submenü-Aktionen kommt ausschließlich aus dem globalen CSS; Module sollen dort keine eigenen Farbvarianten definieren + +## Nexus-Kerngerüst + +Das generelle Nexus-System wird unabhängig von den bestehenden Fachmodulen weiterentwickelt. Aktuell gilt: + +- bestehende Module unter `modules//` bleiben zunächst unberührt +- neue Kernfunktionen müssen kollisionsfrei parallel zum Modulsystem aufgebaut werden +- Zielbild ist ein flexibles Dashboard-System im Stil moderner Startseitenlösungen mit: + - mehreren Dashboards pro Benutzer + - frei platzierbaren Dashboard-Elementen + - zentralen Integrationen zu Fremdsystemen + - datengetriebenen Seitenmodulen ohne eigenen Modulordner + +Die maßgeblichen Begriffe und Regeln dafür stehen in: + +- `PROJECT_CONTEXT.md` als Einstieg +- `NEXUS_SYSTEM.md` +- `MODULE_DEVELOPMENT.md` +- `WIDGET_INTEGRATION.md` + +Wichtig: + +- Integrationen sind globale Systembausteine, keine Module +- Seitenmodule sind on the fly konfigurierbare Inhalte ohne Code unter `modules/` +- spätere Dashboard-Anbindungen bestehender Module sollen nur über definierte Adapter oder Provider erfolgen + + +## Getting started + +To make it easy for you to get started with GitLab, here's a list of recommended next steps. + +Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! + +## Add your files + +* [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files +* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command: + +``` +cd existing_repo +git remote add origin https://gitlab.int.kusche.berlin/private_projects/nexus.git +git branch -M main +git push -uf origin main +``` + +## Integrate with your tools + +* [Set up project integrations](https://gitlab.int.kusche.berlin/private_projects/nexus/-/settings/integrations) + +## Collaborate with your team + +* [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) +* [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) +* [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) +* [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) +* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/) + +## Test and Deploy + +Use the built-in continuous integration in GitLab. + +* [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/) +* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) +* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) +* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) +* [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) + +*** + +# Editing this README + +When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. + +## Suggestions for a good README + +Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. + +## Name +Choose a self-explaining name for your project. + +## Description +Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. + +## Badges +On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. + +## Visuals +Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. + +## Installation +Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. + +## Usage +Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. + +## Support +Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. + +## Roadmap +If you have ideas for releases in the future, it is a good idea to list them in the README. + +## Contributing +State if you are open to contributions and what your requirements are for accepting them. + +For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. + +You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. + +## Authors and acknowledgment +Show your appreciation to those who have contributed to the project. + +## License +For open source projects, say how it is licensed. + +## Project status +If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. diff --git a/temp/nexus-module-import/README.md b/temp/nexus-module-import/README.md new file mode 100644 index 00000000..0c57ca79 --- /dev/null +++ b/temp/nexus-module-import/README.md @@ -0,0 +1,9 @@ +# Nexus Module Import + +Dieser Ordner ist die Rohbasis fuer die spaetere Modulmigration. + +Regeln: + +- Inhalte dienen als Import- und Sichtbasis. +- Anpassungen an die neue Desktop-UI erfolgen separat im neuen Projekt. +- Es gibt keine direkte Laufzeitkopplung aus Produktivcode auf diesen Ordner. diff --git a/temp/nexus-module-import/modules/boersenchecker/assets/boersenchecker.css b/temp/nexus-module-import/modules/boersenchecker/assets/boersenchecker.css new file mode 100644 index 00000000..da5ab8dd --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/assets/boersenchecker.css @@ -0,0 +1,341 @@ +.bc-page { + --bc-accent: var(--brand-accent); + --bc-accent-strong: var(--brand-accent-2); + --bc-ink: var(--text); + --bc-text: var(--text); + --bc-muted: var(--muted); + --bc-line: var(--line); + --bc-panel: rgba(255, 255, 255, 0.78); + --bc-panel-soft: rgba(248, 252, 252, 0.92); + --bc-positive: #137333; + --bc-negative: #c62828; + color: var(--bc-text); +} + +.bc-page { + display: grid; + gap: 16px; +} + +.bc-text { + color: var(--bc-muted); + margin: 0; +} + +.bc-section-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; +} + +.bc-section-title { + margin: 0; + font-size: 1.45rem; + line-height: 1.15; +} + +.bc-section-head p, +.bc-section-copy { + color: var(--bc-muted); + margin: 8px 0 0; +} + +.bc-form-card, +.bc-panel, +.bc-stat, +.bc-chart-card, +.bc-position-row { + border: 1px solid var(--bc-line); + border-radius: 22px; + background: var(--bc-panel); + box-shadow: 0 10px 24px rgba(1, 22, 32, 0.06); +} + +.bc-form-card, +.bc-panel, +.bc-chart-card { + padding: 18px; +} + +.bc-stat { + padding: 16px; +} + +.bc-field-label { + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--bc-muted); +} + +.bc-tabs { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.bc-button, +.bc-tabs a, +.bc-page button, +.bc-page input, +.bc-page select, +.bc-page textarea { + font: inherit; +} + +.bc-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 16px; + padding: 12px 16px; + cursor: pointer; + text-decoration: none; + transition: 160ms ease; +} + +.bc-button:hover { + transform: translateY(-1px); +} + +.bc-button--tab { + background: rgba(255, 255, 255, 0.94); + color: var(--bc-ink); + font-weight: 700; +} + +.bc-button--tab-active { + background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3)); + color: #fff7fb; + font-weight: 700; +} + +.bc-button--primary { + background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3)); + color: #fff7fb; + font-weight: 700; +} + +.bc-button--secondary { + background: rgba(255, 255, 255, 0.94); + color: var(--bc-ink); + font-weight: 700; +} + +.bc-button--ghost { + background: color-mix(in srgb, var(--bc-accent) 14%, transparent); + border-color: color-mix(in srgb, var(--bc-accent) 34%, transparent); + color: var(--bc-accent); + font-weight: 700; +} + +.bc-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.bc-alert { + padding: 16px 18px; + border-radius: 20px; + border: 1px solid transparent; +} + +.bc-alert--error { + background: rgba(127, 29, 29, 0.28); + border-color: rgba(252, 165, 165, 0.24); + color: #fecaca; +} + +.bc-alert--success { + background: rgba(6, 78, 59, 0.28); + border-color: rgba(134, 239, 172, 0.24); + color: #bbf7d0; +} + +.bc-toolbar, +.bc-overview-grid, +.bc-card-grid { + display: grid; + gap: 14px; +} + +.bc-toolbar { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.bc-overview-grid { + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.bc-card-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.bc-stat-value { + margin-top: 8px; + font-size: 1.42rem; + font-weight: 700; +} + +.bc-chart-shell { + position: relative; + min-height: 360px; + overflow: hidden; +} + +.bc-chart-svg { + width: 100%; + height: 340px; + display: block; +} + +.bc-chart-path { + fill: none; + stroke: var(--bc-accent); + stroke-width: 3; + stroke-linecap: round; + stroke-linejoin: round; + filter: drop-shadow(0 12px 24px color-mix(in srgb, var(--bc-accent) 18%, transparent)); +} + +.bc-chart-area { + fill: url(#bc-chart-fill); +} + +.bc-chart-grid line { + stroke: rgba(16, 33, 43, 0.08); + stroke-dasharray: 4 6; +} + +.bc-range-list { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.bc-range-button { + border: 1px solid color-mix(in srgb, var(--bc-accent) 26%, transparent); + background: rgba(255, 255, 255, 0.76); + color: var(--bc-text); + padding: 8px 12px; + border-radius: 999px; + cursor: pointer; + transition: transform .18s ease, background .18s ease, border-color .18s ease; +} + +.bc-range-button:hover, +.bc-range-button[aria-pressed="true"] { + transform: translateY(-1px); + background: color-mix(in srgb, var(--bc-accent) 18%, transparent); + border-color: color-mix(in srgb, var(--bc-accent) 42%, transparent); +} + +.bc-panel-fade { + animation: bcPanelFade .35s ease; +} + +@keyframes bcPanelFade { + from { opacity: 0; transform: translateY(8px) scale(.99); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +.bc-position-list { + display: grid; + gap: 14px; +} + +.bc-position-row { + display: grid; + grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(96px, .72fr)); + gap: 14px; + align-items: center; + padding: 16px 18px; +} + +.bc-performance { + font-weight: 700; +} + +.bc-performance.is-positive { + color: var(--bc-positive); +} + +.bc-performance.is-negative { + color: var(--bc-negative); +} + +.bc-pill-soft { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 6px 10px; + border-radius: 999px; + background: color-mix(in srgb, var(--bc-accent) 10%, transparent); + color: var(--bc-text); + font-size: .85rem; +} + +.bc-table-shell { + overflow: auto; + border-radius: 18px; + border: 1px solid var(--bc-line); +} + +.bc-table { + width: 100%; + border-collapse: collapse; +} + +.bc-table th, +.bc-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid rgba(255,255,255,0.06); + vertical-align: top; +} + +.bc-table thead { + background: rgba(255,255,255,0.04); +} + +.bc-page .setup-field { + display: grid; + gap: 6px; +} + +.bc-page input, +.bc-page select, +.bc-page textarea { + width: 100%; + border: 1px solid var(--bc-line); + border-radius: 14px; + padding: 10px 12px; + background: var(--surface-strong); + color: var(--bc-text); +} + +.bc-page input::placeholder, +.bc-page textarea::placeholder { + color: color-mix(in srgb, var(--bc-muted) 70%, transparent); +} + +.bc-page a { + color: inherit; +} + +.bc-page .muted { + color: var(--bc-muted); +} + +@media (max-width: 980px) { + .bc-hero-top { + grid-template-columns: 1fr; + } + + .bc-position-row { + grid-template-columns: 1fr; + } +} diff --git a/temp/nexus-module-import/modules/boersenchecker/assets/boersenchecker.js b/temp/nexus-module-import/modules/boersenchecker/assets/boersenchecker.js new file mode 100644 index 00000000..b53063f2 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/assets/boersenchecker.js @@ -0,0 +1,152 @@ +(function () { + const app = document.querySelector('[data-bc-home]'); + if (!app) return; + + const chartShell = app.querySelector('[data-bc-chart]'); + const instrumentSelect = app.querySelector('[data-bc-instrument]'); + const instrumentNameNode = app.querySelector('[data-bc-instrument-name]'); + const instrumentMetaNode = app.querySelector('[data-bc-instrument-meta]'); + const rangeButtons = Array.from(app.querySelectorAll('[data-range]')); + const statusNode = app.querySelector('[data-bc-chart-status]'); + const summaryNode = app.querySelector('[data-bc-chart-summary]'); + const endpoint = app.getAttribute('data-chart-endpoint') || ''; + const instrumentsScript = app.querySelector('[data-bc-instruments-json]'); + const instrumentMap = new Map(); + if (instrumentsScript?.textContent) { + try { + const items = JSON.parse(instrumentsScript.textContent); + if (Array.isArray(items)) { + items.forEach((item) => instrumentMap.set(String(item.instrument_id), item)); + } + } catch (_error) {} + } + let activeRange = '1m'; + let currentPayload = null; + + function pointsForRange(payload, range) { + if (!payload) return []; + const daily = payload.daily || []; + const weekly = payload.weekly || []; + const monthly = payload.monthly || []; + switch (range) { + case '1d': return daily.slice(-2); + case '5d': return daily.slice(-5); + case '1m': return daily.slice(-22); + case '3m': return daily.slice(-66); + case '6m': return weekly.slice(-26); + case '1y': return weekly.slice(-52); + case '5y': return monthly.slice(-60); + default: return daily.slice(-22); + } + } + + function renderChart(points) { + if (!chartShell) return; + chartShell.classList.remove('bc-panel-fade'); + void chartShell.offsetWidth; + chartShell.classList.add('bc-panel-fade'); + + if (!points || points.length === 0) { + chartShell.innerHTML = '
Keine Chartdaten verfuegbar.
'; + return; + } + + const values = points.map((point) => Number(point.close || 0)); + const min = Math.min(...values); + const max = Math.max(...values); + const width = 920; + const height = 340; + const paddingX = 24; + const paddingY = 28; + const usableWidth = width - paddingX * 2; + const usableHeight = height - paddingY * 2; + const spread = max - min || 1; + + const coords = points.map((point, index) => { + const x = paddingX + (usableWidth * index / Math.max(points.length - 1, 1)); + const y = paddingY + usableHeight - ((Number(point.close || 0) - min) / spread) * usableHeight; + return { x, y }; + }); + + const path = coords.map((coord, index) => `${index === 0 ? 'M' : 'L'}${coord.x.toFixed(2)},${coord.y.toFixed(2)}`).join(' '); + const area = `${path} L${coords[coords.length - 1].x.toFixed(2)},${height - paddingY} L${coords[0].x.toFixed(2)},${height - paddingY} Z`; + const grid = [0, 1, 2, 3].map((step) => { + const y = paddingY + (usableHeight * step / 3); + return ``; + }).join(''); + + chartShell.innerHTML = ` + + + + + + + + ${grid} + + + + `; + + const first = values[0]; + const last = values[values.length - 1]; + const delta = last - first; + const percent = first !== 0 ? (delta / first) * 100 : 0; + if (summaryNode) { + summaryNode.textContent = `${last.toFixed(2)} | ${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${percent.toFixed(2)}%)`; + } + } + + async function loadChart() { + const instrumentId = instrumentSelect ? instrumentSelect.value : ''; + if (!instrumentId || !endpoint) { + if (statusNode) statusNode.textContent = 'Keine Aktie fuer den Chart ausgewaehlt.'; + if (summaryNode) summaryNode.textContent = '-'; + if (chartShell) chartShell.innerHTML = '
Keine Chartdaten verfuegbar.
'; + return; + } + if (statusNode) statusNode.textContent = 'Chartdaten werden geladen...'; + try { + const response = await fetch(`${endpoint}${endpoint.includes('?') ? '&' : '?'}instrument_id=${encodeURIComponent(instrumentId)}`, { headers: { Accept: 'application/json' } }); + const payload = await response.json(); + if (!payload.ok) { + throw new Error(payload.message || 'Chartdaten konnten nicht geladen werden.'); + } + currentPayload = payload; + renderChart(pointsForRange(payload, activeRange)); + if (statusNode) { + const sourceLabel = payload.source_label || payload.source || 'Lokale Kurshistorie'; + const instrumentRef = payload.symbol || payload.isin || ''; + statusNode.textContent = instrumentRef + ? `Quelle: ${sourceLabel} | ${instrumentRef}` + : `Quelle: ${sourceLabel}`; + } + } catch (error) { + currentPayload = null; + chartShell.innerHTML = `
${error.message}
`; + if (statusNode) statusNode.textContent = 'Fehler beim Laden der Chartdaten.'; + } + } + + rangeButtons.forEach((button) => { + button.addEventListener('click', () => { + activeRange = button.getAttribute('data-range') || '1m'; + rangeButtons.forEach((item) => item.setAttribute('aria-pressed', item === button ? 'true' : 'false')); + renderChart(pointsForRange(currentPayload, activeRange)); + }); + }); + + if (instrumentSelect) { + instrumentSelect.addEventListener('change', () => { + const meta = instrumentMap.get(String(instrumentSelect.value)); + if (meta) { + if (instrumentNameNode) instrumentNameNode.textContent = meta.instrument_name || 'Keine Aktie ausgewaehlt'; + if (instrumentMetaNode) instrumentMetaNode.textContent = `${meta.symbol || ''} · ${meta.isin || '-'}`; + } + loadChart(); + }); + } + + loadChart(); +})(); diff --git a/temp/nexus-module-import/modules/boersenchecker/bootstrap.php b/temp/nexus-module-import/modules/boersenchecker/bootstrap.php new file mode 100644 index 00000000..cea0bba7 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/bootstrap.php @@ -0,0 +1,995 @@ +registerFunction($moduleName, 'table', static function (string $name): string { + $prefix = 'boersencheck_'; + $sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name); + return $prefix . $sanitized; +}); + +$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): \PDO { + $settings = modules()->settings($moduleName); + $useSeparate = !empty($settings['use_separate_db']); + + if ($useSeparate) { + $module = modules()->get($moduleName); + $fallback = $module['db_defaults'] ?? []; + return modules()->modulePdo($moduleName, $fallback); + } + + $base = app()->basePdo(); + if ($base instanceof \PDO) { + return $base; + } + + throw new ModuleConfigException( + $moduleName, + 'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.' + ); +}); + +$mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName): void { + $pdo = module_fn($moduleName, 'pdo'); + $table = static fn (string $name): string => module_fn($moduleName, 'table', $name); + $driver = strtolower((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + + $portfolioTable = $table('portfolios'); + $instrumentTable = $table('instruments'); + $positionTable = $table('positions'); + $quoteTable = $table('quotes'); + + if ($driver === 'pgsql') { + $pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} ( + id SERIAL PRIMARY KEY, + owner_sub VARCHAR(190) NOT NULL, + name VARCHAR(190) NOT NULL, + base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} ( + id SERIAL PRIMARY KEY, + isin VARCHAR(32) NULL, + wkn VARCHAR(32) NULL, + symbol VARCHAR(32) NULL, + name VARCHAR(255) NOT NULL, + quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + market VARCHAR(120) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} ( + id SERIAL PRIMARY KEY, + owner_sub VARCHAR(190) NOT NULL, + portfolio_id INTEGER NOT NULL, + instrument_id INTEGER NOT NULL, + quantity NUMERIC(20,6) NOT NULL, + purchase_price NUMERIC(20,8) NOT NULL, + purchase_currency VARCHAR(10) NOT NULL, + purchase_date DATE NOT NULL, + fees NUMERIC(20,8) NULL, + notes TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} ( + id SERIAL PRIMARY KEY, + instrument_id INTEGER NOT NULL, + price NUMERIC(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + quoted_at TIMESTAMP NOT NULL, + source VARCHAR(64) NOT NULL DEFAULT 'manual', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)"); + $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin) WHERE isin IS NOT NULL"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)"); + } elseif ($driver === 'mysql') { + $pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + owner_sub VARCHAR(190) NOT NULL, + name VARCHAR(190) NOT NULL, + base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY {$portfolioTable}_owner_idx (owner_sub) + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + isin VARCHAR(32) NULL, + wkn VARCHAR(32) NULL, + symbol VARCHAR(32) NULL, + name VARCHAR(255) NOT NULL, + quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + market VARCHAR(120) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY {$instrumentTable}_isin_uniq (isin), + KEY {$instrumentTable}_symbol_idx (symbol) + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + owner_sub VARCHAR(190) NOT NULL, + portfolio_id INTEGER NOT NULL, + instrument_id INTEGER NOT NULL, + quantity DECIMAL(20,6) NOT NULL, + purchase_price DECIMAL(20,8) NOT NULL, + purchase_currency VARCHAR(10) NOT NULL, + purchase_date DATE NOT NULL, + fees DECIMAL(20,8) NULL, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY {$positionTable}_owner_idx (owner_sub), + KEY {$positionTable}_portfolio_idx (portfolio_id), + KEY {$positionTable}_instrument_idx (instrument_id) + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + instrument_id INTEGER NOT NULL, + price DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + quoted_at DATETIME NOT NULL, + source VARCHAR(64) NOT NULL DEFAULT 'manual', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY {$quoteTable}_instrument_time_idx (instrument_id, quoted_at) + )"); + } else { + $pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_sub VARCHAR(190) NOT NULL, + name VARCHAR(190) NOT NULL, + base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + isin VARCHAR(32) NULL, + wkn VARCHAR(32) NULL, + symbol VARCHAR(32) NULL, + name VARCHAR(255) NOT NULL, + quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR', + market VARCHAR(120) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_sub VARCHAR(190) NOT NULL, + portfolio_id INTEGER NOT NULL, + instrument_id INTEGER NOT NULL, + quantity DECIMAL(20,6) NOT NULL, + purchase_price DECIMAL(20,8) NOT NULL, + purchase_currency VARCHAR(10) NOT NULL, + purchase_date DATE NOT NULL, + fees DECIMAL(20,8) NULL, + notes TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instrument_id INTEGER NOT NULL, + price DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + quoted_at DATETIME NOT NULL, + source VARCHAR(64) NOT NULL DEFAULT 'manual', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)"); + $pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)"); + $pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)"); + } +}); + +$mm->registerFunction($moduleName, 'fx_service', static function (): ?object { + if (modules()->isEnabled('fx-rates') && modules()->hasFunction('fx-rates', 'service')) { + try { + return module_fn('fx-rates', 'service'); + } catch (\Throwable) { + return null; + } + } + + return null; +}); + +$mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCurrency = 'EUR', float $maxAgeHours = 6.0): array { + $service = module_fn('boersenchecker', 'fx_service'); + if (!$service || !method_exists($service, 'ensureFreshLatestRates')) { + return [ + 'ok' => false, + 'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.', + ]; + } + + try { + $result = $service->ensureFreshLatestRates($maxAgeHours, strtoupper(trim($baseCurrency)) ?: 'EUR'); + return [ + 'ok' => true, + 'message' => !empty($result['reused']) + ? 'Vorhandene FX-Daten weiterverwendet.' + : 'FX-Daten aktualisiert.', + 'result' => $result, + ]; + } catch (\Throwable $e) { + return [ + 'ok' => false, + 'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(), + ]; + } +}); + +$mm->registerFunction($moduleName, 'fx_prepare_fetch', static function ( + string $baseCurrency = 'EUR', + array $currencies = [], + float $maxAgeHours = 6.0 +): array { + $service = module_fn('boersenchecker', 'fx_service'); + if (!$service || !method_exists($service, 'ensureFreshLatestRates')) { + return [ + 'ok' => false, + 'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.', + ]; + } + + $baseCurrency = strtoupper(trim($baseCurrency)) ?: 'EUR'; + $currencies = array_values(array_unique(array_filter(array_map( + static fn (mixed $code): string => strtoupper(trim((string) $code)), + $currencies + ), static fn (string $code): bool => $code !== ''))); + + try { + $result = $service->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies); + return [ + 'ok' => true, + 'message' => !empty($result['reused']) ? 'Vorhandene FX-Daten weiterverwendet.' : 'FX-Daten aktualisiert.', + 'result' => $result, + 'fetch_id' => is_numeric($result['fetch_id'] ?? null) ? (int) $result['fetch_id'] : null, + 'reused' => !empty($result['reused']), + ]; + } catch (\Throwable $e) { + return [ + 'ok' => false, + 'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(), + ]; + } +}); + +$mm->registerFunction($moduleName, 'fx_source_with_fetch_id', static function (string $source, ?int $fetchId = null): string { + $source = trim($source) !== '' ? trim($source) : 'manual'; + if ($fetchId === null || $fetchId <= 0) { + return preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source; + } + + $source = preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source; + return $source . '|fx_fetch:' . $fetchId; +}); + +$mm->registerFunction($moduleName, 'fx_extract_fetch_id', static function (?string $source): ?int { + $source = trim((string) $source); + if ($source === '') { + return null; + } + + if (preg_match('/\|fx_fetch:(\d+)$/', $source, $matches) === 1) { + $fetchId = (int) ($matches[1] ?? 0); + return $fetchId > 0 ? $fetchId : null; + } + + return null; +}); + +$mm->registerFunction($moduleName, 'fx_convert_with_fetch', static function ( + ?float $amount, + ?string $fromCurrency, + ?string $toCurrency, + ?int $fetchId = null +): ?float { + if ($amount === null) { + return null; + } + + $from = strtoupper(trim((string) $fromCurrency)); + $to = strtoupper(trim((string) $toCurrency)); + if ($from === '' || $to === '') { + return null; + } + if ($from === $to) { + return $amount; + } + + $service = module_fn('boersenchecker', 'fx_service'); + if (!$service) { + return null; + } + + $normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null; + if ($normalizedFetchId !== null && method_exists($service, 'snapshotByFetchId')) { + try { + $snapshot = $service->snapshotByFetchId($normalizedFetchId, null, [$from, $to]); + if (is_array($snapshot)) { + $base = strtoupper(trim((string) ($snapshot['base_currency'] ?? ''))); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + $fromRate = $from === $base ? 1.0 : (is_numeric($rates[$from] ?? null) ? (float) $rates[$from] : null); + $toRate = $to === $base ? 1.0 : (is_numeric($rates[$to] ?? null) ? (float) $rates[$to] : null); + if ($fromRate !== null && $fromRate > 0 && $toRate !== null && $toRate > 0) { + return $amount * ($toRate / $fromRate); + } + } + } catch (\Throwable) { + } + } + + if (!method_exists($service, 'convert')) { + return null; + } + + try { + $value = $service->convert($amount, $from, $to); + return is_numeric($value) ? (float) $value : null; + } catch (\Throwable) { + return null; + } +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_request', static function ( + string $functionName, + array $params = [] +): array { + $settings = modules()->settings('boersenchecker'); + $apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? '')); + $timeout = (int) (($settings['alpha_vantage_timeout_sec'] ?? null) ?: 12); + $timeout = $timeout > 0 ? $timeout : 12; + + if ($apiKey === '') { + module_debug_push('boersenchecker', [ + 'label' => 'Alpha Vantage Request', + 'type' => 'api:error', + 'request' => [ + 'function' => $functionName, + 'params' => $params, + ], + 'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', + ]); + return [ + 'ok' => false, + 'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.', + ]; + } + + $url = 'https://www.alphavantage.co/query?' . http_build_query(array_merge([ + 'function' => $functionName, + 'apikey' => $apiKey, + ], $params), '', '&', PHP_QUERY_RFC3986); + + $responseBody = null; + $httpCode = 0; + $curlError = ''; + if (function_exists('curl_init')) { + $ch = curl_init($url); + if ($ch !== false) { + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => min(5, $timeout), + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + $responseBody = curl_exec($ch); + $curlError = curl_error($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + } + } + + if (!is_string($responseBody) || $responseBody === '') { + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => $timeout, + 'header' => "Accept: application/json\r\n", + ], + ]); + $responseBody = @file_get_contents($url, false, $context); + } + + if (!is_string($responseBody) || $responseBody === '') { + module_debug_push('boersenchecker', [ + 'label' => 'Alpha Vantage Request', + 'type' => 'api:error', + 'request' => [ + 'function' => $functionName, + 'url' => $url, + 'params' => $params, + ], + 'response' => [ + 'http_code' => $httpCode, + 'curl_error' => $curlError, + 'body' => null, + ], + 'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.', + ]); + return [ + 'ok' => false, + 'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.' + . ($curlError !== '' ? ' ' . $curlError : '') + . ($httpCode > 0 ? ' HTTP ' . $httpCode : ''), + ]; + } + + $decoded = json_decode($responseBody, true); + if (!is_array($decoded)) { + module_debug_push('boersenchecker', [ + 'label' => 'Alpha Vantage Request', + 'type' => 'api:error', + 'request' => [ + 'function' => $functionName, + 'url' => $url, + 'params' => $params, + ], + 'response' => [ + 'http_code' => $httpCode, + 'body_preview' => substr($responseBody, 0, 4000), + ], + 'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.', + ]); + return [ + 'ok' => false, + 'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.', + 'raw_body' => $responseBody, + ]; + } + + foreach (['Error Message', 'Information', 'Note'] as $errorKey) { + if (isset($decoded[$errorKey]) && is_string($decoded[$errorKey]) && trim($decoded[$errorKey]) !== '') { + module_debug_push('boersenchecker', [ + 'label' => 'Alpha Vantage Request', + 'type' => 'api:error', + 'request' => [ + 'function' => $functionName, + 'url' => $url, + 'params' => $params, + ], + 'response' => [ + 'http_code' => $httpCode, + 'body' => $decoded, + ], + 'message' => trim((string) $decoded[$errorKey]), + ]); + return [ + 'ok' => false, + 'message' => trim((string) $decoded[$errorKey]), + 'raw' => $decoded, + ]; + } + } + + module_debug_push('boersenchecker', [ + 'label' => 'Alpha Vantage Request', + 'type' => 'api:response', + 'request' => [ + 'function' => $functionName, + 'url' => $url, + 'params' => $params, + ], + 'response' => [ + 'http_code' => $httpCode, + 'body' => $decoded, + ], + ]); + + return [ + 'ok' => true, + 'data' => $decoded, + ]; +}); + +$mm->registerFunction($moduleName, 'display_timezone', static function (): \DateTimeZone { + return new \DateTimeZone(nexus_display_timezone_name()); +}); + +$mm->registerFunction($moduleName, 'normalize_market_timestamp_utc', static function (mixed $value): string { + if (is_numeric($value)) { + return gmdate('Y-m-d H:i:s', (int) $value); + } + + $raw = trim((string) $value); + if ($raw === '') { + return gmdate('Y-m-d H:i:s'); + } + + try { + $date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC')); + return $date->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + } catch (\Throwable) { + $timestamp = strtotime($raw); + return $timestamp !== false ? gmdate('Y-m-d H:i:s', $timestamp) : gmdate('Y-m-d H:i:s'); + } +}); + +$mm->registerFunction($moduleName, 'format_datetime_for_display', static function ( + ?string $value, + ?string $source = null, + string $format = 'Y-m-d H:i:s' +): string { + $raw = trim((string) $value); + if ($raw === '') { + return ''; + } + + $displayTimezone = new \DateTimeZone(nexus_display_timezone_name()); + $source = trim((string) $source); + + if (str_starts_with($source, 'bavest:') || str_starts_with($source, 'alphavantage:')) { + $date = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new \DateTimeZone('UTC')); + if (!$date instanceof \DateTimeImmutable) { + try { + $date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC')); + } catch (\Throwable) { + return $raw; + } + } + return $date->setTimezone($displayTimezone)->format($format); + } + + if (preg_match('/(Z|[+\-]\d{2}:\d{2})$/', $raw) === 1) { + try { + return (new \DateTimeImmutable($raw))->setTimezone($displayTimezone)->format($format); + } catch (\Throwable) { + return $raw; + } + } + + return str_replace('T', ' ', $raw); +}); + +$mm->registerFunction($moduleName, 'local_now_input_value', static function (): string { + return (new \DateTimeImmutable('now', new \DateTimeZone(nexus_display_timezone_name())))->format('Y-m-d\TH:i'); +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_extract_global_quote', static function (array $entry): ?array { + $quote = is_array($entry['Global Quote'] ?? null) ? $entry['Global Quote'] : $entry; + $price = $quote['05. price'] ?? null; + if (!is_numeric($price)) { + return null; + } + + return [ + 'symbol' => trim((string) ($quote['01. symbol'] ?? '')), + 'price' => (float) $price, + 'currency' => '', + 'fetched_at' => gmdate('Y-m-d H:i:s'), + 'market_date' => trim((string) ($quote['07. latest trading day'] ?? '')), + 'source' => 'alphavantage:global_quote', + 'raw' => $quote, + ]; +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quote_by_symbol', static function (string $symbol): array { + $symbol = strtoupper(trim($symbol)); + if ($symbol === '') { + return [ + 'ok' => false, + 'message' => 'Kein Symbol hinterlegt.', + ]; + } + + $response = module_fn('boersenchecker', 'alpha_vantage_request', 'GLOBAL_QUOTE', [ + 'symbol' => $symbol, + ]); + if (empty($response['ok'])) { + return $response; + } + + $quote = module_fn('boersenchecker', 'alpha_vantage_extract_global_quote', (array) ($response['data'] ?? [])); + if (!is_array($quote)) { + return [ + 'ok' => false, + 'message' => 'Alpha Vantage lieferte keinen Preis fuer das Symbol ' . $symbol . '.', + ]; + } + + return ['ok' => true] + $quote; +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quotes', static function (array $instruments): array { + $quotes = []; + $errors = []; + foreach ($instruments as $instrument) { + if (!is_array($instrument)) { + continue; + } + $instrumentId = (int) ($instrument['id'] ?? 0); + $symbol = strtoupper(trim((string) ($instrument['symbol'] ?? ''))); + if ($instrumentId <= 0 || $symbol === '') { + continue; + } + + $result = module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol); + if (empty($result['ok'])) { + $errors[] = $symbol . ': ' . (string) ($result['message'] ?? 'API-Abruf fehlgeschlagen.'); + continue; + } + + $quotes[$instrumentId] = $result + ['instrument_id' => $instrumentId]; + } + + return [ + 'ok' => true, + 'quotes' => $quotes, + 'errors' => $errors, + 'message' => count($quotes) . ' Kurse ueber Alpha Vantage geladen.', + ]; +}); + +$mm->registerFunction($moduleName, 'scheduled_refresh_quotes', static function (array $context = []): array { + $pdo = module_fn('boersenchecker', 'pdo'); + $settings = modules()->settings('boersenchecker'); + $instrumentTable = module_fn('boersenchecker', 'table', 'instruments'); + $positionTable = module_fn('boersenchecker', 'table', 'positions'); + $quoteTable = module_fn('boersenchecker', 'table', 'quotes'); + + $defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; + $minIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); + if ($minIntervalMinutes <= 0) { + $minIntervalMinutes = 60; + } + + $stmt = $pdo->query( + 'SELECT DISTINCT + i.id, + i.name, + i.symbol, + i.quote_currency + FROM ' . $positionTable . ' p + INNER JOIN ' . $instrumentTable . ' i ON i.id = p.instrument_id + WHERE i.symbol IS NOT NULL + AND i.symbol <> \'\' + ORDER BY i.name ASC' + ); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + if ($rows === []) { + return [ + 'ok' => true, + 'message' => 'Kein automatischer Kursabruf: keine Aktien mit Symbol vorhanden.', + ]; + } + + $instrumentIds = array_values(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $rows)); + $instrumentIds = array_values(array_filter($instrumentIds, static fn (int $id): bool => $id > 0)); + $latestQuotes = []; + if ($instrumentIds !== []) { + $placeholders = implode(',', array_fill(0, count($instrumentIds), '?')); + $latestStmt = $pdo->prepare( + 'SELECT * + FROM ' . $quoteTable . ' + WHERE instrument_id IN (' . $placeholders . ') + AND source LIKE ? + ORDER BY quoted_at DESC, created_at DESC, id DESC' + ); + $latestStmt->execute([...$instrumentIds, 'alphavantage:%']); + foreach ($latestStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { + $instrumentId = (int) ($row['instrument_id'] ?? 0); + if ($instrumentId > 0 && !isset($latestQuotes[$instrumentId])) { + $latestQuotes[$instrumentId] = $row; + } + } + } + + $reused = 0; + $candidates = []; + foreach ($rows as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $latest = $latestQuotes[$instrumentId] ?? null; + $latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false; + if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($minIntervalMinutes * 60)) { + $reused++; + continue; + } + $candidates[] = $row; + } + + if ($candidates === []) { + return [ + 'ok' => true, + 'message' => 'Automatischer Kursabruf uebersprungen: alle Kurse liegen noch innerhalb des Mindestabstands.', + ]; + } + + $quoteCurrencies = array_values(array_unique(array_filter(array_map( + static fn (array $row): string => strtoupper(trim((string) ($row['quote_currency'] ?? ''))), + $candidates + ), static fn (string $code): bool => $code !== ''))); + $fxResult = module_fn('boersenchecker', 'fx_prepare_fetch', $defaultReportCurrency, $quoteCurrencies, (float) (($settings['fx_max_age_hours'] ?? null) ?: 6)); + if (empty($fxResult['ok'])) { + return $fxResult; + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + + $bulkResult = module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $candidates); + if (empty($bulkResult['ok'])) { + return [ + 'ok' => false, + 'message' => (string) ($bulkResult['message'] ?? 'Automatischer Alpha-Vantage-Abruf fehlgeschlagen.'), + ]; + } + + $quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; + $errors = is_array($bulkResult['errors'] ?? null) ? $bulkResult['errors'] : []; + $updated = 0; + foreach ($candidates as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $quote = $quotes[$instrumentId] ?? null; + if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) { + continue; + } + + $storeResult = module_fn( + 'boersenchecker', + 'store_market_quote', + $instrumentId, + (float) $quote['price'], + strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $defaultReportCurrency))) ?: $defaultReportCurrency, + (string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')), + (string) module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId) + ); + if (!empty($storeResult['inserted'])) { + $updated++; + } else { + $reused++; + } + } + + $message = 'Automatischer Kursabruf: ' . $updated . ' neu, ' . $reused . ' wiederverwendet, ' . count($errors) . ' Fehler.'; + if ($errors !== []) { + $message .= ' ' . implode(' | ', array_slice($errors, 0, 3)); + } + + module_debug_push('boersenchecker', [ + 'label' => 'Intervall-Aufgabe', + 'type' => 'scheduler:run', + 'task' => 'auto_refresh_quotes', + 'context' => $context, + 'message' => $message, + ]); + + return [ + 'ok' => $errors === [], + 'message' => $message, + 'updated' => $updated, + 'reused' => $reused, + 'errors' => $errors, + ]; +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_search_symbols', static function (string $keywords): array { + $keywords = trim($keywords); + if ($keywords === '') { + return [ + 'ok' => false, + 'message' => 'Bitte Suchbegriff angeben.', + 'results' => [], + ]; + } + + $response = module_fn('boersenchecker', 'alpha_vantage_request', 'SYMBOL_SEARCH', [ + 'keywords' => $keywords, + ]); + if (empty($response['ok'])) { + return $response + ['results' => []]; + } + + $data = is_array($response['data'] ?? null) ? $response['data'] : []; + $items = is_array($data['bestMatches'] ?? null) ? $data['bestMatches'] : []; + $results = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $symbol = trim((string) ($item['1. symbol'] ?? '')); + $name = trim((string) ($item['2. name'] ?? '')); + $type = trim((string) ($item['3. type'] ?? '')); + $region = trim((string) ($item['4. region'] ?? '')); + $currency = strtoupper(trim((string) ($item['8. currency'] ?? ''))); + $matchScore = trim((string) ($item['9. matchScore'] ?? '')); + if ($symbol === '' && $name === '') { + continue; + } + + $results[] = [ + 'symbol' => $symbol, + 'name' => $name, + 'isin' => '', + 'type' => $type, + 'region' => $region, + 'currency' => $currency, + 'match_score' => $matchScore, + 'raw' => $item, + ]; + } + + return [ + 'ok' => true, + 'message' => count($results) . ' Treffer gefunden.', + 'results' => $results, + ]; +}); + +$mm->registerFunction($moduleName, 'alpha_vantage_fetch_chart_series', static function (string $symbol): array { + $symbol = strtoupper(trim($symbol)); + if ($symbol === '') { + return ['ok' => false, 'message' => 'Kein Symbol angegeben.']; + } + + $cacheDir = sys_get_temp_dir() . '/boersenchecker-alphavantage'; + if (!is_dir($cacheDir)) { + @mkdir($cacheDir, 0775, true); + } + $cachePath = $cacheDir . '/' . md5('time_series_daily_adjusted|' . $symbol) . '.json'; + + $decoded = null; + if (is_file($cachePath) && (time() - filemtime($cachePath)) < (6 * 3600)) { + $cached = file_get_contents($cachePath); + $decoded = is_string($cached) ? json_decode($cached, true) : null; + } + + if (!is_array($decoded)) { + $response = module_fn('boersenchecker', 'alpha_vantage_request', 'TIME_SERIES_DAILY_ADJUSTED', [ + 'symbol' => $symbol, + 'outputsize' => 'full', + ]); + if (empty($response['ok'])) { + return $response; + } + $decoded = is_array($response['data'] ?? null) ? $response['data'] : []; + @file_put_contents($cachePath, json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + $rows = is_array($decoded['Time Series (Daily)'] ?? null) + ? $decoded['Time Series (Daily)'] + : (is_array($decoded['Time Series (Daily) Adjusted'] ?? null) ? $decoded['Time Series (Daily) Adjusted'] : []); + + $daily = []; + foreach ($rows as $date => $row) { + if (!is_array($row) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $date)) { + continue; + } + $close = $row['5. adjusted close'] ?? $row['4. close'] ?? null; + if (!is_numeric($close)) { + continue; + } + $daily[] = [ + 'date' => $date, + 'close' => (float) $close, + ]; + } + usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date'])); + if ($daily === []) { + return [ + 'ok' => false, + 'message' => 'Keine historischen Schlusskurse fuer ' . $symbol . ' verfuegbar.', + ]; + } + + $aggregate = static function (array $points, string $format): array { + $result = []; + foreach ($points as $point) { + $bucket = date($format, strtotime((string) $point['date']) ?: time()); + $result[$bucket] = $point; + } + return array_values($result); + }; + + return [ + 'ok' => true, + 'symbol' => $symbol, + 'daily' => $daily, + 'weekly' => $aggregate($daily, 'o-W'), + 'monthly' => $aggregate($daily, 'Y-m'), + 'source' => 'alphavantage:time_series_daily_adjusted', + ]; +}); + +$mm->registerFunction($moduleName, 'store_market_quote', static function ( + int $instrumentId, + float $price, + string $currency, + string $quotedAt, + string $source +): array { + $pdo = module_fn('boersenchecker', 'pdo'); + $quoteTable = module_fn('boersenchecker', 'table', 'quotes'); + + $quotedAt = trim($quotedAt); + $currency = strtoupper(trim($currency)) ?: 'EUR'; + $source = trim($source) !== '' ? trim($source) : 'alphavantage:global_quote'; + + $checkStmt = $pdo->prepare( + 'SELECT id + FROM ' . $quoteTable . ' + WHERE instrument_id = :instrument_id + AND price = :price + AND currency = :currency + AND quoted_at = :quoted_at + AND source = :source + LIMIT 1' + ); + $checkStmt->execute([ + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => $currency, + 'quoted_at' => $quotedAt, + 'source' => $source, + ]); + $existingId = (int) $checkStmt->fetchColumn(); + if ($existingId > 0) { + module_debug_push('boersenchecker', [ + 'label' => 'Quote Store', + 'type' => 'quote:reuse', + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => $currency, + 'quoted_at' => $quotedAt, + 'source' => $source, + 'message' => 'Identischer Snapshot bereits vorhanden.', + ]); + return ['ok' => true, 'inserted' => false, 'id' => $existingId]; + } + + $insertStmt = $pdo->prepare( + 'INSERT INTO ' . $quoteTable . ' (instrument_id, price, currency, quoted_at, source) + VALUES (:instrument_id, :price, :currency, :quoted_at, :source)' + ); + $insertStmt->execute([ + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => $currency, + 'quoted_at' => $quotedAt, + 'source' => $source, + ]); + + $insertedId = (int) $pdo->lastInsertId(); + module_debug_push('boersenchecker', [ + 'label' => 'Quote Store', + 'type' => 'quote:insert', + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => $currency, + 'quoted_at' => $quotedAt, + 'source' => $source, + 'inserted_id' => $insertedId, + 'message' => 'Neuer Snapshot gespeichert.', + ]); + + return ['ok' => true, 'inserted' => true, 'id' => $insertedId]; +}); diff --git a/temp/nexus-module-import/modules/boersenchecker/design.json b/temp/nexus-module-import/modules/boersenchecker/design.json new file mode 100644 index 00000000..400f49b4 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/design.json @@ -0,0 +1,14 @@ +{ + "eyebrow": "Modul", + "title": "Boersenchecker", + "description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.", + "actions": [ + { "label": "Nexus Übersicht", "href": "/" }, + { "label": "Setup", "href": "/modules/setup/boersenchecker" } + ], + "tabs": [ + { "label": "Ueberblick", "href": "/module/boersenchecker" }, + { "label": "Depotverwaltung", "href": "/module/boersenchecker/depotverwaltung" }, + { "label": "Aktienverwaltung", "href": "/module/boersenchecker/aktienverwaltung" } + ] +} diff --git a/temp/nexus-module-import/modules/boersenchecker/module.json b/temp/nexus-module-import/modules/boersenchecker/module.json new file mode 100644 index 00000000..d7d88ae6 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/module.json @@ -0,0 +1,51 @@ +{ + "title": "Börsenchecker", + "version": "0.2.0", + "description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.", + "enabled_by_default": false, + "setup": { + "fields": [ + { "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." }, + { "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" }, + { "name": "db.host", "label": "DB Host", "type": "text", "required": false }, + { "name": "db.port", "label": "DB Port", "type": "number", "required": false }, + { "name": "db.dbname", "label": "DB Name", "type": "text", "required": false }, + { "name": "db.schema", "label": "DB Schema", "type": "text", "required": false }, + { "name": "db.user", "label": "DB User", "type": "text", "required": false }, + { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false }, + { "name": "report_currency", "label": "Standard-Berichtswahrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Portfolio-Summen, z.B. EUR." }, + { "name": "fx_max_age_hours", "label": "Maximales FX-Alter (Stunden)", "type": "number", "required": false, "help": "Wird bei manueller Aktualisierung ueber das Modul fx-rates genutzt." }, + { "name": "alpha_vantage_api_key", "label": "Alpha Vantage API Key", "type": "password", "required": false, "help": "API Key fuer Aktienkursabrufe und Suche ueber Alpha Vantage." }, + { "name": "alpha_vantage_timeout_sec", "label": "Alpha Vantage Timeout (Sek.)", "type": "number", "required": false, "help": "HTTP-Timeout fuer API-Abrufe." }, + { "name": "alpha_vantage_min_interval_minutes", "label": "Alpha Vantage Mindestabstand (Min.)", "type": "number", "required": false, "help": "Wenn bereits ein frischer Alpha-Vantage-Kurs existiert, wird dieser wiederverwendet statt erneut abzurufen." }, + { "name": "auto_refresh_quotes_enabled", "label": "Automatischen Kursabruf aktivieren", "type": "checkbox", "required": false, "help": "Fuehrt Kursupdates automatisch beim ersten Modulaufruf nach Ablauf des Intervalls aus." }, + { "name": "auto_refresh_quotes_interval_hours", "label": "Intervall fuer automatischen Kursabruf (Stunden)", "type": "number", "required": false, "help": "Nach Ablauf dieses Intervalls wird beim naechsten Modulaufruf ein automatischer Kursabruf gestartet." } + ] + }, + "interval_tasks": [ + { + "name": "auto_refresh_quotes", + "label": "Automatischer Kursabruf", + "callback": "scheduled_refresh_quotes", + "enabled_setting": "auto_refresh_quotes_enabled", + "interval_setting": "auto_refresh_quotes_interval_hours", + "default_enabled": false, + "default_interval_hours": 6, + "lock_minutes": 20 + } + ], + "db_defaults": { + "driver": "pgsql", + "host": "localhost", + "port": 5432, + "dbname": "", + "schema": "public", + "user": "", + "password": "" + }, + "auth": { + "required": true, + "users": [], + "groups": [] + } +} diff --git a/temp/nexus-module-import/modules/boersenchecker/pages/aktienverwaltung.php b/temp/nexus-module-import/modules/boersenchecker/pages/aktienverwaltung.php new file mode 100644 index 00000000..5b689811 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/pages/aktienverwaltung.php @@ -0,0 +1,13 @@ +assets(); +if ($assets) { + $assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css'); + $assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true); +} + +$page = new \Modules\Boersenchecker\Support\InstrumentPage(); +module_tpl('boersenchecker', 'instruments', $page->handle()); diff --git a/temp/nexus-module-import/modules/boersenchecker/pages/asset.php b/temp/nexus-module-import/modules/boersenchecker/pages/asset.php new file mode 100644 index 00000000..e47e8827 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/pages/asset.php @@ -0,0 +1,30 @@ + $base . '/boersenchecker.css', + 'boersenchecker.js' => $base . '/boersenchecker.js', +]; + +if (!isset($map[$file])) { + http_response_code(404); + exit('Not found'); +} + +$path = $map[$file]; +if (!$base || !is_file($path) || !str_starts_with($path, $base)) { + http_response_code(404); + exit('Not found'); +} + +$ext = pathinfo($path, PATHINFO_EXTENSION); +if ($ext === 'css') { + header('Content-Type: text/css; charset=utf-8'); +} elseif ($ext === 'js') { + header('Content-Type: application/javascript; charset=utf-8'); +} else { + header('Content-Type: application/octet-stream'); +} + +readfile($path); +exit; diff --git a/temp/nexus-module-import/modules/boersenchecker/pages/chart_data.php b/temp/nexus-module-import/modules/boersenchecker/pages/chart_data.php new file mode 100644 index 00000000..3dbcf4e7 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/pages/chart_data.php @@ -0,0 +1,130 @@ + false, 'message' => 'instrument_id fehlt.'], JSON_UNESCAPED_UNICODE); + exit; +} + +$pdo = module_fn('boersenchecker', 'pdo'); +module_fn('boersenchecker', 'ensure_schema'); +$instrumentTable = module_fn('boersenchecker', 'table', 'instruments'); +$positionTable = module_fn('boersenchecker', 'table', 'positions'); +$quoteTable = module_fn('boersenchecker', 'table', 'quotes'); + +$stmt = $pdo->prepare( + 'SELECT i.id, i.name, i.symbol, i.isin, i.quote_currency + FROM ' . $instrumentTable . ' i + INNER JOIN ' . $positionTable . ' p ON p.instrument_id = i.id + WHERE i.id = :id AND p.owner_sub = :owner_sub + LIMIT 1' +); +$stmt->execute([ + 'id' => $instrumentId, + 'owner_sub' => $ownerSub, +]); +$instrument = $stmt->fetch(PDO::FETCH_ASSOC); + +header('Content-Type: application/json; charset=utf-8'); +if (!is_array($instrument)) { + echo json_encode(['ok' => false, 'message' => 'Aktie nicht verfuegbar.'], JSON_UNESCAPED_UNICODE); + exit; +} + +$quoteStmt = $pdo->prepare( + 'SELECT id, price, currency, quoted_at, source, created_at + FROM ' . $quoteTable . ' + WHERE instrument_id = :instrument_id + ORDER BY quoted_at ASC, created_at ASC, id ASC' +); +$quoteStmt->execute([ + 'instrument_id' => $instrumentId, +]); +$quotes = $quoteStmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + +if ($quotes === []) { + echo json_encode([ + 'ok' => false, + 'message' => 'Keine lokalen Kursdaten fuer diese Aktie vorhanden.', + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$dailyMap = []; +foreach ($quotes as $quote) { + $localDate = trim((string) module_fn( + 'boersenchecker', + 'format_datetime_for_display', + (string) ($quote['quoted_at'] ?? ''), + (string) ($quote['source'] ?? ''), + 'Y-m-d' + )); + $localDateTime = trim((string) module_fn( + 'boersenchecker', + 'format_datetime_for_display', + (string) ($quote['quoted_at'] ?? ''), + (string) ($quote['source'] ?? ''), + 'Y-m-d H:i:s' + )); + if ($localDate === '' || !is_numeric($quote['price'] ?? null)) { + continue; + } + + $point = [ + 'date' => $localDate, + 'close' => (float) $quote['price'], + 'currency' => strtoupper(trim((string) ($quote['currency'] ?? ''))), + 'quoted_at' => $localDateTime, + 'source' => (string) ($quote['source'] ?? ''), + ]; + + if (!isset($dailyMap[$localDate]) || strcmp($localDateTime, (string) ($dailyMap[$localDate]['quoted_at'] ?? '')) >= 0) { + $dailyMap[$localDate] = $point; + } +} + +$daily = array_values($dailyMap); +usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date'])); + +if ($daily === []) { + echo json_encode([ + 'ok' => false, + 'message' => 'Keine gueltigen lokalen Schlusskurse fuer diese Aktie vorhanden.', + ], JSON_UNESCAPED_UNICODE); + exit; +} + +$aggregate = static function (array $points, string $format): array { + $result = []; + $timezone = new DateTimeZone(nexus_display_timezone_name()); + foreach ($points as $point) { + $date = DateTimeImmutable::createFromFormat('Y-m-d', (string) ($point['date'] ?? ''), $timezone); + if (!$date instanceof DateTimeImmutable) { + continue; + } + $bucket = $date->format($format); + $result[$bucket] = $point; + } + return array_values($result); +}; + +echo json_encode([ + 'ok' => true, + 'symbol' => strtoupper(trim((string) ($instrument['symbol'] ?? ''))), + 'isin' => strtoupper(trim((string) ($instrument['isin'] ?? ''))), + 'instrument_name' => (string) ($instrument['name'] ?? ''), + 'currency' => strtoupper(trim((string) ($instrument['quote_currency'] ?? ''))), + 'daily' => $daily, + 'weekly' => $aggregate($daily, 'o-W'), + 'monthly' => $aggregate($daily, 'Y-m'), + 'source' => 'database:quotes', + 'source_label' => 'Lokale Kurshistorie', +], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +exit; diff --git a/temp/nexus-module-import/modules/boersenchecker/pages/depotverwaltung.php b/temp/nexus-module-import/modules/boersenchecker/pages/depotverwaltung.php new file mode 100644 index 00000000..79da06a5 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/pages/depotverwaltung.php @@ -0,0 +1,13 @@ +assets(); +if ($assets) { + $assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css'); + $assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true); +} + +$page = new \Modules\Boersenchecker\Support\DashboardPage(); +module_tpl('boersenchecker', 'dashboard', $page->handle()); diff --git a/temp/nexus-module-import/modules/boersenchecker/pages/index.php b/temp/nexus-module-import/modules/boersenchecker/pages/index.php new file mode 100644 index 00000000..fd5930a1 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/pages/index.php @@ -0,0 +1,13 @@ +assets(); +if ($assets) { + $assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css'); + $assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true); +} + +$page = new \Modules\Boersenchecker\Support\HomePage(); +module_tpl('boersenchecker', 'home', $page->handle()); diff --git a/temp/nexus-module-import/modules/boersenchecker/partials/dashboard.php b/temp/nexus-module-import/modules/boersenchecker/partials/dashboard.php new file mode 100644 index 00000000..e8f69e8f --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/partials/dashboard.php @@ -0,0 +1,512 @@ + + 'Depotverwaltung', +]) ?> +
+ +
+ +
+ + + +
+
+
+

Benutzer-Scope

+

Depots anderer Benutzer sind nur fuer `appadmin` sichtbar und bearbeitbar.

+
+
+
+ + +
+
+ + +
+
+
+
+

+

Stammdaten und Berichtswahrung fuer ein Depot pflegen.

+
+
+
+ + + + + + +
+ + + Abbrechen + +
+
+
+ +
+
+
+

API / FX

+

Kurs- und Waehrungsdaten zentral aktualisieren.

+
+
+

+ Die Umrechnung liest gespeicherte FX-Daten zentral aus dem Modul fx-rates. Eine Aktualisierung wird nur manuell + angestossen und respektiert die dortige Max-Age- und Reuse-Logik. +

+

+ Aktienkurse werden ueber Alpha Vantage anhand des hinterlegten Symbols abgerufen. Die ISIN bleibt als Stammdatum erhalten. +

+
+
+ + + +
+
+ + + +
+
+
+ Alpha Vantage Mindestabstand: Min. +
+
+ API-Key und Timeout fuer Aktienkurse werden ueber dieses Modul-Setup gepflegt. +
+
+ FX-Provider, API-Key und Waehrungskatalog werden im Modul fx-rates gepflegt. +
+
+ Standard-Berichtswahrung: · Max. Alter: h +
+
+
+ +
+
+
+

+

Aktienpositionen fuer ein Depot mit Kaufdaten und Kurswaehrung verwalten.

+
+
+ +
Bitte zuerst ein Depot anlegen.
+ +
+ + + + + +
+ + + + + + +
+
+ + + + + +
+ +
+ + + Abbrechen + +
+
+ +
+ +
+
+
+

Wertpapiersuche

+

Alpha-Vantage-Suchergebnisse pruefen und Daten direkt ins Positionsformular uebernehmen.

+
+
+
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
SymbolNameTypRegionWaehrungMatchAktion
+ + In Formular uebernehmen + +
+ +
+
Noch keine Symbolsuche ausgefuehrt.
+
+ +
+ +
+
+
+

Manuellen Kurs erfassen

+

Kurse mit Uhrzeit und Quelle direkt in die Historie schreiben.

+
+
+ +
Sobald Positionen vorhanden sind, koennen hier Kurse mit Uhrzeit gespeichert werden.
+ +
+ + +
+ + + + + +
+
+ +
+
+ +
+ +
+
+
+

Depots

+

Uebersicht aller Depots mit Kennzahlen und Schnellaktionen.

+
+
+ +
Noch keine Depots vorhanden.
+ +
+ + 0, 'invested' => 0.0, 'current' => 0.0, 'gain' => null, 'has_invested' => false, 'has_current' => false]; + ?> +
+
+
+ +
· Position(en)
+
+
+ Bearbeiten +
+ + + + +
+
+
+ +
+ +
+
+
Investiert
+ +
+
+
Aktuell
+ +
+
+
Gewinn / Verlust
+ +
+
+
+ +
+ +
+ +
+
+
+

Positionen

+

Alle Positionen mit Kaufdaten, letztem Kurs und aktuellen Werten.

+
+
+ +
+
Noch keine Positionen vorhanden.
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DepotAktieISIN / WKNStueckKaufLetzter KursWertDeltaAktion
+ + +
+ +
+ +
+
+ +
+
+ + +
+ + kein Kurs + +
+ + + + n/a + + + + + + n/a + + +
+ Bearbeiten + Kurs erfassen +
+ + + + +
+
+ + + + +
+
+
+ +
+ +
+
+
+

Kursverlauf

+

Historische Kurse pro Aktie mit Zeitstempel und Quelle.

+
+
+ +
Noch keine Kursdaten vorhanden.
+ +
+ $instrument): ?> + +
+
+
+ +
+ · +
+
+ Neuen Kurs erfassen +
+ +
Noch keine historischen Kurse vorhanden.
+ +
+ + + + + + + + + + + + + + + + + + + +
ZeitpunktKursQuelleAktion
+
+ + + + +
+
+
+ +
+ +
+ +
+
+ diff --git a/temp/nexus-module-import/modules/boersenchecker/partials/home.php b/temp/nexus-module-import/modules/boersenchecker/partials/home.php new file mode 100644 index 00000000..4fbbfb7b --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/partials/home.php @@ -0,0 +1,209 @@ + 'Depot-Ueberblick', +]) ?> +
+ + + +
+ +
+ + +
+
+
+

Marktueberblick

+

Depotauswahl, Aktienfokus und aktueller Kursabruf in einem Bereich.

+
+
+
+
+
Depotauswahl
+ +
Keine Depots vorhanden.
+ + + +
+ +
+ +
Aktienauswahl
+ +
Keine Aktien im ausgewaehlten Depot.
+ + + +
+ +
+ + +
Marktdaten
+

Aktuelle Kurse fuer das gewaehlte Depot ueber Alpha Vantage anhand des hinterlegten Symbols abrufen.

+
+ +
+
+
+
+ +
+
+
Positionen
+
+
Aktien im aktuell gewaehlten Depot
+
+
+
Investiert
+
+
In Berichtswahrung bewertet
+
+
+
Aktueller Wert
+
+
Basierend auf dem letzten gespeicherten Kurs
+
+
+
Performance
+
+
+
+
+ +
+
+
Bester Wert
+ +
+
%
+ +
Noch keine Performance verfuegbar.
+ +
+ +
+
Schwaechster Wert
+ +
+
%
+ +
Noch keine Performance verfuegbar.
+ +
+ + +
+
+
+
+
+ +
+ +
+
+
+

Kursverlauf

+

Schlusskurse ueber mehrere Zeitfenster fuer das aktuell gewaehlte Instrument.

+
+
+
+
+
+
Aktie
+
+ +
·
+ +
+
+ + + + + + + +
+
+
Chartdaten werden geladen...
+
-
+
+
+
+ +
+
+
+

Aktien im Depot

+

Stueckzahl, Kaufdaten, letzter Kurs und Performance auf einen Blick.

+
+
+
+ +
Keine Aktien im ausgewaehlten Depot.
+ +
+ + = 0) ? 'is-positive' : 'is-negative'; ?> +
+
+ +
·
+ +
+ +
+
+
Stueckzahl
+
+
+
+
Kaufpreis
+
+
+
+
Letzter Kurs
+
+
+
+
Performance
+
+ +
+
+
+ +
+ +
+
+
+ diff --git a/temp/nexus-module-import/modules/boersenchecker/partials/instruments.php b/temp/nexus-module-import/modules/boersenchecker/partials/instruments.php new file mode 100644 index 00000000..84f7e3d7 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/partials/instruments.php @@ -0,0 +1,187 @@ + 'Aktienverwaltung', +]) ?> +
+ +
+ +
+ + +
+
+
+
+

Aktie waehlen

+

Systemweit vorhandene Aktie aus allen Depots auswaehlen.

+
+
+
+ +
+
+ +
+
+
+

Wertpapiersuche

+

Alpha-Vantage-Suchergebnisse finden und direkt fuer die Aktie uebernehmen.

+
+
+
+
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
SymbolNameRegionWaehrungAktion
+ + Uebernehmen + +
+ +
+
Noch keine Symbolsuche ausgefuehrt.
+
+ +
+
+ +
+
+
+

Aktie bearbeiten

+

Stammdaten, Markt und Kurswaehrung zentral fuer die Aktie pflegen.

+
+
+ +
Keine Aktie vorhanden.
+ +
+ + +
+ + + + + + +
+
+ +
+
+
+ + + +
+ +
+ +
+
+
+

Manuellen Kurs eingeben

+

Einzelne Kurse mit Zeitstempel und Quelle fuer die ausgewaehlte Aktie speichern.

+
+
+ +
Keine Aktie vorhanden.
+ +
+ + +
+ + + + +
+
+ +
+
+ +
+ +
+
+
+

Kursverlauf

+

Gespeicherte Kursdaten der ausgewaehlten Aktie mit Quelle und Loeschoption.

+
+
+ +
+
Keine Kursdaten vorhanden.
+
+ + + + + + + + + + + + + + + + + + + + +
ZeitpunktKursQuelleAktion
+
+ + + + +
+
+ +
+
+ diff --git a/temp/nexus-module-import/modules/boersenchecker/src/Support/DashboardPage.php b/temp/nexus-module-import/modules/boersenchecker/src/Support/DashboardPage.php new file mode 100644 index 00000000..325c3ec6 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/src/Support/DashboardPage.php @@ -0,0 +1,898 @@ +pdo = \module_fn('boersenchecker', 'pdo'); + \module_fn('boersenchecker', 'ensure_schema'); + + $this->user = \auth_user() ?? []; + $this->isAdmin = \auth_is_admin(); + $this->ownerSub = trim((string) ($this->user['sub'] ?? 'local')); + $this->availableOwners = $this->buildAvailableOwners(); + if ($this->isAdmin) { + $requestedOwner = trim((string) ($_GET['owner_sub'] ?? $_POST['owner_sub'] ?? '')); + if ($requestedOwner !== '' && isset($this->availableOwners[$requestedOwner])) { + $this->ownerSub = $requestedOwner; + } + } + $this->moduleSettings = \modules()->settings('boersenchecker'); + $this->defaultReportCurrency = $this->normalizeCurrency((string) ($this->moduleSettings['report_currency'] ?? 'EUR')); + $this->fxMaxAgeHours = (float) ($this->moduleSettings['fx_max_age_hours'] ?? 6); + if ($this->fxMaxAgeHours <= 0) { + $this->fxMaxAgeHours = 6.0; + } + + $this->marketDataMinIntervalMinutes = (int) (($this->moduleSettings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); + if ($this->marketDataMinIntervalMinutes <= 0) { + $this->marketDataMinIntervalMinutes = 60; + } + + $this->editPortfolioId = (int) ($_GET['edit_portfolio'] ?? 0); + $this->editPositionId = (int) ($_GET['edit_position'] ?? 0); + + $table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name); + $this->portfolioTable = $table('portfolios'); + $this->instrumentTable = $table('instruments'); + $this->positionTable = $table('positions'); + $this->quoteTable = $table('quotes'); + $this->instrumentRegistry = new InstrumentRegistry( + $this->pdo, + $this->instrumentTable, + $this->positionTable, + $this->quoteTable, + ); + } + + public function handle(): array + { + $notice = null; + $error = null; + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + try { + $notice = $this->handlePost(); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + $state = $this->loadState(); + + if ($notice === null && isset($state['notice_override']) && is_string($state['notice_override'])) { + $notice = $state['notice_override']; + } + if ($error === null && isset($state['error_override']) && is_string($state['error_override'])) { + $error = $state['error_override']; + } + + return [ + 'notice' => $notice, + 'error' => $error, + 'isAdmin' => $this->isAdmin, + 'ownerSub' => $this->ownerSub, + 'availableOwners' => array_values($this->availableOwners), + 'defaultReportCurrency' => $this->defaultReportCurrency, + 'fxMaxAgeHours' => $this->fxMaxAgeHours, + 'marketDataMinIntervalMinutes' => $this->marketDataMinIntervalMinutes, + 'symbolSearchKeywords' => $this->symbolSearchKeywords, + 'symbolSearchResults' => $this->symbolSearchResults, + 'editPortfolio' => $state['editPortfolio'], + 'editPosition' => $state['editPosition'], + 'portfolios' => $state['portfolios'], + 'portfolioById' => $state['portfolioById'], + 'portfolioStats' => $state['portfolioStats'], + 'positions' => $state['positions'], + 'instrumentList' => $state['instrumentList'], + 'quoteHistory' => $state['quoteHistory'], + 'selectedInstrumentForQuote' => $state['selectedInstrumentForQuote'], + 'selectedInstrumentQuoteCurrency' => $state['selectedInstrumentQuoteCurrency'], + 'fmtNumber' => fn (?float $value, int $scale = 2): string => $this->formatNumber($value, $scale), + 'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source), + 'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'), + ]; + } + + private function handlePost(): string + { + $action = trim((string) ($_POST['action'] ?? '')); + + return match ($action) { + 'save_portfolio' => $this->savePortfolio(), + 'delete_portfolio' => $this->deletePortfolio(), + 'save_position' => $this->savePosition(), + 'delete_position' => $this->deletePosition(), + 'save_quote' => $this->saveQuote(), + 'refresh_market_data_position' => $this->refreshMarketDataPosition(), + 'refresh_market_data_all' => $this->refreshMarketDataAll(), + 'search_symbol' => $this->searchSymbol(), + 'delete_quote' => $this->deleteQuote(), + 'refresh_fx' => $this->refreshFx(), + default => '', + }; + } + + private function loadState(): array + { + $portfolios = $this->fetchPortfolios(); + $positions = $this->fetchPositions(); + $instrumentList = $this->buildInstrumentList($positions); + [$quoteHistory, $latestQuotes] = $this->fetchQuotes(array_keys($instrumentList)); + $portfolioById = $this->buildPortfolioById($portfolios); + [$positions, $portfolioStats] = $this->enrichPositions($positions, $portfolioById, $latestQuotes); + + $editPortfolio = null; + if ($this->editPortfolioId > 0 && isset($portfolioById[$this->editPortfolioId])) { + $editPortfolio = $portfolioById[$this->editPortfolioId]; + } + + $editPosition = null; + if ($this->editPositionId > 0) { + foreach ($positions as $position) { + if ((int) $position['id'] === $this->editPositionId) { + $editPosition = $position; + break; + } + } + } + + $selectedInstrumentForQuote = $editPosition + ? (int) $editPosition['instrument_id'] + : (int) ($_GET['instrument_id'] ?? 0); + $selectedInstrumentQuoteCurrency = $this->defaultReportCurrency; + if ($selectedInstrumentForQuote > 0 && isset($instrumentList[$selectedInstrumentForQuote])) { + $selectedInstrumentQuoteCurrency = $this->normalizeCurrency( + (string) ($instrumentList[$selectedInstrumentForQuote]['quote_currency'] ?? $this->defaultReportCurrency) + ); + } + + $candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? '')); + $candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? '')); + $candidateIsin = trim((string) ($_GET['isin_candidate'] ?? '')); + $candidateMarket = trim((string) ($_GET['market_candidate'] ?? '')); + $candidateCurrency = $this->normalizeCurrency((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency)); + + if ($editPosition === null) { + $editPosition = [ + 'instrument_name' => $candidateName, + 'symbol' => $candidateSymbol, + 'isin' => $candidateIsin, + 'market' => $candidateMarket, + 'quote_currency' => $candidateCurrency, + 'purchase_currency' => $this->defaultReportCurrency, + 'purchase_date' => date('Y-m-d'), + ]; + } else { + if ($candidateName !== '') { + $editPosition['instrument_name'] = $candidateName; + } + if ($candidateSymbol !== '') { + $editPosition['symbol'] = $candidateSymbol; + } + if ($candidateIsin !== '') { + $editPosition['isin'] = $candidateIsin; + } + if ($candidateMarket !== '') { + $editPosition['market'] = $candidateMarket; + } + if ($candidateCurrency !== '') { + $editPosition['quote_currency'] = $candidateCurrency; + } + } + + return [ + 'portfolios' => $portfolios, + 'portfolioById' => $portfolioById, + 'portfolioStats' => $portfolioStats, + 'positions' => $positions, + 'instrumentList' => $instrumentList, + 'quoteHistory' => $quoteHistory, + 'editPortfolio' => $editPortfolio, + 'editPosition' => $editPosition, + 'selectedInstrumentForQuote' => $selectedInstrumentForQuote, + 'selectedInstrumentQuoteCurrency' => $selectedInstrumentQuoteCurrency, + ]; + } + + private function savePortfolio(): string + { + $portfolioId = (int) ($_POST['portfolio_id'] ?? 0); + $name = trim((string) ($_POST['portfolio_name'] ?? '')); + $baseCurrency = $this->normalizeCurrency((string) ($_POST['portfolio_base_currency'] ?? $this->defaultReportCurrency)); + $notes = trim((string) ($_POST['portfolio_notes'] ?? '')); + + if ($name === '') { + throw new RuntimeException('Bitte einen Depotnamen angeben.'); + } + + if ($portfolioId > 0) { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->portfolioTable . ' + SET name = :name, base_currency = :base_currency, notes = :notes, updated_at = CURRENT_TIMESTAMP + WHERE id = :id AND owner_sub = :owner_sub' + ); + $stmt->execute([ + 'id' => $portfolioId, + 'owner_sub' => $this->ownerSub, + 'name' => $name, + 'base_currency' => $baseCurrency, + 'notes' => $notes !== '' ? $notes : null, + ]); + return 'Depot aktualisiert.'; + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->portfolioTable . ' (owner_sub, name, base_currency, notes) + VALUES (:owner_sub, :name, :base_currency, :notes)' + ); + $stmt->execute([ + 'owner_sub' => $this->ownerSub, + 'name' => $name, + 'base_currency' => $baseCurrency, + 'notes' => $notes !== '' ? $notes : null, + ]); + return 'Depot angelegt.'; + } + + private function deletePortfolio(): string + { + $portfolioId = (int) ($_POST['portfolio_id'] ?? 0); + $countStmt = $this->pdo->prepare('SELECT COUNT(*) FROM ' . $this->positionTable . ' WHERE portfolio_id = :portfolio_id AND owner_sub = :owner_sub'); + $countStmt->execute([ + 'portfolio_id' => $portfolioId, + 'owner_sub' => $this->ownerSub, + ]); + if ((int) $countStmt->fetchColumn() > 0) { + throw new RuntimeException('Depot kann erst geloescht werden, wenn alle Positionen entfernt wurden.'); + } + + $stmt = $this->pdo->prepare('DELETE FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub'); + $stmt->execute([ + 'id' => $portfolioId, + 'owner_sub' => $this->ownerSub, + ]); + return 'Depot geloescht.'; + } + + private function savePosition(): string + { + $positionId = (int) ($_POST['position_id'] ?? 0); + $portfolioId = (int) ($_POST['portfolio_id'] ?? 0); + $quantity = (float) ($_POST['quantity'] ?? 0); + $purchasePrice = (float) ($_POST['purchase_price'] ?? 0); + $purchaseCurrency = $this->normalizeCurrency((string) ($_POST['purchase_currency'] ?? $this->defaultReportCurrency)); + $purchaseDate = trim((string) ($_POST['purchase_date'] ?? '')); + $fees = trim((string) ($_POST['fees'] ?? '')); + $notes = trim((string) ($_POST['position_notes'] ?? '')); + + if ($portfolioId <= 0) { + throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.'); + } + if ($quantity <= 0 || $purchasePrice <= 0 || $purchaseDate === '') { + throw new RuntimeException('Bitte Stueckzahl, Kaufpreis und Kaufdatum angeben.'); + } + + $portfolioOwnerStmt = $this->pdo->prepare('SELECT id FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub LIMIT 1'); + $portfolioOwnerStmt->execute([ + 'id' => $portfolioId, + 'owner_sub' => $this->ownerSub, + ]); + if ($portfolioOwnerStmt->fetchColumn() === false) { + throw new RuntimeException('Das ausgewaehlte Depot ist nicht verfuegbar.'); + } + + $instrumentId = $this->upsertInstrument([ + 'id' => (int) ($_POST['instrument_id'] ?? 0), + 'isin' => $_POST['isin'] ?? '', + 'wkn' => $_POST['wkn'] ?? '', + 'symbol' => $_POST['symbol'] ?? '', + 'name' => $_POST['instrument_name'] ?? '', + 'quote_currency' => $_POST['quote_currency'] ?? $purchaseCurrency, + 'market' => $_POST['market'] ?? '', + ]); + + $payload = [ + 'owner_sub' => $this->ownerSub, + 'portfolio_id' => $portfolioId, + 'instrument_id' => $instrumentId, + 'quantity' => $quantity, + 'purchase_price' => $purchasePrice, + 'purchase_currency' => $purchaseCurrency, + 'purchase_date' => $purchaseDate, + 'fees' => $fees !== '' ? (float) $fees : null, + 'notes' => $notes !== '' ? $notes : null, + ]; + + if ($positionId > 0) { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->positionTable . ' + SET portfolio_id = :portfolio_id, + instrument_id = :instrument_id, + quantity = :quantity, + purchase_price = :purchase_price, + purchase_currency = :purchase_currency, + purchase_date = :purchase_date, + fees = :fees, + notes = :notes, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id AND owner_sub = :owner_sub' + ); + $stmt->execute($payload + ['id' => $positionId]); + return 'Position aktualisiert.'; + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->positionTable . ' ( + owner_sub, portfolio_id, instrument_id, quantity, purchase_price, purchase_currency, purchase_date, fees, notes + ) VALUES ( + :owner_sub, :portfolio_id, :instrument_id, :quantity, :purchase_price, :purchase_currency, :purchase_date, :fees, :notes + )' + ); + $stmt->execute($payload); + return 'Position gespeichert.'; + } + + private function deletePosition(): string + { + $positionId = (int) ($_POST['position_id'] ?? 0); + $stmt = $this->pdo->prepare('DELETE FROM ' . $this->positionTable . ' WHERE id = :id AND owner_sub = :owner_sub'); + $stmt->execute([ + 'id' => $positionId, + 'owner_sub' => $this->ownerSub, + ]); + return 'Position geloescht.'; + } + + private function saveQuote(): string + { + $instrumentId = (int) ($_POST['quote_instrument_id'] ?? 0); + $price = (float) ($_POST['quote_price'] ?? 0); + $currency = $this->normalizeCurrency((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency)); + $quotedAt = $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? '')); + $source = trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual'; + + if ($instrumentId <= 0 || $price <= 0) { + throw new RuntimeException('Bitte Aktie und Kurs angeben.'); + } + + $this->storeQuote($instrumentId, $price, $currency, $quotedAt, $source); + return 'Kurs gespeichert.'; + } + + private function refreshMarketDataPosition(): string + { + $positionId = (int) ($_POST['position_id'] ?? 0); + $stmt = $this->pdo->prepare( + 'SELECT + p.instrument_id, + i.name AS instrument_name, + i.symbol, + i.isin, + i.quote_currency + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.id = :id AND p.owner_sub = :owner_sub + LIMIT 1' + ); + $stmt->execute([ + 'id' => $positionId, + 'owner_sub' => $this->ownerSub, + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!is_array($row)) { + throw new RuntimeException('Position nicht gefunden.'); + } + + $instrumentId = (int) $row['instrument_id']; + $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); + $quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency)); + if ($symbol === '') { + throw new RuntimeException('Fuer diese Aktie ist noch kein Symbol hinterlegt.'); + } + + $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); + $latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false; + if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) { + return 'Vorhandener Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.'; + } + + $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol); + if (empty($apiResult['ok'])) { + throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.')); + } + $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours); + if (empty($fxResult['ok'])) { + throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { + $displayTime = (string) \module_fn( + 'boersenchecker', + 'format_datetime_for_display', + (string) ($apiResult['fetched_at'] ?? ''), + (string) ($apiResult['source'] ?? 'alphavantage:global_quote') + ); + return 'Alpha Vantage lieferte fuer ' . (string) $row['instrument_name'] . ' keinen neueren Snapshot als ' . $displayTime . '.'; + } + + $storeResult = \module_fn( + 'boersenchecker', + 'store_market_quote', + $instrumentId, + (float) $apiResult['price'], + $quoteCurrency, + (string) $apiResult['fetched_at'], + (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId) + ); + if (!empty($storeResult['inserted'])) { + return 'Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.'; + } + return 'Vorhandener Alpha-Vantage-Snapshot fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.'; + } + + private function refreshMarketDataAll(): string + { + $stmt = $this->pdo->prepare( + 'SELECT DISTINCT + i.id, + i.name, + i.symbol, + i.isin, + i.quote_currency + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.owner_sub = :owner_sub + ORDER BY i.name ASC' + ); + $stmt->execute(['owner_sub' => $this->ownerSub]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + if ($rows === []) { + throw new RuntimeException('Keine Positionen fuer den API-Abruf vorhanden.'); + } + + $fetched = 0; + $reused = 0; + $stale = 0; + $skipped = 0; + $failed = 0; + $errors = []; + + $bulkRows = []; + foreach ($rows as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); + if ($instrumentId <= 0 || $symbol === '') { + $skipped++; + continue; + } + + $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); + $latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false; + if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) { + $reused++; + continue; + } + + $bulkRows[] = $row; + } + + if ($bulkRows !== []) { + $quoteCurrencies = array_values(array_unique(array_filter(array_map( + fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')), + $bulkRows + )))); + $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours); + if (empty($fxResult['ok'])) { + throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + + $bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkRows); + if (empty($bulkResult['ok'])) { + throw new RuntimeException((string) ($bulkResult['message'] ?? 'Alpha-Vantage-Abruf fehlgeschlagen.')); + } + + $bulkQuotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; + foreach ($bulkRows as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency)); + $apiResult = $bulkQuotes[$instrumentId] ?? null; + if (!is_array($apiResult) || !is_numeric($apiResult['price'] ?? null)) { + $failed++; + $errors[] = (string) ($row['name'] ?? $instrumentId) . ': kein Preis in der Alpha-Vantage-Antwort.'; + continue; + } + $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); + if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { + $stale++; + continue; + } + + $storeResult = \module_fn( + 'boersenchecker', + 'store_market_quote', + $instrumentId, + (float) $apiResult['price'], + $quoteCurrency, + (string) $apiResult['fetched_at'], + (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId) + ); + if (!empty($storeResult['inserted'])) { + $fetched++; + } else { + $reused++; + } + } + } + + if ($errors !== []) { + throw new RuntimeException( + 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler. ' + . implode(' | ', array_slice($errors, 0, 3)) + ); + } + + return 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler.'; + } + + private function searchSymbol(): string + { + $keywords = trim((string) ($_POST['search_keywords'] ?? '')); + $this->symbolSearchKeywords = $keywords; + $result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $keywords); + $this->symbolSearchResults = is_array($result['results'] ?? null) ? $result['results'] : []; + + if (empty($result['ok'])) { + throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.')); + } + + return (string) ($result['message'] ?? 'Suche abgeschlossen.'); + } + + private function deleteQuote(): string + { + $quoteId = (int) ($_POST['quote_id'] ?? 0); + $stmt = $this->pdo->prepare( + 'DELETE FROM ' . $this->quoteTable . ' + WHERE id = :id + AND instrument_id IN ( + SELECT DISTINCT instrument_id + FROM ' . $this->positionTable . ' + WHERE owner_sub = :owner_sub + )' + ); + $stmt->execute([ + 'id' => $quoteId, + 'owner_sub' => $this->ownerSub, + ]); + return 'Kurseintrag geloescht.'; + } + + private function refreshFx(): string + { + $result = \module_fn('boersenchecker', 'fx_refresh', $this->defaultReportCurrency, $this->fxMaxAgeHours); + if (empty($result['ok'])) { + throw new RuntimeException((string) ($result['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + return (string) ($result['message'] ?? 'FX-Daten aktualisiert.'); + } + + private function fetchPortfolios(): array + { + $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC'); + $stmt->execute(['owner_sub' => $this->ownerSub]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function fetchPositions(): array + { + $stmt = $this->pdo->prepare( + 'SELECT + p.*, + i.isin, + i.wkn, + i.symbol, + i.name AS instrument_name, + i.quote_currency, + i.market + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.owner_sub = :owner_sub + ORDER BY p.purchase_date DESC, p.id DESC' + ); + $stmt->execute(['owner_sub' => $this->ownerSub]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function buildInstrumentList(array $positions): array + { + $instrumentList = []; + foreach ($positions as $position) { + $instrumentId = (int) $position['instrument_id']; + if (!isset($instrumentList[$instrumentId])) { + $instrumentList[$instrumentId] = [ + 'id' => $instrumentId, + 'name' => (string) $position['instrument_name'], + 'symbol' => (string) ($position['symbol'] ?? ''), + 'isin' => (string) ($position['isin'] ?? ''), + 'quote_currency' => (string) ($position['quote_currency'] ?? ''), + ]; + } + } + return $instrumentList; + } + + private function fetchQuotes(array $instrumentIds): array + { + $quoteHistory = []; + $latestQuotes = []; + if ($instrumentIds === []) { + return [$quoteHistory, $latestQuotes]; + } + + $placeholders = implode(',', array_fill(0, count($instrumentIds), '?')); + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id IN (' . $placeholders . ') + ORDER BY quoted_at DESC, created_at DESC, id DESC' + ); + $stmt->execute($instrumentIds); + $quotes = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + foreach ($quotes as $quote) { + $instrumentId = (int) $quote['instrument_id']; + $quoteHistory[$instrumentId][] = $quote; + if (!isset($latestQuotes[$instrumentId])) { + $latestQuotes[$instrumentId] = $quote; + } + } + + return [$quoteHistory, $latestQuotes]; + } + + private function buildPortfolioById(array $portfolios): array + { + $portfolioById = []; + foreach ($portfolios as $portfolio) { + $portfolio['base_currency'] = $this->normalizeCurrency((string) ($portfolio['base_currency'] ?? $this->defaultReportCurrency)); + $portfolioById[(int) $portfolio['id']] = $portfolio; + } + return $portfolioById; + } + + private function enrichPositions(array $positions, array $portfolioById, array $latestQuotes): array + { + $portfolioStats = []; + foreach ($portfolioById as $portfolioId => $portfolio) { + $portfolioStats[$portfolioId] = [ + 'invested' => 0.0, + 'current' => 0.0, + 'gain' => 0.0, + 'positions' => 0, + 'has_invested' => false, + 'has_current' => false, + ]; + } + + foreach ($positions as &$position) { + $portfolioId = (int) $position['portfolio_id']; + $baseCurrency = (string) ($portfolioById[$portfolioId]['base_currency'] ?? $this->defaultReportCurrency); + $quantity = (float) $position['quantity']; + $purchasePrice = (float) $position['purchase_price']; + $fees = is_numeric($position['fees'] ?? null) ? (float) $position['fees'] : 0.0; + $purchaseTotal = ($quantity * $purchasePrice) + $fees; + $purchaseTotalBase = $this->convertAmount($purchaseTotal, (string) $position['purchase_currency'], $baseCurrency); + $latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null; + $currentTotalBase = null; + + if (is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null)) { + $currentOriginal = $quantity * (float) $latestQuote['price']; + $currentTotalBase = $this->convertAmount($currentOriginal, (string) $latestQuote['currency'], $baseCurrency, (string) ($latestQuote['source'] ?? '')); + $position['latest_price'] = (float) $latestQuote['price']; + $position['latest_currency'] = (string) $latestQuote['currency']; + $position['latest_quoted_at'] = (string) $latestQuote['quoted_at']; + $position['latest_source'] = (string) ($latestQuote['source'] ?? ''); + $position['current_total_base'] = $currentTotalBase; + } else { + $position['latest_price'] = null; + $position['latest_currency'] = null; + $position['latest_quoted_at'] = null; + $position['latest_source'] = null; + $position['current_total_base'] = null; + } + + $position['purchase_total'] = $purchaseTotal; + $position['purchase_total_base'] = $purchaseTotalBase; + $position['base_currency'] = $baseCurrency; + $position['gain_base'] = $currentTotalBase !== null && $purchaseTotalBase !== null + ? $currentTotalBase - $purchaseTotalBase + : null; + + if (isset($portfolioStats[$portfolioId])) { + $portfolioStats[$portfolioId]['positions']++; + if ($purchaseTotalBase !== null) { + $portfolioStats[$portfolioId]['invested'] += $purchaseTotalBase; + $portfolioStats[$portfolioId]['has_invested'] = true; + } + if ($currentTotalBase !== null) { + $portfolioStats[$portfolioId]['current'] += $currentTotalBase; + $portfolioStats[$portfolioId]['has_current'] = true; + } + } + } + unset($position); + + foreach ($portfolioStats as &$stats) { + $stats['gain'] = ($stats['has_invested'] && $stats['has_current']) + ? $stats['current'] - $stats['invested'] + : null; + } + unset($stats); + + return [$positions, $portfolioStats]; + } + + private function latestApiQuoteForInstrument(int $instrumentId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id = :instrument_id + AND source LIKE :source + ORDER BY quoted_at DESC, created_at DESC, id DESC + LIMIT 1' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'source' => 'alphavantage:%', + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $row : null; + } + + private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool + { + if (!is_array($latestQuote)) { + return false; + } + + $latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? '')); + $incomingTimestamp = strtotime(trim($incomingQuotedAt)); + if ($latestTimestamp === false || $incomingTimestamp === false) { + return false; + } + + return $incomingTimestamp <= $latestTimestamp; + } + + private function upsertInstrument(array $payload): int + { + return $this->instrumentRegistry->save($payload); + } + + private function storeQuote(int $instrumentId, float $price, string $currency, string $quotedAt, string $source): void + { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source) + VALUES (:instrument_id, :price, :currency, :quoted_at, :source)' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => $currency, + 'quoted_at' => $quotedAt, + 'source' => $source, + ]); + } + + private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float + { + if ($amount === null) { + return null; + } + + $from = $this->normalizeCurrency($from); + $to = $this->normalizeCurrency($to); + if ($from === $to) { + return $amount; + } + + try { + $fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0); + $value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null); + return is_numeric($value) ? (float) $value : null; + } catch (\Throwable) { + return null; + } + } + + private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string + { + $normalized = strtoupper(trim((string) $value)); + return $normalized !== '' ? $normalized : $fallback; + } + + private function normalizeDateTimeLocal(?string $value): string + { + $timezone = new \DateTimeZone(nexus_display_timezone_name()); + $value = trim((string) $value); + if ($value === '') { + return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s'); + } + + $date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone); + if ($date instanceof \DateTimeImmutable) { + return $date->format('Y-m-d H:i:s'); + } + + try { + return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s'); + } + } + + private function formatNumber(?float $value, int $scale = 2): string + { + if ($value === null) { + return 'n/a'; + } + + return number_format($value, $scale, ',', '.'); + } + + private function buildAvailableOwners(): array + { + $owners = []; + $currentSub = trim((string) ($this->user['sub'] ?? 'local')); + $owners[$currentSub] = [ + 'sub' => $currentSub, + 'label' => trim((string) ($this->user['name'] ?? $this->user['email'] ?? $currentSub)) ?: $currentSub, + ]; + + if (!$this->isAdmin) { + return $owners; + } + + foreach (\modules()->knownAuthUsers() as $knownUser) { + $sub = trim((string) ($knownUser['sub'] ?? '')); + if ($sub === '') { + continue; + } + $label = trim((string) ($knownUser['name'] ?? $knownUser['email'] ?? $knownUser['username'] ?? $sub)); + $owners[$sub] = [ + 'sub' => $sub, + 'label' => $label !== '' ? $label : $sub, + ]; + } + + uasort($owners, static fn (array $left, array $right): int => strcmp((string) $left['label'], (string) $right['label'])); + return $owners; + } +} diff --git a/temp/nexus-module-import/modules/boersenchecker/src/Support/HomePage.php b/temp/nexus-module-import/modules/boersenchecker/src/Support/HomePage.php new file mode 100644 index 00000000..2768ac34 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/src/Support/HomePage.php @@ -0,0 +1,364 @@ +pdo = \module_fn('boersenchecker', 'pdo'); + \module_fn('boersenchecker', 'ensure_schema'); + $user = \auth_user() ?? []; + $this->ownerSub = trim((string) ($user['sub'] ?? 'local')); + + $settings = \modules()->settings('boersenchecker'); + $this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; + $this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6); + if ($this->fxMaxAgeHours <= 0) { + $this->fxMaxAgeHours = 6.0; + } + $this->marketDataMinIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60); + if ($this->marketDataMinIntervalMinutes <= 0) { + $this->marketDataMinIntervalMinutes = 60; + } + + $table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name); + $this->portfolioTable = $table('portfolios'); + $this->instrumentTable = $table('instruments'); + $this->positionTable = $table('positions'); + $this->quoteTable = $table('quotes'); + } + + public function handle(): array + { + $notice = null; + $error = null; + if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'refresh_current_quotes_home') { + try { + $notice = $this->refreshCurrentQuotesForPortfolio((int) ($_POST['portfolio_id'] ?? 0)); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + $portfolios = $this->fetchPortfolios(); + $selectedPortfolioId = (int) ($_GET['portfolio_id'] ?? ($_POST['portfolio_id'] ?? 0)); + if ($selectedPortfolioId <= 0 && $portfolios !== []) { + $selectedPortfolioId = (int) $portfolios[0]['id']; + } + + $positions = $selectedPortfolioId > 0 ? $this->fetchPortfolioPositions($selectedPortfolioId) : []; + $selectedInstrumentId = (int) ($_GET['instrument_id'] ?? 0); + if ($selectedInstrumentId <= 0 && $positions !== []) { + $selectedInstrumentId = (int) $positions[0]['instrument_id']; + } + + $latestQuotes = $this->fetchLatestQuotes(array_values(array_unique(array_map(static fn (array $row): int => (int) $row['instrument_id'], $positions)))); + foreach ($positions as &$position) { + $latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null; + $position['latest_price'] = is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null) ? (float) $latestQuote['price'] : null; + $position['latest_currency'] = is_array($latestQuote) ? (string) ($latestQuote['currency'] ?? '') : ''; + $position['latest_quoted_at'] = is_array($latestQuote) ? (string) ($latestQuote['quoted_at'] ?? '') : ''; + $position['latest_source'] = is_array($latestQuote) ? (string) ($latestQuote['source'] ?? '') : ''; + $position['current_total_report'] = null; + $position['gain_report'] = null; + $position['gain_percent'] = null; + if ($position['latest_price'] !== null) { + $currentNative = (float) $position['latest_price'] * (float) ($position['quantity'] ?? 0); + $currentReport = $this->convertAmount( + $currentNative, + (string) ($position['latest_currency'] ?: ($position['quote_currency'] ?? $this->defaultReportCurrency)), + $this->defaultReportCurrency, + (string) ($position['latest_source'] ?? '') + ); + $position['current_total_report'] = $currentReport; + if ($position['purchase_total_report'] !== null && $currentReport !== null) { + $gain = $currentReport - (float) $position['purchase_total_report']; + $position['gain_report'] = $gain; + $base = (float) $position['purchase_total_report']; + $position['gain_percent'] = $base > 0 ? ($gain / $base) * 100 : null; + } + } + } + unset($position); + + $selectedInstrument = null; + foreach ($positions as $position) { + if ((int) $position['instrument_id'] === $selectedInstrumentId) { + $selectedInstrument = $position; + break; + } + } + + return [ + 'notice' => $notice, + 'error' => $error, + 'portfolios' => $portfolios, + 'selectedPortfolioId' => $selectedPortfolioId, + 'positions' => $positions, + 'selectedInstrumentId' => $selectedInstrumentId, + 'selectedInstrument' => $selectedInstrument, + 'summary' => $this->buildSummary($positions), + 'defaultReportCurrency' => $this->defaultReportCurrency, + 'chartEndpoint' => '/module/boersenchecker/chart_data', + 'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source), + ]; + } + + private function refreshCurrentQuotesForPortfolio(int $portfolioId): string + { + if ($portfolioId <= 0) { + throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.'); + } + + $stmt = $this->pdo->prepare( + 'SELECT DISTINCT i.id, i.name, i.symbol, i.isin, i.quote_currency + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id' + ); + $stmt->execute([ + 'owner_sub' => $this->ownerSub, + 'portfolio_id' => $portfolioId, + ]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + if ($rows === []) { + throw new RuntimeException('In diesem Depot sind keine Aktien vorhanden.'); + } + + $updated = 0; + $reused = 0; + $stale = 0; + $bulkCandidates = []; + foreach ($rows as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $symbol = strtoupper(trim((string) ($row['symbol'] ?? ''))); + if ($instrumentId <= 0 || $symbol === '') { + continue; + } + + $latest = $this->latestApiQuoteForInstrument($instrumentId); + $latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false; + if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) { + $reused++; + continue; + } + + $bulkCandidates[] = $row; + } + + if ($bulkCandidates !== []) { + $quoteCurrencies = array_values(array_unique(array_filter(array_map( + fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')), + $bulkCandidates + )))); + $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours); + if (empty($fxResult['ok'])) { + throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + + $bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkCandidates); + $quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : []; + foreach ($bulkCandidates as $row) { + $instrumentId = (int) ($row['id'] ?? 0); + $quote = $quotes[$instrumentId] ?? null; + if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) { + continue; + } + $latest = $this->latestApiQuoteForInstrument($instrumentId); + if ($this->isApiSnapshotStale($latest, (string) ($quote['fetched_at'] ?? ''))) { + $stale++; + continue; + } + $storeResult = \module_fn( + 'boersenchecker', + 'store_market_quote', + $instrumentId, + (float) $quote['price'], + strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency, + (string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')), + (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId) + ); + if (!empty($storeResult['inserted'])) { + $updated++; + } else { + $reused++; + } + } + } + + return 'Aktuelle Kurse: ' . $updated . ' aktualisiert, ' . $reused . ' wiederverwendet, ' . $stale . ' API-Snapshots waren nicht neuer.'; + } + + private function fetchPortfolios(): array + { + $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC'); + $stmt->execute(['owner_sub' => $this->ownerSub]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function fetchPortfolioPositions(int $portfolioId): array + { + $stmt = $this->pdo->prepare( + 'SELECT p.*, i.name AS instrument_name, i.symbol, i.isin, i.wkn, i.quote_currency, i.market + FROM ' . $this->positionTable . ' p + INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id + WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id + ORDER BY i.name ASC' + ); + $stmt->execute([ + 'owner_sub' => $this->ownerSub, + 'portfolio_id' => $portfolioId, + ]); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + foreach ($rows as &$row) { + $quantity = (float) ($row['quantity'] ?? 0); + $purchasePrice = (float) ($row['purchase_price'] ?? 0); + $fees = is_numeric($row['fees'] ?? null) ? (float) $row['fees'] : 0.0; + $purchaseTotal = ($quantity * $purchasePrice) + $fees; + $row['purchase_total'] = $purchaseTotal; + $row['purchase_total_report'] = $this->convertAmount( + $purchaseTotal, + (string) ($row['purchase_currency'] ?? $this->defaultReportCurrency), + $this->defaultReportCurrency + ); + } + unset($row); + return $rows; + } + + private function fetchLatestQuotes(array $instrumentIds): array + { + $result = []; + if ($instrumentIds === []) { + return $result; + } + $placeholders = implode(',', array_fill(0, count($instrumentIds), '?')); + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id IN (' . $placeholders . ') + ORDER BY quoted_at DESC, created_at DESC, id DESC' + ); + $stmt->execute($instrumentIds); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { + $instrumentId = (int) $row['instrument_id']; + if (!isset($result[$instrumentId])) { + $result[$instrumentId] = $row; + } + } + return $result; + } + + private function latestApiQuoteForInstrument(int $instrumentId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id = :instrument_id AND source LIKE :source + ORDER BY quoted_at DESC, created_at DESC, id DESC + LIMIT 1' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'source' => 'alphavantage:%', + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $row : null; + } + + private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool + { + if (!is_array($latestQuote)) { + return false; + } + + $latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? '')); + $incomingTimestamp = strtotime(trim($incomingQuotedAt)); + if ($latestTimestamp === false || $incomingTimestamp === false) { + return false; + } + + return $incomingTimestamp <= $latestTimestamp; + } + + private function buildSummary(array $positions): array + { + $invested = 0.0; + $current = 0.0; + $hasInvested = false; + $hasCurrent = false; + $best = null; + $worst = null; + + foreach ($positions as $position) { + if (is_numeric($position['purchase_total_report'] ?? null)) { + $invested += (float) $position['purchase_total_report']; + $hasInvested = true; + } + if (is_numeric($position['current_total_report'] ?? null)) { + $current += (float) $position['current_total_report']; + $hasCurrent = true; + } + if (is_numeric($position['gain_percent'] ?? null)) { + if ($best === null || (float) $position['gain_percent'] > (float) ($best['gain_percent'] ?? 0)) { + $best = $position; + } + if ($worst === null || (float) $position['gain_percent'] < (float) ($worst['gain_percent'] ?? 0)) { + $worst = $position; + } + } + } + + return [ + 'positions' => count($positions), + 'invested' => $hasInvested ? $invested : null, + 'current' => $hasCurrent ? $current : null, + 'gain' => ($hasInvested && $hasCurrent) ? $current - $invested : null, + 'best' => $best, + 'worst' => $worst, + ]; + } + + private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float + { + if ($amount === null) { + return null; + } + + $from = $this->normalizeCurrency($from, $this->defaultReportCurrency); + $to = $this->normalizeCurrency($to, $this->defaultReportCurrency); + if ($from === $to) { + return $amount; + } + + try { + $fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0); + $value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null); + return is_numeric($value) ? (float) $value : null; + } catch (\Throwable) { + return null; + } + } + + private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string + { + $normalized = strtoupper(trim((string) $value)); + return $normalized !== '' ? $normalized : $fallback; + } +} diff --git a/temp/nexus-module-import/modules/boersenchecker/src/Support/InstrumentPage.php b/temp/nexus-module-import/modules/boersenchecker/src/Support/InstrumentPage.php new file mode 100644 index 00000000..4a65ccbe --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/src/Support/InstrumentPage.php @@ -0,0 +1,351 @@ +pdo = \module_fn('boersenchecker', 'pdo'); + \module_fn('boersenchecker', 'ensure_schema'); + $user = \auth_user() ?? []; + $this->ownerSub = trim((string) ($user['sub'] ?? 'local')); + + $settings = \modules()->settings('boersenchecker'); + $this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR'; + $this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6); + if ($this->fxMaxAgeHours <= 0) { + $this->fxMaxAgeHours = 6.0; + } + $table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name); + $this->instrumentTable = $table('instruments'); + $this->positionTable = $table('positions'); + $this->quoteTable = $table('quotes'); + $this->instrumentRegistry = new InstrumentRegistry( + $this->pdo, + $this->instrumentTable, + $this->positionTable, + $this->quoteTable, + ); + } + + public function handle(): array + { + $notice = null; + $error = null; + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + try { + $notice = $this->handlePost(); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } + } + + $instruments = $this->fetchInstruments(); + $selectedInstrumentId = $this->selectedInstrumentOverrideId > 0 + ? $this->selectedInstrumentOverrideId + : (int) ($_GET['instrument_id'] ?? ($_POST['instrument_id'] ?? 0)); + if ($selectedInstrumentId <= 0 && $instruments !== []) { + $selectedInstrumentId = (int) $instruments[0]['id']; + } + + $selectedInstrument = null; + foreach ($instruments as $instrument) { + if ((int) $instrument['id'] === $selectedInstrumentId) { + $selectedInstrument = $instrument; + break; + } + } + + $quotes = $selectedInstrumentId > 0 ? $this->fetchQuotes($selectedInstrumentId) : []; + + $candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? '')); + $candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? '')); + $candidateIsin = trim((string) ($_GET['isin_candidate'] ?? '')); + $candidateMarket = trim((string) ($_GET['market_candidate'] ?? '')); + $candidateCurrency = strtoupper(trim((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency; + if ($selectedInstrument === null && ($candidateName !== '' || $candidateSymbol !== '' || $candidateMarket !== '')) { + $selectedInstrument = [ + 'id' => 0, + 'name' => $candidateName, + 'symbol' => $candidateSymbol, + 'isin' => $candidateIsin, + 'market' => $candidateMarket, + 'quote_currency' => $candidateCurrency, + 'wkn' => '', + ]; + } + + return [ + 'notice' => $notice, + 'error' => $error, + 'instruments' => $instruments, + 'selectedInstrument' => $selectedInstrument, + 'selectedInstrumentId' => $selectedInstrumentId, + 'quotes' => $quotes, + 'searchKeywords' => $this->searchKeywords, + 'searchResults' => $this->searchResults, + 'defaultReportCurrency' => $this->defaultReportCurrency, + 'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source), + 'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'), + ]; + } + + private function handlePost(): string + { + $action = trim((string) ($_POST['action'] ?? '')); + return match ($action) { + 'save_instrument' => $this->saveInstrument(), + 'save_quote' => $this->saveQuote(), + 'delete_quote' => $this->deleteQuote(), + 'refresh_market_data_instrument' => $this->refreshInstrumentQuote(), + 'search_symbol' => $this->searchSymbol(), + default => '', + }; + } + + private function fetchInstruments(): array + { + $stmt = $this->pdo->prepare( + 'SELECT DISTINCT i.* + FROM ' . $this->instrumentTable . ' i + INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id + WHERE p.owner_sub = :owner_sub + ORDER BY i.name ASC' + ); + $stmt->execute(['owner_sub' => $this->ownerSub]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function fetchQuotes(int $instrumentId): array + { + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id = :instrument_id + ORDER BY quoted_at DESC, created_at DESC, id DESC + LIMIT 30' + ); + $stmt->execute(['instrument_id' => $instrumentId]); + return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []; + } + + private function saveInstrument(): string + { + $instrumentId = (int) ($_POST['instrument_id'] ?? 0); + if ($instrumentId <= 0) { + throw new RuntimeException('Bitte eine Aktie auswaehlen.'); + } + $this->assertInstrumentAccessible($instrumentId); + + $resolvedId = $this->instrumentRegistry->save([ + 'id' => $instrumentId, + 'name' => $_POST['instrument_name'] ?? '', + 'symbol' => $_POST['symbol'] ?? '', + 'isin' => $_POST['isin'] ?? '', + 'wkn' => $_POST['wkn'] ?? '', + 'market' => $_POST['market'] ?? '', + 'quote_currency' => $_POST['quote_currency'] ?? $this->defaultReportCurrency, + ]); + $this->selectedInstrumentOverrideId = $resolvedId; + + return $resolvedId === $instrumentId + ? 'Aktie aktualisiert.' + : 'Aktie aktualisiert und mit bestehendem Systemeintrag zusammengefuehrt.'; + } + + private function saveQuote(): string + { + $instrumentId = (int) ($_POST['instrument_id'] ?? 0); + $price = (float) ($_POST['quote_price'] ?? 0); + if ($instrumentId <= 0 || $price <= 0) { + throw new RuntimeException('Bitte Aktie und Kurs angeben.'); + } + $this->assertInstrumentAccessible($instrumentId); + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source) + VALUES (:instrument_id, :price, :currency, :quoted_at, :source)' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'price' => $price, + 'currency' => strtoupper(trim((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency, + 'quoted_at' => $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? '')), + 'source' => trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual', + ]); + return 'Kurs gespeichert.'; + } + + private function deleteQuote(): string + { + $quoteId = (int) ($_POST['quote_id'] ?? 0); + if ($quoteId <= 0) { + throw new RuntimeException('Bitte einen Kurseintrag auswaehlen.'); + } + + $stmt = $this->pdo->prepare( + 'SELECT q.instrument_id + FROM ' . $this->quoteTable . ' q + WHERE q.id = :id + LIMIT 1' + ); + $stmt->execute(['id' => $quoteId]); + $instrumentId = (int) $stmt->fetchColumn(); + $this->assertInstrumentAccessible($instrumentId); + + $stmt = $this->pdo->prepare('DELETE FROM ' . $this->quoteTable . ' WHERE id = :id'); + $stmt->execute(['id' => $quoteId]); + return 'Kurs geloescht.'; + } + + private function refreshInstrumentQuote(): string + { + $instrumentId = (int) ($_POST['instrument_id'] ?? 0); + $instrument = $this->assertInstrumentAccessible($instrumentId); + $symbol = strtoupper(trim((string) ($instrument['symbol'] ?? ''))); + if ($symbol === '') { + throw new RuntimeException('Fuer diese Aktie ist kein Symbol hinterlegt.'); + } + + $apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol); + if (empty($apiResult['ok'])) { + throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.')); + } + $quoteCurrency = strtoupper(trim((string) ($instrument['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency; + $fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours); + if (empty($fxResult['ok'])) { + throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.')); + } + $fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null; + $latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId); + if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) { + $displayTime = (string) \module_fn( + 'boersenchecker', + 'format_datetime_for_display', + (string) ($apiResult['fetched_at'] ?? ''), + (string) ($apiResult['source'] ?? 'alphavantage:global_quote') + ); + return 'Alpha Vantage lieferte keinen neueren Snapshot als ' . $displayTime . '.'; + } + + $storeResult = \module_fn( + 'boersenchecker', + 'store_market_quote', + $instrumentId, + (float) $apiResult['price'], + $quoteCurrency, + (string) $apiResult['fetched_at'], + (string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId) + ); + + return !empty($storeResult['inserted']) + ? 'Alpha-Vantage-Kurs gespeichert.' + : 'Vorhandener Alpha-Vantage-Snapshot wiederverwendet.'; + } + + private function searchSymbol(): string + { + $this->searchKeywords = trim((string) ($_POST['search_keywords'] ?? '')); + $result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $this->searchKeywords); + $this->searchResults = is_array($result['results'] ?? null) ? $result['results'] : []; + if (empty($result['ok'])) { + throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.')); + } + return (string) ($result['message'] ?? 'Suche abgeschlossen.'); + } + + private function assertInstrumentAccessible(int $instrumentId): array + { + if ($instrumentId <= 0) { + throw new RuntimeException('Aktie nicht gefunden.'); + } + + $stmt = $this->pdo->prepare( + 'SELECT DISTINCT i.* + FROM ' . $this->instrumentTable . ' i + INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id + WHERE i.id = :id AND p.owner_sub = :owner_sub + LIMIT 1' + ); + $stmt->execute([ + 'id' => $instrumentId, + 'owner_sub' => $this->ownerSub, + ]); + $instrument = $stmt->fetch(PDO::FETCH_ASSOC); + if (!is_array($instrument)) { + throw new RuntimeException('Aktie ist nicht verfuegbar.'); + } + + return $instrument; + } + + private function normalizeDateTimeLocal(?string $value): string + { + $timezone = new \DateTimeZone(nexus_display_timezone_name()); + $value = trim((string) $value); + if ($value === '') { + return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s'); + } + + $date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone); + if ($date instanceof \DateTimeImmutable) { + return $date->format('Y-m-d H:i:s'); + } + + try { + return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s'); + } + } + + private function latestApiQuoteForInstrument(int $instrumentId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * + FROM ' . $this->quoteTable . ' + WHERE instrument_id = :instrument_id + AND source LIKE :source + ORDER BY quoted_at DESC, created_at DESC, id DESC + LIMIT 1' + ); + $stmt->execute([ + 'instrument_id' => $instrumentId, + 'source' => 'alphavantage:%', + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $row : null; + } + + private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool + { + if (!is_array($latestQuote)) { + return false; + } + + $latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? '')); + $incomingTimestamp = strtotime(trim($incomingQuotedAt)); + if ($latestTimestamp === false || $incomingTimestamp === false) { + return false; + } + + return $incomingTimestamp <= $latestTimestamp; + } +} diff --git a/temp/nexus-module-import/modules/boersenchecker/src/Support/InstrumentRegistry.php b/temp/nexus-module-import/modules/boersenchecker/src/Support/InstrumentRegistry.php new file mode 100644 index 00000000..fc8f4718 --- /dev/null +++ b/temp/nexus-module-import/modules/boersenchecker/src/Support/InstrumentRegistry.php @@ -0,0 +1,190 @@ +normalizePayload($payload); + $matchingId = $this->findMatchingInstrumentId($data, $currentId); + + if ($currentId > 0 && $matchingId > 0 && $matchingId !== $currentId) { + return $this->mergeIntoExistingInstrument($currentId, $matchingId, $data); + } + + if ($currentId > 0) { + $this->updateInstrument($currentId, $data); + return $currentId; + } + + if ($matchingId > 0) { + $this->updateInstrument($matchingId, $data); + return $matchingId; + } + + return $this->insertInstrument($data); + } + + public function findMatchingInstrumentId(array $payload, int $excludeId = 0): ?int + { + $data = $this->normalizePayload($payload); + $conditions = []; + $excludeSql = $excludeId > 0 ? ' AND id <> :exclude_id' : ''; + + if ($data['isin'] !== null) { + $conditions[] = [ + 'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE isin = :isin' . $excludeSql . ' LIMIT 1', + 'params' => ['isin' => $data['isin']], + ]; + } + + if ($data['symbol'] !== null && $data['market'] !== null) { + $conditions[] = [ + 'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND market = :market' . $excludeSql . ' LIMIT 1', + 'params' => ['symbol' => $data['symbol'], 'market' => $data['market']], + ]; + } + + if ($data['symbol'] !== null && $data['name'] !== '') { + $conditions[] = [ + 'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND name = :name' . $excludeSql . ' LIMIT 1', + 'params' => ['symbol' => $data['symbol'], 'name' => $data['name']], + ]; + } + + foreach ($conditions as $condition) { + $params = $condition['params']; + if ($excludeId > 0) { + $params['exclude_id'] = $excludeId; + } + + $stmt = $this->pdo->prepare($condition['sql']); + $stmt->execute($params); + $id = $stmt->fetchColumn(); + if ($id !== false) { + return (int) $id; + } + } + + return null; + } + + private function normalizePayload(array $payload): array + { + $data = [ + 'isin' => $this->normalizeUpper($payload['isin'] ?? null), + 'wkn' => $this->normalizeUpper($payload['wkn'] ?? null), + 'symbol' => $this->normalizeUpper($payload['symbol'] ?? null), + 'name' => trim((string) ($payload['name'] ?? '')), + 'quote_currency' => $this->normalizeUpper($payload['quote_currency'] ?? 'EUR', 'EUR'), + 'market' => trim((string) ($payload['market'] ?? '')) ?: null, + ]; + + if ($data['name'] === '') { + throw new RuntimeException('Bitte mindestens einen Aktiennamen angeben.'); + } + + return $data; + } + + private function normalizeUpper(mixed $value, string $fallback = ''): ?string + { + $normalized = strtoupper(trim((string) $value)); + if ($normalized !== '') { + return $normalized; + } + + return $fallback !== '' ? $fallback : null; + } + + private function updateInstrument(int $instrumentId, array $data): void + { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->instrumentTable . ' + SET isin = :isin, + wkn = :wkn, + symbol = :symbol, + name = :name, + quote_currency = :quote_currency, + market = :market, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id' + ); + $stmt->execute($data + ['id' => $instrumentId]); + } + + private function insertInstrument(array $data): int + { + $driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + if ($driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market) + VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market) + RETURNING id' + ); + $stmt->execute($data); + return (int) $stmt->fetchColumn(); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market) + VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)' + ); + $stmt->execute($data); + return (int) $this->pdo->lastInsertId(); + } + + private function mergeIntoExistingInstrument(int $sourceId, int $targetId, array $data): int + { + $this->pdo->beginTransaction(); + try { + $this->updateInstrument($targetId, $data); + + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->positionTable . ' + SET instrument_id = :target_id, updated_at = CURRENT_TIMESTAMP + WHERE instrument_id = :source_id' + ); + $stmt->execute([ + 'target_id' => $targetId, + 'source_id' => $sourceId, + ]); + + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->quoteTable . ' + SET instrument_id = :target_id + WHERE instrument_id = :source_id' + ); + $stmt->execute([ + 'target_id' => $targetId, + 'source_id' => $sourceId, + ]); + + $stmt = $this->pdo->prepare('DELETE FROM ' . $this->instrumentTable . ' WHERE id = :id'); + $stmt->execute(['id' => $sourceId]); + + $this->pdo->commit(); + } catch (\Throwable $e) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $e; + } + + return $targetId; + } +} diff --git a/temp/nexus-module-import/modules/fx-rates/api/index.php b/temp/nexus-module-import/modules/fx-rates/api/index.php new file mode 100644 index 00000000..9a9f8bc7 --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/api/index.php @@ -0,0 +1,7 @@ +handle($_GET['path'] ?? ''); diff --git a/temp/nexus-module-import/modules/fx-rates/assets/fx-rates-currencies.js b/temp/nexus-module-import/modules/fx-rates/assets/fx-rates-currencies.js new file mode 100644 index 00000000..14e34763 --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/assets/fx-rates-currencies.js @@ -0,0 +1,168 @@ +(() => { + const root = document.getElementById('fx-rates-currencies'); + if (!root) { + return; + } + + const page = JSON.parse(root.dataset.page || '{}'); + const currencies = Array.isArray(page.currencies) ? page.currencies : []; + const selected = new Set( + (Array.isArray(page.preferred_currencies) ? page.preferred_currencies : []) + .map((code) => String(code || '').trim().toUpperCase()) + .filter(Boolean) + ); + let displayBase = String(page.display_base_currency || '').trim().toUpperCase(); + const savedDisplayBase = String(page.saved_display_base_currency || displayBase || '').trim().toUpperCase(); + + const nodes = { + tokenList: root.querySelector('[data-fx-token-list]'), + searchInput: root.querySelector('[data-fx-search-input]'), + suggestions: root.querySelector('[data-fx-suggestions]'), + displayBaseSelect: root.querySelector('[data-fx-display-base-select]'), + displayBaseHidden: root.querySelector('[data-fx-display-base-hidden]'), + hiddenPreferred: root.querySelector('[data-fx-hidden-preferred]'), + }; + + const escapeHtml = (value) => String(value || '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + + const currencyByCode = new Map( + currencies.map((currency) => [String(currency.code || '').toUpperCase(), currency]) + ); + + const sortedSelectedCodes = () => Array.from(selected).sort((left, right) => left.localeCompare(right)); + + const ensureDisplayBase = () => { + const available = sortedSelectedCodes(); + if (available.length === 0) { + displayBase = ''; + return; + } + if (!displayBase || !selected.has(displayBase)) { + displayBase = available[0]; + } + }; + + const renderHiddenInputs = () => { + if (!nodes.hiddenPreferred) { + return; + } + nodes.hiddenPreferred.innerHTML = sortedSelectedCodes() + .map((code) => ``) + .join(''); + if (nodes.displayBaseHidden) { + nodes.displayBaseHidden.value = displayBase; + } + }; + + const renderDisplayBase = () => { + if (!nodes.displayBaseSelect) { + return; + } + ensureDisplayBase(); + const available = sortedSelectedCodes(); + nodes.displayBaseSelect.innerHTML = available.length + ? available.map((code) => ``).join('') + : ''; + nodes.displayBaseSelect.disabled = available.length === 0; + }; + + const removeCode = (code) => { + selected.delete(code); + renderAll(); + }; + + const renderTokens = () => { + if (!nodes.tokenList) { + return; + } + const selectedCodes = sortedSelectedCodes(); + if (selectedCodes.length === 0) { + nodes.tokenList.innerHTML = '
Noch keine bevorzugten Waehrungen ausgewaehlt.
'; + return; + } + nodes.tokenList.innerHTML = selectedCodes.map((code) => { + const currency = currencyByCode.get(code) || { code, name: code }; + return ` + + `; + }).join(''); + + nodes.tokenList.querySelectorAll('[data-remove-code]').forEach((button) => { + button.addEventListener('click', () => { + removeCode(String(button.getAttribute('data-remove-code') || '').toUpperCase()); + }); + }); + }; + + const renderSuggestions = () => { + if (!nodes.suggestions || !nodes.searchInput) { + return; + } + const needle = String(nodes.searchInput.value || '').trim().toLowerCase(); + if (!needle) { + nodes.suggestions.innerHTML = ''; + return; + } + const matches = currencies + .filter((currency) => !selected.has(String(currency.code || '').toUpperCase())) + .filter((currency) => { + const code = String(currency.code || '').toLowerCase(); + const name = String(currency.name || '').toLowerCase(); + return code.includes(needle) || name.includes(needle); + }) + .slice(0, 12); + + nodes.suggestions.innerHTML = matches.map((currency) => ` + + `).join(''); + + nodes.suggestions.querySelectorAll('[data-add-code]').forEach((button) => { + button.addEventListener('click', () => { + const code = String(button.getAttribute('data-add-code') || '').toUpperCase(); + if (code) { + selected.add(code); + if (nodes.searchInput) { + nodes.searchInput.value = ''; + } + renderAll(); + } + }); + }); + }; + + const renderAll = () => { + ensureDisplayBase(); + renderTokens(); + renderDisplayBase(); + renderHiddenInputs(); + renderSuggestions(); + }; + + nodes.searchInput?.addEventListener('input', renderSuggestions); + nodes.displayBaseSelect?.addEventListener('change', () => { + displayBase = String(nodes.displayBaseSelect?.value || '').trim().toUpperCase(); + renderHiddenInputs(); + const url = new URL(window.location.href); + if (displayBase) { + url.searchParams.set('base', displayBase); + } else if (savedDisplayBase) { + url.searchParams.set('base', savedDisplayBase); + } else { + url.searchParams.delete('base'); + } + window.location.href = url.toString(); + }); + + renderAll(); +})(); diff --git a/temp/nexus-module-import/modules/fx-rates/assets/fx-rates.css b/temp/nexus-module-import/modules/fx-rates/assets/fx-rates.css new file mode 100644 index 00000000..ba76b373 --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/assets/fx-rates.css @@ -0,0 +1,287 @@ +#fx-rates-app, +#fx-rates-currencies { + display: contents; +} + +.fx-section-head { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + flex-wrap: wrap; +} + +.fx-section-head h2, +.section-box h2, +.card-box h2 { + margin: 0 0 0.5rem; +} + +.fx-section-head p, +.section-box p, +.card-box p { + margin: 0 0 0.75rem; + color: var(--muted); +} + +.fx-card-head { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; + flex-wrap: wrap; +} + +.fx-actions { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.fx-form-grid { + display: grid; + gap: 0.75rem; +} + +.fx-form-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.fx-form-grid label, +.fx-block { + display: grid; + gap: 0.35rem; +} + +.fx-form-grid span, +.fx-block span { + font-size: 0.9rem; + color: var(--muted); +} + +.fx-form-grid input, +.fx-form-grid select, +.fx-block input { + width: 100%; + border: 1px solid var(--line); + border-radius: 12px; + padding: 0.7rem 0.8rem; + background: var(--surface-strong); + color: var(--text); +} + +.fx-message { + color: var(--text); +} + +.fx-message.is-error { + color: #d92d20; +} + +.fx-message.is-success { + color: color-mix(in srgb, var(--accent-green) 78%, var(--text)); +} + +.fx-table-wrap { + overflow-x: auto; +} + +.fx-table { + width: 100%; + border-collapse: collapse; +} + +.fx-table th, +.fx-table td { + text-align: left; + border-bottom: 1px solid var(--line); + padding: 0.65rem 0.4rem; +} + +.fx-api-note { + margin-top: 0.75rem; + font-size: 0.95rem; +} + +.fx-convert-result { + margin-top: 1rem; + min-height: 1.5rem; + font-size: 1rem; + font-weight: 700; + color: var(--text); +} + +.fx-card-meta { + display: grid; + gap: 0.35rem; + color: var(--muted); + font-size: 0.95rem; + text-align: right; +} + +.fx-action-row { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.fx-action-row form { + margin: 0; +} + +.fx-save-row { + margin-top: 1rem; +} + +.fx-save-row form { + margin: 0; +} + +.fx-mini-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.fx-card-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.fx-mini-label, +.fx-field-label { + font-size: 0.9rem; + color: var(--muted); + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.fx-card-value { + font-size: 1.2rem; + font-weight: 700; +} + +.fx-currency-selection-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-start; +} + +.fx-token-list { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.fx-token-list--inline { + flex: 1 1 auto; +} + +.fx-currency-search { + flex: 0 1 360px; + min-width: 260px; +} + +.fx-input, +.fx-select { + width: 100%; + border: 1px solid var(--line); + border-radius: 18px; + padding: 0.8rem 1rem; + background: var(--surface-strong); + color: var(--text); +} + +.fx-field { + display: grid; + gap: 0.45rem; + margin-top: 1rem; +} + +.fx-token, +.fx-suggestion { + display: inline-flex; + align-items: center; + gap: 0.6rem; + border: 1px solid var(--line); + border-radius: 999px; + padding: 0.7rem 1rem; + background: var(--surface-strong); + color: var(--text); +} + +.fx-token { + cursor: pointer; +} + +.fx-token:hover, +.fx-suggestion:hover { + border-color: color-mix(in srgb, var(--brand-accent-3) 45%, transparent); + background: color-mix(in srgb, var(--brand-accent) 6%, var(--surface-strong)); +} + +.fx-token-close { + color: var(--brand-accent-3); + font-weight: 700; + text-transform: uppercase; +} + +.fx-suggestion-list { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 0.9rem; +} + +.fx-suggestion { + cursor: pointer; +} + +.fx-suggestion strong { + color: var(--text); +} + +.fx-text { + color: var(--muted); +} + +.fx-history-date { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.fx-info-button { + width: 1.4rem; + height: 1.4rem; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-strong); + color: var(--muted); + font-size: 0.78rem; + font-weight: 700; + line-height: 1; + cursor: help; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; +} + +@media (max-width: 860px) { + .fx-currency-selection-row { + flex-direction: column; + } + + .fx-card-head { + align-items: flex-start; + } + + .fx-currency-search { + flex: 1 1 auto; + width: 100%; + min-width: 0; + } +} diff --git a/temp/nexus-module-import/modules/fx-rates/assets/fx-rates.js b/temp/nexus-module-import/modules/fx-rates/assets/fx-rates.js new file mode 100644 index 00000000..5137cb72 --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/assets/fx-rates.js @@ -0,0 +1,286 @@ +(() => { + const root = document.getElementById('fx-rates-app'); + if (!root) { + return; + } + + const page = JSON.parse(root.dataset.page || '{}'); + const settings = page.settings || {}; + const nodes = { + historyHead: root.querySelector('[data-bind="history-head"]'), + historyBody: root.querySelector('[data-bind="history-body"]'), + convertResult: root.querySelector('[data-bind="convert-result"]'), + convertFrom: root.querySelector('select[name="convert_from"]'), + convertTo: root.querySelector('select[name="convert_to"]'), + convertAmount: root.querySelector('input[name="convert_amount"]'), + }; + const apiBase = '/api/fx-rates/v1'; + const preferredCurrencies = Array.isArray(page.preferred_currencies) + ? page.preferred_currencies + .map((item) => String(item || '').trim().toUpperCase()) + .filter(Boolean) + : []; + const refreshMaxAgeMinutes = Math.max(1, Number(settings.refresh_max_age_minutes || 60)); + + const parseDateValue = (value) => { + const raw = String(value || '').trim(); + if (!raw) { + return null; + } + + let normalized = raw.replace(' ', 'T'); + normalized = normalized.replace(/([+-]\d{2})$/, '$1:00'); + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed; + }; + + const latestFetchedAt = () => { + const latest = page.latest && typeof page.latest === 'object' ? page.latest : null; + const direct = parseDateValue(latest?.fetched_at); + if (direct) { + return direct; + } + + const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : []; + for (const entry of recentFetches) { + const parsed = parseDateValue(entry?.fetched_at); + if (parsed) { + return parsed; + } + } + + return null; + }; + + const bindManualRefreshAction = () => { + const refreshLink = Array.from(document.querySelectorAll('a[href]')).find((link) => { + try { + const url = new URL(link.href, window.location.origin); + return url.pathname === '/module/fx-rates' && url.searchParams.get('refresh') === '1'; + } catch (_error) { + return false; + } + }); + + if (!refreshLink) { + return; + } + + refreshLink.addEventListener('click', (event) => { + const url = new URL(refreshLink.href, window.location.origin); + if (url.searchParams.get('force') === '1') { + return; + } + + const lastFetch = latestFetchedAt(); + if (!lastFetch) { + return; + } + + const ageMinutes = (Date.now() - lastFetch.getTime()) / 60000; + if (!Number.isFinite(ageMinutes) || ageMinutes >= refreshMaxAgeMinutes) { + return; + } + + event.preventDefault(); + const confirmed = window.confirm( + `Der letzte gespeicherte Abruf ist juenger als ${refreshMaxAgeMinutes} Minuten. ` + + 'Ein manueller Abruf wuerde die externe API trotzdem erneut aufrufen. Jetzt trotzdem abrufen?' + ); + + if (!confirmed) { + return; + } + + url.searchParams.set('force', '1'); + window.location.href = url.toString(); + }); + }; + + const escapeHtml = (value) => String(value || '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + + const renderHistory = (rows, currencies) => { + if (!nodes.historyHead || !nodes.historyBody) { + return; + } + + const series = Array.isArray(currencies) ? currencies : []; + if (!series.length) { + nodes.historyHead.innerHTML = 'DatumKurse'; + nodes.historyBody.innerHTML = 'Keine bevorzugten Waehrungen fuer den Verlauf vorhanden.'; + return; + } + + nodes.historyHead.innerHTML = ` + + Datum + ${series.map((currency) => `${currency}`).join('')} + + `; + + const entries = Array.isArray(rows) ? rows : []; + if (!entries.length) { + nodes.historyBody.innerHTML = `Noch keine Verlaufsdaten vorhanden.`; + return; + } + + nodes.historyBody.innerHTML = entries.map((entry) => ` + + +
+ ${entry.label} + ${entry.fetch ? ` + + ` : ''} +
+ + ${series.map((currency) => { + const value = entry.rates?.[currency]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + return '–'; + } + return `${value.toLocaleString('de-DE', { maximumFractionDigits: 8 })}`; + }).join('')} + + `).join(''); + }; + + const request = async (path, options = {}) => { + const response = await fetch(`${apiBase}${path}`, { + credentials: 'same-origin', + headers: { + Accept: 'application/json', + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(options.headers || {}), + }, + ...options, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`); + } + return payload.data; + }; + + const loadHistory = async () => { + const base = String( + settings.display_base_currency || settings.default_base_currency || 'EUR' + ).trim().toUpperCase(); + const selectedCurrencies = preferredCurrencies.length + ? preferredCurrencies + : [base]; + const historyCurrencies = selectedCurrencies.filter((currency) => currency !== base); + + if (!selectedCurrencies.length) { + renderHistory([], []); + return; + } + + const histories = await Promise.all(historyCurrencies.map(async (currency) => { + const query = new URLSearchParams({ from: base, to: currency, limit: '20' }); + const rows = await request(`/history?${query.toString()}`); + return { currency, rows: Array.isArray(rows) ? rows : [] }; + })); + + const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : []; + const byDate = new Map(); + recentFetches.forEach((fetch) => { + const key = String(fetch?.fetched_at || '').trim(); + if (!key || byDate.has(key)) { + return; + } + byDate.set(key, { + sortKey: key, + label: fetch?.fetched_at_display || fetch?.fetched_at || key, + fetch, + rates: base !== '' ? { [base]: 1 } : {}, + }); + }); + + histories.forEach(({ currency, rows }) => { + rows.forEach((row) => { + const key = String(row?.fetched_at || row?.rate_date || '').trim(); + if (!key) { + return; + } + if (!byDate.has(key)) { + byDate.set(key, { + sortKey: key, + label: row?.fetched_at_display || row?.fetched_at || row?.rate_date || key, + fetch: recentFetches.find((fetch) => String(fetch?.fetched_at || '').trim() === key) || null, + rates: base !== '' ? { [base]: 1 } : {}, + }); + } + const entry = byDate.get(key); + if (entry && typeof row?.rate === 'number' && Number.isFinite(row.rate)) { + entry.rates[currency] = row.rate; + } + }); + }); + + const mergedRows = Array.from(byDate.values()) + .sort((left, right) => String(right.sortKey).localeCompare(String(left.sortKey))) + .slice(0, 15); + + renderHistory(mergedRows, selectedCurrencies); + }; + + const calculateConversion = async () => { + if (!nodes.convertFrom || !nodes.convertTo || !nodes.convertAmount || !nodes.convertResult) { + return; + } + + const from = String(nodes.convertFrom.value || '').trim().toUpperCase(); + const to = String(nodes.convertTo.value || '').trim().toUpperCase(); + const amount = Number(nodes.convertAmount.value || '0'); + + if (!from || !to || !Number.isFinite(amount)) { + nodes.convertResult.textContent = 'Bitte Quellwaehrung, Zielwaehrung und Betrag angeben.'; + return; + } + + if (from === to) { + nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to}`; + return; + } + + try { + const query = new URLSearchParams({ from, to }); + const data = await request(`/rate?${query.toString()}`); + const rate = Number(data?.rate || 0); + if (!Number.isFinite(rate) || rate <= 0) { + throw new Error('Kein Kurs verfuegbar.'); + } + const converted = amount * rate; + nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${converted.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to} | Kurs ${rate.toLocaleString('de-DE', { maximumFractionDigits: 8 })}`; + } catch (error) { + nodes.convertResult.textContent = error.message || 'Umrechnung konnte nicht berechnet werden.'; + } + }; + + [nodes.convertFrom, nodes.convertTo, nodes.convertAmount].forEach((node) => { + node?.addEventListener('change', () => { + calculateConversion().catch(() => {}); + }); + node?.addEventListener('input', () => { + calculateConversion().catch(() => {}); + }); + }); + + bindManualRefreshAction(); + + loadHistory().catch(() => { + renderHistory([], preferredCurrencies); + }); + calculateConversion().catch(() => {}); +})(); diff --git a/temp/nexus-module-import/modules/fx-rates/bootstrap.php b/temp/nexus-module-import/modules/fx-rates/bootstrap.php new file mode 100644 index 00000000..ce1613f6 --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/bootstrap.php @@ -0,0 +1,269 @@ +registerFunction($moduleName, 'table', static function (string $name): string { + $prefix = 'fxrate_'; + $sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name); + return $prefix . $sanitized; +}); + +$mm->registerFunction($moduleName, 'settings', static function (): array { + $saved = modules()->settings('fx-rates'); + $provider = trim((string) ($saved['provider'] ?? (getenv('FX_RATES_PROVIDER') ?: getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi'))); + $apiVersion = strtolower(trim((string) ($saved['api_version'] ?? 'v2'))); + $apiUrl = rtrim((string) ($saved['api_url'] ?? (getenv('FX_RATES_API_URL') ?: getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net')), '/'); + $apiKey = trim((string) ($saved['api_key'] ?? (getenv('FX_RATES_API_KEY') ?: getenv('MINING_CHECKER_FX_API_KEY') ?: ''))); + $timeout = max(2, (int) ($saved['timeout_sec'] ?? (getenv('FX_RATES_TIMEOUT') ?: getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10))); + + $preferredCurrencies = $saved['preferred_currencies'] ?? ['EUR', 'USD', 'DOGE']; + if (is_string($preferredCurrencies)) { + $preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: []; + } + $preferredCurrencies = array_values(array_unique(array_filter(array_map( + static fn (mixed $code): string => strtoupper(trim((string) $code)), + is_array($preferredCurrencies) ? $preferredCurrencies : [] + ), static fn (string $code): bool => $code !== ''))); + + $currencyCatalog = $saved['currency_catalog'] ?? []; + $currencyCatalog = array_values(array_filter(array_map(static function (mixed $item): ?array { + if (!is_array($item)) { + return null; + } + $code = strtoupper(trim((string) ($item['code'] ?? ''))); + $name = trim((string) ($item['name'] ?? '')); + if ($code === '' || $name === '') { + return null; + } + return ['code' => $code, 'name' => $name]; + }, is_array($currencyCatalog) ? $currencyCatalog : []))); + + return [ + 'provider' => $provider !== '' ? $provider : 'currencyapi', + 'api_version' => in_array($apiVersion, ['v2', 'v3'], true) ? $apiVersion : 'v2', + 'api_url' => $apiUrl, + 'api_key' => $apiKey, + 'timeout_sec' => $timeout, + 'refresh_max_age_minutes' => max(1, (int) ($saved['refresh_max_age_minutes'] ?? 60)), + 'default_base_currency' => strtoupper(trim((string) ($saved['default_base_currency'] ?? 'EUR'))) ?: 'EUR', + 'display_base_currency' => strtoupper(trim((string) ($saved['display_base_currency'] ?? ($saved['default_base_currency'] ?? 'EUR')))) ?: 'EUR', + 'preferred_currencies' => $preferredCurrencies, + 'currency_catalog' => $currencyCatalog, + 'currency_catalog_synced_at' => trim((string) ($saved['currency_catalog_synced_at'] ?? '')), + 'schedule_timezone' => trim((string) ($saved['schedule_timezone'] ?? nexus_cron_timezone_name())) ?: nexus_cron_timezone_name(), + ]; +}); + +$mm->registerFunction($moduleName, 'save_runtime_settings', static function (array $payload): array { + $current = modules()->settings('fx-rates'); + $normalized = module_fn('fx-rates', 'settings'); + + if (array_key_exists('default_base_currency', $payload)) { + $normalized['default_base_currency'] = strtoupper(trim((string) $payload['default_base_currency'])) ?: $normalized['default_base_currency']; + } + if (array_key_exists('display_base_currency', $payload)) { + $normalized['display_base_currency'] = strtoupper(trim((string) $payload['display_base_currency'])) ?: $normalized['display_base_currency']; + } + if (array_key_exists('preferred_currencies', $payload)) { + $preferredCurrencies = $payload['preferred_currencies']; + if (is_string($preferredCurrencies)) { + $preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: []; + } + $normalized['preferred_currencies'] = array_values(array_unique(array_filter(array_map( + static fn (mixed $code): string => strtoupper(trim((string) $code)), + is_array($preferredCurrencies) ? $preferredCurrencies : [] + ), static fn (string $code): bool => $code !== ''))); + } + + $catalogCodes = []; + foreach (($normalized['currency_catalog'] ?? []) as $currency) { + if (is_array($currency)) { + $code = strtoupper(trim((string) ($currency['code'] ?? ''))); + if ($code !== '') { + $catalogCodes[$code] = true; + } + } + } + if ($catalogCodes !== [] && !isset($catalogCodes[$normalized['display_base_currency']])) { + $normalized['display_base_currency'] = $normalized['default_base_currency']; + } + if ($catalogCodes !== []) { + $normalized['preferred_currencies'] = array_values(array_filter( + $normalized['preferred_currencies'], + static fn (string $code): bool => isset($catalogCodes[$code]) + )); + } + + $toSave = array_merge($current, [ + 'default_base_currency' => $normalized['default_base_currency'], + 'display_base_currency' => $normalized['display_base_currency'], + 'preferred_currencies' => $normalized['preferred_currencies'], + ]); + + modules()->saveSettings('fx-rates', $toSave); + + return module_fn('fx-rates', 'settings'); +}); + +$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): PDO { + $settings = modules()->settings($moduleName); + $useSeparate = !empty($settings['use_separate_db']); + + if ($useSeparate) { + $module = modules()->get($moduleName); + $fallback = $module['db_defaults'] ?? []; + return modules()->modulePdo($moduleName, $fallback); + } + + $base = app()->basePdo(); + if ($base instanceof PDO) { + return $base; + } + + throw new ModuleConfigException( + $moduleName, + 'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.' + ); +}); + +$mm->registerFunction($moduleName, 'repository', static function (): FxRatesRepository { + return new FxRatesRepository(module_fn('fx-rates', 'pdo'), 'fxrate_'); +}); + +$mm->registerFunction($moduleName, 'ensure_schema', static function (): void { + module_fn('fx-rates', 'repository')->ensureSchema(); +}); + +$mm->registerFunction($moduleName, 'service', static function (): FxRatesService { + module_fn('fx-rates', 'ensure_schema'); + return new FxRatesService( + module_fn('fx-rates', 'repository'), + module_fn('fx-rates', 'settings') + ); +}); + +$mm->registerFunction($moduleName, 'setup_actions', static function (): array { + return [ + [ + 'name' => 'sync_currency_catalog', + 'label' => 'Waehrungskatalog synchronisieren', + 'help' => 'Laedt die verfuegbaren Waehrungen einmalig aus dem konfigurierten FX-Provider.', + ], + ]; +}); + +$mm->registerFunction($moduleName, 'run_setup_action', static function (string $action): array { + return match ($action) { + 'sync_currency_catalog' => (static function (): array { + $result = module_fn('fx-rates', 'service')->refreshCurrencyCatalog(); + $current = modules()->settings('fx-rates'); + + $catalog = []; + foreach (is_array($result['currencies'] ?? null) ? $result['currencies'] : [] as $item) { + if (!is_array($item)) { + continue; + } + $code = strtoupper(trim((string) ($item['code'] ?? ''))); + $name = trim((string) ($item['name'] ?? '')); + if ($code === '' || $name === '') { + continue; + } + $catalog[$code] = ['code' => $code, 'name' => $name]; + } + + $latest = module_fn('fx-rates', 'service')->latestStatus(); + if (is_array($latest) && !empty($latest['id'])) { + $snapshot = module_fn('fx-rates', 'snapshot', null, null, null, null); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + foreach (array_keys($rates) as $code) { + $code = strtoupper(trim((string) $code)); + if ($code !== '' && !isset($catalog[$code])) { + $catalog[$code] = ['code' => $code, 'name' => $code]; + } + } + $baseCode = strtoupper(trim((string) ($snapshot['base_currency'] ?? ''))); + if ($baseCode !== '' && !isset($catalog[$baseCode])) { + $catalog[$baseCode] = ['code' => $baseCode, 'name' => $baseCode]; + } + } + + foreach ([ + (string) ($current['default_base_currency'] ?? ''), + (string) ($current['display_base_currency'] ?? ''), + ...((is_array($current['preferred_currencies'] ?? null) ? $current['preferred_currencies'] : [])), + ] as $code) { + $code = strtoupper(trim((string) $code)); + if ($code !== '' && !isset($catalog[$code])) { + $catalog[$code] = ['code' => $code, 'name' => $code]; + } + } + + ksort($catalog); + $current['currency_catalog'] = array_values($catalog); + $current['currency_catalog_synced_at'] = gmdate('Y-m-d H:i:s'); + modules()->saveSettings('fx-rates', $current); + return $result + [ + 'currencies' => array_values($catalog), + 'synced_count' => count($catalog), + 'message' => 'Waehrungskatalog synchronisiert. ' . count($catalog) . ' Waehrungen verarbeitet.', + ]; + })(), + default => throw new \RuntimeException('Unbekannte Setup-Aktion.'), + }; +}); + +$mm->registerFunction($moduleName, 'refresh_latest', static function (?array $currencies = null, ?string $baseCurrency = null): array { + return module_fn('fx-rates', 'service')->refreshLatestRates($currencies, $baseCurrency); +}); + +$mm->registerFunction($moduleName, 'ensure_fresh_latest_rates', static function (float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null): array { + return module_fn('fx-rates', 'service')->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies); +}); + +$mm->registerFunction($moduleName, 'rate', static function (string $fromCurrency, string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array { + return module_fn('fx-rates', 'service')->findRate($fromCurrency, $toCurrency, $at, $windowMinutes); +}); + +$mm->registerFunction($moduleName, 'convert', static function (?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float { + return module_fn('fx-rates', 'service')->convert($amount, $fromCurrency, $toCurrency, $at, $windowMinutes); +}); + +$mm->registerFunction($moduleName, 'snapshot', static function (?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array { + return module_fn('fx-rates', 'service')->snapshot($baseCurrency, $at, $symbols, $windowMinutes); +}); + +$mm->registerFunction($moduleName, 'recent_fetches', static function (int $limit = 20): array { + return module_fn('fx-rates', 'service')->recentFetches($limit); +}); + +$mm->registerFunction($moduleName, 'scheduled_refresh', static function (array $context = []): array { + $result = module_fn('fx-rates', 'service')->runScheduledRefresh($context); + if (function_exists('module_debug_push')) { + module_debug_push('fx-rates', [ + 'label' => 'Intervall-Aufgabe', + 'type' => 'scheduler:run', + 'task' => 'daily_refresh', + 'context' => $context, + 'message' => (string) ($result['message'] ?? ''), + ]); + } + return $result; +}); diff --git a/temp/nexus-module-import/modules/fx-rates/design.json b/temp/nexus-module-import/modules/fx-rates/design.json new file mode 100644 index 00000000..06ddc24e --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/design.json @@ -0,0 +1,12 @@ +{ + "eyebrow": "Modul", + "title": "Waehrungskurse", + "description": "Zentrale Verwaltung fuer Waehrungskurse, Snapshots und FX-API-Abrufe.", + "actions": [ + { "label": "Setup", "href": "/modules/setup/fx-rates", "variant": "secondary" } + ], + "tabs": [ + { "label": "Ueberblick", "href": "/module/fx-rates" }, + { "label": "Waehrungen", "href": "/module/fx-rates/currencies", "match_prefixes": ["/module/fx-rates/currencies"] } + ] +} diff --git a/temp/nexus-module-import/modules/fx-rates/module.json b/temp/nexus-module-import/modules/fx-rates/module.json new file mode 100644 index 00000000..557a2f5b --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/module.json @@ -0,0 +1,58 @@ +{ + "title": "Waehrungskurse", + "version": "0.1.5", + "description": "Zentrales Modul fuer Waehrungskurse, Historie und API-Abrufe.", + "enabled_by_default": true, + "setup": { + "fields": [ + { "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." }, + { "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" }, + { "name": "db.host", "label": "DB Host", "type": "text", "required": false }, + { "name": "db.port", "label": "DB Port", "type": "number", "required": false }, + { "name": "db.dbname", "label": "DB Name", "type": "text", "required": false }, + { "name": "db.schema", "label": "DB Schema", "type": "text", "required": false }, + { "name": "db.user", "label": "DB User", "type": "text", "required": false }, + { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false }, + { "name": "provider", "label": "FX Provider", "type": "text", "required": false, "help": "Unterstuetzt legacy currencyapi.net und currencyapi.com v3." }, + { "name": "api_version", "label": "FX API Version", "type": "select", "required": false, "help": "Steuert die Endpoint-Version unabhaengig von der Domain." }, + { "name": "api_url", "label": "FX API URL", "type": "text", "required": false, "help": "Nur die Basis-URL eintragen, z.B. https://api.currencyapi.com oder https://currencyapi.net." }, + { "name": "api_key", "label": "FX API Key", "type": "password", "required": false }, + { "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false }, + { "name": "refresh_max_age_minutes", "label": "Max. Alter fuer API-Refresh (Min.)", "type": "number", "required": false, "help": "Blockiert neue API-Refresh-Aufrufe, solange der letzte gespeicherte Abruf juenger ist. Manuelle Abrufe koennen nach Hinweis trotzdem erzwungen werden; Cron ignoriert diesen Wert." }, + { "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." }, + { "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" } + ] + }, + "scheduler_jobs": [ + { + "name": "rates_refresh", + "label": "Kursabruf", + "callback": "scheduled_refresh", + "mode": "multi", + "default_enabled": true, + "default_cron": "0 18 * * *", + "default_timezone": "Europe/Berlin", + "timezone_setting": "schedule_timezone", + "lock_minutes": 120, + "help": "Zeitgesteuerter Abruf und das Speichern neuer FX-Snapshots.", + "builder": { + "allow_manual": true, + "presets": ["daily", "every_x_days", "weekly", "monthly_day", "every_x_hours"] + } + } + ], + "db_defaults": { + "driver": "pgsql", + "host": "localhost", + "port": 5432, + "dbname": "", + "schema": "public", + "user": "", + "password": "" + }, + "auth": { + "required": true, + "users": [], + "groups": [] + } +} diff --git a/temp/nexus-module-import/modules/fx-rates/pages/asset.php b/temp/nexus-module-import/modules/fx-rates/pages/asset.php new file mode 100644 index 00000000..23e8dafd --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/pages/asset.php @@ -0,0 +1,31 @@ + $base . '/fx-rates.css', + 'fx-rates.js' => $base . '/fx-rates.js', + 'fx-rates-currencies.js' => $base . '/fx-rates-currencies.js', +]; + +if (!isset($map[$file])) { + http_response_code(404); + exit('Not found'); +} + +$path = $map[$file]; +if (!$base || !is_file($path) || !str_starts_with($path, $base)) { + http_response_code(404); + exit('Not found'); +} + +$ext = pathinfo($path, PATHINFO_EXTENSION); +if ($ext === 'css') { + header('Content-Type: text/css; charset=utf-8'); +} elseif ($ext === 'js') { + header('Content-Type: application/javascript; charset=utf-8'); +} else { + header('Content-Type: application/octet-stream'); +} + +readfile($path); +exit; diff --git a/temp/nexus-module-import/modules/fx-rates/pages/currencies.php b/temp/nexus-module-import/modules/fx-rates/pages/currencies.php new file mode 100644 index 00000000..fc069446 --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/pages/currencies.php @@ -0,0 +1,280 @@ +assets(); +if ($assets) { + $assets->addStyle('/module/fx-rates/asset?file=fx-rates.css'); + $assets->addScript('/module/fx-rates/asset?file=fx-rates-currencies.js', 'footer', true); +} + +$settings = module_fn('fx-rates', 'settings'); +$service = module_fn('fx-rates', 'service'); +$notice = trim((string) ($_GET['notice'] ?? '')); +$error = trim((string) ($_GET['error'] ?? '')); + +if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') { + try { + $action = trim((string) ($_POST['fx_action'] ?? '')); + if ($action === 'save_selection') { + $payload = [ + 'display_base_currency' => (string) ($_POST['display_base_currency'] ?? ''), + 'preferred_currencies' => $_POST['preferred_currencies'] ?? [], + ]; + $saved = module_fn('fx-rates', 'save_runtime_settings', $payload); + $params = ['notice' => 'Waehrungs-Auswahl gespeichert.']; + if (is_array($saved) && !empty($saved['display_base_currency'])) { + $params['base'] = (string) $saved['display_base_currency']; + } + redirect('/module/fx-rates/currencies?' . http_build_query($params)); + } + + if ($action === 'sync_catalog') { + $result = module_fn('fx-rates', 'run_setup_action', 'sync_currency_catalog'); + redirect('/module/fx-rates/currencies?' . http_build_query([ + 'notice' => sprintf('Waehrungskatalog synchronisiert. %d Waehrungen verarbeitet.', (int) ($result['synced_count'] ?? 0)), + ])); + } + + if ($action === 'refresh_rates') { + $result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual'); + redirect('/module/fx-rates/currencies?' . http_build_query([ + 'notice' => sprintf('Alle Wechselkurse aktualisiert. %d Werte gespeichert.', (int) ($result['updated_count'] ?? 0)), + ])); + } + } catch (\Throwable $exception) { + redirect('/module/fx-rates/currencies?' . http_build_query([ + 'error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Aktion konnte nicht ausgefuehrt werden.', + ])); + } +} + +$catalog = is_array($settings['currency_catalog'] ?? null) ? $settings['currency_catalog'] : []; +$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : []; +$savedDisplayBaseCurrency = strtoupper(trim((string) ($settings['display_base_currency'] ?? $settings['default_base_currency'] ?? 'EUR'))); +$requestedDisplayBaseCurrency = strtoupper(trim((string) ($_GET['base'] ?? ''))); +$latest = $service->latestStatus(); +$recentFetches = $service->recentFetches(15); + +$currencies = []; +foreach ($catalog as $item) { + if (!is_array($item)) { + continue; + } + $code = strtoupper(trim((string) ($item['code'] ?? ''))); + $name = trim((string) ($item['name'] ?? '')); + if ($code === '' || $name === '') { + continue; + } + $currencies[] = [ + 'code' => $code, + 'name' => $name, + ]; +} + +$cryptoCodes = array_fill_keys([ + 'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC', + 'SOL', 'USDC', 'USDT', 'XAG', 'XAU', 'XRP', +], true); +$fiatCount = 0; +$cryptoCount = 0; +foreach ($currencies as $currency) { + if (isset($cryptoCodes[$currency['code']])) { + $cryptoCount++; + } else { + $fiatCount++; + } +} + +$catalogCodes = []; +foreach ($currencies as $currency) { + $catalogCodes[(string) $currency['code']] = true; +} + +$displayBaseCurrency = $requestedDisplayBaseCurrency !== '' ? $requestedDisplayBaseCurrency : $savedDisplayBaseCurrency; +if ($displayBaseCurrency === '' || (!isset($catalogCodes[$displayBaseCurrency]) && $preferredCurrencies !== [])) { + $displayBaseCurrency = $savedDisplayBaseCurrency !== '' ? $savedDisplayBaseCurrency : (string) ($preferredCurrencies[0] ?? 'EUR'); +} + +$tableCurrencies = []; +foreach ([$displayBaseCurrency, ...$preferredCurrencies] as $currency) { + $currency = strtoupper(trim((string) $currency)); + if ($currency !== '' && !in_array($currency, $tableCurrencies, true)) { + $tableCurrencies[] = $currency; + } +} + +$currencyPageData = json_encode([ + 'currencies' => $currencies, + 'preferred_currencies' => array_values(array_unique(array_map(static fn (mixed $code): string => strtoupper(trim((string) $code)), $preferredCurrencies))), + 'display_base_currency' => $displayBaseCurrency, + 'saved_display_base_currency' => $savedDisplayBaseCurrency, +], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + +$tabs = [ + ['label' => 'Ueberblick', 'href' => '/module/fx-rates'], + ['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies', 'active' => true], +]; +?> + 'Waehrungen', + 'tabs' => $tabs, + 'actions' => [ + ['label' => 'Nexus Übersicht', 'href' => '/', 'variant' => 'secondary', 'size' => 'sm'], + ['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'], + ], +]) ?> +
'> + +
+
+
+ +
+
+
+ + +
+
+
+

Waehrungs-Update

+

Auswahl wird in den Waehrungskurs-Einstellungen gespeichert und steht damit auf Handy und Desktop gleich zur Verfuegung.

+
+
+ +
+
+ + +
+
+ + +
+
+
+ +
+
+
Fiat
+
Waehrungen
+
+
+
Krypto
+
Waehrungen
+
+
+ +
+
Bevorzugte Waehrungen fuer Anzeige
+
+
+ +
+
+ + + +
+
+ + +
+ +
+
+
+ +
+
+
+

Letzte 15 Kurs-Uploads

+

Zeigt die zuletzt gespeicherten Wechselkurse aus der Datenbank.

+
+
+
Anzeige-Basis:
+
Letzter Abruf:
+
+
+
+ + + + + + + + + + + + + + + snapshotByFetchId((int) ($fetch['id'] ?? 0), $fetchBaseCurrency, $tableCurrencies); + $originalRates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + $displayBaseRate = $displayBaseCurrency === $fetchBaseCurrency + ? 1.0 + : (is_numeric($originalRates[$displayBaseCurrency] ?? null) ? (float) $originalRates[$displayBaseCurrency] : null); + $tableRates = []; + foreach ($tableCurrencies as $currency) { + $currency = strtoupper(trim((string) $currency)); + if ($currency === '') { + continue; + } + if ($currency === $displayBaseCurrency) { + $tableRates[$currency] = 1.0; + continue; + } + if ($displayBaseRate === null || $displayBaseRate <= 0) { + $tableRates[$currency] = null; + continue; + } + if ($currency === $fetchBaseCurrency) { + $tableRates[$currency] = 1 / $displayBaseRate; + continue; + } + $rawRate = $originalRates[$currency] ?? null; + $tableRates[$currency] = is_numeric($rawRate) ? ((float) $rawRate / $displayBaseRate) : null; + } + $infoTitle = sprintf( + 'Basis: %s | Provider: %s | Ausloeser: %s', + (string) ($fetch['base_currency'] ?? '-'), + (string) ($fetch['provider'] ?? '-'), + (string) ($fetch['trigger_source_label'] ?? $fetch['trigger_source'] ?? '-') + ); + ?> + + + + + + + + + + +
Zeit
Noch keine Abrufe vorhanden.
+
+ + +
+
+
+
+
+ diff --git a/temp/nexus-module-import/modules/fx-rates/pages/index.php b/temp/nexus-module-import/modules/fx-rates/pages/index.php new file mode 100644 index 00000000..1dcd7a62 --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/pages/index.php @@ -0,0 +1,149 @@ +assets(); +if ($assets) { + $assets->addStyle('/module/fx-rates/asset?file=fx-rates.css'); + $assets->addScript('/module/fx-rates/asset?file=fx-rates.js', 'footer', true); +} + +$settings = module_fn('fx-rates', 'settings'); +$service = module_fn('fx-rates', 'service'); +$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : []; +$apiDescribeUrl = APP_API_BASE . '/fx-rates/v1/endpoints'; +$notice = trim((string) ($_GET['notice'] ?? '')); +$error = trim((string) ($_GET['error'] ?? '')); + +if ((string) ($_GET['refresh'] ?? '') === '1') { + try { + $force = !empty($_GET['force']); + if ($force) { + $result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual'); + } else { + $result = $service->autoRefreshLatestRates( + (string) ($settings['default_base_currency'] ?? ''), + null, + (int) ($settings['refresh_max_age_minutes'] ?? 60), + 'manual' + ); + } + + $params = !empty($result['reused']) + ? [ + 'notice' => sprintf( + 'Kein neuer API-Abruf. Der letzte gespeicherte Snapshot ist juenger als %d Minuten. Fuer einen erzwungenen Abruf bitte bestaetigen.', + (int) ($settings['refresh_max_age_minutes'] ?? 60) + ), + ] + : [ + 'notice' => sprintf( + 'Aktuelle Kurse gespeichert. %d Werte aktualisiert.', + (int) ($result['updated_count'] ?? 0) + ), + ]; + } catch (\Throwable $exception) { + $params = ['error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Kurse konnten nicht aktualisiert werden.']; + } + + redirect('/module/fx-rates?' . http_build_query($params)); +} + +$latest = $service->latestStatus(); +$recentFetches = $service->recentFetches(15); +$pageData = json_encode([ + 'settings' => $settings, + 'latest' => $latest, + 'preferred_currencies' => $preferredCurrencies, + 'recent_fetches' => $recentFetches, +], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + +$tabs = [ + ['label' => 'Ueberblick', 'href' => '/module/fx-rates', 'active' => true], + ['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies'], +]; +?> + 'Ueberblick', + 'tabs' => $tabs, + 'actions' => [ + ['label' => 'Nexus Übersicht', 'href' => '/', 'variant' => 'secondary', 'size' => 'sm'], + ['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'], + ['label' => 'Aktuelle Kurse abrufen', 'href' => '/module/fx-rates?refresh=1', 'variant' => 'secondary', 'size' => 'sm'], + ], +]) ?> +
'> + +
+
+
+ +
+
+
+ + +
+
+
+

Umrechnung

+

Umrechnung auf Basis des letzten verfuegbaren Kurses zwischen den bevorzugten Waehrungen.

+
+
+

+ API-Self-Describe-Endpoint: + +

+
+ + + +
+
Noch keine Umrechnung berechnet.
+
+ +
+
+
+

Kursverlauf

+

Neueste Abrufe zuerst. Verlauf der bevorzugten Waehrungen relativ zur Anzeige-Basiswaehrung.

+
+
+
Anzeige-Basis:
+
Letzter Abruf:
+
+
+
+ + + + + + + + + + +
DatumKurse
Noch keine Verlaufsdaten geladen.
+
+
+
+ diff --git a/temp/nexus-module-import/modules/fx-rates/src/Api/Router.php b/temp/nexus-module-import/modules/fx-rates/src/Api/Router.php new file mode 100644 index 00000000..c32958c0 --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/src/Api/Router.php @@ -0,0 +1,270 @@ +respond(['ok' => true, 'module' => 'fx-rates']); + } + + if ($path === 'v1/endpoints' && $method === 'GET') { + $this->respond(['data' => $this->endpointCatalog()]); + } + + if ($path === 'v1/status' && $method === 'GET') { + $this->respond(['data' => $this->service->latestStatuses()]); + } + + if ($path === 'v1/recent-fetches' && $method === 'GET') { + $limit = max(1, min(50, (int) ($_GET['limit'] ?? 12))); + $this->respond(['data' => $this->service->recentFetches($limit)]); + } + + if ($path === 'v1/latest' && $method === 'GET') { + $symbols = $this->parseCsv($_GET['symbols'] ?? null); + $base = $this->stringOrNull($_GET['base'] ?? null); + if ($symbols === null) { + $settings = module_fn('fx-rates', 'settings'); + $symbols = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : null; + } + $snapshot = $this->service->snapshot($base, null, $symbols, null); + $this->respond(['data' => $snapshot]); + } + + if ($path === 'v1/fetch' && $method === 'GET') { + $fetchId = max(0, (int) ($_GET['fetch_id'] ?? 0)); + $base = $this->stringOrNull($_GET['base'] ?? null); + $symbols = $this->parseCsv($_GET['symbols'] ?? null); + $snapshot = $this->service->snapshotByFetchId($fetchId, $base, $symbols); + $this->respond(['data' => $snapshot]); + } + + if ($path === 'v1/nearest' && $method === 'GET') { + $base = $this->stringOrNull($_GET['base'] ?? null); + $symbols = $this->parseCsv($_GET['symbols'] ?? null); + $at = $this->stringOrNull($_GET['at'] ?? null); + $windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null); + $snapshot = $this->service->nearestSnapshot($base, (string) $at, $symbols, $windowMinutes); + $this->respond(['data' => $snapshot]); + } + + if ($path === 'v1/snapshot' && $method === 'GET') { + $symbols = $this->parseCsv($_GET['symbols'] ?? null); + $base = $this->stringOrNull($_GET['base'] ?? null); + $at = $this->stringOrNull($_GET['at'] ?? null); + $windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null); + $snapshot = $this->service->snapshot($base, $at, $symbols, $windowMinutes); + $this->respond(['data' => $snapshot]); + } + + if ($path === 'v1/rate' && $method === 'GET') { + $from = $this->stringOrNull($_GET['from'] ?? null); + $to = $this->stringOrNull($_GET['to'] ?? null); + $at = $this->stringOrNull($_GET['at'] ?? null); + $windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null); + $rate = $this->service->findRate($from, $to, $at, $windowMinutes); + $this->respond(['data' => $rate]); + } + + if ($path === 'v1/history' && $method === 'GET') { + $from = $this->stringOrNull($_GET['from'] ?? null); + $to = $this->stringOrNull($_GET['to'] ?? null); + $fromAt = $this->stringOrNull($_GET['from_at'] ?? null); + $toAt = $this->stringOrNull($_GET['to_at'] ?? null); + $limit = max(1, min(1000, (int) ($_GET['limit'] ?? 200))); + $history = $this->service->history((string) $from, (string) $to, $fromAt, $toAt, $limit); + $this->respond(['data' => $history]); + } + + if ($path === 'v1/refresh' && $method === 'POST') { + $input = $this->input(); + $base = $this->stringOrNull($input['base'] ?? null); + $force = !empty($input['force']); + $maxAgeMinutes = is_numeric($input['max_age_minutes'] ?? null) ? (int) $input['max_age_minutes'] : null; + + $result = $force + ? $this->service->refreshLatestRates(null, $base, 'api') + : $this->service->autoRefreshLatestRates($base, null, $maxAgeMinutes, 'api'); + + $this->respond(['data' => $result], 201); + } + + if ($path === 'v1/probe' && $method === 'GET') { + $base = $this->stringOrNull($_GET['base'] ?? null); + $this->respond(['data' => $this->service->probeLatestRates($base)]); + } + + if ($path === 'v1/settings' && $method === 'GET') { + $this->respond(['data' => module_fn('fx-rates', 'settings')]); + } + + if ($path === 'v1/settings' && $method === 'PUT') { + $this->respond(['data' => module_fn('fx-rates', 'save_runtime_settings', $this->input())]); + } + + $this->respond(['error' => 'Unbekannter API-Pfad.'], 404); + } catch (\Throwable $exception) { + $this->respond([ + 'error' => 'FX-API Fehler.', + 'context' => ['message' => $exception->getMessage()], + ], 500); + } + } + + private function respond(array $payload, int $statusCode = 200): never + { + http_response_code($statusCode); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit; + } + + private function input(): array + { + $raw = file_get_contents('php://input'); + $decoded = json_decode((string) $raw, true); + return is_array($decoded) ? $decoded : []; + } + + private function parseCsv(mixed $value): ?array + { + if (is_array($value)) { + $items = $value; + } else { + $value = trim((string) $value); + if ($value === '') { + return null; + } + $items = explode(',', $value); + } + + $result = []; + foreach ($items as $item) { + $item = strtoupper(trim((string) $item)); + if ($item !== '') { + $result[] = $item; + } + } + + $result = array_values(array_unique($result)); + return $result !== [] ? $result : null; + } + + private function stringOrNull(mixed $value): ?string + { + $value = trim((string) $value); + return $value !== '' ? $value : null; + } + + private function intOrNull(mixed $value): ?int + { + return is_numeric($value) ? (int) $value : null; + } + + private function endpointCatalog(): array + { + return [ + 'module' => 'fx-rates', + 'version' => 'v1', + 'languages' => ['de', 'en'], + 'endpoints' => [ + [ + 'path' => '/api/fx-rates/v1/endpoints', + 'method' => 'GET', + 'description_de' => 'Gibt alle verfuegbaren FX-API-Endpunkte mit deutscher und englischer Erklaerung zurueck.', + 'description_en' => 'Returns all available FX API endpoints with German and English explanations.', + ], + [ + 'path' => '/api/fx-rates/v1/latest', + 'method' => 'GET', + 'params' => ['base', 'symbols'], + 'description_de' => 'Liefert den neuesten gespeicherten Snapshot, optional auf eine Zielbasis umgerechnet und auf ausgewaehlte Waehrungen gefiltert.', + 'description_en' => 'Returns the latest stored snapshot, optionally rebased to a target currency and filtered to selected symbols.', + ], + [ + 'path' => '/api/fx-rates/v1/fetch', + 'method' => 'GET', + 'params' => ['fetch_id', 'base', 'symbols'], + 'description_de' => 'Liefert einen gespeicherten Snapshot anhand der fetch_id, optional umgerechnet auf eine Zielbasis und gefiltert auf einzelne Waehrungen.', + 'description_en' => 'Returns a stored snapshot by fetch_id, optionally rebased to a target currency and filtered to selected symbols.', + ], + [ + 'path' => '/api/fx-rates/v1/nearest', + 'method' => 'GET', + 'params' => ['at', 'base', 'symbols', 'window_minutes'], + 'description_de' => 'Liefert den zeitlich naechsten gespeicherten Snapshot zu einem Datum/Uhrzeit-Wert.', + 'description_en' => 'Returns the stored snapshot nearest to a given date/time value.', + ], + [ + 'path' => '/api/fx-rates/v1/snapshot', + 'method' => 'GET', + 'params' => ['at', 'base', 'symbols', 'window_minutes'], + 'description_de' => 'Liefert einen Snapshot zur Zielbasis und sucht fuer einen Zeitpunkt den naechsten passenden gespeicherten Kurs.', + 'description_en' => 'Returns a snapshot for the requested base and finds the nearest matching stored rate for a given timestamp.', + ], + [ + 'path' => '/api/fx-rates/v1/rate', + 'method' => 'GET', + 'params' => ['from', 'to', 'at', 'window_minutes'], + 'description_de' => 'Liefert einen Einzelkurs zwischen zwei Waehrungen, direkt oder als Kreuzkurs aus gespeicherten Snapshots.', + 'description_en' => 'Returns a single rate between two currencies, directly or as a cross-rate from stored snapshots.', + ], + [ + 'path' => '/api/fx-rates/v1/history', + 'method' => 'GET', + 'params' => ['from', 'to', 'from_at', 'to_at', 'limit'], + 'description_de' => 'Liefert den gespeicherten Kursverlauf zwischen zwei Waehrungen fuer einen Zeitraum.', + 'description_en' => 'Returns the stored rate history between two currencies for a given time range.', + ], + [ + 'path' => '/api/fx-rates/v1/refresh', + 'method' => 'POST', + 'body' => ['base', 'force', 'max_age_minutes'], + 'description_de' => 'Aktualisiert Kurse nur dann neu, wenn der letzte Abruf aelter als die erlaubte Zeitspanne ist. Die Antwort enthaelt immer die fetch_id des verwendeten Snapshots.', + 'description_en' => 'Refreshes rates only if the last fetch is older than the allowed age. The response always includes the fetch_id of the snapshot used.', + ], + [ + 'path' => '/api/fx-rates/v1/status', + 'method' => 'GET', + 'description_de' => 'Liefert den neuesten gespeicherten Abruf je Basiswaehrung.', + 'description_en' => 'Returns the most recent stored fetch per base currency.', + ], + [ + 'path' => '/api/fx-rates/v1/recent-fetches', + 'method' => 'GET', + 'params' => ['limit'], + 'description_de' => 'Liefert die zuletzt gespeicherten Abrufe mit fetch_id und Zeitstempel.', + 'description_en' => 'Returns the most recently stored fetches including fetch_id and timestamp.', + ], + [ + 'path' => '/api/fx-rates/v1/probe', + 'method' => 'GET', + 'params' => ['base'], + 'description_de' => 'Prueft, ob der konfigurierte Provider aktuelle Kurse liefern kann.', + 'description_en' => 'Checks whether the configured provider can return current rates.', + ], + [ + 'path' => '/api/fx-rates/v1/settings', + 'method' => 'GET', + 'description_de' => 'Liefert die aktuellen Modul-Settings.', + 'description_en' => 'Returns the current module settings.', + ], + ], + ]; + } +} diff --git a/temp/nexus-module-import/modules/fx-rates/src/Domain/FxRatesService.php b/temp/nexus-module-import/modules/fx-rates/src/Domain/FxRatesService.php new file mode 100644 index 00000000..76650bd4 --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/src/Domain/FxRatesService.php @@ -0,0 +1,1015 @@ +localizeFetch($this->repository->getLatestFetch(null)); + } + + public function latestStatuses(): array + { + return array_map(fn (array $fetch): array => $this->localizeFetch($fetch), $this->repository->listLatestFetches()); + } + + public function recentFetches(int $limit = 20): array + { + return array_map(fn (array $fetch): array => $this->localizeFetch($fetch), $this->repository->listRecentFetches($limit)); + } + + public function snapshot(?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array + { + $base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + if ($base === '') { + return null; + } + + if ($at === null || trim($at) === '') { + $latest = $this->repository->getLatestFetch(null); + if ($latest === null) { + return null; + } + + $snapshot = $this->repository->getSnapshotByFetchId((int) $latest['id'], null); + if ($snapshot === null) { + return null; + } + + return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $base, $symbols)); + } + + $atUtc = $this->normalizeTimestamp($at); + if ($atUtc === null) { + return null; + } + + $nearest = $this->repository->getNearestFetch($base, $atUtc, $windowMinutes); + if ($nearest === null) { + return null; + } + + $snapshot = $this->repository->getSnapshotByFetchId((int) $nearest['id'], $symbols); + if ($snapshot === null) { + return null; + } + + $rebased = $this->rebaseSnapshot($snapshot, $base, $symbols); + if ($rebased === null) { + return null; + } + + return $this->localizeSnapshot($rebased + [ + 'requested_at' => $atUtc, + 'distance_seconds' => $nearest['distance_seconds'] ?? null, + ]); + } + + public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array + { + if ($fetchId <= 0) { + return null; + } + + $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + if ($requestedBase === '') { + return null; + } + + $snapshot = $this->repository->getSnapshotByFetchId($fetchId, null); + if ($snapshot === null) { + return null; + } + + return $this->localizeSnapshot($this->rebaseSnapshot($snapshot, $requestedBase, $symbols)); + } + + public function nearestSnapshot(?string $baseCurrency = null, string $at = '', ?array $symbols = null, ?int $windowMinutes = null): ?array + { + $timestamp = $this->normalizeTimestamp($at); + if ($timestamp === null) { + return null; + } + + $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + if ($requestedBase === '') { + return null; + } + + $nearest = $this->repository->findNearestFetch(null, $timestamp, $windowMinutes); + if ($nearest === null) { + return null; + } + + $snapshot = $this->repository->getSnapshotByFetchId((int) ($nearest['id'] ?? 0), null); + if ($snapshot === null) { + return null; + } + + $rebased = $this->rebaseSnapshot($snapshot, $requestedBase, $symbols); + if ($rebased === null) { + return null; + } + + return $this->localizeSnapshot($rebased + [ + 'requested_at' => $timestamp, + 'distance_seconds' => $nearest['distance_seconds'] ?? null, + ]); + } + + public function findRate(?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array + { + $from = $this->normalizeCurrency($fromCurrency); + $to = $this->normalizeCurrency($toCurrency); + if ($from === '' || $to === '') { + return null; + } + + if ($from === $to) { + return $this->localizeRateResult([ + 'base_currency' => $from, + 'target_currency' => $to, + 'rate' => 1.0, + 'provider' => 'identity', + 'fetched_at' => $at ? $this->normalizeTimestamp($at) : null, + 'rate_date' => $at ? substr((string) $this->normalizeTimestamp($at), 0, 10) : gmdate('Y-m-d'), + 'is_exact_pair' => true, + ]); + } + + $cacheKey = implode(':', [$from, $to, $at ?? '', (string) ($windowMinutes ?? 0)]); + if (array_key_exists($cacheKey, $this->memoryCache)) { + return $this->memoryCache[$cacheKey]; + } + + $candidates = array_values(array_unique(array_filter([ + $this->defaultBaseCurrency(), + 'EUR', + 'USD', + $from, + $to, + ], static fn (?string $value): bool => is_string($value) && trim($value) !== ''))); + + foreach ($candidates as $snapshotBase) { + $snapshot = $this->snapshot($snapshotBase, $at, [$from, $to], $windowMinutes); + if (!is_array($snapshot)) { + continue; + } + + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + $resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $from, $to); + if ($resolved !== null) { + return $this->memoryCache[$cacheKey] = $this->localizeRateResult($resolved); + } + } + + return $this->memoryCache[$cacheKey] = null; + } + + public function convert(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float + { + if ($amount === null) { + return null; + } + + $rate = $this->findRate($fromCurrency, $toCurrency, $at, $windowMinutes); + if (!is_numeric($rate['rate'] ?? null)) { + return null; + } + + return $amount * (float) $rate['rate']; + } + + public function refreshLatestRates(?array $currencies = null, ?string $baseCurrency = null, string $triggerSource = 'manual'): array + { + $requestedBase = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + $requestedCurrencies = $this->normalizeRequestedCurrencies($currencies, $requestedBase); + $payload = $this->fetchLatestPayload($requestedBase, $requestedCurrencies); + $base = $this->normalizeCurrency((string) ($payload['base'] ?? $requestedBase)); + if ($base === '') { + $base = $requestedBase !== '' ? $requestedBase : 'USD'; + } + $rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : []; + $rateDate = $this->normalizeRateDate($payload['date'] ?? null); + $saved = $this->repository->saveFetch( + $base, + $this->provider(), + $rateDate, + $rates, + gmdate('Y-m-d H:i:s'), + $triggerSource + ); + + return [ + 'base' => $base, + 'requested_base' => $requestedBase, + 'rate_date' => $rateDate, + 'updated_count' => count($saved['rates'] ?? []), + 'rates' => $saved['rates'] ?? [], + 'fetch_id' => isset($saved['fetch']['id']) ? (int) $saved['fetch']['id'] : null, + 'fetch' => $this->localizeFetch(is_array($saved['fetch'] ?? null) ? $saved['fetch'] : null), + ]; + } + + public function ensureFreshLatestRates(float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null, string $triggerSource = 'manual'): array + { + $base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + $requestedCurrencies = $this->normalizeRequestedCurrencies($currencies, $base); + $latest = $this->repository->getLatestFetch($base); + $maxAgeSeconds = (int) round(max(1.0, $maxAgeHours) * 3600); + $fetchedAt = is_array($latest) ? $this->parseStoredUtcTimestamp((string) ($latest['fetched_at'] ?? '')) : null; + + if ( + $fetchedAt !== null + && (time() - $fetchedAt) <= $maxAgeSeconds + && $this->latestFetchCoversCurrencies($latest, $requestedCurrencies) + ) { + return [ + 'base' => $base, + 'rate_date' => $latest['rate_date'] ?? null, + 'updated_count' => 0, + 'rates' => [], + 'fetch_id' => isset($latest['id']) ? (int) $latest['id'] : null, + 'fetch' => $this->localizeFetch($latest), + 'reused' => true, + ]; + } + + $result = $this->refreshLatestRates($requestedCurrencies, $base, $triggerSource); + $result['reused'] = false; + return $result; + } + + public function autoRefreshLatestRates(?string $baseCurrency = null, ?array $currencies = null, ?int $maxAgeMinutes = null, string $triggerSource = 'api'): array + { + $minutes = $maxAgeMinutes ?? $this->refreshMaxAgeMinutes(); + $hours = max(1, $minutes) / 60; + return $this->ensureFreshLatestRates($hours, $baseCurrency, $currencies, $triggerSource); + } + + public function history(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array + { + $fromCurrency = $this->normalizeCurrency($fromCurrency); + $toCurrency = $this->normalizeCurrency($toCurrency); + if ($fromCurrency === '' || $toCurrency === '') { + return []; + } + + if ($fromCurrency === $toCurrency) { + return []; + } + + return $this->crossHistory($fromCurrency, $toCurrency, $from, $to, $limit); + } + + public function runScheduledRefresh(array $context = []): array + { + $triggerSource = ($context['trigger'] ?? null) === 'manual_test' ? 'manual' : 'cron'; + $result = $this->refreshLatestRates(null, $this->defaultBaseCurrency(), $triggerSource); + return [ + 'ok' => true, + 'message' => 'Geplanter FX-Abruf ausgefuehrt: ' . (int) ($result['updated_count'] ?? 0) . ' Kurse gespeichert.', + 'result' => $result, + 'context' => $context, + ]; + } + + public function refreshCurrencyCatalog(): array + { + $payload = $this->fetchCurrenciesPayload(); + $currencies = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : []; + $items = []; + foreach ($currencies as $code => $name) { + $code = $this->normalizeCurrency((string) $code); + $name = trim((string) $name); + if ($code === '' || $name === '') { + continue; + } + $items[] = [ + 'code' => $code, + 'name' => $name, + ]; + } + + return [ + 'synced_count' => count($items), + 'currencies' => $items, + ]; + } + + public function probeCurrencyCatalog(): array + { + $payload = $this->fetchCurrenciesPayload(); + return [ + 'ok' => !empty($payload['currencies']), + 'provider' => $this->provider(), + 'currencies_count' => is_array($payload['currencies'] ?? null) ? count($payload['currencies']) : 0, + ]; + } + + public function probeLatestRates(?string $baseCurrency = null): array + { + $base = $this->normalizeCurrency($baseCurrency ?: $this->defaultBaseCurrency()); + $payload = $this->fetchLatestPayload($base, null); + return [ + 'ok' => !empty($payload['rates']), + 'provider' => $this->provider(), + 'base' => $base, + 'rate_count' => is_array($payload['rates'] ?? null) ? count($payload['rates']) : 0, + 'date' => $payload['date'] ?? null, + ]; + } + + private function resolveRateFromSnapshot(array $snapshot, array $rates, string $from, string $to): ?array + { + $base = strtoupper((string) ($snapshot['base_currency'] ?? '')); + $rate = null; + $isExactPair = false; + + if ($base === $from && is_numeric($rates[$to] ?? null)) { + $rate = (float) $rates[$to]; + $isExactPair = true; + } elseif ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) { + $rate = 1 / (float) $rates[$from]; + $isExactPair = true; + } elseif (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) { + $rate = (float) $rates[$to] / (float) $rates[$from]; + } + + if ($rate === null || $rate <= 0) { + return null; + } + + return [ + 'base_currency' => $from, + 'target_currency' => $to, + 'rate' => $rate, + 'provider' => $snapshot['provider'] ?? null, + 'fetched_at' => $snapshot['fetched_at'] ?? null, + 'rate_date' => $snapshot['rate_date'] ?? null, + 'snapshot_base_currency' => $base, + 'distance_seconds' => $snapshot['distance_seconds'] ?? null, + 'requested_at' => $snapshot['requested_at'] ?? null, + 'is_exact_pair' => $isExactPair, + ]; + } + + private function fetchLatestPayload(string $baseCurrency, ?array $currencies = null): array + { + $request = $this->buildLatestRequest($baseCurrency, $currencies); + if ($request === null) { + throw new \RuntimeException('FX-URL oder API-Key fehlt.'); + } + + $payload = $this->requestJson($request['url'], $request['headers'] ?? [], 'FX-Kurse konnten nicht geladen werden.'); + + if ($this->usesApiVersion('v3')) { + return $this->normalizeCurrencyApiComLatestPayload($payload, $baseCurrency, $currencies); + } + + $resolvedBase = $baseCurrency; + $rates = []; + if ($this->provider() === 'currencyapi') { + $resolvedBase = $this->normalizeCurrency((string) ($payload['base'] ?? $payload['base_currency'] ?? ($payload['query']['base_currency'] ?? 'USD'))); + if ($resolvedBase === '') { + $resolvedBase = 'USD'; + } + if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) { + throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.')); + } + foreach ($payload['rates'] as $code => $rate) { + $code = $this->normalizeCurrency((string) $code); + if ($code === '' || $code === $resolvedBase || !is_numeric($rate)) { + continue; + } + $rates[$code] = (float) $rate; + } + } else { + $resolvedBase = $this->normalizeCurrency((string) ($payload['base'] ?? $payload['base_currency'] ?? $baseCurrency)); + if ($resolvedBase === '') { + $resolvedBase = $baseCurrency; + } + $rawRates = is_array($payload['rates'] ?? null) ? $payload['rates'] : []; + foreach ($rawRates as $code => $rate) { + $code = $this->normalizeCurrency((string) $code); + if ($code === '' || $code === $resolvedBase || !is_numeric($rate)) { + continue; + } + $rates[$code] = (float) $rate; + } + } + + if (is_array($currencies) && $currencies !== []) { + $wanted = []; + foreach ($currencies as $currency) { + $currency = $this->normalizeCurrency((string) $currency); + if ($currency !== '' && isset($rates[$currency])) { + $wanted[$currency] = $rates[$currency]; + } + } + $rates = $wanted; + } + + return [ + 'base' => $resolvedBase, + 'date' => $payload['updated'] ?? $payload['date'] ?? null, + 'rates' => $rates, + ]; + } + + private function fetchCurrenciesPayload(): array + { + $request = $this->buildCurrenciesRequest(); + if ($request === null) { + throw new \RuntimeException('FX-API-Key fehlt.'); + } + + $payload = $this->requestJson($request['url'], $request['headers'] ?? [], 'Waehrungskatalog konnte nicht geladen werden.'); + + if ($this->usesApiVersion('v3')) { + return $this->normalizeCurrencyApiComCurrenciesPayload($payload); + } + + if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) { + throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.')); + } + + return $payload; + } + + private function buildLatestRequest(string $baseCurrency, ?array $currencies = null): ?array + { + $apiKey = $this->apiKey(); + if ($this->usesApiVersion('v3')) { + if ($apiKey === '') { + return null; + } + + $query = []; + $normalizedCurrencies = []; + foreach ($currencies ?? [] as $currency) { + $currency = $this->normalizeCurrency((string) $currency); + if ($currency !== '' && $currency !== $baseCurrency) { + $normalizedCurrencies[] = $currency; + } + } + if ($normalizedCurrencies !== []) { + $query[] = 'currencies=' . rawurlencode(implode(',', array_values(array_unique($normalizedCurrencies)))); + } + + return [ + 'url' => $this->apiUrl() . '/v3/latest?' . implode('&', $query), + 'headers' => [ + 'Accept: application/json', + 'apikey: ' . $apiKey, + ], + ]; + } + + if ($this->provider() === 'currencyapi') { + if ($apiKey === '') { + return null; + } + + return [ + 'url' => sprintf( + '%s/api/v2/rates?output=json&key=%s', + $this->apiUrl(), + rawurlencode($apiKey) + ), + 'headers' => ['Accept: application/json'], + ]; + } + + return [ + 'url' => sprintf('%s/latest?base=%s', $this->apiUrl(), rawurlencode($baseCurrency)), + 'headers' => ['Accept: application/json'], + ]; + } + + private function buildCurrenciesRequest(): ?array + { + $apiKey = $this->apiKey(); + if ($apiKey === '') { + return null; + } + + if ($this->usesApiVersion('v3')) { + return [ + 'url' => $this->currenciesApiUrl() . '/v3/currencies', + 'headers' => [ + 'Accept: application/json', + 'apikey: ' . $apiKey, + ], + ]; + } + + return [ + 'url' => sprintf( + '%s/api/v2/currencies?output=json&key=%s', + $this->currenciesApiUrl(), + rawurlencode($apiKey) + ), + 'headers' => ['Accept: application/json'], + ]; + } + + private function requestJson(string $url, array $headers, string $fallbackError): array + { + if (!function_exists('curl_init')) { + throw new \RuntimeException('curl_init ist nicht verfuegbar.'); + } + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeoutSeconds(), + CURLOPT_HTTPHEADER => $headers !== [] ? $headers : ['Accept: application/json'], + ]); + $response = curl_exec($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($response === false || $curlError !== '' || $httpStatus >= 400) { + $payload = is_string($response) ? json_decode($response, true) : null; + throw new \RuntimeException($this->extractProviderError(is_array($payload) ? $payload : [], $fallbackError)); + } + + $payload = json_decode((string) $response, true); + if (!is_array($payload)) { + throw new \RuntimeException('FX-Antwort ist kein gueltiges JSON.'); + } + + return $payload; + } + + private function normalizeCurrencyApiComLatestPayload(array $payload, string $baseCurrency, ?array $currencies = null): array + { + $rawRates = is_array($payload['data'] ?? null) ? $payload['data'] : null; + if ($rawRates === null) { + throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.')); + } + + $resolvedBase = $this->normalizeCurrency((string) ($payload['meta']['base_currency_code'] ?? $payload['base'] ?? $baseCurrency)); + if ($resolvedBase === '') { + $resolvedBase = $baseCurrency; + } + + $filter = []; + foreach ($currencies ?? [] as $currency) { + $currency = $this->normalizeCurrency((string) $currency); + if ($currency !== '' && $currency !== $resolvedBase) { + $filter[$currency] = true; + } + } + + $rates = []; + foreach ($rawRates as $code => $rateData) { + $code = $this->normalizeCurrency((string) $code); + if ($code === '' || $code === $resolvedBase) { + continue; + } + if ($filter !== [] && !isset($filter[$code])) { + continue; + } + + $value = is_array($rateData) ? ($rateData['value'] ?? null) : null; + if (!is_numeric($value)) { + continue; + } + + $rates[$code] = (float) $value; + } + + return [ + 'base' => $resolvedBase, + 'date' => $payload['meta']['last_updated_at'] ?? null, + 'rates' => $rates, + ]; + } + + private function rebaseSnapshot(array $snapshot, string $requestedBase, ?array $symbols = null): ?array + { + $snapshotBase = $this->normalizeCurrency((string) ($snapshot['base_currency'] ?? '')); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + + if ($snapshotBase === '') { + return null; + } + + if ($requestedBase === '' || $requestedBase === $snapshotBase) { + $filteredRates = $this->filterRates($rates, $symbols); + if ($this->symbolsContain($symbols, $requestedBase)) { + $filteredRates = [$requestedBase => 1.0] + $filteredRates; + } + + return $snapshot + [ + 'base_currency' => $snapshotBase, + 'rates' => $filteredRates, + ]; + } + + $baseRate = $rates[$requestedBase] ?? null; + if (!is_numeric($baseRate) || (float) $baseRate <= 0) { + return null; + } + + $rebasedRates = []; + foreach ($rates as $code => $rate) { + $code = $this->normalizeCurrency((string) $code); + if ($code === '' || $code === $requestedBase || !is_numeric($rate)) { + continue; + } + $rebasedRates[$code] = (float) $rate / (float) $baseRate; + } + + if ($snapshotBase !== '' && $snapshotBase !== $requestedBase) { + $rebasedRates[$snapshotBase] = 1 / (float) $baseRate; + } + + $filteredRates = $this->filterRates($rebasedRates, $symbols); + if ($this->symbolsContain($symbols, $requestedBase)) { + $filteredRates = [$requestedBase => 1.0] + $filteredRates; + } + + return $snapshot + [ + 'base_currency' => $requestedBase, + 'rates' => $filteredRates, + 'snapshot_base_currency' => $snapshotBase, + ]; + } + + private function filterRates(array $rates, ?array $symbols = null): array + { + if (!is_array($symbols) || $symbols === []) { + ksort($rates); + return $rates; + } + + $filtered = []; + foreach ($symbols as $symbol) { + $symbol = $this->normalizeCurrency((string) $symbol); + if ($symbol !== '' && isset($rates[$symbol])) { + $filtered[$symbol] = (float) $rates[$symbol]; + } + } + + ksort($filtered); + return $filtered; + } + + private function symbolsContain(?array $symbols, string $currency): bool + { + if (!is_array($symbols) || $symbols === []) { + return false; + } + + foreach ($symbols as $symbol) { + if ($this->normalizeCurrency((string) $symbol) === $currency) { + return true; + } + } + + return false; + } + + private function crossHistory(string $fromCurrency, string $toCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array + { + $fromAt = $this->normalizeTimestamp($from); + $toAt = $this->normalizeTimestamp($to); + $candidates = $this->repository->listRecentFetches(max($limit * 4, 50)); + $result = []; + + foreach ($candidates as $fetch) { + $fetchedAt = (string) ($fetch['fetched_at'] ?? ''); + if ($fetchedAt === '') { + continue; + } + + if ($fromAt !== null && strcmp($fetchedAt, $fromAt) < 0) { + continue; + } + if ($toAt !== null && strcmp($fetchedAt, $toAt) > 0) { + continue; + } + + $snapshot = $this->repository->getSnapshotByFetchId((int) ($fetch['id'] ?? 0), [$fromCurrency, $toCurrency]); + if ($snapshot === null) { + continue; + } + + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + $resolved = $this->resolveRateFromSnapshot($snapshot, $rates, $fromCurrency, $toCurrency); + if ($resolved === null) { + continue; + } + + $result[] = $this->localizeRateResult($resolved + [ + 'fetch_id' => $fetch['id'] ?? null, + ]); + + if (count($result) >= $limit) { + break; + } + } + + return $result; + } + + private function localizeFetch(?array $fetch): ?array + { + if (!is_array($fetch)) { + return null; + } + + $fetch['fetched_at_display'] = $this->formatDisplayTimestamp($fetch['fetched_at'] ?? null); + $fetch['created_at_display'] = $this->formatDisplayTimestamp($fetch['created_at'] ?? null); + $fetch['trigger_source_label'] = $this->triggerSourceLabel((string) ($fetch['trigger_source'] ?? 'manual')); + return $fetch; + } + + private function localizeSnapshot(?array $snapshot): ?array + { + if (!is_array($snapshot)) { + return null; + } + + $snapshot['fetched_at_display'] = $this->formatDisplayTimestamp($snapshot['fetched_at'] ?? null); + if (array_key_exists('requested_at', $snapshot)) { + $snapshot['requested_at_display'] = $this->formatDisplayTimestamp($snapshot['requested_at']); + } + return $snapshot; + } + + private function localizeRateResult(array $rate): array + { + $rate['fetched_at_display'] = $this->formatDisplayTimestamp($rate['fetched_at'] ?? null); + if (array_key_exists('requested_at', $rate)) { + $rate['requested_at_display'] = $this->formatDisplayTimestamp($rate['requested_at']); + } + return $rate; + } + + private function formatDisplayTimestamp(mixed $value): string + { + $raw = trim((string) $value); + if ($raw === '') { + return ''; + } + + try { + $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new DateTimeZone('UTC')); + if (!$date instanceof DateTimeImmutable) { + $date = new DateTimeImmutable($raw, new DateTimeZone('UTC')); + } + return $date->setTimezone($this->displayTimezone())->format('d.m.Y H:i:s'); + } catch (\Throwable) { + return $raw; + } + } + + private function normalizeCurrencyApiComCurrenciesPayload(array $payload): array + { + $rawCurrencies = is_array($payload['data'] ?? null) ? $payload['data'] : null; + if ($rawCurrencies === null) { + throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.')); + } + + $currencies = []; + foreach ($rawCurrencies as $code => $currencyData) { + $normalizedCode = $this->normalizeCurrency((string) $code); + if ($normalizedCode === '') { + continue; + } + + $name = ''; + if (is_array($currencyData)) { + $name = trim((string) ($currencyData['name'] ?? $currencyData['name_plural'] ?? $currencyData['code'] ?? '')); + } + + if ($name === '') { + continue; + } + + $currencies[$normalizedCode] = $name; + } + + return [ + 'valid' => true, + 'currencies' => $currencies, + ]; + } + + private function extractProviderError(array $payload, string $fallback): string + { + $error = $payload['error'] ?? null; + if (is_array($error)) { + foreach (['message', 'info', 'code'] as $field) { + $value = $error[$field] ?? null; + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + } + + $errors = $payload['errors'] ?? null; + if (is_array($errors)) { + foreach ($errors as $entry) { + if (is_string($entry) && trim($entry) !== '') { + return trim($entry); + } + if (is_array($entry)) { + foreach (['message', 'detail', 'title'] as $field) { + $value = $entry[$field] ?? null; + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + } + } + } + + foreach (['error', 'message', 'msg'] as $field) { + $value = $payload[$field] ?? null; + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + + return $fallback; + } + + private function normalizeCurrency(?string $currency): string + { + return strtoupper(trim((string) $currency)); + } + + private function normalizeRequestedCurrencies(?array $currencies, string $baseCurrency): ?array + { + if (!is_array($currencies)) { + return null; + } + + $base = $this->normalizeCurrency($baseCurrency); + $normalized = array_values(array_unique(array_filter(array_map( + fn (mixed $currency): string => $this->normalizeCurrency((string) $currency), + $currencies + ), fn (string $currency): bool => $currency !== '' && $currency !== $base))); + + return $normalized === [] ? null : $normalized; + } + + private function latestFetchCoversCurrencies(?array $latestFetch, ?array $currencies): bool + { + if (!is_array($latestFetch) || !is_numeric($latestFetch['id'] ?? null) || !is_array($currencies) || $currencies === []) { + return true; + } + + $snapshot = $this->repository->getSnapshotByFetchId((int) $latestFetch['id'], $currencies); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + foreach ($currencies as $currency) { + if (!array_key_exists($currency, $rates)) { + return false; + } + } + + return true; + } + + private function normalizeTimestamp(?string $value): ?string + { + $value = trim((string) $value); + if ($value === '') { + return null; + } + + try { + if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $value) === 1) { + $date = new DateTimeImmutable(str_replace(' ', 'T', $value), new DateTimeZone('UTC')); + } else { + $date = new DateTimeImmutable($value); + } + return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return null; + } + } + + private function parseStoredUtcTimestamp(string $value): ?int + { + $normalized = $this->normalizeTimestamp($value); + if ($normalized === null) { + return null; + } + + try { + return (new DateTimeImmutable($normalized, new DateTimeZone('UTC')))->getTimestamp(); + } catch (\Throwable) { + return null; + } + } + + private function normalizeRateDate(mixed $value): string + { + if (is_string($value) && trim($value) !== '') { + $timestamp = strtotime($value); + if ($timestamp !== false) { + return gmdate('Y-m-d', $timestamp); + } + } + + if (is_int($value) || is_float($value)) { + return gmdate('Y-m-d', (int) $value); + } + + return gmdate('Y-m-d'); + } + + private function provider(): string + { + $provider = strtolower(trim((string) ($this->settings['provider'] ?? 'currencyapi'))); + return $provider !== '' ? $provider : 'currencyapi'; + } + + private function apiUrl(): string + { + return rtrim((string) ($this->settings['api_url'] ?? 'https://currencyapi.net'), '/'); + } + + private function currenciesApiUrl(): string + { + return rtrim((string) ($this->settings['currencies_url'] ?? $this->apiUrl()), '/'); + } + + private function apiVersion(): string + { + $version = strtolower(trim((string) ($this->settings['api_version'] ?? 'v2'))); + return in_array($version, ['v2', 'v3'], true) ? $version : 'v2'; + } + + private function usesApiVersion(string $version): bool + { + return $this->apiVersion() === strtolower(trim($version)); + } + + private function apiKey(): string + { + return trim((string) ($this->settings['api_key'] ?? '')); + } + + private function timeoutSeconds(): int + { + return max(2, (int) ($this->settings['timeout_sec'] ?? 10)); + } + + private function refreshMaxAgeMinutes(): int + { + return max(1, (int) ($this->settings['refresh_max_age_minutes'] ?? 60)); + } + + private function defaultBaseCurrency(): string + { + return $this->normalizeCurrency((string) ($this->settings['default_base_currency'] ?? 'EUR')) ?: 'EUR'; + } + + private function scheduleTimezone(): DateTimeZone + { + $timezone = trim((string) ($this->settings['schedule_timezone'] ?? nexus_cron_timezone_name())); + try { + return new DateTimeZone($timezone); + } catch (\Throwable) { + return new DateTimeZone(nexus_cron_timezone_name()); + } + } + + private function displayTimezone(): DateTimeZone + { + return $this->scheduleTimezone(); + } + + private function triggerSourceLabel(string $source): string + { + return match (strtolower(trim($source))) { + 'cron' => 'Cron', + 'api' => 'API', + default => 'Manuell', + }; + } +} diff --git a/temp/nexus-module-import/modules/fx-rates/src/Infrastructure/FxRatesRepository.php b/temp/nexus-module-import/modules/fx-rates/src/Infrastructure/FxRatesRepository.php new file mode 100644 index 00000000..bb0fa31f --- /dev/null +++ b/temp/nexus-module-import/modules/fx-rates/src/Infrastructure/FxRatesRepository.php @@ -0,0 +1,546 @@ +driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + } + + public function ensureSchema(): void + { + $fetchTable = $this->table('fetches'); + $rateTable = $this->table('rates'); + + if ($this->driver === 'pgsql') { + $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} ( + id SERIAL PRIMARY KEY, + provider VARCHAR(64) NOT NULL, + trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual', + base_currency VARCHAR(10) NOT NULL, + rate_date DATE NOT NULL, + fetched_at TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $this->pdo->exec("ALTER TABLE {$fetchTable} ADD COLUMN IF NOT EXISTS trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'"); + $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} ( + id SERIAL PRIMARY KEY, + fetch_id INTEGER NOT NULL REFERENCES {$fetchTable}(id) ON DELETE CASCADE, + currency_code VARCHAR(10) NOT NULL, + current_value NUMERIC(20,10) NOT NULL + )"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)"); + } elseif ($this->driver === 'mysql') { + $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + provider VARCHAR(64) NOT NULL, + trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual', + base_currency VARCHAR(10) NOT NULL, + rate_date DATE NOT NULL, + fetched_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY {$fetchTable}_base_fetch_idx (base_currency, fetched_at, id), + KEY {$fetchTable}_rate_date_idx (rate_date) + )"); + $this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'"); + $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + fetch_id INTEGER NOT NULL, + currency_code VARCHAR(10) NOT NULL, + current_value DECIMAL(20,10) NOT NULL, + KEY {$rateTable}_fetch_idx (fetch_id), + KEY {$rateTable}_currency_idx (currency_code) + )"); + } else { + $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider VARCHAR(64) NOT NULL, + trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual', + base_currency VARCHAR(10) NOT NULL, + rate_date DATE NOT NULL, + fetched_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'"); + $this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fetch_id INTEGER NOT NULL, + currency_code VARCHAR(10) NOT NULL, + current_value DECIMAL(20,10) NOT NULL + )"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)"); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)"); + } + } + + public function getLatestFetch(?string $baseCurrency = null): ?array + { + $sql = 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches'); + $params = []; + if ($baseCurrency !== null && trim($baseCurrency) !== '') { + $sql .= ' WHERE base_currency = :base_currency'; + $params['base_currency'] = strtoupper(trim($baseCurrency)); + } + $sql .= ' ORDER BY fetched_at DESC, id DESC LIMIT 1'; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $this->normalizeFetch($row) : null; + } + + public function listLatestFetches(): array + { + $stmt = $this->pdo->query( + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + ORDER BY fetched_at DESC, id DESC' + ); + + $latestByBase = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { + $base = strtoupper(trim((string) ($row['base_currency'] ?? ''))); + if ($base === '' || isset($latestByBase[$base])) { + continue; + } + $latestByBase[$base] = $this->normalizeFetch($row); + } + + ksort($latestByBase); + return array_values($latestByBase); + } + + public function listRecentFetches(int $limit = 20): array + { + $stmt = $this->pdo->prepare( + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + ORDER BY fetched_at DESC, id DESC + LIMIT :limit' + ); + $stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT); + $stmt->execute(); + + return array_map( + fn (array $row): array => $this->normalizeFetch($row), + $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] + ); + } + + public function getSnapshotByFetchId(int $fetchId, ?array $symbols = null): ?array + { + $fetch = $this->getFetchById($fetchId); + if ($fetch === null) { + return null; + } + + return $fetch + [ + 'rates' => $this->ratesForFetch($fetchId, $symbols), + ]; + } + + public function findNearestFetch(?string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array + { + $targetTs = strtotime($timestamp); + if ($targetTs === false) { + return null; + } + + if ($baseCurrency !== null && trim($baseCurrency) !== '') { + return $this->getNearestFetch(strtoupper(trim($baseCurrency)), $timestamp, $windowMinutes); + } + + $candidates = []; + foreach (['<=', '>='] as $operator) { + $order = $operator === '<=' ? 'DESC' : 'ASC'; + $stmt = $this->pdo->prepare( + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + WHERE fetched_at ' . $operator . ' :target_at + ORDER BY fetched_at ' . $order . ', id ' . $order . ' + LIMIT 1' + ); + $stmt->execute(['target_at' => $timestamp]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (is_array($row)) { + $candidate = $this->normalizeFetch($row); + $candidateTs = strtotime((string) ($candidate['fetched_at'] ?? '')); + if ($candidateTs !== false) { + $candidate['distance_seconds'] = abs($candidateTs - $targetTs); + $candidates[] = $candidate; + } + } + } + + if ($candidates === []) { + return null; + } + + usort($candidates, static function (array $left, array $right): int { + return ((int) ($left['distance_seconds'] ?? PHP_INT_MAX)) <=> ((int) ($right['distance_seconds'] ?? PHP_INT_MAX)); + }); + + $selected = $candidates[0]; + if ($windowMinutes !== null && $windowMinutes > 0 && (int) ($selected['distance_seconds'] ?? 0) > ($windowMinutes * 60)) { + return null; + } + + return $selected; + } + + public function getNearestFetch(string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array + { + $baseCurrency = strtoupper(trim($baseCurrency)); + if ($baseCurrency === '') { + return null; + } + + $before = $this->findNeighborFetch($baseCurrency, $timestamp, '<='); + $after = $this->findNeighborFetch($baseCurrency, $timestamp, '>='); + $targetTs = strtotime($timestamp); + if ($targetTs === false) { + return null; + } + + $selected = null; + $selectedDiff = null; + foreach ([$before, $after] as $candidate) { + if (!is_array($candidate)) { + continue; + } + $candidateTs = strtotime((string) ($candidate['fetched_at'] ?? '')); + if ($candidateTs === false) { + continue; + } + $diffSeconds = abs($candidateTs - $targetTs); + if ($selected === null || $diffSeconds < (int) $selectedDiff) { + $selected = $candidate; + $selectedDiff = $diffSeconds; + } + } + + if ($selected === null) { + return null; + } + + if ($windowMinutes !== null && $windowMinutes > 0 && $selectedDiff !== null && $selectedDiff > ($windowMinutes * 60)) { + return null; + } + + return $selected + ['distance_seconds' => $selectedDiff]; + } + + public function listDirectHistory(string $baseCurrency, string $targetCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array + { + $sql = 'SELECT + r.id, + f.id AS fetch_id, + f.base_currency, + r.currency_code AS target_currency, + r.current_value AS rate, + f.rate_date, + f.provider, + f.fetched_at + FROM ' . $this->table('rates') . ' r + INNER JOIN ' . $this->table('fetches') . ' f ON f.id = r.fetch_id + WHERE f.base_currency = :base_currency + AND r.currency_code = :target_currency'; + $params = [ + 'base_currency' => strtoupper(trim($baseCurrency)), + 'target_currency' => strtoupper(trim($targetCurrency)), + ]; + + if ($from !== null && trim($from) !== '') { + $sql .= ' AND f.fetched_at >= :from_at'; + $params['from_at'] = $from; + } + if ($to !== null && trim($to) !== '') { + $sql .= ' AND f.fetched_at <= :to_at'; + $params['to_at'] = $to; + } + + $sql .= ' ORDER BY f.fetched_at DESC, r.id DESC LIMIT :limit'; + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => $value) { + $stmt->bindValue(':' . $key, $value); + } + $stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT); + $stmt->execute(); + + return array_map(fn (array $row): array => $this->normalizeRate($row), $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []); + } + + public function saveFetch(string $baseCurrency, string $provider, string $rateDate, array $rates, ?string $fetchedAt = null, string $triggerSource = 'manual'): array + { + $baseCurrency = strtoupper(trim($baseCurrency)); + $provider = trim($provider) !== '' ? trim($provider) : 'currencyapi'; + $fetchedAt = trim((string) $fetchedAt) !== '' ? trim((string) $fetchedAt) : gmdate('Y-m-d H:i:s'); + $triggerSource = $this->normalizeTriggerSource($triggerSource); + $normalizedRates = []; + foreach ($rates as $currencyCode => $rate) { + $currencyCode = strtoupper(trim((string) $currencyCode)); + if ($currencyCode === '' || $currencyCode === $baseCurrency || !is_numeric($rate)) { + continue; + } + $normalizedRates[$currencyCode] = (float) $rate; + } + + $startedTransaction = false; + if (!$this->pdo->inTransaction()) { + $this->pdo->beginTransaction(); + $startedTransaction = true; + } + + try { + if ($this->driver === 'pgsql') { + $fetchStmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('fetches') . ' ( + provider, trigger_source, base_currency, rate_date, fetched_at + ) VALUES ( + :provider, :trigger_source, :base_currency, :rate_date, :fetched_at + ) + RETURNING *' + ); + $fetchStmt->execute([ + 'provider' => $provider, + 'trigger_source' => $triggerSource, + 'base_currency' => $baseCurrency, + 'rate_date' => $rateDate, + 'fetched_at' => $fetchedAt, + ]); + $fetch = $this->normalizeFetch($fetchStmt->fetch(PDO::FETCH_ASSOC) ?: []); + } else { + $fetchStmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('fetches') . ' ( + provider, trigger_source, base_currency, rate_date, fetched_at + ) VALUES ( + :provider, :trigger_source, :base_currency, :rate_date, :fetched_at + )' + ); + $fetchStmt->execute([ + 'provider' => $provider, + 'trigger_source' => $triggerSource, + 'base_currency' => $baseCurrency, + 'rate_date' => $rateDate, + 'fetched_at' => $fetchedAt, + ]); + $fetch = $this->getFetchById((int) $this->pdo->lastInsertId()) ?? []; + } + + $savedRates = []; + if ($normalizedRates !== []) { + $placeholders = []; + $params = ['fetch_id' => (int) ($fetch['id'] ?? 0)]; + $index = 0; + foreach ($normalizedRates as $currencyCode => $rate) { + $codeKey = 'currency_code_' . $index; + $valueKey = 'current_value_' . $index; + $placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})"; + $params[$codeKey] = $currencyCode; + $params[$valueKey] = $rate; + $savedRates[] = [ + 'fetch_id' => $fetch['id'] ?? null, + 'base_currency' => $baseCurrency, + 'target_currency' => $currencyCode, + 'rate' => $rate, + 'rate_date' => $rateDate, + 'provider' => $provider, + 'fetched_at' => $fetchedAt, + ]; + $index++; + } + + $insert = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders) + ); + $insert->execute($params); + } + + if ($startedTransaction) { + $this->pdo->commit(); + } + + return [ + 'fetch' => $fetch, + 'rates' => $savedRates, + ]; + } catch (\Throwable $exception) { + if ($startedTransaction && $this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + throw $exception; + } + } + + public function findFetchByBaseAndFetchedAt(string $baseCurrency, string $fetchedAt): ?array + { + $baseCurrency = strtoupper(trim($baseCurrency)); + $fetchedAt = trim($fetchedAt); + if ($baseCurrency === '' || $fetchedAt === '') { + return null; + } + + $stmt = $this->pdo->prepare( + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + WHERE base_currency = :base_currency + AND fetched_at = :fetched_at + ORDER BY id ASC + LIMIT 1' + ); + $stmt->execute([ + 'base_currency' => $baseCurrency, + 'fetched_at' => $fetchedAt, + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $this->normalizeFetch($row) : null; + } + + private function getFetchById(int $fetchId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + WHERE id = :id + LIMIT 1' + ); + $stmt->execute(['id' => $fetchId]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $this->normalizeFetch($row) : null; + } + + private function findNeighborFetch(string $baseCurrency, string $timestamp, string $operator): ?array + { + $order = $operator === '<=' ? 'DESC' : 'ASC'; + $stmt = $this->pdo->prepare( + 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at + FROM ' . $this->table('fetches') . ' + WHERE base_currency = :base_currency + AND fetched_at ' . $operator . ' :target_at + ORDER BY fetched_at ' . $order . ', id ' . $order . ' + LIMIT 1' + ); + $stmt->execute([ + 'base_currency' => $baseCurrency, + 'target_at' => $timestamp, + ]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + return is_array($row) ? $this->normalizeFetch($row) : null; + } + + private function ratesForFetch(int $fetchId, ?array $symbols = null): array + { + $sql = 'SELECT currency_code, current_value FROM ' . $this->table('rates') . ' WHERE fetch_id = :fetch_id'; + $params = ['fetch_id' => $fetchId]; + + $normalizedSymbols = []; + if (is_array($symbols)) { + foreach ($symbols as $symbol) { + $symbol = strtoupper(trim((string) $symbol)); + if ($symbol !== '') { + $normalizedSymbols[] = $symbol; + } + } + $normalizedSymbols = array_values(array_unique($normalizedSymbols)); + } + + if ($normalizedSymbols !== []) { + $placeholders = []; + foreach ($normalizedSymbols as $index => $symbol) { + $key = 'symbol_' . $index; + $placeholders[] = ':' . $key; + $params[$key] = $symbol; + } + $sql .= ' AND currency_code IN (' . implode(', ', $placeholders) . ')'; + } + + $sql .= ' ORDER BY currency_code ASC'; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + + $rates = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) { + $code = strtoupper(trim((string) ($row['currency_code'] ?? ''))); + $rate = $row['current_value'] ?? null; + if ($code === '' || !is_numeric($rate)) { + continue; + } + $rates[$code] = (float) $rate; + } + + return $rates; + } + + private function normalizeFetch(array $row): array + { + return [ + 'id' => isset($row['id']) ? (int) $row['id'] : null, + 'provider' => (string) ($row['provider'] ?? ''), + 'trigger_source' => (string) ($row['trigger_source'] ?? 'manual'), + 'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')), + 'rate_date' => (string) ($row['rate_date'] ?? ''), + 'fetched_at' => (string) ($row['fetched_at'] ?? ''), + 'created_at' => (string) ($row['created_at'] ?? ''), + ]; + } + + private function ensureColumn(string $table, string $column, string $alterSql): void + { + try { + $stmt = $this->pdo->query('SELECT * FROM ' . $table . ' LIMIT 1'); + if ($stmt instanceof \PDOStatement) { + $row = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; + if (in_array(strtolower($column), array_map('strtolower', array_keys($row)), true)) { + return; + } + } + } catch (\Throwable) { + } + + try { + $this->pdo->exec($alterSql); + } catch (\Throwable) { + } + } + + private function normalizeTriggerSource(string $source): string + { + $source = strtolower(trim($source)); + return match ($source) { + 'cron', 'manual', 'api', 'migration' => $source, + default => 'manual', + }; + } + + private function normalizeRate(array $row): array + { + return [ + 'id' => isset($row['id']) ? (int) $row['id'] : null, + 'fetch_id' => isset($row['fetch_id']) ? (int) $row['fetch_id'] : null, + 'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')), + 'target_currency' => strtoupper((string) ($row['target_currency'] ?? '')), + 'rate' => is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null, + 'rate_date' => (string) ($row['rate_date'] ?? ''), + 'provider' => (string) ($row['provider'] ?? ''), + 'fetched_at' => (string) ($row['fetched_at'] ?? ''), + ]; + } + + private function table(string $logicalName): string + { + return $this->tablePrefix . preg_replace('/[^a-zA-Z0-9_]/', '', $logicalName); + } +} diff --git a/temp/nexus-module-import/modules/kea/design.json b/temp/nexus-module-import/modules/kea/design.json new file mode 100644 index 00000000..30d62ddb --- /dev/null +++ b/temp/nexus-module-import/modules/kea/design.json @@ -0,0 +1,12 @@ +{ + "eyebrow": "Modul", + "title": "KEA DHCP", + "description": "Verwaltung von KEA DHCP Hosts und Reservierungen.", + "actions": [ + { "label": "Setup", "href": "/modules/setup/kea", "variant": "secondary" } + ], + "tabs": [ + { "label": "Hosts", "href": "/module/kea", "match_prefixes": ["/module/kea", "/module/kea/edit"] }, + { "label": "Gruppen", "href": "/module/kea/groups", "match_prefixes": ["/module/kea/groups"] } + ] +} diff --git a/temp/nexus-module-import/modules/kea/migrations/001_1.0.0_baseline.php b/temp/nexus-module-import/modules/kea/migrations/001_1.0.0_baseline.php new file mode 100644 index 00000000..ec668a03 --- /dev/null +++ b/temp/nexus-module-import/modules/kea/migrations/001_1.0.0_baseline.php @@ -0,0 +1,26 @@ +settings(); + $fallback = is_array($context->module['metadata_db_defaults'] ?? null) + ? $context->module['metadata_db_defaults'] + : []; + $config = is_array($settings['metadata_db'] ?? null) + ? array_replace($fallback, $settings['metadata_db']) + : $fallback; + + if (empty($config['driver']) || empty($config['dbname'])) { + return; + } + + $repo = new KeaHostMetadataRepository(Database::createFromArray($config)); + $repo->ensureSchema(); + } +}; diff --git a/temp/nexus-module-import/modules/kea/migrations/002_1.1.0_groups.php b/temp/nexus-module-import/modules/kea/migrations/002_1.1.0_groups.php new file mode 100644 index 00000000..ec668a03 --- /dev/null +++ b/temp/nexus-module-import/modules/kea/migrations/002_1.1.0_groups.php @@ -0,0 +1,26 @@ +settings(); + $fallback = is_array($context->module['metadata_db_defaults'] ?? null) + ? $context->module['metadata_db_defaults'] + : []; + $config = is_array($settings['metadata_db'] ?? null) + ? array_replace($fallback, $settings['metadata_db']) + : $fallback; + + if (empty($config['driver']) || empty($config['dbname'])) { + return; + } + + $repo = new KeaHostMetadataRepository(Database::createFromArray($config)); + $repo->ensureSchema(); + } +}; diff --git a/temp/nexus-module-import/modules/kea/migrations/003_1.2.0_device_checks.php b/temp/nexus-module-import/modules/kea/migrations/003_1.2.0_device_checks.php new file mode 100644 index 00000000..ec668a03 --- /dev/null +++ b/temp/nexus-module-import/modules/kea/migrations/003_1.2.0_device_checks.php @@ -0,0 +1,26 @@ +settings(); + $fallback = is_array($context->module['metadata_db_defaults'] ?? null) + ? $context->module['metadata_db_defaults'] + : []; + $config = is_array($settings['metadata_db'] ?? null) + ? array_replace($fallback, $settings['metadata_db']) + : $fallback; + + if (empty($config['driver']) || empty($config['dbname'])) { + return; + } + + $repo = new KeaHostMetadataRepository(Database::createFromArray($config)); + $repo->ensureSchema(); + } +}; diff --git a/temp/nexus-module-import/modules/kea/module.json b/temp/nexus-module-import/modules/kea/module.json new file mode 100644 index 00000000..94b121ba --- /dev/null +++ b/temp/nexus-module-import/modules/kea/module.json @@ -0,0 +1,46 @@ +{ + "title": "KEA DHCP", + "version": "1.2.0", + "schema_version": 3, + "description": "Verwaltung von KEA DHCP Hosts und Reservierungen.", + "setup": { + "fields": [ + { "name": "db.driver", "label": "KEA DB Driver", "type": "text", "required": true, "help": "Standard-KEA-Datenbank, die auch vom KEA-Dienst selbst genutzt wird." }, + { "name": "db.host", "label": "KEA DB Host", "type": "text", "required": true }, + { "name": "db.port", "label": "KEA DB Port", "type": "number", "required": true }, + { "name": "db.dbname", "label": "KEA DB Name", "type": "text", "required": true }, + { "name": "db.schema", "label": "KEA DB Schema", "type": "text", "required": false }, + { "name": "db.user", "label": "KEA DB User", "type": "text", "required": true }, + { "name": "db.password", "label": "KEA DB Passwort", "type": "password", "required": true }, + { "name": "metadata_db.driver", "label": "Nexus DHCP DB Driver", "type": "text", "required": true, "help": "Separate Datenbank fuer Nexus-eigene DHCP-Zusatzinfos, nicht fuer KEA-Standardtabellen." }, + { "name": "metadata_db.host", "label": "Nexus DHCP DB Host", "type": "text", "required": true }, + { "name": "metadata_db.port", "label": "Nexus DHCP DB Port", "type": "number", "required": true }, + { "name": "metadata_db.dbname", "label": "Nexus DHCP DB Name", "type": "text", "required": true }, + { "name": "metadata_db.schema", "label": "Nexus DHCP DB Schema", "type": "text", "required": false }, + { "name": "metadata_db.user", "label": "Nexus DHCP DB User", "type": "text", "required": true }, + { "name": "metadata_db.password", "label": "Nexus DHCP DB Passwort", "type": "password", "required": true }, + { "name": "kea_db_version", "label": "KEA DB Version", "type": "text", "required": false }, + { "name": "kea_init_script", "label": "KEA Init Script", "type": "text", "required": false }, + { "name": "kea_init_cmd", "label": "KEA Init Command", "type": "text", "required": false }, + { "name": "kea_auto_init", "label": "KEA Auto-Init", "type": "checkbox", "required": false } + ] + }, + "db_defaults": { + "driver": "pgsql", + "host": "localhost", + "port": 5432, + "dbname": "", + "schema": "public", + "user": "", + "password": "" + }, + "metadata_db_defaults": { + "driver": "mysql", + "host": "192.168.178.10", + "port": 3306, + "dbname": "", + "schema": "", + "user": "", + "password": "" + } +} diff --git a/temp/nexus-module-import/modules/kea/pages/data.php b/temp/nexus-module-import/modules/kea/pages/data.php new file mode 100644 index 00000000..f7106462 --- /dev/null +++ b/temp/nexus-module-import/modules/kea/pages/data.php @@ -0,0 +1,91 @@ +get('kea'); +$fallback = $module['db_defaults'] ?? []; +$settings = modules()->settings('kea'); +$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : []; +$metadataConfig = is_array($settings['metadata_db'] ?? null) + ? array_replace($metadataFallback, $settings['metadata_db']) + : $metadataFallback; + +try { + $metadataRepo = null; + $pdo = modules()->modulePdo('kea', $fallback); + if (!empty($metadataConfig['driver']) && !empty($metadataConfig['dbname'])) { + try { + $metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig)); + $metadataRepo->ensureSchema(); + } catch (\Throwable) { + $metadataRepo = null; + } + } + + $repo = new KeaHostRepository($pdo, $metadataRepo); + $hosts = $repo->findAll(200); + $stats = [ + 'total' => $repo->countReservations() + $repo->countLeases(), + 'reservations' => $repo->countReservations(), + 'leases' => $repo->countLeases(), + 'groups' => [], + 'free_ips' => [], + ]; + + foreach ($hosts as $host) { + $group = trim((string)($host['metadata']['group_name'] ?? '')); + if ($group !== '') { + $stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1; + } + } + + if ($metadataRepo !== null) { + $stats['free_ips'] = array_map( + static fn(array $ips): int => count($ips), + $metadataRepo->availableIpsByGroup( + array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()), + 4096 + ) + ); + } + + $rows = array_map(static function (array $host): array { + return [ + 'source' => (string)($host['source'] ?? 'reservation'), + 'host_id' => (string)($host['host_id'] ?? '0'), + 'display_name' => (string)($host['metadata']['device_name'] ?? $host['metadata']['real_name'] ?? $host['display_name'] ?? $host['hostname'] ?? 'Unbekannt'), + 'ipv4_address' => (string)($host['ipv4_address'] ?? ''), + 'dhcp_identifier' => (string)($host['dhcp_identifier'] ?? ''), + 'last_seen_at' => (string)($host['last_seen_at'] ?? '-'), + 'lease_expires_at' => (string)($host['lease_expires_at'] ?? '-'), + 'real_name' => (string)($host['metadata']['real_name'] ?? '-'), + 'location' => (string)($host['metadata']['location'] ?? '-'), + 'group_name' => (string)($host['metadata']['group_name'] ?? '-'), + ]; + }, $hosts); + + echo json_encode([ + 'ok' => true, + 'stats' => [ + 'total' => (int)$stats['total'], + 'reservations' => (int)$stats['reservations'], + 'leases' => (int)$stats['leases'], + 'groups' => count($stats['groups']), + 'free_ips' => array_sum($stats['free_ips']), + ], + 'rows' => $rows, + 'updated_at' => date('H:i:s'), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} catch (\Throwable $e) { + http_response_code(500); + echo json_encode([ + 'ok' => false, + 'error' => $e->getMessage(), + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +} + +exit; diff --git a/temp/nexus-module-import/modules/kea/pages/edit.php b/temp/nexus-module-import/modules/kea/pages/edit.php new file mode 100644 index 00000000..011acc54 --- /dev/null +++ b/temp/nexus-module-import/modules/kea/pages/edit.php @@ -0,0 +1,266 @@ +get('kea'); +$settings = modules()->settings('kea'); +$fallback = $module['db_defaults'] ?? []; +$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : []; +$metadataConfig = is_array($settings['metadata_db'] ?? null) + ? array_replace($metadataFallback, $settings['metadata_db']) + : $metadataFallback; + +$source = (string)($_GET['source'] ?? $_POST['source'] ?? 'reservation'); +$source = $source === 'lease' ? 'lease' : 'reservation'; +$id = (int)($_GET['id'] ?? $_POST['id'] ?? 0); +$error = null; +$notice = null; +$host = null; +$metadataRepo = null; +$groups = []; +$availableIpsByGroup = []; +$checks = []; +$hostKey = ''; + +try { + $pdo = modules()->modulePdo('kea', $fallback); + if (empty($metadataConfig['driver']) || empty($metadataConfig['dbname'])) { + throw new RuntimeException('Nexus DHCP Zusatzdatenbank ist nicht konfiguriert.'); + } + + $metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig)); + $metadataRepo->ensureSchema(); + $groups = $metadataRepo->listGroups(); + $repo = new KeaHostRepository($pdo, $metadataRepo); + $host = $repo->findDisplayByKey($source, $id); + if (!$host) { + throw new RuntimeException('KEA Eintrag wurde nicht gefunden.'); + } + $hostKey = $source . ':' . (string)($host['host_id'] ?? $id); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = (string)($_POST['action'] ?? 'save_metadata'); + if ($action === 'dns_lookup') { + $ip = (string)($host['ipv4_address'] ?? ''); + $hostname = trim((string)($host['metadata']['device_name'] ?? $host['hostname'] ?? '')); + $reverse = $ip !== '' ? gethostbyaddr($ip) : ''; + $forward = $hostname !== '' ? gethostbyname($hostname) : ''; + $success = ($reverse !== '' && $reverse !== $ip) || ($forward !== '' && $forward !== $hostname); + + $metadataRepo->saveCheck($hostKey, 'dns', $success ? 'success' : 'warning', [ + 'ip' => $ip, + 'hostname' => $hostname, + 'reverse' => $reverse, + 'forward' => $forward, + ]); + $notice = $success ? 'DNS-Pruefung gespeichert.' : 'DNS-Pruefung gespeichert, aber kein eindeutiger Name gefunden.'; + } else { + $metadata = [ + 'real_name' => $_POST['real_name'] ?? '', + 'device_name' => $_POST['device_name'] ?? '', + 'owner' => $_POST['owner'] ?? '', + 'location' => $_POST['location'] ?? '', + 'device_type' => $_POST['device_type'] ?? '', + 'group_name' => $_POST['group_name'] ?? '', + 'desired_ip' => $_POST['desired_ip'] ?? '', + 'notes' => $_POST['notes'] ?? '', + 'tags' => [], + ]; + $desiredIp = trim((string)$metadata['desired_ip']); + if ($desiredIp !== '') { + $newHostId = $repo->reserveDisplayEntry($host, $desiredIp, $metadata); + $source = 'reservation'; + $id = $newHostId; + $notice = 'Zusatzdaten gespeichert und KEA-Reservierung gesetzt.'; + } else { + $metadataRepo->saveForHost( + $id, + (string)($host['dhcp_identifier'] ?? ''), + (string)($host['ipv4_address'] ?? ''), + $metadata + ); + $notice = 'Zusatzdaten gespeichert.'; + } + } + $host = $repo->findDisplayByKey($source, $id) ?: $host; + $hostKey = $source . ':' . (string)($host['host_id'] ?? $id); + } + + $usedIps = array_diff( + array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()), + [(string)($host['ipv4_address'] ?? ''), (string)($host['metadata']['desired_ip'] ?? '')] + ); + $availableIpsByGroup = $metadataRepo->availableIpsByGroup($usedIps); + $checks = $metadataRepo->latestChecks([$hostKey])[$hostKey] ?? []; +} catch (Throwable $e) { + $error = $e->getMessage(); +} + +$metadata = is_array($host['metadata'] ?? null) ? $host['metadata'] : []; +$selectedGroup = (string)($metadata['group_name'] ?? ''); +$selectedIp = (string)($metadata['desired_ip'] ?? ''); +?> + 'KEA Eintrag bearbeiten', +]) ?> +
+
+
+
+

KEA Eintrag bearbeiten

+

Zusatzdaten werden separat von der KEA-Datenbank gespeichert.

+
+ +
+
+ + + + + +
+ +
+ + +
+
+
+ +

+

+ IP · MAC +

+
+
+ +
+ + + + + + + + + + + + + +
+ + Abbrechen +
+
+
+ +
+
+
+ Pruefungen +

Gerätechecks

+

Pruefergebnisse werden in der Nexus-DHCP-Datenbank gespeichert und koennen spaeter fuer Reports genutzt werden.

+
+
+
+
+

DNS / Hostname

+ + + +

Status: ·

+

Reverse:

+

Forward:

+ +

Noch keine DNS-Pruefung gespeichert.

+ +
+ + + + +
+
+
+

Login-Erkennung

+

Vorbereitet fuer spaetere HTTP/Port-Erkennung. Noch nicht automatisch aktiv, damit keine ungewollten Scans laufen.

+
+
+
+ + +
+ diff --git a/temp/nexus-module-import/modules/kea/pages/groups.php b/temp/nexus-module-import/modules/kea/pages/groups.php new file mode 100644 index 00000000..4bfab444 --- /dev/null +++ b/temp/nexus-module-import/modules/kea/pages/groups.php @@ -0,0 +1,227 @@ +get('kea'); +$settings = modules()->settings('kea'); +$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : []; +$metadataConfig = is_array($settings['metadata_db'] ?? null) + ? array_replace($metadataFallback, $settings['metadata_db']) + : $metadataFallback; +$fallback = $module['db_defaults'] ?? []; +$error = null; +$notice = null; +$groups = []; +$availableIpsByGroup = []; +$usedIps = []; + +try { + if (empty($metadataConfig['driver']) || empty($metadataConfig['dbname'])) { + throw new RuntimeException('Nexus DHCP Zusatzdatenbank ist nicht konfiguriert.'); + } + + $metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig)); + $metadataRepo->ensureSchema(); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $action = (string)($_POST['action'] ?? ''); + if ($action === 'save_group') { + $metadataRepo->saveGroup( + (string)($_POST['name'] ?? ''), + (string)($_POST['description'] ?? ''), + (string)($_POST['parent_name'] ?? '') + ); + $notice = 'Gruppe gespeichert.'; + } elseif ($action === 'add_range') { + $metadataRepo->addRange( + (string)($_POST['group_name'] ?? ''), + (string)($_POST['start_ip'] ?? ''), + (string)($_POST['end_ip'] ?? '') + ); + $notice = 'IP-Bereich gespeichert.'; + } + } + + $groups = $metadataRepo->listGroupsWithRanges(); + $keaRepo = new KeaHostRepository(modules()->modulePdo('kea', $fallback), $metadataRepo); + $usedIps = array_merge($keaRepo->usedIpAddresses(), $metadataRepo->desiredIps()); + $availableIpsByGroup = $metadataRepo->availableIpsByGroup($usedIps, 4096); +} catch (Throwable $e) { + $error = $e->getMessage(); +} + +$usedIpLookup = array_flip(array_filter(array_map('strval', $usedIps))); +$matrixForGroup = static function (array $group) use ($usedIpLookup): array { + $dots = []; + foreach (($group['ranges'] ?? []) as $range) { + $start = ip2long((string)($range['start_ip'] ?? '')); + $end = ip2long((string)($range['end_ip'] ?? '')); + if ($start === false || $end === false) { + continue; + } + for ($ip = $start; $ip <= $end && count($dots) < 512; $ip++) { + $address = long2ip($ip); + if ($address === false) { + continue; + } + $dots[] = [ + 'ip' => $address, + 'used' => isset($usedIpLookup[$address]), + ]; + } + } + + return $dots; +}; +?> + 'KEA Gruppen', +]) ?> +
+
+
+
+

KEA Gruppen

+

Gruppen und IP-Bereiche fuer DHCP-Reservierungen.

+
+ Zurueck +
+
+ + + + +
+ + +
+
+
+ Gruppe +

Gruppe anlegen

+
+
+
+ + + + +
+ +
+
+
+ +
+
+
+ IP-Bereich +

Bereich zuweisen

+
+
+
+ + + + +
+ +
+
+
+ +
+
+
+ Uebersicht +

Gruppen und freie IPs

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
GruppeUntergruppe vonBeschreibungBereicheFreie IPsMatrix
Noch keine Gruppen definiert.
+ + Kein Bereich + + +
-
+ + +
+ + + Kein Bereich + +
+ + + +
+ +
+
+
+
+ diff --git a/temp/nexus-module-import/modules/kea/pages/index.php b/temp/nexus-module-import/modules/kea/pages/index.php new file mode 100644 index 00000000..ecd11667 --- /dev/null +++ b/temp/nexus-module-import/modules/kea/pages/index.php @@ -0,0 +1,62 @@ +get('kea'); +$fallback = $module['db_defaults'] ?? []; + +$settings = modules()->settings('kea'); +$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : []; +$metadataConfig = is_array($settings['metadata_db'] ?? null) + ? array_replace($metadataFallback, $settings['metadata_db']) + : $metadataFallback; +$metadataRepo = null; +$hosts = []; +$error = null; +$warnings = []; +$stats = [ + 'total' => 0, + 'reservations' => 0, + 'leases' => 0, + 'groups' => [], + 'free_ips' => [], +]; + +try { + $pdo = modules()->modulePdo('kea', $fallback); + if (!empty($metadataConfig['driver']) && !empty($metadataConfig['dbname'])) { + try { + $metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig)); + $metadataRepo->ensureSchema(); + } catch (\Throwable $e) { + $warnings[] = 'Nexus DHCP Zusatzdatenbank nicht verfuegbar: ' . $e->getMessage(); + $metadataRepo = null; + } + } + + $repo = new KeaHostRepository($pdo, $metadataRepo); + $hosts = $repo->findAll(200); + $stats['reservations'] = $repo->countReservations(); + $stats['leases'] = $repo->countLeases(); + $stats['total'] = $stats['reservations'] + $stats['leases']; + foreach ($hosts as $host) { + $group = trim((string)($host['metadata']['group_name'] ?? '')); + if ($group !== '') { + $stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1; + } + } + if ($metadataRepo !== null) { + $stats['free_ips'] = array_map( + static fn(array $ips): int => count($ips), + $metadataRepo->availableIpsByGroup( + array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()), + 4096 + ) + ); + } +} catch (\Exception $e) { + $error = "Datenbankfehler: " . $e->getMessage(); +} + +module_tpl('kea', 'dashboard', compact('hosts', 'error', 'warnings', 'stats')); diff --git a/temp/nexus-module-import/modules/kea/partials/dashboard.php b/temp/nexus-module-import/modules/kea/partials/dashboard.php new file mode 100644 index 00000000..aea47ce2 --- /dev/null +++ b/temp/nexus-module-import/modules/kea/partials/dashboard.php @@ -0,0 +1,231 @@ + + 'KEA DHCP Hosts', + 'description' => 'Reservierungen und aktuelle Leases aus der KEA-Datenbank.', +]) ?> +
+
+
+
+

KEA DHCP Hosts

+

Reservierungen und aktuelle Leases aus der KEA-Datenbank.

+

+ Automatische Aktualisierung alle 5 Sekunden. +

+
+
+
+ + + + + + + + + +
+
+ Einträge + +
+
+ Reservierungen + +
+
+ Leases + +
+
+ Gruppen + +
+
+ Freie Gruppen-IPs + +
+
+ +
+
+
+ Inventar +

Registrierte Geräte

+

Zusatzdaten werden in der separaten Nexus-DHCP-Datenbank gespeichert.

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
QuelleHostnameIP AdresseMAC AdresseZuletzt gesehenLease bisEchter NameStandortGruppeAktion
+ Keine Reservierungen oder aktiven Leases gefunden. +
+ + + + + + + + + + + + + + + + + + + + Bearbeiten + +
+
+
+
+ + diff --git a/temp/nexus-module-import/modules/mining-checker/api/index.php b/temp/nexus-module-import/modules/mining-checker/api/index.php new file mode 100644 index 00000000..ae696764 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/api/index.php @@ -0,0 +1,6 @@ +handle($_GET['path'] ?? ''); diff --git a/temp/nexus-module-import/modules/mining-checker/assets/css/.gitkeep b/temp/nexus-module-import/modules/mining-checker/assets/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/temp/nexus-module-import/modules/mining-checker/assets/css/app.css b/temp/nexus-module-import/modules/mining-checker/assets/css/app.css new file mode 100644 index 00000000..d5b58b31 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/assets/css/app.css @@ -0,0 +1,772 @@ +#mining-checker-app { + --mc-surface: var(--surface); + --mc-surface-strong: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(248,252,252,0.92)); + --mc-line: var(--line); + --mc-line-strong: color-mix(in srgb, var(--brand-accent) 28%, transparent); + --mc-text: var(--text); + --mc-text-muted: var(--muted); + --mc-accent: var(--brand-accent); + --mc-accent-strong: var(--brand-accent-3); + --mc-danger: #d92d20; + --mc-success: #14804a; + --mc-warning: #b54708; + min-height: 0; + background: none; + color: var(--mc-text); + font-family: inherit; + max-width: 100%; + overflow-x: clip; +} + +#mining-checker-app, +#mining-checker-app * { + box-sizing: border-box; +} + +#mining-checker-app .mc-grid-bg { + background: none; + max-width: 100%; + overflow-x: clip; +} + +#mining-checker-app .mc-shell { + width: 100%; + max-width: 100%; + margin: 0; + padding: 0; +} + +#mining-checker-app .mc-stack { + display: grid; + gap: 16px; +} + +#mining-checker-app .mc-panel, +#mining-checker-app .mc-stat-card, +#mining-checker-app .mc-dashboard-card, +#mining-checker-app .mc-target-card, +#mining-checker-app .mc-alert, +#mining-checker-app .mc-empty, +#mining-checker-app .mc-table-shell, +#mining-checker-app .mc-display-field { + border: 1px solid var(--mc-line); + border-radius: 22px; + background: var(--mc-surface); + /* box-shadow: 0 12px 30px rgba(1, 22, 32, 0.08); */ + backdrop-filter: blur(8px); +} + +#mining-checker-app .mc-panel, +#mining-checker-app .mc-dashboard-card, +#mining-checker-app .mc-alert, +#mining-checker-app .mc-empty, +#mining-checker-app .mc-table-shell { + padding: 18px 20px; +} + +#mining-checker-app .mc-hero-top, +#mining-checker-app .mc-inline-row, +#mining-checker-app .mc-flex-split, +#mining-checker-app .mc-section-head { + display: flex; + gap: 16px; +} + +#mining-checker-app .mc-hero-top, +#mining-checker-app .mc-flex-split, +#mining-checker-app .mc-section-head { + justify-content: space-between; +} + +#mining-checker-app .mc-hero-copy, +#mining-checker-app .mc-hero-controls, +#mining-checker-app .mc-panel-body, +#mining-checker-app .mc-form, +#mining-checker-app .mc-field, +#mining-checker-app .mc-filter-grid, +#mining-checker-app .mc-chart, +#mining-checker-app .mc-target-grid { + display: grid; + gap: 14px; +} + +#mining-checker-app .mc-filter-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +#mining-checker-app .mc-text, +#mining-checker-app p, +#mining-checker-app td, +#mining-checker-app th, +#mining-checker-app label, +#mining-checker-app summary, +#mining-checker-app pre { + color: var(--mc-text-muted); +} + +#mining-checker-app h1, +#mining-checker-app h2, +#mining-checker-app h3 { + margin: 0; + color: var(--mc-text); +} + +#mining-checker-app .mc-stats-grid, +#mining-checker-app .mc-target-grid, +#mining-checker-app .mc-overview-grid, +#mining-checker-app .mc-two-col, +#mining-checker-app .mc-main-grid { + display: grid; + gap: 16px; +} + +#mining-checker-app .mc-stats-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +#mining-checker-app .mc-overview-grid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +#mining-checker-app .mc-target-grid { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +#mining-checker-app .mc-two-col { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} + +#mining-checker-app .mc-asset-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 14px; +} + +#mining-checker-app .mc-asset-card { + gap: 8px; +} + +#mining-checker-app .mc-asset-balance { + font-size: 1.15rem; + font-weight: 700; + color: var(--mc-text); +} + +#mining-checker-app .mc-asset-list { + display: grid; + gap: 10px; + min-width: 240px; +} + +#mining-checker-app .mc-asset-row { + display: grid; + gap: 2px; +} + +#mining-checker-app .mc-asset-row strong { + color: var(--mc-text); +} + +#mining-checker-app .mc-main-grid { + grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); +} + +#mining-checker-app .mc-stat-card, +#mining-checker-app .mc-target-card { + padding: 20px; +} + +#mining-checker-app .mc-stat-card { + background: var(--mc-surface-strong); +} + +#mining-checker-app .mc-kicker { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--mc-accent-strong); +} + +#mining-checker-app .mc-stat-value { + font-size: 2rem; + font-weight: 700; + line-height: 1.1; + color: var(--mc-text); +} + +#mining-checker-app .mc-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.18em; + border: 1px solid var(--mc-line-strong); + background: color-mix(in srgb, var(--mc-accent) 16%, transparent); + color: var(--mc-accent-strong); +} + +#mining-checker-app .mc-badge--warn { + background: color-mix(in srgb, var(--mc-warning) 12%, transparent); + color: var(--mc-warning); +} + +#mining-checker-app .mc-badge--info { + background: color-mix(in srgb, var(--mc-accent) 16%, transparent); + color: var(--mc-accent-strong); +} + +#mining-checker-app .mc-badge--danger { + background: color-mix(in srgb, var(--mc-danger) 12%, transparent); + color: var(--mc-danger); +} + +#mining-checker-app .mc-badge--success { + background: color-mix(in srgb, var(--mc-success) 12%, transparent); + color: var(--mc-success); +} + +#mining-checker-app .mc-button, +#mining-checker-app button, +#mining-checker-app input, +#mining-checker-app select, +#mining-checker-app textarea { + font: inherit; +} + +#mining-checker-app .mc-button { + border: 1px solid transparent; + border-radius: 16px; + padding: 12px 16px; + cursor: pointer; + text-decoration: none; + transition: 160ms ease; +} + +#mining-checker-app .mc-button:hover { + transform: translateY(-1px); +} + +#mining-checker-app .mc-button:disabled { + opacity: 0.6; + cursor: default; + transform: none; +} + +#mining-checker-app .mc-button--primary { + background: linear-gradient(135deg, var(--mc-accent), var(--mc-accent-strong)); + color: #05121f; + font-weight: 700; +} + +#mining-checker-app .mc-button--secondary { + background: rgba(255, 255, 255, 0.92); + color: var(--mc-text); + font-weight: 700; +} + +#mining-checker-app .mc-button--danger { + background: linear-gradient(135deg, rgba(251, 113, 133, 0.92), rgba(239, 68, 68, 0.92)); + color: #fff7f7; + font-weight: 700; +} + +#mining-checker-app .mc-button--ghost { + background: color-mix(in srgb, var(--mc-accent) 14%, transparent); + border-color: color-mix(in srgb, var(--mc-accent) 34%, transparent); + color: var(--mc-accent-strong); +} + +#mining-checker-app .mc-debug-tools { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: flex-start; +} + +#mining-checker-app .mc-debug-console { + border-color: rgba(125, 211, 252, 0.28); +} + +#mining-checker-app .mc-debug-view-switch { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 14px; +} + +#mining-checker-app .mc-debug-log { + display: grid; + gap: 12px; + max-height: 520px; + overflow: auto; +} + +#mining-checker-app .mc-debug-text-console { + max-height: 520px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +#mining-checker-app .mc-debug-entry { + border: 1px solid var(--mc-line); + border-radius: 18px; + padding: 14px 16px; + background: color-mix(in srgb, var(--brand-accent-2) 8%, var(--mc-surface)); +} + +#mining-checker-app .mc-field { + gap: 8px; +} + +#mining-checker-app .mc-field-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--mc-text-muted); +} + +#mining-checker-app .mc-input, +#mining-checker-app .mc-select, +#mining-checker-app .mc-textarea, +#mining-checker-app .mc-file { + width: 100%; + border: 1px solid var(--mc-line); + border-radius: 16px; + padding: 13px 15px; + color: var(--mc-text); + background: rgba(255, 255, 255, 0.72); + outline: none; +} + +#mining-checker-app .mc-select option { + color: #09111f; + background: #f8fafc; +} + +#mining-checker-app .mc-textarea { + min-height: 120px; + resize: vertical; +} + +#mining-checker-app .mc-input::placeholder, +#mining-checker-app .mc-textarea::placeholder { + color: #6d7c90; +} + +#mining-checker-app .mc-input:focus, +#mining-checker-app .mc-select:focus, +#mining-checker-app .mc-textarea:focus, +#mining-checker-app .mc-file:focus { + border-color: rgba(125, 211, 252, 0.5); + box-shadow: 0 0 0 3px rgba(125, 211, 252, 0.12); +} + +#mining-checker-app .mc-checkbox { + display: flex; + align-items: center; + gap: 12px; + border: 1px solid var(--mc-line); + border-radius: 16px; + padding: 14px 16px; + background: rgba(255, 255, 255, 0.05); +} + +#mining-checker-app .mc-inline-fields { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: end; +} + +#mining-checker-app .mc-inline-fields > .mc-field { + flex: 1 1 280px; +} + +#mining-checker-app .mc-alert--error { + border-color: rgba(251, 113, 133, 0.28); + background: rgba(127, 29, 29, 0.35); + color: #ffe4e6; +} + +#mining-checker-app .mc-alert--warning { + border-color: rgba(245, 158, 11, 0.28); + background: rgba(120, 53, 15, 0.34); + color: #fef3c7; +} + +#mining-checker-app .mc-alert--success { + border-color: rgba(52, 211, 153, 0.28); + background: rgba(6, 78, 59, 0.34); + color: #d1fae5; +} + +#mining-checker-app .mc-table-shell { + overflow: auto; + padding: 0; +} + +#mining-checker-app .mc-table { + width: 100%; + border-collapse: collapse; +} + +#mining-checker-app .mc-table th, +#mining-checker-app .mc-table td { + padding: 14px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + text-align: left; + vertical-align: top; +} + +#mining-checker-app .mc-table thead { + background: rgba(255, 255, 255, 0.04); +} + +#mining-checker-app .mc-empty { + border-style: dashed; +} + +#mining-checker-app details { + border: 1px solid var(--mc-line); + border-radius: 18px; + padding: 16px; + background: rgba(255, 255, 255, 0.04); +} + +#mining-checker-app pre { + white-space: pre-wrap; + margin: 12px 0 0; + line-height: 1.65; +} + +#mining-checker-app .mc-mini-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +#mining-checker-app .mc-mini-card, +#mining-checker-app .mc-display-field { + padding: 12px 14px; + background: rgba(255, 255, 255, 0.04); +} + +#mining-checker-app .mc-code-block { + margin: 0; + padding: 12px 14px; + border: 1px solid var(--mc-line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.03); + white-space: pre-wrap; + word-break: break-word; + font-family: "JetBrains Mono", "SFMono-Regular", monospace; + font-size: 0.92rem; + line-height: 1.6; +} + +#mining-checker-app .mc-chart svg { + width: 100%; + height: 220px; +} + +#mining-checker-app .mc-modal-backdrop { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(2, 6, 23, 0.72); +} + +#mining-checker-app .mc-modal { + width: min(720px, 100%); + max-height: min(80vh, 900px); + overflow: auto; + padding: 24px; + border: 1px solid var(--mc-line); + border-radius: 28px; + background: rgba(9, 17, 31, 0.96); + box-shadow: 0 28px 60px rgba(0, 0, 0, 0.34); + backdrop-filter: blur(14px); +} + +#mining-checker-app .mc-chart path, +#mining-checker-app .mc-chart polyline, +#mining-checker-app .mc-chart line, +#mining-checker-app .mc-chart rect { + vector-effect: non-scaling-stroke; +} + +@media (max-width: 960px) { + #mining-checker-app .mc-hero-top, + #mining-checker-app .mc-inline-row, + #mining-checker-app .mc-flex-split, + #mining-checker-app .mc-section-head { + flex-direction: column; + align-items: stretch; + } + + #mining-checker-app .mc-main-grid { + grid-template-columns: 1fr; + } + + #mining-checker-app .mc-shell { + width: min(100% - 10px, 1360px); + padding: 8px 0 20px; + } + + #mining-checker-app .mc-stack { + gap: 14px; + } + + #mining-checker-app .mc-hero, + #mining-checker-app .mc-panel, + #mining-checker-app .mc-dashboard-card, + #mining-checker-app .mc-alert, + #mining-checker-app .mc-empty, + #mining-checker-app .mc-table-shell { + padding: 14px; + border-radius: 20px; + } + + #mining-checker-app .mc-title { + font-size: clamp(1.45rem, 8vw, 2.15rem); + } + + #mining-checker-app .mc-hero-copy { + gap: 8px; + } + + #mining-checker-app .mc-hero-copy p { + margin: 0; + } + + #mining-checker-app .mc-hero-controls { + grid-template-columns: 1fr; + } + + #mining-checker-app .mc-home-link { + justify-self: stretch; + justify-content: center; + } + + #mining-checker-app .mc-tabs { + flex-wrap: nowrap; + gap: 8px; + margin: 12px -4px 0; + padding: 0 4px 4px; + overflow-x: auto; + scrollbar-width: thin; + } + + #mining-checker-app .mc-button { + padding: 10px 12px; + border-radius: 14px; + } + + #mining-checker-app .mc-button--tab { + flex: 0 0 auto; + white-space: nowrap; + } + + #mining-checker-app .mc-table th, + #mining-checker-app .mc-table td { + padding: 10px 12px; + } + + #mining-checker-app .mc-modal { + max-height: min(92vh, 900px); + padding: 16px; + border-radius: 20px; + } +} + +@media (max-width: 600px) { + #mining-checker-app, + #mining-checker-app * { + max-width: 100%; + } + + #mining-checker-app { + min-height: 100vh; + overflow-x: hidden; + } + + #mining-checker-app .mc-grid-bg { + overflow-x: hidden; + background-size: 18px 18px; + } + + #mining-checker-app .mc-shell { + width: 100%; + padding: 0 0 16px; + } + + #mining-checker-app .mc-stack { + gap: 12px; + } + + #mining-checker-app .mc-hero, + #mining-checker-app .mc-panel, + #mining-checker-app .mc-dashboard-card, + #mining-checker-app .mc-alert, + #mining-checker-app .mc-empty { + width: 100%; + border-radius: 18px; + padding: 14px; + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.16); + } + + #mining-checker-app .mc-hero { + border-radius: 0 0 18px 18px; + } + + #mining-checker-app .mc-panel, + #mining-checker-app .mc-dashboard-card, + #mining-checker-app .mc-alert, + #mining-checker-app .mc-empty, + #mining-checker-app .mc-table-shell { + border-left: 0; + border-right: 0; + } + + #mining-checker-app .mc-title, + #mining-checker-app .mc-hero-copy p { + display: none; + } + + #mining-checker-app .mc-hero-top { + gap: 10px; + } + + #mining-checker-app .mc-kicker { + font-size: 0.68rem; + letter-spacing: 0.14em; + } + + #mining-checker-app .mc-tabs { + margin: 10px -6px 0; + padding: 0 6px 6px; + gap: 6px; + } + + #mining-checker-app .mc-button { + min-height: 38px; + padding: 8px 10px; + border-radius: 12px; + font-size: 0.92rem; + } + + #mining-checker-app .mc-button:hover { + transform: none; + } + + #mining-checker-app .mc-stats-grid, + #mining-checker-app .mc-target-grid, + #mining-checker-app .mc-overview-grid, + #mining-checker-app .mc-two-col, + #mining-checker-app .mc-main-grid, + #mining-checker-app .mc-filter-grid, + #mining-checker-app .mc-mini-grid { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + } + + #mining-checker-app .mc-stat-card, + #mining-checker-app .mc-target-card, + #mining-checker-app .mc-mini-card, + #mining-checker-app .mc-display-field { + padding: 12px; + border-radius: 16px; + } + + #mining-checker-app .mc-stat-value { + font-size: clamp(1.45rem, 8vw, 1.85rem); + overflow-wrap: anywhere; + } + + #mining-checker-app h2 { + font-size: 1.25rem; + } + + #mining-checker-app h3 { + font-size: 1.05rem; + } + + #mining-checker-app .mc-text, + #mining-checker-app p, + #mining-checker-app td, + #mining-checker-app th, + #mining-checker-app label, + #mining-checker-app summary, + #mining-checker-app pre { + font-size: 0.92rem; + } + + #mining-checker-app .mc-input, + #mining-checker-app .mc-select, + #mining-checker-app .mc-textarea, + #mining-checker-app .mc-file { + min-width: 0; + padding: 10px 12px; + border-radius: 12px; + font-size: 16px; + } + + #mining-checker-app .mc-inline-fields, + #mining-checker-app .mc-debug-tools, + #mining-checker-app .mc-debug-view-switch { + gap: 8px; + } + + #mining-checker-app .mc-inline-fields > .mc-field { + flex-basis: 100%; + min-width: 0; + } + + #mining-checker-app .mc-badge { + padding: 7px 10px; + font-size: 0.78rem; + } + + #mining-checker-app .mc-table-shell { + width: 100%; + max-width: 100%; + border-radius: 16px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + #mining-checker-app .mc-table { + max-width: none; + min-width: 640px; + } + + #mining-checker-app .mc-table th, + #mining-checker-app .mc-table td { + padding: 9px 10px; + font-size: 0.86rem; + } + + #mining-checker-app .mc-chart svg { + height: 180px; + } + + #mining-checker-app .mc-modal-backdrop { + align-items: stretch; + padding: 8px; + } + + #mining-checker-app .mc-modal { + width: 100%; + max-height: calc(100vh - 16px); + padding: 14px; + border-radius: 18px; + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/assets/js/.gitkeep b/temp/nexus-module-import/modules/mining-checker/assets/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/temp/nexus-module-import/modules/mining-checker/assets/js/app.js b/temp/nexus-module-import/modules/mining-checker/assets/js/app.js new file mode 100644 index 00000000..14782404 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/assets/js/app.js @@ -0,0 +1,3188 @@ +(function () { + const root = document.getElementById('mining-checker-app'); + if (!root || !window.React || !window.ReactDOM) { + return; + } + + const h = React.createElement; + const { useEffect, useMemo, useRef, useState } = React; + const apiBase = root.dataset.apiBase || '/api/mining-checker/v1'; + const initialProjectKey = root.dataset.defaultProjectKey || 'doge-main'; + const initialActiveTab = String(root.dataset.activeView || 'overview').trim() || 'overview'; + const configuredSections = (() => { + try { + const parsed = JSON.parse(root.dataset.sectionsJson || '[]'); + if (!Array.isArray(parsed)) { + return []; + } + return parsed + .map((section) => { + const key = section && typeof section.key === 'string' ? section.key.trim() : ''; + const label = section && typeof section.label === 'string' ? section.label.trim() : ''; + return key && label ? [key, label] : null; + }) + .filter(Boolean); + } catch (error) { + return []; + } + })(); + const initialDebugMode = document.body && document.body.dataset.moduleDebugEnabled === '1'; + function getCookie(name) { + const pattern = `; ${document.cookie}`; + const parts = pattern.split(`; ${name}=`); + if (parts.length < 2) { + return ''; + } + return decodeURIComponent(parts.pop().split(';').shift() || ''); + } + + function setCookie(name, value, maxAgeSeconds) { + document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAgeSeconds}; samesite=lax`; + } + const debugBus = window.__nexusDebugBus || { + enabled: initialDebugMode, + listener: null, + sequence: 0, + }; + window.__nexusDebugBus = debugBus; + const browserTimezone = (() => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Berlin'; + } catch (_error) { + return 'Europe/Berlin'; + } + })(); + + function emitDebug(entry) { + debugBus.sequence += 1; + const payload = { + id: debugBus.sequence, + time: new Date().toISOString(), + ...entry, + }; + + if (typeof debugBus.listener === 'function') { + debugBus.listener(payload); + } + } + + function emitServerTraceEntries(trace, meta) { + if (!Array.isArray(trace) || !trace.length) { + emitDebug({ + type: meta && meta.type ? meta.type : 'server:trace-empty', + source: meta && meta.source ? meta.source : null, + message: 'Keine Server-Debug-Eintraege vorhanden.', + }); + return; + } + + trace.forEach((item) => { + emitDebug({ + type: `server:${item.event || 'trace'}`, + source: meta && meta.source ? meta.source : null, + server_time: item.time || null, + ...(item.context && typeof item.context === 'object' ? item.context : {}), + }); + }); + } + + async function loadLatestDebugTrace() { + try { + const response = await fetch(`${apiBase}/debug/latest`, { + headers: { 'X-Mining-Debug': '1' }, + }); + const payload = await response.json().catch(() => ({})); + if (payload && payload.data && Array.isArray(payload.data.entries)) { + emitServerTraceEntries(payload.data.entries, { + type: 'server:trace-latest', + source: payload.data.file || null, + }); + } + } catch (error) { + emitDebug({ + type: 'server:trace-latest-error', + message: error && error.message ? error.message : 'Latest-Debug konnte nicht geladen werden', + }); + } + } + + function cx() { + return Array.from(arguments).filter(Boolean).join(' '); + } + + function fmtNumber(value, digits) { + if (value === null || value === undefined || value === '') { + return 'n/a'; + } + return Number(value).toLocaleString('de-DE', { + minimumFractionDigits: 0, + maximumFractionDigits: digits === undefined ? 6 : digits, + }); + } + + function fmtMoney(value, currency) { + if (value === null || value === undefined || !currency) { + return 'n/a'; + } + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency, + maximumFractionDigits: 4, + }).format(Number(value)); + } + + function recentMeasurementWindow(rows, windowDays) { + if (!Array.isArray(rows) || rows.length === 0) { + return []; + } + + const normalizedWindowDays = Number(windowDays); + if (!Number.isFinite(normalizedWindowDays) || normalizedWindowDays <= 0) { + return rows; + } + + const sortedRows = rows + .filter((row) => row && row.measured_at) + .slice() + .sort((left, right) => new Date(left.measured_at).getTime() - new Date(right.measured_at).getTime()); + if (sortedRows.length === 0) { + return rows; + } + + const latestTs = new Date(sortedRows[sortedRows.length - 1].measured_at).getTime(); + if (!Number.isFinite(latestTs)) { + return sortedRows; + } + + const minTs = latestTs - (normalizedWindowDays * 86400 * 1000); + const filteredRows = sortedRows.filter((row) => { + const measuredTs = new Date(row.measured_at).getTime(); + return Number.isFinite(measuredTs) && measuredTs >= minTs; + }); + + return filteredRows.length ? filteredRows : [sortedRows[sortedRows.length - 1]]; + } + + function parseStoredUtcDate(value) { + const raw = String(value || '').trim(); + if (!raw) { + return null; + } + let normalized = raw.replace(' ', 'T'); + if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + normalized = `${normalized}T00:00:00Z`; + } else if (!/[zZ]$|[+-]\d{2}:\d{2}$/.test(normalized)) { + normalized = `${normalized}Z`; + } + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + + function formatDateByParts(value, includeTime) { + const parsed = parseStoredUtcDate(value); + if (!parsed) { + return value ? String(value).replace('T', ' ').slice(0, includeTime ? 16 : 10) : 'n/a'; + } + const parts = new Intl.DateTimeFormat('de-DE', { + timeZone: browserTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + ...(includeTime ? { hour: '2-digit', minute: '2-digit' } : {}), + }).formatToParts(parsed); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return includeTime + ? `${map.day}.${map.month}.${map.year} ${map.hour}:${map.minute}` + : `${map.day}.${map.month}.${map.year}`; + } + + function toDateTimeLocalValue(value) { + const parsed = parseStoredUtcDate(value); + if (!parsed) { + return ''; + } + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: browserTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(parsed); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}T${map.hour}:${map.minute}`; + } + + function nowDateTimeLocalValue() { + const now = new Date(); + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: browserTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(now); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}T${map.hour}:${map.minute}`; + } + + function todayLocalDateValue() { + const now = new Date(); + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: browserTimezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); + return `${map.year}-${map.month}-${map.day}`; + } + + function fmtDate(value) { + if (!value) { + return 'n/a'; + } + return formatDateByParts(value, true); + } + + function fmtDateTime(value) { + if (!value) { + return 'n/a'; + } + return formatDateByParts(value, true); + } + + async function request(path, options) { + const requestOptions = options && typeof options === 'object' ? { ...options } : {}; + const debugEnabled = !!debugBus.enabled; + const timeoutMs = typeof requestOptions.timeoutMs === 'number' + ? (debugEnabled ? Math.max(requestOptions.timeoutMs, 20000) : requestOptions.timeoutMs) + : (debugEnabled ? 20000 : 8000); + delete requestOptions.timeoutMs; + + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); + + const requestUrl = path; + const headers = { ...(requestOptions.headers || {}) }; + if (debugEnabled) { + headers['X-Mining-Debug'] = '1'; + } + requestOptions.headers = headers; + + emitDebug({ + type: 'request:start', + method: requestOptions.method || 'GET', + url: requestUrl, + body: typeof requestOptions.body === 'string' ? requestOptions.body.slice(0, 2000) : null, + }); + + let response; + try { + response = await fetch(requestUrl, { + ...requestOptions, + credentials: 'same-origin', + redirect: 'manual', + signal: controller.signal, + }); + } catch (error) { + window.clearTimeout(timeoutId); + if (error && error.name === 'AbortError') { + emitDebug({ + type: 'request:timeout', + method: requestOptions.method || 'GET', + url: requestUrl, + timeout_ms: timeoutMs, + }); + if (debugEnabled) { + loadLatestDebugTrace(); + } + throw new Error('API request timeout'); + } + emitDebug({ + type: 'request:error', + method: requestOptions.method || 'GET', + url: requestUrl, + page_url: window.location.href, + online: window.navigator ? window.navigator.onLine : null, + message: error && error.message ? error.message : 'Fetch fehlgeschlagen', + }); + throw error; + } + + window.clearTimeout(timeoutId); + if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) { + const location = response.headers ? response.headers.get('Location') : ''; + emitDebug({ + type: 'request:redirect', + method: requestOptions.method || 'GET', + url: requestUrl, + status: response.status, + location: location || null, + }); + throw new Error(`API request wurde weitergeleitet${location ? ` nach ${location}` : ''}. Bitte Login/Session und Proxy-Rewrite pruefen.`); + } + + const payload = await response.json().catch(() => ({})); + emitDebug({ + type: 'request:response', + method: requestOptions.method || 'GET', + url: requestUrl, + status: response.status, + ok: response.ok, + has_debug: Array.isArray(payload && payload.debug) && payload.debug.length > 0, + }); + if (debugEnabled && payload && payload.debug) { + emitServerTraceEntries(payload.debug, { + type: 'server:trace', + source: requestUrl, + }); + } + if (!response.ok) { + const context = payload && payload.context && typeof payload.context === 'object' ? payload.context : null; + const detail = context + ? [context.message, context.statement ? `SQL: ${String(context.statement).slice(0, 500)}` : null] + .filter(Boolean) + .join(' ') + : ''; + throw new Error([payload.error || 'API request failed', detail].filter(Boolean).join(' ')); + } + if (payload && Object.prototype.hasOwnProperty.call(payload, 'data')) { + return payload.data; + } + return payload; + } + + function normalizeBootstrap(data, projectKey) { + const normalized = data && typeof data === 'object' ? data : {}; + return { + project: normalized.project || { project_key: projectKey }, + settings: normalized.settings || { + project_key: projectKey, + baseline_measured_at: '', + baseline_coins_total: '', + daily_cost_amount: '', + daily_cost_currency: 'EUR', + report_currency: 'EUR', + crypto_currency: 'DOGE', + preferred_currencies: ['DOGE', 'USD', 'EUR'], + cost_plans: [], + currencies: [], + payouts: [], + miner_offers: [], + purchased_miners: [], + measurement_rates: [], + }, + wallet_snapshots: Array.isArray(normalized.wallet_snapshots) ? normalized.wallet_snapshots : [], + measurements: Array.isArray(normalized.measurements) ? normalized.measurements : [], + targets: Array.isArray(normalized.targets) ? normalized.targets : [], + dashboards: Array.isArray(normalized.dashboards) ? normalized.dashboards : [], + fx_snapshots: normalized.fx_snapshots && typeof normalized.fx_snapshots === 'object' ? normalized.fx_snapshots : {}, + summary: normalized.summary || { + latest_measurement: null, + baseline: normalized.settings || null, + targets: Array.isArray(normalized.targets) ? normalized.targets : [], + payouts: { + total_count: 0, + total_coins: 0, + current_visible_coins: null, + current_effective_coins: null, + wallet_balances: {}, + wallet_balance_current_asset: null, + holdings_current_asset: null, + }, + current_hashrate_mh: null, + miner_offers: [], + }, + }; + } + + function normalizeSchemaStatus(data) { + const normalized = data && typeof data === 'object' ? data : {}; + return { + required_tables: Array.isArray(normalized.required_tables) ? normalized.required_tables : [], + present_tables: Array.isArray(normalized.present_tables) ? normalized.present_tables : [], + missing_tables: Array.isArray(normalized.missing_tables) ? normalized.missing_tables : [], + pending_upgrades: Array.isArray(normalized.pending_upgrades) ? normalized.pending_upgrades : [], + present_count: typeof normalized.present_count === 'number' ? normalized.present_count : 0, + missing_count: typeof normalized.missing_count === 'number' ? normalized.missing_count : 0, + pending_upgrade_count: typeof normalized.pending_upgrade_count === 'number' ? normalized.pending_upgrade_count : 0, + all_present: !!normalized.all_present, + }; + } + + function normalizeOcrPreview(data) { + const normalized = data && typeof data === 'object' ? data : {}; + const suggested = normalized.suggested && typeof normalized.suggested === 'object' + ? normalized.suggested + : {}; + + return { + kind: normalized.kind === 'wallet' ? 'wallet' : 'measurement', + suggested: { + measured_at: suggested.measured_at || '', + coins_total: suggested.coins_total ?? '', + price_per_coin: suggested.price_per_coin ?? '', + price_currency: suggested.price_currency || '', + note: suggested.note || '', + source: suggested.source || 'image_ocr', + }, + suggested_wallet: normalized.suggested_wallet && typeof normalized.suggested_wallet === 'object' + ? { + measured_at: normalized.suggested_wallet.measured_at || '', + total_value_amount: normalized.suggested_wallet.total_value_amount ?? '', + total_value_currency: normalized.suggested_wallet.total_value_currency || '', + wallet_balance: normalized.suggested_wallet.wallet_balance ?? '', + wallet_currency: normalized.suggested_wallet.wallet_currency || '', + balances_json: normalized.suggested_wallet.balances_json && typeof normalized.suggested_wallet.balances_json === 'object' + ? normalized.suggested_wallet.balances_json + : {}, + note: normalized.suggested_wallet.note || '', + source: normalized.suggested_wallet.source || 'image_ocr', + } + : { + measured_at: '', + total_value_amount: '', + total_value_currency: '', + wallet_balance: '', + wallet_currency: '', + balances_json: {}, + note: '', + source: 'image_ocr', + }, + confidence: typeof normalized.confidence === 'number' ? normalized.confidence : 0, + flags: Array.isArray(normalized.flags) ? normalized.flags : [], + image_path: normalized.image_path || '', + raw_text: normalized.raw_text || '', + }; + } + + function getOcrStatusMessage(preview) { + const flags = Array.isArray(preview && preview.flags) ? preview.flags : []; + const missingProviders = flags + .filter((flag) => typeof flag === 'string' && flag.indexOf('ocr_provider_missing:') === 0) + .map((flag) => flag.split(':')[1]) + .filter(Boolean); + + if (flags.includes('ocr_engine_missing') || missingProviders.length) { + return { + tone: 'error', + text: missingProviders.length + ? `Auf dem Server ist kein nutzbarer OCR-Provider verfuegbar. Fehlend: ${missingProviders.join(', ')}. Bitte OCR.space oder Tesseract pruefen.` + : 'Auf dem Server ist kein nutzbarer OCR-Provider verfuegbar. Bitte OCR.space oder Tesseract pruefen.', + }; + } + + const emptyProviders = flags + .filter((flag) => typeof flag === 'string' && flag.indexOf('ocr_provider_empty:') === 0) + .map((flag) => flag.split(':')[1]) + .filter(Boolean); + + if (emptyProviders.length) { + return { + tone: 'warn', + text: `Der OCR-Provider ${emptyProviders.join(', ')} hat fuer diesen Screenshot keinen verwertbaren Rohtext geliefert.`, + }; + } + + if (flags.includes('ocr_raw_text_empty')) { + return { + tone: 'warn', + text: 'Es wurde kein OCR-Rohtext erkannt. Bitte Screenshot pruefen oder optionalen OCR-Hinweistext angeben.', + }; + } + + return null; + } + + function StatCard(props) { + return h('div', { className: 'mc-stat-card' }, [ + h('div', { key: 'label', className: 'mc-kicker' }, props.label), + h('div', { key: 'value', className: 'mc-stat-value' }, props.value), + props.sub ? h('div', { key: 'sub', className: 'mc-text' }, props.sub) : null, + ]); + } + + function Badge(props) { + return h('span', { + className: cx( + 'mc-badge', + props.tone === 'warn' ? 'mc-badge--warn' : + props.tone === 'danger' ? 'mc-badge--danger' : + props.tone === 'success' ? 'mc-badge--success' : + 'mc-badge--info' + ) + }, props.children); + } + + function SectionTitle(props) { + return h('div', { className: 'mc-section-head' }, [ + h('div', { key: 'copy' }, [ + h('h2', { key: 'title', className: 'mc-section-title' }, props.title), + props.subtitle ? h('p', { key: 'subtitle', className: 'mc-text' }, props.subtitle) : null, + ]), + props.action || null, + ]); + } + + function SimpleChart(props) { + function pointTooltip(seriesLabel, point) { + const label = seriesLabel ? `${seriesLabel} · ` : ''; + return `${label}${String(point.x)}: ${fmtNumber(point.y, 6)}`; + } + + const series = Array.isArray(props.series) + ? props.series + .map((item, index) => ({ + key: item && item.key ? String(item.key) : `series-${index}`, + label: item && item.label ? String(item.label) : `Serie ${index + 1}`, + color: item && item.color ? String(item.color) : ['#60a5fa', '#2dd4bf', '#f59e0b', '#f472b6'][index % 4], + data: Array.isArray(item && item.data) + ? item.data.filter((point) => point && point.y !== null && point.y !== undefined) + : [], + })) + .filter((item) => item.data.length > 0) + : []; + const points = Array.isArray(props.data) ? props.data.filter((point) => point && point.y !== null && point.y !== undefined) : []; + const isMultiSeries = series.length > 0; + const activeSeries = isMultiSeries ? series : [{ + key: 'default', + label: props.yLabel || 'Wert', + color: props.type === 'area' ? '#2dd4bf' : '#60a5fa', + data: points, + }]; + const allPoints = activeSeries.flatMap((item) => item.data); + + if (!allPoints.length) { + return h('div', { className: 'mc-empty' }, 'Keine Daten fuer diese Ansicht.'); + } + + if (props.type === 'table') { + return h('div', { className: 'mc-table-shell' }, [ + h('table', { key: 'table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, [ + h('th', { key: 'x' }, props.xLabel || 'X'), + h('th', { key: 'y' }, props.yLabel || 'Y'), + ])), + h('tbody', { key: 'body' }, + points.map((point, index) => h('tr', { key: index }, [ + h('td', { key: 'x' }, String(point.x)), + h('td', { key: 'y' }, fmtNumber(point.y, 6)), + ])) + ), + ]), + ]); + } + + const width = 640; + const height = 220; + const padding = 24; + const values = allPoints.map((point) => Number(point.y)); + const minY = Math.min.apply(null, values); + const maxY = Math.max.apply(null, values); + const range = maxY - minY || 1; + const seriesCoords = activeSeries.map((item) => { + const stepX = item.data.length > 1 ? (width - padding * 2) / (item.data.length - 1) : 0; + const coords = item.data.map((point, index) => { + const x = padding + stepX * index; + const y = height - padding - ((Number(point.y) - minY) / range) * (height - padding * 2); + return [x, y]; + }); + + return { + ...item, + coords, + line: coords.map((coord) => coord.join(',')).join(' '), + area: coords.length + ? [[coords[0][0], height - padding]].concat(coords, [[coords[coords.length - 1][0], height - padding]]) + .map((coord) => coord.join(',')).join(' ') + : '', + }; + }); + + return h('div', { className: 'mc-chart space-y-3' }, [ + h('div', { key: 'meta', className: 'mc-flex-split mc-kicker' }, [ + h('span', { key: 'min' }, 'Min ' + fmtNumber(minY, 4)), + h('span', { key: 'max' }, 'Max ' + fmtNumber(maxY, 4)), + ]), + h('svg', { key: 'svg', viewBox: `0 0 ${width} ${height}`, className: 'overflow-visible' }, [ + h('g', { key: 'grid', stroke: 'rgba(255,255,255,0.08)' }, [ + h('line', { key: 'top', x1: padding, x2: width - padding, y1: padding, y2: padding }), + h('line', { key: 'mid', x1: padding, x2: width - padding, y1: height / 2, y2: height / 2 }), + h('line', { key: 'base', x1: padding, x2: width - padding, y1: height - padding, y2: height - padding }), + ]), + props.type === 'bar' && !isMultiSeries + ? h('g', { key: 'bars' }, seriesCoords[0].coords.map((coord, index) => { + const barWidth = Math.max(10, (((width - padding * 2) / Math.max(seriesCoords[0].coords.length - 1, 1)) * 0.6) || 24); + return h('rect', { + key: index, + x: coord[0] - barWidth / 2, + y: coord[1], + width: barWidth, + height: height - padding - coord[1], + rx: 8, + fill: 'rgba(59, 130, 246, 0.75)', + }, [ + h('title', { key: 'title' }, pointTooltip(seriesCoords[0].label, seriesCoords[0].data[index])), + ]); + })) + : h('g', { key: 'series' }, seriesCoords.flatMap((item, index) => { + const nodes = []; + if (props.type === 'area' && !isMultiSeries && item.area) { + nodes.push(h('polygon', { + key: `${item.key}-area`, + points: item.area, + fill: 'rgba(45, 212, 191, 0.18)', + })); + } + nodes.push(h('polyline', { + key: `${item.key}-line`, + points: item.line, + fill: 'none', + stroke: item.color, + strokeWidth: 3, + strokeLinecap: 'round', + strokeLinejoin: 'round', + })); + nodes.push(h('g', { key: `${item.key}-dots` }, item.coords.map((coord, pointIndex) => h('circle', { + key: `${item.key}-${pointIndex}`, + cx: coord[0], + cy: coord[1], + r: 4, + fill: '#f8fafc', + stroke: item.color, + strokeWidth: 2, + }, [ + h('title', { key: 'title' }, pointTooltip(item.label, item.data[pointIndex])), + ])))); + return nodes; + })), + ]), + isMultiSeries + ? h('div', { key: 'legend', className: 'mc-mini-grid' }, + seriesCoords.map((item) => { + const latestPoint = item.data[item.data.length - 1] || null; + return h('div', { key: item.key, className: 'mc-mini-card' }, [ + h('div', { key: 'label', className: 'mc-kicker', style: { color: item.color } }, item.label), + h('div', { key: 'value' }, latestPoint ? `${fmtNumber(latestPoint.y, 4)} · ${latestPoint.x}` : 'n/a'), + ]); + }) + ) + : h('div', { key: 'labels', className: 'mc-mini-grid' }, + points.slice(-3).map((point, index) => h('div', { key: index, className: 'mc-mini-card' }, `${point.x}: ${fmtNumber(point.y, 6)}`)) + ), + ]); + } + + function DashboardCard(props) { + return h('div', { className: 'mc-dashboard-card' }, [ + h('div', { key: 'head', className: 'mc-flex-split' }, [ + h('div', { key: 'titles' }, [ + h('h3', { key: 'name' }, props.definition.name), + h('p', { key: 'meta', className: 'mc-kicker' }, + `${props.definition.chart_type} · ${props.definition.x_field} → ${props.definition.y_field} · ${props.definition.aggregation}`), + ]), + h(Badge, { key: 'badge' }, props.definition.is_active ? 'aktiv' : 'inaktiv'), + ]), + props.loading + ? h('div', { key: 'loading', className: 'mc-empty' }, 'Lade Dashboarddaten …') + : h(SimpleChart, { + key: 'chart', + type: props.definition.chart_type, + data: props.data || [], + xLabel: props.definition.x_field, + yLabel: props.definition.y_field, + }), + ]); + } + + function miningCheckerViewFromHref(href) { + try { + const url = new URL(href, window.location.origin); + if (url.pathname !== '/module/mining-checker') { + return null; + } + return url.searchParams.get('view') || 'overview'; + } catch (err) { + return null; + } + } + + function syncMiningCheckerTabButtons(activeTab) { + const buttons = Array.from(document.querySelectorAll('.module-tabs a[href*="/module/mining-checker"]')); + for (const button of buttons) { + const tab = miningCheckerViewFromHref(button.getAttribute('href') || ''); + if (!tab) { + continue; + } + const isActive = tab === activeTab; + button.classList.toggle('module-button--tab-active', isActive); + button.classList.toggle('module-button--tab', !isActive); + } + } + + function App() { + const [projectKey, setProjectKey] = useState(initialProjectKey); + const [activeTab, setActiveTab] = useState(initialActiveTab); + const [payload, setPayload] = useState(() => normalizeBootstrap(null, initialProjectKey)); + const [dashboardData, setDashboardData] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const [schemaStatus, setSchemaStatus] = useState(normalizeSchemaStatus(null)); + const bootstrapCacheRef = useRef(new Map()); + const [initForm, setInitForm] = useState({ drop_existing: false }); + const [sqlImportFile, setSqlImportFile] = useState(null); + const [dbCheck, setDbCheck] = useState(null); + const [measurementForm, setMeasurementForm] = useState({ + measured_at: '', + coins_total: '', + price_per_coin: '', + price_currency: '', + note: '', + source: 'manual', + }); + const [importForm, setImportForm] = useState({ + rows_text: '', + default_currency: 'USD', + source: 'manual', + }); + const [importHelpOpen, setImportHelpOpen] = useState(false); + const [ocrForm, setOcrForm] = useState({ + image: null, + date_context: todayLocalDateValue(), + ocr_hint_text: '', + }); + const [ocrPreview, setOcrPreview] = useState(null); + const [dashboardForm, setDashboardForm] = useState({ + name: 'Neues Dashboard', + chart_type: 'line', + x_field: 'measured_at', + y_field: 'coins_total', + aggregation: 'none', + filters: { source: '', currency: '' }, + }); + const [settingsForm, setSettingsForm] = useState({ + baseline_measured_at: '', + baseline_coins_total: '', + report_currency: 'EUR', + crypto_currency: 'DOGE', + }); + const [moduleAuthForm, setModuleAuthForm] = useState({ + required: true, + users: '', + groups: '', + }); + const [fxHistory, setFxHistory] = useState([]); + const [fxSelection, setFxSelection] = useState(['DOGE', 'USD', 'EUR']); + const [reportCurrencyOverride, setReportCurrencyOverride] = useState(() => { + const value = String(getCookie('mining_checker_report_currency') || '').toUpperCase(); + return /^[A-Z0-9]{3,10}$/.test(value) ? value : ''; + }); + const [targetForm, setTargetForm] = useState({ + label: '', + target_amount_fiat: '', + currency: 'EUR', + miner_offer_id: '', + is_active: true, + sort_order: 0, + }); + const [targetModalOpen, setTargetModalOpen] = useState(false); + const [selectedMinerScenarioId, setSelectedMinerScenarioId] = useState(null); + const [minerOfferFilters, setMinerOfferFilters] = useState({ + speed_min: '', + speed_unit: 'auto', + price_max: '', + runtime_months: '', + }); + const [costPlanForm, setCostPlanForm] = useState({ + label: '', + starts_at: '', + runtime_months: 1, + mining_speed_value: '', + mining_speed_unit: 'MH/s', + bonus_percent: '', + auto_renew: true, + base_price_amount: '', + payment_type: 'fiat', + total_cost_amount: '', + currency: 'EUR', + note: '', + is_active: true, + }); + const [costPlanModalOpen, setCostPlanModalOpen] = useState(false); + const [payoutForm, setPayoutForm] = useState({ + payout_at: '', + coins_amount: '', + payout_currency: 'DOGE', + note: '', + }); + const [payoutModalOpen, setPayoutModalOpen] = useState(false); + const [minerOfferForm, setMinerOfferForm] = useState({ + label: '', + runtime_months: '', + bonus_percent: '', + base_price_amount: '', + base_price_currency: 'USD', + payment_type: 'fiat', + auto_renew: false, + note: '', + is_active: true, + }); + const [minerOfferModalOpen, setMinerOfferModalOpen] = useState(false); + const [purchaseMinerModalOpen, setPurchaseMinerModalOpen] = useState(false); + const [purchaseMinerForm, setPurchaseMinerForm] = useState({ + offer_id: '', + base_offer_id: '', + purchased_at: '', + label: '', + mining_speed_value: '', + mining_speed_unit: '', + bonus_percent: '', + total_cost_amount: '', + currency: '', + reference_price_amount: '', + reference_price_currency: '', + auto_renew: false, + note: '', + }); + + useEffect(() => { + debugBus.enabled = initialDebugMode; + emitDebug({ + type: 'debug:mode', + enabled: initialDebugMode, + }); + }, []); + + const measurements = Array.isArray(payload?.measurements) ? payload.measurements : []; + const latest = payload?.summary?.latest_measurement || null; + const currentSettings = payload?.settings || { + cost_plans: [], + currencies: [], + }; + const reportCurrency = reportCurrencyOverride || currentSettings.report_currency || 'EUR'; + const currentTargets = Array.isArray(payload?.summary?.targets) ? payload.summary.targets : []; + const currentDashboards = Array.isArray(payload?.dashboards) ? payload.dashboards : []; + const currencies = Array.isArray(currentSettings.currencies) && currentSettings.currencies.length + ? currentSettings.currencies + : [ + { code: 'EUR', name: 'Euro' }, + { code: 'USD', name: 'US-Dollar' }, + { code: 'DOGE', name: 'Dogecoin' }, + { code: 'BTC', name: 'Bitcoin' }, + { code: 'ETH', name: 'Ethereum' }, + { code: 'LTC', name: 'Litecoin' }, + { code: 'USDT', name: 'Tether' }, + { code: 'USDC', name: 'USD Coin' }, + ]; + const currentCostPlans = Array.isArray(currentSettings.cost_plans) ? currentSettings.cost_plans : []; + const currentPayouts = Array.isArray(currentSettings.payouts) ? currentSettings.payouts : []; + const currentWalletSnapshots = Array.isArray(payload?.wallet_snapshots) ? payload.wallet_snapshots : []; + const currentMinerOffers = Array.isArray(currentSettings.miner_offers) ? currentSettings.miner_offers : []; + const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : []; + const currentMiningCurrency = String((latest && latest.coin_currency) || currentSettings.crypto_currency || 'DOGE').toUpperCase(); + const latestWalletSnapshot = currentWalletSnapshots.length ? currentWalletSnapshots[0] : null; + const currentWalletMiningBalance = walletAssetBalance(latestWalletSnapshot, currentMiningCurrency); + const renewableOfferIds = new Set( + currentMinerOffers + .filter((offer) => !!offer.auto_renew) + .map((offer) => Number(offer.id)) + .filter((value) => Number.isFinite(value) && value > 0) + ); + const preferredCurrencyCodes = Array.isArray(currentSettings.preferred_currencies) + ? currentSettings.preferred_currencies.map((code) => String(code || '').toUpperCase()).filter(Boolean) + : []; + const preferredCurrencySet = new Set(preferredCurrencyCodes); + const preferredSelectableCurrencies = preferredCurrencySet.size + ? currencies.filter((currency) => preferredCurrencySet.has(String(currency.code || '').toUpperCase())) + : currencies; + const selectableCurrencies = preferredSelectableCurrencies.length ? preferredSelectableCurrencies : currencies; + const evaluatedMinerOffers = Array.isArray(payload?.summary?.miner_offers) ? payload.summary.miner_offers : []; + const availableMinerOffers = evaluatedMinerOffers.filter((offer) => isOfferAvailableForWallet(offer, currentMiningCurrency, currentWalletMiningBalance)); + const filteredMinerOffers = availableMinerOffers.filter((offer) => { + const speedMin = minerOfferFilters.speed_min === '' ? null : Number(minerOfferFilters.speed_min); + const speedUnit = String(minerOfferFilters.speed_unit || 'auto'); + const priceMax = minerOfferFilters.price_max === '' ? null : Number(minerOfferFilters.price_max); + const runtimeMonths = minerOfferFilters.runtime_months === '' ? null : Number(minerOfferFilters.runtime_months); + const offerHashrate = Number(offer.offer_hashrate_mh); + const comparablePrice = convertCurrencyValue( + offer.base_price_amount ?? offer.effective_price_amount, + offer.base_price_currency || offer.effective_price_currency, + reportCurrency + ); + const runtime = Number(offer.runtime_months); + const comparableHashrate = speedUnit === 'kh' + ? offerHashrate * 1000 + : offerHashrate; + + if (Number.isFinite(speedMin) && (!Number.isFinite(comparableHashrate) || comparableHashrate < speedMin)) { + return false; + } + + if (Number.isFinite(priceMax) && (!Number.isFinite(comparablePrice) || comparablePrice > priceMax)) { + return false; + } + + if (Number.isFinite(runtimeMonths) && runtime !== runtimeMonths) { + return false; + } + + return true; + }); + const speedUnits = ['kH/s', 'MH/s']; + const fiatCurrencies = currencies.filter((currency) => !currency.is_crypto); + const cryptoCurrencies = currencies.filter((currency) => !!currency.is_crypto); + const preferredSelectableFiatCurrencies = preferredCurrencySet.size + ? fiatCurrencies.filter((currency) => preferredCurrencySet.has(String(currency.code || '').toUpperCase())) + : fiatCurrencies; + const preferredSelectableCryptoCurrencies = preferredCurrencySet.size + ? cryptoCurrencies.filter((currency) => preferredCurrencySet.has(String(currency.code || '').toUpperCase())) + : cryptoCurrencies; + const selectableFiatCurrencies = preferredSelectableFiatCurrencies.length ? preferredSelectableFiatCurrencies : fiatCurrencies; + const selectableCryptoCurrencies = preferredSelectableCryptoCurrencies.length ? preferredSelectableCryptoCurrencies : cryptoCurrencies; + const selectedMinerScenario = availableMinerOffers.find((offer) => String(offer.id) === String(selectedMinerScenarioId)) || null; + const activeMinerRows = currentPurchasedMiners.map((miner) => ({ + id: `purchase-${miner.id}`, + source: 'miete', + starts_at: miner.purchased_at, + label: miner.label, + runtime_months: miner.runtime_months, + auto_renew: !!miner.auto_renew, + effective_amount: miner.total_cost_amount, + effective_currency: miner.currency, + base_amount: miner.reference_price_amount, + base_currency: miner.reference_price_currency, + miner_id: miner.id, + miner_offer_id: miner.miner_offer_id, + payment_type: miner.reference_price_currency + && miner.currency + && String(miner.reference_price_currency).toUpperCase() !== String(miner.currency).toUpperCase() + ? 'crypto' + : 'fiat', + is_active: miner.is_active !== false, + can_toggle_auto_renew: Number(miner.runtime_months) > 0 && renewableOfferIds.has(Number(miner.miner_offer_id)), + hashrate_text: formatHashrateWithBonus(miner.mining_speed_value, miner.mining_speed_unit, miner.bonus_speed_value, miner.bonus_speed_unit), + type_label: 'Aus Angebot gemietet', + })).concat(currentCostPlans.map((plan) => ({ + id: `plan-${plan.id}`, + source: 'manual', + starts_at: plan.starts_at, + label: plan.label, + runtime_months: plan.runtime_months, + auto_renew: !!plan.auto_renew, + effective_amount: plan.total_cost_amount, + effective_currency: plan.currency, + base_amount: plan.base_price_amount, + base_currency: currentSettings.report_currency || 'EUR', + payment_type: plan.payment_type, + is_active: !!plan.is_active, + can_toggle_auto_renew: false, + hashrate_text: formatHashrateWithBonus(plan.mining_speed_value, plan.mining_speed_unit, plan.bonus_speed_value, plan.bonus_speed_unit), + type_label: 'Manuell eingetragen', + }))).sort((left, right) => String(right.starts_at || '').localeCompare(String(left.starts_at || ''))); + + useEffect(() => { + if (reportCurrencyOverride) { + setCookie('mining_checker_report_currency', reportCurrencyOverride, 60 * 60 * 24 * 30); + } + }, [reportCurrencyOverride]); + + useEffect(() => { + if (selectedMinerScenarioId === null) { + return; + } + + const exists = availableMinerOffers.some((offer) => String(offer.id) === String(selectedMinerScenarioId)); + if (!exists) { + setSelectedMinerScenarioId(null); + } + }, [availableMinerOffers, selectedMinerScenarioId]); + + useEffect(() => { + if (!purchaseMinerModalOpen) { + return; + } + + const selectedOffer = availableMinerOffers.find((offer) => String(offer.id) === String(purchaseMinerForm.offer_id)) + || availableMinerOffers[0] + || null; + if (!selectedOffer) { + return; + } + + setPurchaseMinerForm((current) => ({ + ...current, + offer_id: current.offer_id || String(selectedOffer.id), + base_offer_id: current.base_offer_id || String(selectedOffer.base_offer_id || selectedOffer.id || ''), + label: current.label || String(selectedOffer.label || ''), + mining_speed_value: current.mining_speed_value || String(selectedOffer.mining_speed_value || ''), + mining_speed_unit: current.mining_speed_unit || String(selectedOffer.mining_speed_unit || ''), + bonus_percent: current.bonus_percent || (selectedOffer.bonus_percent !== null && selectedOffer.bonus_percent !== undefined ? String(selectedOffer.bonus_percent) : ''), + currency: current.currency || (!selectedOffer.auto_renew ? (currentSettings.crypto_currency || 'DOGE') : (selectedOffer.effective_price_currency || selectedOffer.base_price_currency || 'USD')), + total_cost_amount: current.total_cost_amount || (selectedOffer.effective_price_amount !== null && selectedOffer.effective_price_amount !== undefined ? String(selectedOffer.effective_price_amount) : ''), + reference_price_amount: current.reference_price_amount || (selectedOffer.reference_price_amount !== null && selectedOffer.reference_price_amount !== undefined ? String(selectedOffer.reference_price_amount) : ''), + reference_price_currency: current.reference_price_currency || selectedOffer.reference_price_currency || '', + auto_renew: current.auto_renew || !!selectedOffer.auto_renew, + })); + }, [purchaseMinerModalOpen, availableMinerOffers, purchaseMinerForm.offer_id, currentSettings.crypto_currency]); + + function measurementFxRate(measurementId, fromCurrency, toCurrency) { + const from = String(fromCurrency || '').toUpperCase(); + const to = String(toCurrency || '').toUpperCase(); + if (!from || !to) { + return null; + } + if (from === to) { + return 1; + } + + const measurement = measurements.find((row) => Number(row.id) === Number(measurementId)); + const fetchId = measurement && measurement.fx_fetch_id !== null && measurement.fx_fetch_id !== undefined + ? String(measurement.fx_fetch_id) + : ''; + const snapshots = payload && payload.fx_snapshots && typeof payload.fx_snapshots === 'object' + ? payload.fx_snapshots + : {}; + const snapshot = fetchId && snapshots[fetchId] && typeof snapshots[fetchId] === 'object' + ? snapshots[fetchId] + : null; + + if (snapshot) { + const baseCurrency = String(snapshot.base_currency || '').toUpperCase(); + const rates = snapshot.rates && typeof snapshot.rates === 'object' ? snapshot.rates : {}; + const directRate = from === baseCurrency ? rates[to] : null; + if (Number.isFinite(Number(directRate)) && Number(directRate) > 0) { + return Number(directRate); + } + + const inverseRate = to === baseCurrency ? rates[from] : null; + if (Number.isFinite(Number(inverseRate)) && Number(inverseRate) > 0) { + return 1 / Number(inverseRate); + } + + const fromRate = from === baseCurrency ? 1 : Number(rates[from]); + const toRate = to === baseCurrency ? 1 : Number(rates[to]); + if (Number.isFinite(fromRate) && Number.isFinite(toRate) && fromRate > 0 && toRate > 0) { + return toRate / fromRate; + } + } + + const priceQuotes = measurement && measurement.price_quotes && typeof measurement.price_quotes === 'object' + ? measurement.price_quotes + : null; + if (priceQuotes && from === 'DOGE') { + const directQuote = Number(priceQuotes[to]); + if (Number.isFinite(directQuote) && directQuote > 0) { + return directQuote; + } + } + if (priceQuotes && to === 'DOGE') { + const inverseQuote = Number(priceQuotes[from]); + if (Number.isFinite(inverseQuote) && inverseQuote > 0) { + return 1 / inverseQuote; + } + } + + return null; + } + + function convertMeasurementMoney(measurement, value, targetCurrency) { + if (!measurement || value === null || value === undefined) { + return null; + } + + const sourceCurrency = String(measurement.effective_price_currency || measurement.price_currency || '').toUpperCase(); + const target = String(targetCurrency || '').toUpperCase(); + const numericValue = Number(value); + if (!sourceCurrency || !target || !Number.isFinite(numericValue)) { + return null; + } + if (sourceCurrency === target) { + return numericValue; + } + + const rate = measurementFxRate(measurement.id, sourceCurrency, target) ?? latestFxHistoryRate(sourceCurrency, target); + return rate === null ? null : numericValue * rate; + } + + function convertCurrencyValue(value, sourceCurrency, targetCurrency) { + const from = String(sourceCurrency || '').toUpperCase(); + const to = String(targetCurrency || '').toUpperCase(); + const numericValue = Number(value); + if (!from || !to || !Number.isFinite(numericValue)) { + return null; + } + if (from === to) { + return numericValue; + } + + const rate = latest && latest.id + ? (measurementFxRate(latest.id, from, to) ?? latestFxHistoryRate(from, to)) + : latestFxHistoryRate(from, to); + return rate === null ? null : numericValue * rate; + } + + function walletAssetBalance(snapshot, currency) { + const code = String(currency || '').toUpperCase(); + if (!snapshot || !code) { + return null; + } + + const assets = snapshot.balances_json && typeof snapshot.balances_json === 'object' ? snapshot.balances_json : {}; + const asset = assets[code] ?? assets[code.toLowerCase()] ?? assets[code.toUpperCase()]; + const snapshotCurrency = String(snapshot.wallet_currency || '').toUpperCase(); + const rawBalance = asset && typeof asset === 'object' + ? asset.balance + : (asset ?? (snapshotCurrency === code ? snapshot.wallet_balance : null)); + const balance = Number(rawBalance); + return Number.isFinite(balance) ? balance : null; + } + + function isOfferAvailableForWallet(offer, miningCurrency, walletBalance) { + if (!offer || !offer.is_active) { + return false; + } + + if (String(offer.payment_type || '').toLowerCase() !== 'crypto') { + return true; + } + + const currency = String(offer.effective_price_currency || offer.price_currency || '').toUpperCase(); + const expectedCurrency = String(miningCurrency || '').toUpperCase(); + const price = Number(offer.effective_price_amount); + const balance = Number(walletBalance); + + if (!currency || !expectedCurrency || currency !== expectedCurrency || !Number.isFinite(price) || !Number.isFinite(balance)) { + return true; + } + + return price <= balance + 0.00000001; + } + + function latestFxHistoryRate(fromCurrency, toCurrency) { + const from = String(fromCurrency || '').toUpperCase(); + const to = String(toCurrency || '').toUpperCase(); + if (!from || !to) { + return null; + } + if (from === to) { + return 1; + } + + const rows = Array.isArray(fxHistory) ? fxHistory : []; + for (const row of rows) { + const rowBase = String(row.base_currency || '').toUpperCase(); + const rowTarget = String(row.target_currency || row.currency_code || '').toUpperCase(); + const rowRate = Number(row.rate); + if (!Number.isFinite(rowRate) || rowRate <= 0) { + continue; + } + + if (rowBase === from && rowTarget === to) { + return rowRate; + } + if (rowBase === to && rowTarget === from) { + return 1 / rowRate; + } + } + + return null; + } + + async function loadSchemaStatus(key) { + try { + const schema = await request(`${apiBase}/projects/${encodeURIComponent(key)}/schema-status`, { timeoutMs: 4000 }); + setSchemaStatus(normalizeSchemaStatus(schema)); + } catch (err) { + setSchemaStatus(normalizeSchemaStatus(null)); + } + } + + async function loadBootstrap(key) { + const cacheKey = `${key}:${activeTab || 'overview'}`; + const cachedPayload = bootstrapCacheRef.current.get(cacheKey) || null; + + setLoading(!cachedPayload); + setError(''); + if (cachedPayload) { + setPayload(cachedPayload); + } else { + setPayload((previous) => previous || normalizeBootstrap(null, key)); + } + let loadGuardTriggered = false; + const loadGuard = window.setTimeout(() => { + loadGuardTriggered = true; + setLoading(false); + setPayload((previous) => previous || cachedPayload || normalizeBootstrap(null, key)); + setError((previous) => previous || 'Bootstrap-Request haengt oder braucht zu lange.'); + }, 12000); + + try { + if (schemaStatus.missing_count > 0 || schemaStatus.pending_upgrade_count > 0) { + setPayload(normalizeBootstrap(null, key)); + setError('Mining-Checker Schema ist noch nicht initialisiert. Bitte im Tab Settings die Datenbank initialisieren.'); + return; + } + + const params = new URLSearchParams({ view: activeTab || 'overview' }); + const data = await request(`${apiBase}/projects/${encodeURIComponent(key)}/bootstrap?${params.toString()}`, { timeoutMs: 10000 }); + const normalized = normalizeBootstrap(data, key); + bootstrapCacheRef.current.set(cacheKey, normalized); + setPayload(normalized); + setSettingsForm({ + baseline_measured_at: normalized.settings.baseline_measured_at || '', + baseline_coins_total: normalized.settings.baseline_coins_total || '', + report_currency: normalized.settings.report_currency || 'EUR', + crypto_currency: normalized.settings.crypto_currency || 'DOGE', + }); + setFxSelection(Array.isArray(normalized.settings.preferred_currencies) && normalized.settings.preferred_currencies.length + ? normalized.settings.preferred_currencies + : ['DOGE', 'USD', 'EUR']); + setTargetForm((previous) => ({ + ...previous, + currency: normalized.settings.currencies?.[0]?.code || previous.currency || 'EUR', + })); + setCostPlanForm((previous) => ({ + ...previous, + currency: normalized.settings.currencies?.[0]?.code || previous.currency || 'EUR', + })); + } catch (err) { + setError(err.message); + setPayload(normalizeBootstrap(null, key)); + } finally { + window.clearTimeout(loadGuard); + if (!loadGuardTriggered) { + setLoading(false); + } + } + } + + useEffect(() => { + loadBootstrap(projectKey); + }, [projectKey, activeTab]); + + useEffect(() => { + if (activeTab !== 'mining') { + return; + } + loadFxHistory(projectKey); + }, [activeTab, projectKey]); + + useEffect(() => { + syncMiningCheckerTabButtons(activeTab); + + function handleNavigationClick(event) { + const link = event.target instanceof Element + ? event.target.closest('.module-tabs a[href*="/module/mining-checker"]') + : null; + if (!link) { + return; + } + + const nextTab = miningCheckerViewFromHref(link.getAttribute('href') || ''); + if (!nextTab || nextTab === activeTab) { + return; + } + + event.preventDefault(); + window.history.pushState({ miningCheckerView: nextTab }, '', `/module/mining-checker?view=${encodeURIComponent(nextTab)}`); + setActiveTab(nextTab); + } + + function handlePopState() { + const nextTab = miningCheckerViewFromHref(window.location.href) || 'overview'; + setActiveTab(nextTab); + } + + document.addEventListener('click', handleNavigationClick); + window.addEventListener('popstate', handlePopState); + return () => { + document.removeEventListener('click', handleNavigationClick); + window.removeEventListener('popstate', handlePopState); + }; + }, [activeTab]); + + useEffect(() => { + loadSchemaStatus(projectKey); + loadModuleAuth(); + }, [projectKey]); + + useEffect(() => { + async function loadSavedDashboards() { + if (!payload || !currentDashboards.length) { + return; + } + + const next = {}; + for (const definition of currentDashboards) { + const params = new URLSearchParams({ + x_field: definition.x_field, + y_field: definition.y_field, + aggregation: definition.aggregation || 'none', + }); + if (definition.filters && definition.filters.source) { + params.set('source', definition.filters.source); + } + if (definition.filters && definition.filters.currency) { + params.set('currency', definition.filters.currency); + } + + try { + next[definition.id] = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/dashboard-data?${params.toString()}`); + } catch (err) { + next[definition.id] = []; + } + } + setDashboardData(next); + } + + loadSavedDashboards(); + }, [payload, projectKey]); + + const overviewCharts = useMemo(() => { + const overviewWindowDays = Number(payload?.bootstrap_meta?.overview_window_days || 15); + const overviewRows = recentMeasurementWindow(measurements, overviewWindowDays); + const chartCoinCurrency = String(currentSettings.crypto_currency || 'DOGE').toUpperCase(); + const comparisonRows = overviewRows.filter((row) => { + const miningRate = Number(row.doge_per_hour_per_mh_interval); + const price = Number(row.effective_price_per_coin ?? row.price_per_coin); + return Number.isFinite(miningRate) && miningRate > 0 && Number.isFinite(price) && price > 0; + }); + const baseComparison = comparisonRows[0] || null; + const baseMining = baseComparison ? Number(baseComparison.doge_per_hour_per_mh_interval) : null; + const basePrice = baseComparison ? Number(baseComparison.effective_price_per_coin ?? baseComparison.price_per_coin) : null; + + return { + mining: overviewRows.map((row) => ({ x: fmtDate(row.measured_at), y: row.coins_total })), + performance: overviewRows.filter((row) => row.doge_per_day_interval !== null) + .map((row) => ({ x: fmtDate(row.measured_at), y: row.doge_per_day_interval })), + pricing: overviewRows.filter((row) => row.price_per_coin !== null) + .map((row) => ({ x: fmtDate(row.measured_at), y: row.price_per_coin })), + miningVsPrice: baseMining && basePrice ? [ + { + key: 'mining-rate', + label: `${chartCoinCurrency}/h je MH/s Index`, + color: '#2dd4bf', + data: comparisonRows.map((row) => ({ + x: fmtDate(row.measured_at), + y: (Number(row.doge_per_hour_per_mh_interval) / baseMining) * 100, + })), + }, + { + key: 'doge-price', + label: `${chartCoinCurrency}-Kurs Index`, + color: '#f59e0b', + data: comparisonRows.map((row) => ({ + x: fmtDate(row.measured_at), + y: (Number(row.effective_price_per_coin ?? row.price_per_coin) / basePrice) * 100, + })), + }, + ] : [], + }; + }, [currentSettings.crypto_currency, measurements, payload?.bootstrap_meta?.overview_window_days]); + + async function submitMeasurement(fromPreview) { + const preview = normalizeOcrPreview(ocrPreview); + const raw = fromPreview ? { + ...preview.suggested, + image_path: preview.image_path, + ocr_raw_text: preview.raw_text, + ocr_confidence: preview.confidence, + ocr_flags: preview.flags, + } : measurementForm; + + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/measurements`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(raw), + }); + setMessage(fromPreview ? 'OCR-Vorschlag bestaetigt und gespeichert.' : 'Messpunkt gespeichert.'); + setMeasurementForm({ + measured_at: '', + coins_total: '', + price_per_coin: '', + price_currency: '', + note: '', + source: 'manual', + }); + setOcrPreview(null); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitWalletSnapshotFromPreview() { + const preview = normalizeOcrPreview(ocrPreview); + const raw = { + ...preview.suggested_wallet, + image_path: preview.image_path, + ocr_raw_text: preview.raw_text, + ocr_confidence: preview.confidence, + ocr_flags: preview.flags, + }; + + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/wallet-snapshots`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(raw), + }); + setMessage('Wallet-Snapshot gespeichert.'); + setOcrPreview(null); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function deleteMeasurement(id) { + if (!id) { + return; + } + + if (!window.confirm('Diesen Messpunkt wirklich loeschen?')) { + return; + } + + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/measurements/${encodeURIComponent(id)}`, { + method: 'DELETE', + }); + setMessage('Messpunkt geloescht.'); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitMeasurementImport(event) { + event.preventDefault(); + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/measurements-import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(importForm), + timeoutMs: 20000, + }); + + const summary = [ + `${result.imported || 0} importiert`, + `${result.duplicates_ignored || 0} Duplikate ignoriert`, + `${result.error_count || 0} Fehler`, + ].join(', '); + + setMessage(`Import abgeschlossen: ${summary}.`); + if (!result.error_count) { + setImportForm((previous) => ({ ...previous, rows_text: '' })); + } + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function loadOcrPreview(file, overrides) { + const nextForm = { + ...ocrForm, + ...(overrides || {}), + image: file || null, + }; + + if (!nextForm.image) { + setOcrPreview(null); + setError('Bitte ein Bild auswaehlen.'); + return; + } + + setOcrForm(nextForm); + setSaving(true); + setError(''); + setMessage(''); + try { + const body = new FormData(); + body.append('image', nextForm.image); + body.append('date_context', nextForm.date_context); + body.append('ocr_hint_text', nextForm.ocr_hint_text); + body.append('wallet_currency_hint', currentSettings.crypto_currency || 'DOGE'); + const data = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/ocr-preview`, { + method: 'POST', + body, + timeoutMs: 45000, + }); + setOcrPreview(normalizeOcrPreview(data)); + setMessage('OCR-Ergebnis geladen. Bei Bedarf direkt speichern.'); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitDashboard(event) { + event.preventDefault(); + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/dashboards`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...dashboardForm, + is_active: true, + filters: dashboardForm.filters, + }), + }); + setMessage('Dashboard gespeichert.'); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitSettings(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + baseline_measured_at: settingsForm.baseline_measured_at, + baseline_coins_total: settingsForm.baseline_coins_total, + daily_cost_amount: currentSettings.daily_cost_amount, + daily_cost_currency: currentSettings.daily_cost_currency, + report_currency: settingsForm.report_currency || 'EUR', + crypto_currency: settingsForm.crypto_currency || 'DOGE', + preferred_currencies: Array.isArray(currentSettings.preferred_currencies) + ? currentSettings.preferred_currencies + : fxSelection, + }), + }); + setMessage('Settings gespeichert.'); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function loadModuleAuth() { + try { + const auth = await request('/api/module-auth/mining-checker', { timeoutMs: 5000 }); + setModuleAuthForm({ + required: !!auth.required, + users: Array.isArray(auth.users) ? auth.users.join(', ') : '', + groups: Array.isArray(auth.groups) ? auth.groups.join(', ') : '', + }); + } catch (err) { + setError(err.message); + } + } + + async function submitModuleAuth(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request('/api/module-auth/mining-checker', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + required: !!moduleAuthForm.required, + users: moduleAuthForm.users, + groups: moduleAuthForm.groups, + }), + }); + setMessage('Modulrechte gespeichert.'); + await loadModuleAuth(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitTarget(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/targets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(targetForm), + }); + setMessage('Ziel gespeichert.'); + setTargetForm({ label: '', target_amount_fiat: '', currency: currencies[0]?.code || 'EUR', miner_offer_id: '', is_active: true, sort_order: 0 }); + setTargetModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function deleteTarget(target) { + const label = target?.label || 'dieses Ziel'; + if (!window.confirm(`Soll ${label} wirklich geloescht werden?`)) { + return; + } + + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/targets/${encodeURIComponent(target.id)}`, { + method: 'DELETE', + }); + setMessage('Ziel geloescht.'); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function togglePurchasedMinerAutoRenew(row) { + if (!row || !row.can_toggle_auto_renew || !row.miner_id) { + return; + } + + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/purchased-miners/${encodeURIComponent(row.miner_id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ auto_renew: !row.auto_renew }), + }); + setMessage(`Automatische Verlängerung ${row.auto_renew ? 'deaktiviert' : 'aktiviert'}.`); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitCostPlan(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/cost-plans`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(costPlanForm), + }); + setMessage('Miner gespeichert.'); + setCostPlanForm({ + label: '', + starts_at: '', + runtime_months: 1, + mining_speed_value: '', + mining_speed_unit: 'MH/s', + bonus_percent: '', + auto_renew: true, + base_price_amount: '', + payment_type: 'fiat', + total_cost_amount: '', + currency: currencies[0]?.code || 'EUR', + note: '', + is_active: true, + }); + setCostPlanModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitPayout(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/payouts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payoutForm), + }); + setMessage('Auszahlung gespeichert.'); + setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: currentSettings.crypto_currency || 'DOGE', note: '' }); + setPayoutModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitMinerOffer(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + const offerPayload = { + ...minerOfferForm, + base_price_currency: minerOfferForm.payment_type === 'crypto' ? 'USD' : minerOfferForm.base_price_currency, + }; + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/miner-offers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(offerPayload), + }); + setMessage('Miner-Angebot gespeichert.'); + setMinerOfferForm({ + label: '', + runtime_months: '', + bonus_percent: '', + base_price_amount: '', + base_price_currency: 'USD', + payment_type: 'fiat', + auto_renew: false, + note: '', + is_active: true, + }); + setMinerOfferModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function purchaseMinerOffer(offerId, overrides) { + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/miner-offers/${offerId}/purchase`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(overrides || { purchased_at: nowDateTimeLocalValue() }), + }); + setMessage('Miner als gemietet erfasst.'); + setPurchaseMinerForm({ + offer_id: '', + base_offer_id: '', + purchased_at: '', + label: '', + mining_speed_value: '', + mining_speed_unit: '', + bonus_percent: '', + total_cost_amount: '', + currency: '', + reference_price_amount: '', + reference_price_currency: '', + auto_renew: false, + note: '', + }); + setPurchaseMinerModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitPurchaseMiner(event) { + event.preventDefault(); + if (!purchaseMinerForm.offer_id) { + setError('Bitte ein Miner-Angebot auswaehlen.'); + return; + } + + const selectedOffer = availableMinerOffers.find((offer) => String(offer.id) === String(purchaseMinerForm.offer_id)); + if (!selectedOffer) { + setError('Bitte ein gueltiges Miner-Angebot auswaehlen.'); + return; + } + + await purchaseMinerOffer(String(selectedOffer.base_offer_id || selectedOffer.id), { + label: purchaseMinerForm.label || null, + mining_speed_value: purchaseMinerForm.mining_speed_value || null, + mining_speed_unit: purchaseMinerForm.mining_speed_unit || null, + bonus_percent: purchaseMinerForm.bonus_percent || null, + purchased_at: purchaseMinerForm.purchased_at || nowDateTimeLocalValue(), + total_cost_amount: purchaseMinerForm.total_cost_amount || null, + currency: purchaseMinerForm.currency || null, + reference_price_amount: purchaseMinerForm.reference_price_amount || null, + reference_price_currency: purchaseMinerForm.reference_price_currency || null, + auto_renew: !!purchaseMinerForm.auto_renew, + note: purchaseMinerForm.note || '', + }); + } + + async function initializeModule(event) { + event.preventDefault(); + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/initialize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(initForm), + }); + const nextStatus = normalizeSchemaStatus(result.after); + setSchemaStatus(nextStatus); + setMessage( + `${result.message} Vorhanden: ${nextStatus.present_count}/${nextStatus.required_tables.length}. ` + + (Array.isArray(result.dropped_tables) && result.dropped_tables.length + ? `Geloeschte Tabellen: ${result.dropped_tables.join(', ')}.` + : 'Keine Tabellen geloescht.') + ); + try { + await loadBootstrap(projectKey); + } catch (bootstrapError) { + setError(`Schema wurde initialisiert, aber Bootstrap-Daten konnten nicht geladen werden: ${bootstrapError.message}`); + } + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function upgradeDatabaseSchema() { + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/upgrade`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const nextStatus = normalizeSchemaStatus(result.after); + setSchemaStatus(nextStatus); + setMessage( + `${result.message} ` + + (Array.isArray(result.upgraded) && result.upgraded.length + ? `Angewendete Upgrades: ${result.upgraded.join(', ')}.` + : 'Keine Upgrades erforderlich.') + ); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function importOldData() { + if (!window.confirm('Alte Mining-Checker Daten ueber alle Tabellen sichern, Schema neu aufbauen und danach importieren?')) { + return; + } + + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/rebuild-preserve-core`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const restored = result.restored && typeof result.restored === 'object' ? result.restored : {}; + const restoredParts = Object.keys(restored) + .filter((key) => Number(restored[key]) > 0) + .map((key) => `${key}: ${restored[key]}`); + setMessage( + `${result.message || 'Alte Daten wurden importiert.'} ` + + (restoredParts.length ? `Importiert: ${restoredParts.join(', ')}.` : 'Keine Altdaten gefunden.') + ); + await loadSchemaStatus(projectKey); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function migrateLegacyFxData() { + if (!window.confirm('Legacy-FX-Rates aus dem Mining-Checker nach fx-rates migrieren und Messpunkte auf die neuen fetch_id-Verweise aktualisieren?')) { + return; + } + + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/legacy-fx-migrate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + timeoutMs: 30000, + }); + setMessage( + `${result.message || 'Legacy-FX-Rates wurden migriert.'} ` + + `Fetches gefunden: ${Number(result.legacy_fetches_found || 0)}, ` + + `neu importiert: ${Number(result.fx_fetches_imported || 0)}, ` + + `wiederverwendet: ${Number(result.fx_fetches_reused || 0)}, ` + + `Messpunkte aktualisiert: ${Number(result.measurements_updated || 0)}, ` + + `offen: ${Number(result.measurements_unresolved || 0)}.` + ); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function importSqlFile() { + if (!sqlImportFile) { + setError('Bitte zuerst eine SQL-Datei auswaehlen.'); + setMessage(''); + return; + } + + setSaving(true); + setError(''); + setMessage(''); + try { + const body = new FormData(); + body.append('sql_file', sqlImportFile); + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/sql-import`, { + method: 'POST', + body, + timeoutMs: 30000, + }); + setSqlImportFile(null); + setMessage( + `${result.message || 'SQL-Datei wurde importiert.'} ` + + `${result.statement_count || 0} Statements aus ${result.file || 'der Datei'} ausgefuehrt.` + ); + await loadSchemaStatus(projectKey); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function testDatabaseConnection() { + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/connection-test`); + setDbCheck(result); + setMessage(`DB-Verbindung erfolgreich. Driver: ${result.driver}, Datenbank: ${result.database}.`); + } catch (err) { + setDbCheck(null); + setError(err.message); + } finally { + setSaving(false); + } + } + + async function loadFxHistory(key) { + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(key)}/fx-history`, { timeoutMs: 6000 }); + setFxHistory(Array.isArray(result) ? result : []); + } catch (err) { + setFxHistory([]); + } + } + + function resetReportCurrencyOverride() { + setReportCurrencyOverride(''); + setCookie('mining_checker_report_currency', '', 0); + } + + function renderSharedOcrPanel() { + const preview = normalizeOcrPreview(ocrPreview); + const isWalletPreview = preview.kind === 'wallet'; + const hasMiningSuggestion = preview.suggested.coins_total !== '' && preview.suggested.coins_total !== null; + const hasWalletSuggestion = preview.suggested_wallet.wallet_balance !== '' && preview.suggested_wallet.wallet_balance !== null; + const hasUsableOcrSuggestion = isWalletPreview + ? hasWalletSuggestion + : hasMiningSuggestion; + const ocrStatus = getOcrStatusMessage(preview); + + return panel('OCR Upload', 'Screenshot auswaehlen, Ergebnis direkt pruefen und speichern.', [ + h('div', { + key: 'ocr-form', + className: 'mc-form', + }, [ + fileField('Screenshot', (file) => loadOcrPreview(file, { image: file })), + ]), + saving && ocrForm.image + ? h('div', { key: 'ocr-loading', className: 'mc-empty' }, 'Analysiere Screenshot …') + : null, + ocrPreview + ? h('div', { key: 'ocr-preview', className: 'mc-form' }, [ + h('div', { key: 'badges', className: 'mc-inline-row' }, [ + h(Badge, { key: 'kind', tone: isWalletPreview ? 'info' : 'success' }, isWalletPreview ? 'Wallet' : 'Mining'), + h(Badge, { key: 'confidence', tone: preview.confidence >= 0.75 ? 'success' : 'warn' }, `confidence ${fmtNumber(preview.confidence, 4)}`), + ]), + isWalletPreview + ? h('div', { key: 'wallet-form', className: 'mc-two-col' }, [ + displayField('Erkannter Typ', 'Wallet-Snapshot'), + displayField('Mining-Waehrung im Wallet', `${fmtNumber(preview.suggested_wallet.wallet_balance, 8)} ${preview.suggested_wallet.wallet_currency || currentSettings.crypto_currency || 'DOGE'}`), + displayField('Gesamtwert', preview.suggested_wallet.total_value_amount !== '' && preview.suggested_wallet.total_value_amount !== null + ? `${fmtNumber(preview.suggested_wallet.total_value_amount, 4)} ${preview.suggested_wallet.total_value_currency || ''}`.trim() + : 'n/a'), + displayField('Erkannte Wallet-Assets', Object.entries(preview.suggested_wallet.balances_json || {}) + .slice(0, 8) + .map(([code, asset]) => { + const balance = asset && typeof asset === 'object' ? asset.balance : asset; + const priceAmount = asset && typeof asset === 'object' ? asset.price_amount : null; + const priceCurrency = asset && typeof asset === 'object' ? asset.price_currency : null; + return `${fmtNumber(balance, 8)} ${code}${priceAmount ? ` @ ${fmtNumber(priceAmount, 6)} ${priceCurrency || ''}`.trim() : ''}`; + }) + .join(' · ') || 'n/a'), + ]) + : h('div', { key: 'measurement-form', className: 'mc-two-col' }, [ + displayField('Erkannter Typ', 'Mining-Messpunkt'), + displayField('Datum/Zeit', 'Wird beim Speichern auf den aktuellen Bestätigungszeitpunkt gesetzt.'), + displayField('Coins total', fmtNumber(preview.suggested.coins_total, 6)), + displayField('Kurs', fmtNumber(preview.suggested.price_per_coin, 6)), + displayField('Waehrung', preview.suggested.price_currency || 'n/a'), + ]), + ocrStatus + ? h('div', { + key: 'ocr-status', + className: cx( + 'mc-alert', + ocrStatus.tone === 'error' ? 'mc-alert--error' : 'mc-alert--warning' + ), + }, ocrStatus.text) + : null, + !hasUsableOcrSuggestion + ? h('div', { key: 'ocr-warning', className: 'mc-alert mc-alert--error' }, + 'Kein verwertbarer OCR-Vorschlag erkannt. Bitte Bild erneut hochladen oder die Daten manuell erfassen.') + : null, + h('button', { + key: 'confirm', + type: 'button', + className: 'mc-button mc-button--primary', + onClick: () => isWalletPreview ? submitWalletSnapshotFromPreview() : submitMeasurement(true), + disabled: saving || !hasUsableOcrSuggestion, + }, saving ? 'Speichert …' : 'Ergebnis speichern'), + isWalletPreview && hasMiningSuggestion + ? h('button', { + key: 'force-mining', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => submitMeasurement(true), + disabled: saving, + }, 'Als Mining speichern') + : null, + !isWalletPreview && hasWalletSuggestion + ? h('button', { + key: 'force-wallet', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => submitWalletSnapshotFromPreview(), + disabled: saving, + }, 'Als Wallet speichern') + : null, + ]) + : h('div', { key: 'ocr-empty', className: 'mc-empty' }, + 'Noch kein Screenshot ausgewaehlt.'), + ]); + } + return h('div', { + className: 'mc-grid-bg', + }, [ + h('div', { key: 'shell', className: 'mc-shell mc-stack' }, [ + error ? h('div', { key: 'error', className: 'mc-alert mc-alert--error' }, error) : null, + message ? h('div', { key: 'message', className: 'mc-alert mc-alert--success' }, message) : null, + loading ? h('div', { key: 'loading', className: 'mc-alert mc-alert--warning' }, 'Mining-Checker Daten werden aktualisiert …') : null, + renderTab(), + ]), + ]); + + function renderTab() { + const currentCoinCurrency = currentMiningCurrency; + const perDayLabel = `${currentCoinCurrency} pro Tag`; + + if (activeTab === 'overview') { + const latestValue = latest ? convertMeasurementMoney(latest, latest.current_value, reportCurrency) : null; + const latestPriceSource = latest && latest.effective_price_per_coin !== null && latest.effective_price_per_coin !== undefined + ? latest.effective_price_per_coin + : (latest ? latest.price_per_coin : null); + const latestPrice = latest && latestPriceSource !== null && latestPriceSource !== undefined + ? convertMeasurementMoney(latest, latestPriceSource, reportCurrency) + : null; + const dailyRevenue = latest ? convertMeasurementMoney(latest, latest.theoretical_daily_revenue, reportCurrency) : null; + const dailyProfit = latest ? convertMeasurementMoney(latest, latest.theoretical_daily_profit, reportCurrency) : null; + const dailyCost = latest ? convertMeasurementMoney(latest, latest.effective_daily_cost, reportCurrency) : null; + const breakEvenPrice = latest && latest.break_even_price_per_coin !== null && latest.break_even_price_per_coin !== undefined + ? convertMeasurementMoney(latest, latest.break_even_price_per_coin, reportCurrency) + : null; + const breakEvenRemainingAmount = latest ? convertMeasurementMoney(latest, latest.break_even_remaining_amount, reportCurrency) : null; + const breakEvenDaysOverall = latest && latest.break_even_days_overall !== null && latest.break_even_days_overall !== undefined + ? Number(latest.break_even_days_overall) + : null; + const investedCapital = latest ? convertMeasurementMoney(latest, latest.cash_invested_capital ?? latest.invested_capital, reportCurrency) : null; + const reinvestedCapital = latest ? convertMeasurementMoney(latest, latest.reinvested_capital, reportCurrency) : null; + const walletValue = latest ? convertMeasurementMoney(latest, latest.wallet_value, reportCurrency) : null; + const totalHoldingsValue = latest ? convertMeasurementMoney(latest, latest.total_holdings_value, reportCurrency) : null; + const breakEvenReached = breakEvenRemainingAmount !== null && breakEvenRemainingAmount <= 0; + const breakEvenEta = latest && latest.break_even_eta_at ? fmtDate(latest.break_even_eta_at) : null; + const walletBalanceCurrentAsset = payload?.summary?.payouts?.wallet_balance_current_asset; + const holdingsCurrentAsset = payload?.summary?.payouts?.holdings_current_asset; + + return h('div', { className: 'mc-stack' }, [ + panel('Berichtswährung', 'Bestimmt die Währung für Kennzahlen im Überblick. Standard kommt aus den Settings, diese Auswahl gilt nur für den aktuellen Besuch.', [ + h('div', { className: 'mc-inline-fields' }, [ + selectField( + 'Aktueller Besuch', + reportCurrency, + selectableCurrencies.map((currency) => currency.code), + (value) => setReportCurrencyOverride(String(value || '').toUpperCase()) + ), + h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: resetReportCurrencyOverride, + disabled: !reportCurrencyOverride, + }, 'Standard verwenden'), + ]), + ]), + h('div', { key: 'stats', className: 'mc-stats-grid' }, [ + h(StatCard, { + key: 'coins', + label: `${currentCoinCurrency} im Miner`, + value: latest ? fmtNumber(latest.coins_total_visible || latest.coins_total, 6) : 'n/a', + sub: latest ? `Stand ${fmtDate(latest.measured_at)}` : '', + }), + h(StatCard, { + key: 'holdings', + label: `Theoretischer Bestand ${currentCoinCurrency}`, + value: payload?.summary?.payouts ? fmtNumber(holdingsCurrentAsset, 6) : 'n/a', + sub: payload?.summary?.payouts + ? `Wallet ${fmtNumber(walletBalanceCurrentAsset, 6)} ${currentCoinCurrency} · Miner ${fmtNumber(latest?.coins_total_visible || latest?.coins_total, 6)} ${currentCoinCurrency}` + : '', + }), + h(StatCard, { + key: 'perday', + label: perDayLabel, + value: latest ? fmtNumber(latest.doge_per_day_interval, 4) : 'n/a', + sub: payload?.summary?.current_hashrate_mh ? `Hashrate ${fmtNumber(payload.summary.current_hashrate_mh, 4)} MH/s` : (latest ? `Trend ${latest.trend_label}` : ''), + }), + h(StatCard, { + key: 'value', + label: 'Aktueller Gegenwert', + value: latestValue !== null ? fmtMoney(latestValue, reportCurrency) : 'n/a', + sub: latestPrice !== null + ? `Kurs ${fmtNumber(latestPrice, 6)} ${reportCurrency}${latest && latest.price_is_fallback ? ' · Fallback aus letztem Kurs' : ''}` + : 'Kein umrechenbarer Kurs am letzten Punkt', + }), + h(StatCard, { + key: 'profit', + label: 'Theoretischer Tagesgewinn', + value: dailyProfit !== null ? fmtMoney(dailyProfit, reportCurrency) : 'n/a', + sub: dailyCost !== null + ? `Tageskosten ${fmtMoney(dailyCost, reportCurrency)} · Walletwert ${walletValue !== null ? fmtMoney(walletValue, reportCurrency) : 'n/a'}` + : 'Kein aktiver Miner fuer diese Waehrung', + }), + h(StatCard, { + key: 'break-even-point', + label: 'Cash-Break-even', + value: breakEvenReached + ? 'Erreicht' + : breakEvenDaysOverall !== null + ? `${fmtNumber(breakEvenDaysOverall, 2)} Tage` + : (investedCapital === null ? 'Keine Mietbasis' : 'Nicht erreichbar'), + sub: investedCapital !== null + ? `${breakEvenEta ? `ETA ${breakEvenEta} · ` : ''}Cash ${fmtMoney(investedCapital, reportCurrency)}${reinvestedCapital !== null ? ` · Reinvest ${fmtMoney(reinvestedCapital, reportCurrency)}` : ''}${totalHoldingsValue !== null ? ` · Bestand ${fmtMoney(totalHoldingsValue, reportCurrency)}` : ''}` + : (breakEvenPrice !== null + ? `Break-even-Kurs ${fmtNumber(breakEvenPrice, 6)} ${reportCurrency}` + : (investedCapital === null + ? 'Noch keine Miner als Mietbasis hinterlegt' + : 'Keine belastbare Break-even-Basis')), + }), + ]), + h('div', { key: 'charts', className: 'mc-overview-grid' }, [ + panel('Mining-Verlauf', 'Coins total der letzten 15 Tage.', h(SimpleChart, { type: 'line', data: overviewCharts.mining })), + panel('Performance-Verlauf', `${perDayLabel}-Raten der letzten 15 Tage.`, h(SimpleChart, { type: 'area', data: overviewCharts.performance })), + panel('Kurs-Verlauf', 'Preiswerte der letzten 15 Tage.', h(SimpleChart, { type: 'line', data: overviewCharts.pricing })), + panel('Mining vs. Kurs', `Index-Vergleich der letzten 15 Tage. Mining wird auf ${currentCoinCurrency} pro Stunde je MH/s normalisiert, beide Reihen starten bei 100.`, h(SimpleChart, { + type: 'line', + series: overviewCharts.miningVsPrice, + })), + ]), + panel('Zielmonitor', `Rest-${currentCoinCurrency} und Resttage werden gegen den letzten verfuegbaren Kurs je Zielwaehrung berechnet.`, + h('div', { className: 'mc-target-grid' }, + currentTargets.map((target, index) => h('div', { + key: index, + className: 'mc-target-card' + }, [ + h('div', { key: 'head', className: 'mc-flex-split' }, [ + h('h3', { key: 'title' }, target.label), + h(Badge, { key: 'status', tone: target.status === 'reached' ? 'success' : 'info' }, target.status), + ]), + h('div', { key: 'body', className: 'mc-text mc-target-grid' }, [ + h('div', { key: 'amount' }, `Ziel: ${fmtMoney(target.target_amount_fiat, target.currency)}`), + h('div', { key: 'price' }, `Letzter Kurs: ${target.latest_price_for_currency ? fmtNumber(target.latest_price_for_currency, 6) + ' ' + target.currency : 'n/a'}`), + h('div', { key: 'doge' }, `Benoetigte ${currentCoinCurrency}: ${fmtNumber(target.required_doge, 6)}`), + h('div', { key: 'remaining' }, `Rest-${currentCoinCurrency}: ${fmtNumber(target.remaining_doge, 6)}`), + h('div', { key: 'days' }, `Resttage: ${fmtNumber(target.remaining_days, 4)}`), + ]), + ])) + ) + ), + ]); + } + + if (activeTab === 'measurements') { + return h('div', { className: 'mc-stack' }, [ + panel('Mining-History', 'Die letzten 10 Mining-Uploads inkl. Performance-Werten und OCR-Metadaten.', h('div', { className: 'mc-table-shell' }, [ + h('table', { key: 'table', className: 'mc-table' }, [ + h('thead', { key: 'thead' }, h('tr', null, [ + 'Zeit', 'Coins', 'Kurs', 'Quelle', perDayLabel, 'Trend', 'Notiz', 'Aktion' + ].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'tbody' }, + measurements.slice(-10).reverse().map((row) => h('tr', { key: row.id }, [ + h('td', { key: 'measured' }, fmtDate(row.measured_at)), + h('td', { key: 'coins' }, `${fmtNumber(row.coins_total, 6)} ${row.coin_currency || currentCoinCurrency}`), + h('td', { key: 'price' }, row.price_per_coin ? `${fmtNumber(row.price_per_coin, 6)} ${row.price_currency}` : 'n/a'), + h('td', { key: 'source' }, row.source), + h('td', { key: 'rate' }, fmtNumber(row.doge_per_day_interval, 4)), + h('td', { key: 'trend' }, row.trend_label), + h('td', { key: 'note' }, row.note || row.ocr_flags.join(', ') || '—'), + h('td', { key: 'action' }, h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => deleteMeasurement(row.id), + disabled: saving, + }, 'Loeschen')), + ])) + ), + ]), + ])), + ]); + } + + if (activeTab === 'upload') { + return h('div', { className: 'mc-stack' }, [ + h('div', { className: 'mc-two-col' }, [ + renderSharedOcrPanel(), + panel('Mining manuell erfassen', 'Direkte Eingabe eines einzelnen Mining-Messpunkts mit serverseitiger Validierung.', h('form', { + className: 'mc-form', + onSubmit: function (event) { + event.preventDefault(); + submitMeasurement(false); + }, + }, [ + displayField('Zeitpunkt', 'Wird beim Speichern automatisch auf den aktuellen Bestätigungszeitpunkt gesetzt.'), + inputField('Coins total', 'number', measurementForm.coins_total, (value) => setMeasurementForm({ ...measurementForm, coins_total: value }), '0.000001'), + inputField('Kurs', 'number', measurementForm.price_per_coin, (value) => setMeasurementForm({ ...measurementForm, price_per_coin: value }), '0.000001'), + selectField('Waehrung', measurementForm.price_currency, [''].concat(selectableCurrencies.map((currency) => currency.code)), (value) => setMeasurementForm({ ...measurementForm, price_currency: value })), + textareaField('Notiz', measurementForm.note, (value) => setMeasurementForm({ ...measurementForm, note: value })), + h('button', { + type: 'submit', + className: 'mc-button mc-button--secondary', + disabled: saving, + }, saving ? 'Speichert …' : 'Messpunkt speichern'), + ])), + importHelpOpen ? h('div', { className: 'mc-modal-backdrop', onClick: () => setImportHelpOpen(false) }, [ + h('div', { + key: 'modal', + className: 'mc-modal', + onClick: (event) => event.stopPropagation(), + }, [ + h('div', { key: 'head', className: 'mc-flex-split' }, [ + h('h3', { key: 'title' }, 'Import-Hilfe'), + h('button', { + key: 'close', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => setImportHelpOpen(false), + }, 'Schliessen'), + ]), + h('div', { key: 'body', className: 'mc-form' }, [ + displayField('Format', 'DD.MM.YYYY HH:MM | Coins | Kurs | Waehrung | Notiz'), + h('div', { key: 'rules', className: 'mc-display-field' }, [ + h('div', { key: 'rules-label', className: 'mc-field-label' }, 'Hinweise'), + h('div', { key: 'rules-text', className: 'mc-text' }, [ + 'Leere Zeilen sind erlaubt. ', + 'Zeilen mit # oder // am Anfang werden ignoriert. ', + 'Wenn ein Kurs gesetzt ist, muss auch eine Waehrung gesetzt sein. ', + 'Duplikate werden automatisch ignoriert.' + ]), + ]), + h('div', { key: 'example-wrap', className: 'mc-display-field' }, [ + h('div', { key: 'example-label', className: 'mc-field-label' }, 'Beispiel'), + h('pre', { key: 'example', className: 'mc-code-block' }, [ + '21.03.2026 23:48 | 50.988525 | 0.09316 | USD | Screenshot importiert\n', + '22.03.2026 08:10 | 51.402100 | 0.09420 | USD | Morgens\n', + '22.03.2026 14:30 | 51.998700 | | | ohne Kurs' + ]), + ]), + ]), + ]), + ]) : null, + ]), + panel('Mining-Import per Copy & Paste', 'Mehrere historische Mining-Messpunkte auf einmal einfuegen. Doppelte Eintraege werden ignoriert.', h('form', { + className: 'mc-form', + onSubmit: submitMeasurementImport, + }, [ + h('div', { className: 'mc-inline-row' }, [ + h('button', { + key: 'help', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => setImportHelpOpen(true), + }, 'Import-Hilfe'), + ]), + displayField('Format', 'DD.MM.YYYY HH:MM | Coins | Kurs | Waehrung | Notiz'), + textareaField('Importdaten', importForm.rows_text, (value) => setImportForm({ ...importForm, rows_text: value })), + selectField('Standard-Waehrung', importForm.default_currency, [''].concat(selectableCurrencies.map((currency) => currency.code)), (value) => setImportForm({ ...importForm, default_currency: value })), + selectField('Import-Quelle', importForm.source, ['manual', 'seed_import'], (value) => setImportForm({ ...importForm, source: value })), + h('button', { + type: 'submit', + className: 'mc-button mc-button--secondary', + disabled: saving, + }, saving ? 'Importiert …' : 'Import ausfuehren'), + ])), + ]); + } + + if (activeTab === 'wallet') { + const latestWalletSnapshot = currentWalletSnapshots.length ? currentWalletSnapshots[0] : null; + const latestWalletAssets = latestWalletSnapshot && latestWalletSnapshot.balances_json && typeof latestWalletSnapshot.balances_json === 'object' + ? Object.entries(latestWalletSnapshot.balances_json) + : []; + + return h('div', { className: 'mc-stack' }, [ + panel('Wallet-Bestaende', 'Der letzte Wallet-Snapshot zeigt alle erkannten Assets separat.', latestWalletAssets.length + ? h('div', { className: 'mc-asset-grid' }, latestWalletAssets.map(([code, asset]) => { + const balance = asset && typeof asset === 'object' ? asset.balance : asset; + const priceAmount = asset && typeof asset === 'object' ? asset.price_amount : null; + const priceCurrency = asset && typeof asset === 'object' ? asset.price_currency : null; + return h('div', { key: code, className: 'mc-display-field mc-asset-card' }, [ + h('div', { key: 'code', className: 'mc-field-label' }, code), + h('div', { key: 'balance', className: 'mc-asset-balance' }, `${fmtNumber(balance, 8)} ${code}`), + h('div', { key: 'price', className: 'mc-text' }, priceAmount !== null && priceAmount !== undefined + ? `1 ${code} = ${fmtNumber(priceAmount, 6)} ${priceCurrency || ''}`.trim() + : 'Kein Screenshot-Kurs erkannt'), + ]); + })) + : h('div', { className: 'mc-empty' }, 'Noch keine Wallet-Assets erkannt.')), + panel('Wallet-Historie', 'Die letzten 10 Wallet-Uploads mit allen aus dem Screenshot gelesenen Assets.', h('div', { className: 'mc-table-shell' }, [ + h('table', { key: 'wallet-table', className: 'mc-table' }, [ + h('thead', { key: 'thead' }, h('tr', null, [ + 'Zeit', 'Quelle', 'Assets' + ].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'tbody' }, + currentWalletSnapshots.length + ? currentWalletSnapshots.slice(0, 10).map((row) => h('tr', { key: row.id }, [ + h('td', { key: 'measured' }, fmtDate(row.measured_at)), + h('td', { key: 'source' }, row.source || 'manual'), + h('td', { key: 'assets' }, h('div', { className: 'mc-asset-list' }, Object.entries(row.balances_json || {}).map(([code, asset]) => { + const balance = asset && typeof asset === 'object' ? asset.balance : asset; + const priceAmount = asset && typeof asset === 'object' ? asset.price_amount : null; + const priceCurrency = asset && typeof asset === 'object' ? asset.price_currency : null; + return h('div', { key: code, className: 'mc-asset-row' }, [ + h('strong', { key: 'code' }, code), + h('span', { key: 'balance' }, `${fmtNumber(balance, 8)} ${code}`), + priceAmount !== null && priceAmount !== undefined + ? h('span', { key: 'price', className: 'mc-text' }, `1 ${code} = ${fmtNumber(priceAmount, 6)} ${priceCurrency || ''}`.trim()) + : null, + ]); + }))), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 3 }, 'Noch keine Wallet-Snapshots gespeichert.'))] + ), + ]), + ])), + ]); + } + + if (activeTab === 'dashboards') { + return h('div', { className: 'mc-main-grid' }, [ + panel('Dashboard-Builder V1', 'Chart-Typ, X/Y-Feld, Aggregation und einfache Filter werden gespeichert.', h('form', { + className: 'mc-form', + onSubmit: submitDashboard, + }, [ + inputField('Name', 'text', dashboardForm.name, (value) => setDashboardForm({ ...dashboardForm, name: value })), + selectField('Chart-Typ', dashboardForm.chart_type, ['line', 'bar', 'area', 'table'], (value) => setDashboardForm({ ...dashboardForm, chart_type: value })), + selectField('X-Feld', dashboardForm.x_field, ['measured_at', 'measured_date', 'source', 'price_currency', 'trend_label'], (value) => setDashboardForm({ ...dashboardForm, x_field: value })), + selectField('Y-Feld', dashboardForm.y_field, ['coins_total', 'price_per_coin', 'growth_since_baseline', 'doge_per_hour_since_baseline', 'doge_per_day_since_baseline', 'doge_per_hour_interval', 'doge_per_day_interval', 'current_value', 'theoretical_daily_revenue', 'theoretical_daily_profit'], (value) => setDashboardForm({ ...dashboardForm, y_field: value })), + selectField('Aggregation', dashboardForm.aggregation, ['none', 'sum', 'avg', 'min', 'max', 'count', 'latest'], (value) => setDashboardForm({ ...dashboardForm, aggregation: value })), + selectField('Filter Quelle', dashboardForm.filters.source, ['', 'manual', 'image_ocr', 'seed_import'], (value) => setDashboardForm({ ...dashboardForm, filters: { ...dashboardForm.filters, source: value } })), + selectField('Filter Waehrung', dashboardForm.filters.currency, [''].concat(selectableCurrencies.map((currency) => currency.code)), (value) => setDashboardForm({ ...dashboardForm, filters: { ...dashboardForm.filters, currency: value } })), + h('button', { + type: 'submit', + className: 'mc-button mc-button--secondary', + disabled: saving, + }, saving ? 'Speichert …' : 'Dashboard speichern'), + ])), + h('div', { className: 'mc-stack' }, currentDashboards.map((definition) => h(DashboardCard, { + key: definition.id, + definition, + data: dashboardData[definition.id], + loading: !dashboardData[definition.id], + }))), + ]); + } + + if (activeTab === 'mining') { + const scenarioCurrency = selectedMinerScenario?.scenario_currency || reportCurrency; + const scenarioCurrentDailyProfit = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_current_daily_profit, reportCurrency) : null; + const scenarioDailyProfit = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_daily_profit, reportCurrency) : null; + const scenarioDailyProfitDelta = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_daily_profit_delta, reportCurrency) : null; + const scenarioInvestedCapital = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_invested_capital, reportCurrency) : null; + const scenarioOfferCost = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_offer_cost, reportCurrency) : null; + const scenarioBreakEvenRemaining = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_break_even_remaining_amount, reportCurrency) : null; + return h('div', { className: 'mc-stack' }, [ + panel('Aktive Miner', 'Alle bereits gemieteten oder manuell eingetragenen Miner in einer gemeinsamen Liste.', [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-server', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => setCostPlanModalOpen(true), + }, 'Miner eintragen'), + h('button', { + key: 'rent-miner', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => { + setPurchaseMinerForm({ + offer_id: '', + base_offer_id: '', + purchased_at: '', + label: '', + mining_speed_value: '', + mining_speed_unit: '', + bonus_percent: '', + total_cost_amount: '', + currency: '', + reference_price_amount: '', + reference_price_currency: '', + auto_renew: false, + note: '', + }); + setPurchaseMinerModalOpen(true); + }, + disabled: !availableMinerOffers.length, + }, 'Neuen Miner mieten'), + ]), + h('div', { key: 'list', className: 'mc-table-shell' }, [ + h('table', { key: 'table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Label', 'Start', 'Laufzeit', 'Auto', 'Kosten', 'Waehrung', 'Aktiv', 'Aktion'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + activeMinerRows.length + ? activeMinerRows.map((row) => h('tr', { key: row.id }, [ + h('td', { key: 'label' }, [ + h('div', { key: 'main' }, row.label), + h('div', { key: 'type', className: 'mc-kicker' }, row.type_label), + ]), + h('td', { key: 'start' }, fmtDateTime(row.starts_at)), + h('td', { key: 'runtime' }, [ + h('div', { key: 'months' }, `${row.runtime_months} Monate`), + h('div', { key: 'hash', className: 'mc-kicker' }, row.hashrate_text), + ]), + h('td', { key: 'renew' }, row.auto_renew ? 'ja' : 'nein'), + h('td', { key: 'cost' }, [ + h('div', { key: 'effective' }, fmtNumber(row.effective_amount, 6)), + row.base_amount !== null && row.base_amount !== undefined && row.base_currency + ? h('div', { key: 'base', className: 'mc-kicker' }, `Basis ${fmtNumber(row.base_amount, 6)} ${row.base_currency}`) + : null, + ]), + h('td', { key: 'currency' }, [ + h('div', { key: 'currency-main' }, row.effective_currency), + row.payment_type ? h('div', { key: 'currency-mode', className: 'mc-kicker' }, row.payment_type === 'crypto' ? 'Zahlung Krypto' : 'Zahlung FIAT') : null, + ]), + h('td', { key: 'active' }, row.is_active ? 'ja' : 'nein'), + h('td', { key: 'action' }, + row.can_toggle_auto_renew + ? h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => togglePurchasedMinerAutoRenew(row), + disabled: saving, + }, row.auto_renew ? 'Verlaengerung aus' : 'Verlaengerung an') + : '—' + ), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 8 }, 'Noch keine Miner hinterlegt.'))] + ), + ]), + ]), + ]), + panel('Auszahlungen', 'Auszahlungen reduzieren den sichtbaren Coin-Bestand, bleiben aber in der Gesamtleistung erhalten.', [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-payout', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => { + setPayoutForm((previous) => ({ + payout_at: previous.payout_at || nowDateTimeLocalValue(), + coins_amount: previous.coins_amount || '', + payout_currency: currentCoinCurrency, + note: previous.note || '', + })); + setPayoutModalOpen(true); + }, + }, 'Auszahlung erfassen'), + ]), + h('div', { key: 'payout-list', className: 'mc-table-shell' }, [ + h('table', { key: 'payout-table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Zeit', 'Coins', 'Waehrung', 'Notiz'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + currentPayouts.length + ? currentPayouts.slice().reverse().map((payout) => h('tr', { key: payout.id }, [ + h('td', { key: 'time' }, fmtDate(payout.payout_at)), + h('td', { key: 'coins' }, fmtNumber(payout.coins_amount, 6)), + h('td', { key: 'currency' }, payout.payout_currency), + h('td', { key: 'note' }, payout.note || '—'), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 4 }, 'Noch keine Auszahlungen hinterlegt.'))] + ), + ]), + ]), + ]), + panel('Miner-Angebote', currentWalletMiningBalance !== null + ? `Hier werden nur Basis-Miner gepflegt. Krypto-Angebote werden gegen deinen aktuellen Wallet-Bestand (${fmtNumber(currentWalletMiningBalance, 8)} ${currentMiningCurrency}) gefiltert.` + : 'Hier werden nur Basis-Miner gepflegt. Alle vorgegebenen Geschwindigkeiten und Preise werden daraus automatisch abgeleitet.', + [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-offer', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => setMinerOfferModalOpen(true), + }, 'Basis-Angebot anlegen'), + ]), + h('div', { key: 'filters', className: 'mc-filter-grid' }, [ + inputField(`Min. Geschwindigkeit (${minerOfferFilters.speed_unit === 'kh' ? 'kH/s' : 'MH/s'})`, 'number', minerOfferFilters.speed_min, (value) => setMinerOfferFilters({ ...minerOfferFilters, speed_min: value }), '0.0001'), + selectField('Geschwindigkeitseinheit', minerOfferFilters.speed_unit, [ + { value: 'auto', label: 'MH/s' }, + { value: 'kh', label: 'kH/s' }, + ], (value) => setMinerOfferFilters({ ...minerOfferFilters, speed_unit: value || 'auto' })), + inputField(`Max. Basispreis (${reportCurrency})`, 'number', minerOfferFilters.price_max, (value) => setMinerOfferFilters({ ...minerOfferFilters, price_max: value }), '0.0001'), + selectField('Laufzeit', minerOfferFilters.runtime_months, [{ value: '', label: 'Alle Laufzeiten' }].concat(Array.from(new Set(availableMinerOffers.map((offer) => String(offer.runtime_months || '')).filter(Boolean))).sort((a, b) => Number(a) - Number(b)).map((value) => ({ + value, + label: `${value} Monate`, + }))), (value) => setMinerOfferFilters({ ...minerOfferFilters, runtime_months: value })), + ]), + h('div', { key: 'offers-table', className: 'mc-table-shell' }, [ + h('table', { key: 'table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Label', 'Hashrate', 'Preis', 'Erwartet/Tag', 'Break-even', 'Empfehlung', 'Aktion'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + filteredMinerOffers.length + ? filteredMinerOffers.map((offer) => h('tr', { key: offer.id }, [ + h('td', { key: 'label' }, offer.label), + h('td', { key: 'hashrate' }, formatAdaptiveSpeed(offer.offer_hashrate_mh)), + h('td', { key: 'price' }, [ + h('div', { key: 'price-main' }, [ + h('div', { key: 'amount' }, `${fmtNumber(offer.effective_price_amount, 6)} ${offer.effective_price_currency}`), + h('div', { key: 'label', className: 'mc-kicker' }, 'Zu zahlen'), + ]), + offer.base_price_amount !== null && offer.base_price_currency + ? h('div', { key: 'price-base' }, [ + h('div', { key: 'amount' }, `${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`), + h('div', { key: 'label', className: 'mc-kicker' }, 'Gegenwert'), + ]) + : null, + ]), + h('td', { key: 'day' }, offer.expected_doge_per_day !== null ? `${fmtNumber(offer.expected_doge_per_day, 6)} ${currentCoinCurrency}` : 'n/a'), + h('td', { key: 'break' }, offer.break_even_days !== null ? `${fmtNumber(offer.break_even_days, 2)} Tage` : 'n/a'), + h('td', { key: 'rec' }, [ + h('div', { key: 'rec-main' }, offer.recommendation), + offer.base_price_amount !== null && offer.base_price_currency + ? h('div', { key: 'rec-ref', className: 'mc-kicker' }, `Basis ${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`) + : null, + offer.payment_type ? h('div', { key: 'paytype', className: 'mc-kicker' }, offer.payment_type === 'crypto' ? `Zahlung in Krypto (${currentSettings.crypto_currency || 'DOGE'})` : `Zahlung in FIAT (${offer.base_price_currency || 'EUR'})`) : null, + h('div', { key: 'renew', className: 'mc-kicker' }, offer.auto_renew ? 'Automatische Verlängerung' : 'Laeuft aus'), + ]), + h('td', { key: 'action' }, [ + h('button', { + key: 'scenario', + type: 'button', + className: cx('mc-button', String(selectedMinerScenario?.id) === String(offer.id) ? 'mc-button--secondary' : 'mc-button--ghost'), + onClick: () => setSelectedMinerScenarioId(offer.id), + }, 'Szenario'), + h('button', { + key: 'target', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => { + setTargetForm({ + label: offer.label, + target_amount_fiat: String(offer.base_price_amount ?? offer.effective_price_amount ?? ''), + currency: offer.base_price_currency || offer.effective_price_currency || 'EUR', + miner_offer_id: '', + is_active: true, + sort_order: 0, + }); + setTargetModalOpen(true); + }, + }, 'Als Ziel'), + h('button', { + key: 'buy', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => { + setPurchaseMinerForm({ + offer_id: String(offer.id), + base_offer_id: String(offer.base_offer_id || offer.id || ''), + purchased_at: nowDateTimeLocalValue(), + label: String(offer.label || ''), + mining_speed_value: String(offer.mining_speed_value || ''), + mining_speed_unit: String(offer.mining_speed_unit || ''), + bonus_percent: offer.bonus_percent !== null && offer.bonus_percent !== undefined ? String(offer.bonus_percent) : '', + total_cost_amount: offer.effective_price_amount !== null && offer.effective_price_amount !== undefined ? String(offer.effective_price_amount) : '', + currency: offer.effective_price_currency || offer.base_price_currency || 'USD', + reference_price_amount: offer.base_price_amount !== null && offer.base_price_amount !== undefined ? String(offer.base_price_amount) : '', + reference_price_currency: offer.base_price_currency || '', + auto_renew: !!offer.auto_renew, + note: '', + }); + setPurchaseMinerModalOpen(true); + }, + }, 'Mieten'), + ]), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 7 }, 'Keine Angebote passen auf die gesetzten Filter.'))] + ), + ]), + ]), + selectedMinerScenario ? panel( + `Szenario: ${selectedMinerScenario.label}`, + 'Zeigt, wie sich Kennzahlen veraendern wuerden, wenn dieser Miner jetzt zusaetzlich gemietet wird.', + [ + h('div', { key: 'scenario-stats', className: 'mc-stats-grid' }, [ + h(StatCard, { + key: 'scenario-profit', + label: 'Tagesgewinn Neu', + value: scenarioDailyProfit !== null ? fmtMoney(scenarioDailyProfit, reportCurrency) : 'n/a', + sub: scenarioDailyProfitDelta !== null + ? `Aenderung pro Tag ${fmtMoney(scenarioDailyProfitDelta, reportCurrency)}` + : 'Keine belastbare Gewinnprognose', + }), + h(StatCard, { + key: 'scenario-doge', + label: `${currentCoinCurrency} pro Tag Neu`, + value: selectedMinerScenario.scenario_doge_per_day !== null ? fmtNumber(selectedMinerScenario.scenario_doge_per_day, 4) : 'n/a', + sub: selectedMinerScenario.scenario_current_doge_per_day !== null + ? `Aktuell ${fmtNumber(selectedMinerScenario.scenario_current_doge_per_day, 4)}` + : `Keine aktuelle ${currentCoinCurrency}/Tag-Basis`, + }), + h(StatCard, { + key: 'scenario-break-even', + label: 'Break-even Neu', + value: selectedMinerScenario.scenario_break_even_days !== null + ? `${fmtNumber(selectedMinerScenario.scenario_break_even_days, 2)} Tage` + : 'n/a', + sub: selectedMinerScenario.scenario_break_even_date + ? `Theoretisch ${fmtDate(selectedMinerScenario.scenario_break_even_date)}` + : 'Kein belastbares Break-even-Datum', + }), + h(StatCard, { + key: 'scenario-capital', + label: 'Kosten inkl. Miete', + value: scenarioInvestedCapital !== null ? fmtMoney(scenarioInvestedCapital, reportCurrency) : 'n/a', + sub: scenarioOfferCost !== null + ? `Neue Miete ${fmtMoney(scenarioOfferCost, reportCurrency)}` + : `Mietpreis in ${scenarioCurrency}`, + }), + h(StatCard, { + key: 'scenario-two-year', + label: '2 Jahre Ergebnis Neu', + value: selectedMinerScenario.scenario_two_year_profit !== null + ? fmtMoney(convertMeasurementMoney(latest, selectedMinerScenario.scenario_two_year_profit, reportCurrency), reportCurrency) + : 'n/a', + sub: selectedMinerScenario.scenario_two_year_profit_delta !== null + ? `Aenderung ggü. heute ${fmtMoney(convertMeasurementMoney(latest, selectedMinerScenario.scenario_two_year_profit_delta, reportCurrency), reportCurrency)}` + : 'Laufzeit und Verlaengerung beruecksichtigt', + }), + ]), + h('div', { key: 'scenario-meta', className: 'mc-mini-grid' }, [ + h('div', { key: 'hashrate', className: 'mc-mini-card' }, [ + h('div', { key: 'label', className: 'mc-field-label' }, 'Hashrate'), + h('div', { key: 'value' }, selectedMinerScenario.scenario_hashrate_mh !== null ? `${fmtNumber(selectedMinerScenario.scenario_hashrate_mh, 4)} MH/s` : 'n/a'), + h('div', { key: 'sub', className: 'mc-kicker' }, selectedMinerScenario.scenario_current_hashrate_mh !== null ? `Aktuell ${fmtNumber(selectedMinerScenario.scenario_current_hashrate_mh, 4)} MH/s` : 'Aktuell n/a'), + ]), + h('div', { key: 'remaining', className: 'mc-mini-card' }, [ + h('div', { key: 'label', className: 'mc-field-label' }, 'Offen bis Break-even'), + h('div', { key: 'value' }, scenarioBreakEvenRemaining !== null ? fmtMoney(scenarioBreakEvenRemaining, reportCurrency) : 'n/a'), + h('div', { key: 'sub', className: 'mc-kicker' }, scenarioCurrentDailyProfit !== null ? `Aktueller Tagesgewinn ${fmtMoney(scenarioCurrentDailyProfit, reportCurrency)}` : 'Aktueller Tagesgewinn n/a'), + ]), + ]), + ] + ) : h('div', { key: 'scenario-empty', className: 'mc-empty' }, 'Waehle bei einem Angebot "Szenario", um die Auswirkung hier anzuzeigen.'), + ]), + panel('Ziele', 'Ziele koennen direkt oder aus einem Miner-Angebot heraus angelegt werden.', [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-target', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => setTargetModalOpen(true), + }, 'Ziel anlegen'), + ]), + h('div', { key: 'target-list', className: 'mc-table-shell' }, [ + h('table', { key: 'target-table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Label', 'Betrag', 'Waehrung', 'Resttage', 'Ziel erreicht ca.', 'Sortierung', 'Aktiv', 'Aktion'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + currentTargets.length + ? currentTargets.map((target) => h('tr', { key: target.id || target.label }, [ + h('td', { key: 'label' }, target.label), + h('td', { key: 'amount' }, fmtNumber(target.effective_target_amount_fiat ?? target.target_amount_fiat, 2)), + h('td', { key: 'currency' }, [ + h('div', { key: 'currency-main' }, target.effective_currency || target.currency), + target.linked_offer_label ? h('div', { key: 'currency-offer', className: 'mc-kicker' }, `Angebot ${target.linked_offer_label}`) : null, + ]), + h('td', { key: 'days' }, target.remaining_days !== null && target.remaining_days !== undefined ? fmtNumber(target.remaining_days, 2) : 'n/a'), + h('td', { key: 'eta' }, target.target_eta_at ? fmtDateTime(target.target_eta_at) : 'n/a'), + h('td', { key: 'sort' }, String(target.sort_order ?? 0)), + h('td', { key: 'active' }, target.is_active ? 'ja' : 'nein'), + h('td', { key: 'action' }, + h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => deleteTarget(target), + disabled: saving, + }, 'Loeschen') + ), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 8 }, 'Noch keine Ziele hinterlegt.'))] + ), + ]), + ]), + ]), + costPlanModalOpen ? renderModal('Miner eintragen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitCostPlan }, [ + inputField('Label', 'text', costPlanForm.label, (value) => setCostPlanForm({ ...costPlanForm, label: value })), + inputField('Startdatum', 'datetime-local', costPlanForm.starts_at, (value) => setCostPlanForm({ ...costPlanForm, starts_at: value })), + inputField('Laufzeit in Monaten', 'number', String(costPlanForm.runtime_months), (value) => setCostPlanForm({ ...costPlanForm, runtime_months: Number(value) || 0 })), + inputField('Mining-Geschwindigkeit', 'number', costPlanForm.mining_speed_value, (value) => setCostPlanForm({ ...costPlanForm, mining_speed_value: value }), '0.0001'), + selectField('Mining-Einheit', costPlanForm.mining_speed_unit, speedUnits, (value) => setCostPlanForm({ ...costPlanForm, mining_speed_unit: value })), + inputField('Bonus-Hashrate in %', 'number', costPlanForm.bonus_percent, (value) => setCostPlanForm({ ...costPlanForm, bonus_percent: value }), '0.01'), + inputField(`Basispreis in ${settingsForm.report_currency || 'EUR'}`, 'number', costPlanForm.base_price_amount, (value) => setCostPlanForm({ ...costPlanForm, base_price_amount: value }), '0.000001'), + selectField('Zahlungsart', costPlanForm.payment_type, [{ value: 'fiat', label: 'FIAT' }, { value: 'crypto', label: 'Krypto' }], (value) => setCostPlanForm({ ...costPlanForm, payment_type: value })), + textareaField('Notiz', costPlanForm.note, (value) => setCostPlanForm({ ...costPlanForm, note: value })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!costPlanForm.auto_renew, onChange: (event) => setCostPlanForm({ ...costPlanForm, auto_renew: event.target.checked }) }), + 'Automatisch verlaengernd', + ]), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!costPlanForm.is_active, onChange: (event) => setCostPlanForm({ ...costPlanForm, is_active: event.target.checked }) }), + 'Aktiv', + ]), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setCostPlanModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Miner speichern'), + ]), + ]), + ], () => setCostPlanModalOpen(false)) : null, + payoutModalOpen ? renderModal('Auszahlung erfassen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitPayout }, [ + inputField('Auszahlungszeitpunkt', 'datetime-local', payoutForm.payout_at, (value) => setPayoutForm({ ...payoutForm, payout_at: value })), + inputField('Coins', 'number', payoutForm.coins_amount, (value) => setPayoutForm({ ...payoutForm, coins_amount: value }), '0.000001'), + selectField('Waehrung', payoutForm.payout_currency, [currentCoinCurrency].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== currentCoinCurrency)), (value) => setPayoutForm({ ...payoutForm, payout_currency: value })), + textareaField('Notiz', payoutForm.note, (value) => setPayoutForm({ ...payoutForm, note: value })), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setPayoutModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Auszahlung speichern'), + ]), + ]), + ], () => setPayoutModalOpen(false)) : null, + minerOfferModalOpen ? renderModal('Basis-Miner-Angebot anlegen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitMinerOffer }, [ + inputField('Label', 'text', minerOfferForm.label, (value) => setMinerOfferForm({ ...minerOfferForm, label: value })), + inputField('Laufzeit in Monaten', 'number', minerOfferForm.runtime_months, (value) => setMinerOfferForm({ ...minerOfferForm, runtime_months: value })), + displayField('Basis-Geschwindigkeit', baseOfferSpeedLabel(minerOfferForm.payment_type)), + inputField('Bonus-Hashrate in %', 'number', minerOfferForm.bonus_percent, (value) => setMinerOfferForm({ ...minerOfferForm, bonus_percent: value }), '0.01'), + inputField(minerOfferForm.payment_type === 'crypto' ? 'Basispreis in USD' : 'Basispreis', 'number', minerOfferForm.base_price_amount, (value) => setMinerOfferForm({ ...minerOfferForm, base_price_amount: value }), '0.000001'), + selectField('Zahlungsart', minerOfferForm.payment_type, [{ value: 'fiat', label: 'FIAT' }, { value: 'crypto', label: 'Krypto' }], (value) => setMinerOfferForm({ + ...minerOfferForm, + payment_type: value, + base_price_currency: value === 'crypto' + ? 'USD' + : (selectableFiatCurrencies[0]?.code || 'USD'), + })), + minerOfferForm.payment_type === 'crypto' + ? displayField('Kostenwaehrung', 'USD') + : selectField('Basiswährung', minerOfferForm.base_price_currency, selectableFiatCurrencies.map((currency) => currency.code), (value) => setMinerOfferForm({ ...minerOfferForm, base_price_currency: value })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!minerOfferForm.auto_renew, onChange: (event) => setMinerOfferForm({ ...minerOfferForm, auto_renew: event.target.checked }) }), + 'Automatische Verlängerung', + ]), + textareaField('Notiz', minerOfferForm.note, (value) => setMinerOfferForm({ ...minerOfferForm, note: value })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!minerOfferForm.is_active, onChange: (event) => setMinerOfferForm({ ...minerOfferForm, is_active: event.target.checked }) }), + 'Als verfuegbar markieren', + ]), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setMinerOfferModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Angebot speichern'), + ]), + ]), + ], () => setMinerOfferModalOpen(false)) : null, + purchaseMinerModalOpen ? renderModal('Neuen Miner mieten', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitPurchaseMiner }, [ + selectField('Angebot', purchaseMinerForm.offer_id, [{ value: '', label: 'Bitte waehlen' }].concat(availableMinerOffers.map((offer) => ({ + value: String(offer.id), + label: `${offer.label} · ${fmtNumber(offer.effective_price_amount, 6)} ${offer.effective_price_currency}`, + }))), (value) => { + const offer = availableMinerOffers.find((item) => String(item.id) === String(value)); + setPurchaseMinerForm({ + offer_id: value, + base_offer_id: offer ? String(offer.base_offer_id || offer.id || '') : '', + purchased_at: purchaseMinerForm.purchased_at || nowDateTimeLocalValue(), + label: offer ? String(offer.label || '') : '', + mining_speed_value: offer && offer.mining_speed_value !== null && offer.mining_speed_value !== undefined ? String(offer.mining_speed_value) : '', + mining_speed_unit: offer?.mining_speed_unit || '', + bonus_percent: offer && offer.bonus_percent !== null && offer.bonus_percent !== undefined ? String(offer.bonus_percent) : '', + total_cost_amount: offer && offer.effective_price_amount !== null && offer.effective_price_amount !== undefined ? String(offer.effective_price_amount) : '', + currency: offer?.effective_price_currency || offer?.base_price_currency || 'USD', + reference_price_amount: offer && offer.reference_price_amount !== null && offer.reference_price_amount !== undefined ? String(offer.reference_price_amount) : '', + reference_price_currency: offer?.reference_price_currency || '', + auto_renew: !!offer?.auto_renew, + note: purchaseMinerForm.note || '', + }); + }), + inputField('Mietdatum/-zeit', 'datetime-local', purchaseMinerForm.purchased_at, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, purchased_at: value })), + inputField('Label', 'text', purchaseMinerForm.label, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, label: value })), + inputField('Mining-Geschwindigkeit', 'number', purchaseMinerForm.mining_speed_value, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, mining_speed_value: value }), '0.0001'), + selectField('Mining-Einheit', purchaseMinerForm.mining_speed_unit, speedUnits, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, mining_speed_unit: value })), + inputField('Bonus-Hashrate in %', 'number', purchaseMinerForm.bonus_percent, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, bonus_percent: value }), '0.01'), + inputField('Exakter Mietpreis', 'number', purchaseMinerForm.total_cost_amount, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, total_cost_amount: value }), '0.000001'), + selectField('Mietwährung', purchaseMinerForm.currency, selectableCurrencies.map((currency) => currency.code), (value) => setPurchaseMinerForm({ ...purchaseMinerForm, currency: value })), + inputField('Referenzpreis', 'number', purchaseMinerForm.reference_price_amount, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, reference_price_amount: value }), '0.000001'), + selectField('Referenzwährung', purchaseMinerForm.reference_price_currency, [''].concat(selectableCurrencies.map((currency) => currency.code)), (value) => setPurchaseMinerForm({ ...purchaseMinerForm, reference_price_currency: value })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!purchaseMinerForm.auto_renew, onChange: (event) => setPurchaseMinerForm({ ...purchaseMinerForm, auto_renew: event.target.checked }) }), + 'Automatische Verlängerung', + ]), + textareaField('Notiz', purchaseMinerForm.note, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, note: value })), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setPurchaseMinerModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Miner mieten'), + ]), + ]), + ], () => setPurchaseMinerModalOpen(false)) : null, + targetModalOpen ? renderModal('Ziel anlegen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitTarget }, [ + inputField('Label', 'text', targetForm.label, (value) => setTargetForm({ ...targetForm, label: value })), + selectField('Angebots-Verknuepfung', targetForm.miner_offer_id || '', [{ value: '', label: 'Kein verknuepftes Angebot' }].concat(currentMinerOffers.map((offer) => ({ + value: String(offer.id), + label: `${offer.label} · ${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`, + }))), (value) => { + const offer = currentMinerOffers.find((item) => String(item.id) === String(value)); + setTargetForm({ + ...targetForm, + miner_offer_id: value, + label: targetForm.label || offer?.label || '', + target_amount_fiat: offer ? String(offer.base_price_amount ?? offer.effective_price_amount ?? '') : targetForm.target_amount_fiat, + currency: offer?.base_price_currency || offer?.effective_price_currency || targetForm.currency, + }); + }), + inputField('Betrag', 'number', targetForm.target_amount_fiat, (value) => setTargetForm({ ...targetForm, target_amount_fiat: value }), '0.01'), + selectField('Waehrung', targetForm.currency, selectableCurrencies.map((currency) => currency.code), (value) => setTargetForm({ ...targetForm, currency: value })), + inputField('Sortierung', 'number', String(targetForm.sort_order), (value) => setTargetForm({ ...targetForm, sort_order: Number(value) || 0 })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!targetForm.is_active, onChange: (event) => setTargetForm({ ...targetForm, is_active: event.target.checked }) }), + 'Aktiv', + ]), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setTargetModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Ziel speichern'), + ]), + ]), + ], () => setTargetModalOpen(false)) : null, + ]); + } + + return h('div', { className: 'mc-two-col' }, [ + h('div', { className: 'mc-stack' }, [ + panel('Initialisierung', 'Prueft den Tabellenstatus und kann das Mining-Checker Schema neu anlegen. Reset loescht bestehende miningcheck_ Tabellen inklusive Daten.', [ + h('div', { key: 'status', className: 'mc-form' }, [ + displayField('Status', schemaStatus.all_present ? 'Schema vollstaendig vorhanden' : 'Schema unvollstaendig'), + displayField('Vorhandene Tabellen', `${schemaStatus.present_count}/${schemaStatus.required_tables.length}`), + displayField('Fehlende Tabellen', schemaStatus.missing_tables.length ? schemaStatus.missing_tables.join(', ') : 'keine'), + displayField('Ausstehende Upgrades', schemaStatus.pending_upgrades.length ? schemaStatus.pending_upgrades.join(', ') : 'keine'), + ]), + h('form', { key: 'form', className: 'mc-form', onSubmit: initializeModule }, [ + h('label', { className: 'mc-checkbox' }, [ + h('input', { + key: 'drop-existing', + type: 'checkbox', + checked: !!initForm.drop_existing, + onChange: (event) => setInitForm({ drop_existing: event.target.checked }), + }), + 'Bestehende Mining-Checker Tabellen inkl. Daten loeschen und neu anlegen', + ]), + h('button', { + type: 'submit', + className: initForm.drop_existing ? 'mc-button mc-button--danger' : 'mc-button mc-button--primary', + disabled: saving, + }, saving ? 'Initialisiert …' : (initForm.drop_existing ? 'Reset + Schema neu anlegen' : 'Schema initialisieren')), + ]), + h('button', { + key: 'upgrade', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: upgradeDatabaseSchema, + disabled: saving, + }, saving ? 'Upgradet …' : 'DB auf neueste Version upgraden'), + h('button', { + key: 'old-data-import', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: importOldData, + disabled: saving, + }, saving ? 'Importiert …' : 'Alte Daten importieren'), + h('button', { + key: 'legacy-fx-migrate', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: migrateLegacyFxData, + disabled: saving, + }, saving ? 'Migriert …' : 'Legacy FX zu fx-rates migrieren'), + h('div', { key: 'sql-import', className: 'mc-form' }, [ + h('label', { className: 'mc-field' }, [ + h('span', { className: 'mc-field-label' }, 'SQL-Datei importieren'), + h('input', { + type: 'file', + accept: '.sql,text/sql,application/sql', + onChange: (event) => setSqlImportFile(event.target.files && event.target.files[0] ? event.target.files[0] : null), + }), + ]), + h('div', { className: 'mc-text' }, + sqlImportFile + ? `Ausgewaehlt: ${sqlImportFile.name}` + : 'Fuehrt die ausgewaehlte SQL-Datei direkt in der aktuellen Projekt-Datenbank aus. Bestehende Daten werden dabei nicht automatisch geloescht.' + ), + h('button', { + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: importSqlFile, + disabled: saving || !sqlImportFile, + }, saving ? 'Importiert …' : 'SQL-Datei einspielen'), + ]), + ]), + panel('Datenbank-Test', 'Prueft, ob das Modul die Projekt-Datenbank erreichen und eine einfache Anfrage ausfuehren kann.', [ + dbCheck + ? h('div', { key: 'dbcheck-result', className: 'mc-form' }, [ + displayField('Status', dbCheck.ok ? 'Verbindung erfolgreich' : 'Verbindung fehlgeschlagen'), + displayField('Driver', dbCheck.driver || 'n/a'), + displayField('Datenbank', dbCheck.database || 'n/a'), + displayField('Tabellenpraefix', dbCheck.table_prefix || 'n/a'), + ]) + : h('div', { key: 'dbcheck-empty', className: 'mc-empty' }, 'Noch kein Verbindungstest ausgefuehrt.'), + h('button', { + key: 'dbcheck-button', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: testDatabaseConnection, + disabled: saving, + }, saving ? 'Prueft …' : 'DB-Verbindung testen'), + ]), + ]), + h('div', { className: 'mc-stack' }, [ + panel('Basis-Settings', 'Baseline bleibt als Referenzwert mit Datum und Uhrzeit bestehen.', h('form', { + className: 'mc-form', + onSubmit: submitSettings, + }, [ + inputField('Baseline Zeitpunkt', 'datetime-local', toDateTimeLocalValue(settingsForm.baseline_measured_at), (value) => setSettingsForm({ ...settingsForm, baseline_measured_at: value })), + inputField('Baseline Coins', 'number', settingsForm.baseline_coins_total, (value) => setSettingsForm({ ...settingsForm, baseline_coins_total: value }), '0.000001'), + selectField('Standard-FIAT-Währung', settingsForm.report_currency || 'EUR', selectableFiatCurrencies.map((currency) => currency.code), (value) => setSettingsForm({ ...settingsForm, report_currency: value })), + selectField('Standard-Krypto-Währung', settingsForm.crypto_currency || 'DOGE', selectableCryptoCurrencies.map((currency) => currency.code), (value) => setSettingsForm({ ...settingsForm, crypto_currency: value })), + h('button', { + type: 'submit', + className: 'mc-button mc-button--primary', + disabled: saving, + }, saving ? 'Speichert …' : 'Settings speichern'), + ])), + panel('Modulrechte', 'Steuert, wer den Mining-Checker auf der Startseite sieht und direkt aufrufen darf.', h('form', { + className: 'mc-form', + onSubmit: submitModuleAuth, + }, [ + h('label', { className: 'mc-checkbox' }, [ + h('input', { + key: 'required', + type: 'checkbox', + checked: !!moduleAuthForm.required, + onChange: (event) => setModuleAuthForm({ ...moduleAuthForm, required: event.target.checked }), + }), + 'Login fuer dieses Modul erforderlich', + ]), + inputField('Erlaubte Benutzer / Subs', 'text', moduleAuthForm.users, (value) => setModuleAuthForm({ ...moduleAuthForm, users: value })), + inputField('Erlaubte Gruppen', 'text', moduleAuthForm.groups, (value) => setModuleAuthForm({ ...moduleAuthForm, groups: value })), + h('div', { className: 'mc-text' }, + 'Mehrere Werte mit Komma trennen. Benutzerfeld akzeptiert Keycloak-Sub, Benutzername oder E-Mail. Leer bedeutet: jeder eingeloggte Benutzer darf das Modul nutzen.'), + h('button', { + type: 'submit', + className: 'mc-button mc-button--primary', + disabled: saving, + }, saving ? 'Speichert …' : 'Modulrechte speichern'), + ])), + ]), + ]); + } + + function panel(title, subtitle, content) { + return h('section', { className: 'mc-panel' }, [ + h(SectionTitle, { key: 'title', title, subtitle }), + h('div', { key: 'body', className: 'mc-panel-body' }, content), + ]); + } + + function fieldWrapper(label, child) { + return h('label', { className: 'mc-field' }, [ + h('span', { key: 'label', className: 'mc-field-label' }, label), + child, + ]); + } + + function inputField(label, type, value, onChange, step) { + return fieldWrapper(label, h('input', { + className: 'mc-input', + type, + step: step || undefined, + value: value, + onChange: (event) => onChange(event.target.value), + })); + } + + function selectField(label, value, options, onChange) { + return fieldWrapper(label, h('select', { + className: 'mc-select', + value, + onChange: (event) => onChange(event.target.value), + }, options.map((option) => { + const normalized = option && typeof option === 'object' + ? option + : { value: option, label: option || 'alle' }; + return h('option', { + key: normalized.value || 'empty', + value: normalized.value, + }, normalized.label || 'alle'); + }))); + } + + function textareaField(label, value, onChange) { + return fieldWrapper(label, h('textarea', { + className: 'mc-textarea', + value, + onChange: (event) => onChange(event.target.value), + })); + } + + function fileField(label, onChange) { + return fieldWrapper(label, h('input', { + className: 'mc-file', + type: 'file', + accept: 'image/png,image/jpeg,image/webp', + onChange: (event) => onChange(event.target.files && event.target.files[0] ? event.target.files[0] : null), + })); + } + + function displayField(label, value) { + return h('div', { className: 'mc-display-field' }, [ + h('div', { key: 'label', className: 'mc-field-label' }, label), + h('div', { key: 'value', className: 'mc-text' }, value || 'n/a'), + ]); + } + + function renderModal(title, content, onClose) { + return h('div', { + className: 'mc-modal-backdrop', + onClick: onClose, + }, [ + h('div', { + key: 'modal', + className: 'mc-modal', + onClick: (event) => event.stopPropagation(), + }, [ + h('div', { key: 'head', className: 'mc-section-head' }, [ + h('div', { key: 'title-wrap' }, [ + h('h3', { key: 'title' }, title), + ]), + h('button', { + key: 'close', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: onClose, + }, 'Schliessen'), + ]), + h('div', { key: 'body', className: 'mc-panel-body' }, content), + ]), + ]); + } + + function formatSpeed(value, unit, label) { + if (value === null || value === undefined || value === '' || !unit) { + return ''; + } + + return `${label ? label + ' ' : ''}${fmtNumber(value, 4)} ${unit}`; + } + + function baseOfferSpeedLabel(paymentType) { + return String(paymentType || 'fiat') === 'crypto' ? '75 kH/s' : '50 kH/s'; + } + + function formatHashrateWithBonus(speedValue, speedUnit, bonusValue, bonusUnit) { + const parts = [ + formatSpeed(speedValue, speedUnit, 'Basis'), + Number(bonusValue) > 0 ? formatSpeed(bonusValue, bonusUnit, 'Bonus') : '', + ].filter(Boolean); + return parts.length ? parts.join(' · ') : 'n/a'; + } + + function formatAdaptiveSpeed(valueMh) { + const numericValue = Number(valueMh); + if (!Number.isFinite(numericValue)) { + return 'n/a'; + } + + if (numericValue > 0 && numericValue < 1) { + return `${fmtNumber(numericValue * 1000, 2)} kH/s`; + } + + return `${fmtNumber(numericValue, 4)} MH/s`; + } + } + + ReactDOM.createRoot(root).render(h(App)); +})(); diff --git a/temp/nexus-module-import/modules/mining-checker/bootstrap.php b/temp/nexus-module-import/modules/mining-checker/bootstrap.php new file mode 100644 index 00000000..f4eadd91 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/bootstrap.php @@ -0,0 +1,235 @@ +registerFunction($moduleName, 'runtime_settings', static function (): array { + $moduleBasePath = __DIR__; + $config = ModuleConfig::load($moduleBasePath); + $projectKey = $config->defaultProjectKey(); + $user = auth_user() ?? []; + $ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local'; + + $pdo = ConnectionFactory::make($config); + $repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub); + + $settings = $repository->getSettings($projectKey) ?? []; + $displayTimezone = trim((string) ($settings['display_timezone'] ?? nexus_display_timezone_name())); + if ($displayTimezone === '') { + $displayTimezone = nexus_display_timezone_name(); + } + + $baselineMeasuredAt = trim((string) ($settings['baseline_measured_at'] ?? '')); + if ($baselineMeasuredAt !== '') { + try { + $baselineMeasuredAt = (new DateTimeImmutable($baselineMeasuredAt, new DateTimeZone('UTC'))) + ->setTimezone(new DateTimeZone($displayTimezone)) + ->format('Y-m-d\TH:i'); + } catch (\Throwable) { + $baselineMeasuredAt = ''; + } + } + + return [ + 'baseline_measured_at' => $baselineMeasuredAt, + 'baseline_coins_total' => isset($settings['baseline_coins_total']) ? (string) $settings['baseline_coins_total'] : '', + 'report_currency' => strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR', + ]; +}); + +$mm->registerFunction($moduleName, 'save_runtime_settings', static function (array $payload): array { + $moduleBasePath = __DIR__; + $config = ModuleConfig::load($moduleBasePath); + $projectKey = $config->defaultProjectKey(); + $user = auth_user() ?? []; + $ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local'; + + $pdo = ConnectionFactory::make($config); + $schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath); + $schema->ensureSchema(); + + $repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub); + $repository->ensureProject($projectKey, strtoupper(str_replace('-', ' ', $projectKey))); + + $existing = $repository->getSettings($projectKey) ?? []; + $displayTimezone = trim((string) ($existing['display_timezone'] ?? nexus_display_timezone_name())); + if ($displayTimezone === '') { + $displayTimezone = nexus_display_timezone_name(); + } + + try { + $displayTz = new DateTimeZone($displayTimezone); + } catch (\Throwable) { + throw new RuntimeException('Ungueltige Anzeige-Zeitzone.'); + } + + $baselineInput = trim((string) ($payload['baseline_measured_at'] ?? ($existing['baseline_measured_at'] ?? ''))); + if ($baselineInput === '') { + throw new RuntimeException('Baseline Zeitpunkt fehlt.'); + } + + try { + $baselineUtc = (new DateTimeImmutable($baselineInput, $displayTz)) + ->setTimezone(new DateTimeZone('UTC')) + ->format('Y-m-d H:i:s'); + } catch (\Throwable) { + throw new RuntimeException('Baseline Zeitpunkt ist ungueltig.'); + } + + $baselineCoins = trim((string) ($payload['baseline_coins_total'] ?? ($existing['baseline_coins_total'] ?? ''))); + if ($baselineCoins === '' || !is_numeric($baselineCoins)) { + throw new RuntimeException('Baseline Coins muessen numerisch sein.'); + } + + $preferredCurrencies = $existing['preferred_currencies'] ?? ['DOGE', 'USD', 'EUR']; + if (is_string($preferredCurrencies)) { + $preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: []; + } + $preferredCurrencies = array_values(array_unique(array_filter(array_map( + static fn (mixed $value): string => strtoupper(trim((string) $value)), + is_array($preferredCurrencies) ? $preferredCurrencies : [] + ), static fn (string $value): bool => $value !== ''))); + + $settings = [ + 'baseline_measured_at' => $baselineUtc, + 'baseline_coins_total' => (float) $baselineCoins, + 'daily_cost_amount' => isset($existing['daily_cost_amount']) && is_numeric((string) $existing['daily_cost_amount']) + ? (float) $existing['daily_cost_amount'] + : 0.0, + 'daily_cost_currency' => strtoupper(trim((string) ($existing['daily_cost_currency'] ?? 'EUR'))) ?: 'EUR', + 'report_currency' => strtoupper(trim((string) ($payload['report_currency'] ?? ($existing['report_currency'] ?? 'EUR')))) ?: 'EUR', + 'crypto_currency' => strtoupper(trim((string) ($existing['crypto_currency'] ?? 'DOGE'))) ?: 'DOGE', + 'display_timezone' => $displayTimezone, + 'fx_max_age_hours' => isset($existing['fx_max_age_hours']) && is_numeric((string) $existing['fx_max_age_hours']) + ? (int) $existing['fx_max_age_hours'] + : 3, + 'module_theme_mode' => in_array((string) ($existing['module_theme_mode'] ?? 'inherit'), ['inherit', 'custom'], true) + ? (string) $existing['module_theme_mode'] + : 'inherit', + 'module_theme_accent' => in_array((string) ($existing['module_theme_accent'] ?? 'teal'), ['teal', 'logo', 'pink', 'cyan', 'orange', 'green'], true) + ? (string) $existing['module_theme_accent'] + : 'teal', + 'preferred_currencies' => $preferredCurrencies, + ]; + + $repository->saveSettings($projectKey, $settings); + + return module_fn('mining-checker', 'runtime_settings'); +}); + +$mm->registerFunction($moduleName, 'setup_actions', static function (): array { + return [ + [ + 'name' => 'initialize_schema', + 'label' => 'Tabellen importieren', + 'section' => 'database', + 'help' => 'Legt die Mining-Checker Tabellen an, wenn sie noch nicht vorhanden sind.', + ], + [ + 'name' => 'upgrade_schema', + 'label' => 'Tabellen updaten', + 'section' => 'database', + 'help' => 'Fuehrt fehlende Tabellen- und Spalten-Upgrades fuer den Mining-Checker aus.', + ], + [ + 'name' => 'seed_import', + 'label' => 'Seed-Daten importieren', + 'section' => 'database', + 'help' => 'Importiert die vordefinierten Startdaten fuer das Standardprojekt.', + ], + ]; +}); + +$mm->registerFunction($moduleName, 'setup_status', static function (): array { + $moduleBasePath = __DIR__; + $config = ModuleConfig::load($moduleBasePath); + $pdo = ConnectionFactory::make($config); + $schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath); + $status = $schema->schemaStatus(); + + return [ + 'title' => 'Tabellenstatus', + 'type' => !empty($status['all_present']) && empty($status['pending_upgrades']) ? 'success' : 'hint', + 'text' => !empty($status['all_present']) + ? (empty($status['pending_upgrades']) + ? 'Alle Mining-Checker Tabellen sind vorhanden.' + : 'Alle Grundtabellen sind vorhanden, aber es gibt noch ausstehende Upgrades.') + : 'Das Mining-Checker Schema ist noch unvollstaendig.', + 'stats' => [ + ['label' => 'Vorhandene Tabellen', 'value' => (string) ((int) ($status['present_count'] ?? 0) . '/' . count((array) ($status['required_tables'] ?? [])))], + ['label' => 'Fehlende Tabellen', 'value' => !empty($status['missing_tables']) ? implode(', ', (array) $status['missing_tables']) : 'keine'], + ['label' => 'Ausstehende Upgrades', 'value' => !empty($status['pending_upgrades']) ? implode(', ', (array) $status['pending_upgrades']) : 'keine'], + ], + ]; +}); + +$mm->registerFunction($moduleName, 'run_setup_action', static function (string $action): array { + $moduleBasePath = __DIR__; + $config = ModuleConfig::load($moduleBasePath); + $projectKey = $config->defaultProjectKey(); + $user = auth_user() ?? []; + $ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local'; + + $pdo = ConnectionFactory::make($config); + $schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath); + $repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub); + + return match ($action) { + 'initialize_schema' => (static function () use ($schema): array { + $result = $schema->initializeSchema(false); + $after = is_array($result['after'] ?? null) ? $result['after'] : []; + return [ + 'message' => ($result['message'] ?? 'Schema initialisiert.') + . ' Vorhanden: ' . (int) ($after['present_count'] ?? 0) + . '/' . count((array) ($after['required_tables'] ?? [])) . '.', + ]; + })(), + 'upgrade_schema' => (static function () use ($schema): array { + $result = $schema->upgradeSchemaDirect(); + $applied = array_values(array_filter(array_map('strval', (array) ($result['upgraded'] ?? [])))); + return [ + 'message' => ($result['message'] ?? 'Schema-Upgrade ausgefuehrt.') + . ($applied !== [] ? ' Upgrades: ' . implode(', ', $applied) . '.' : ''), + ]; + })(), + 'seed_import' => (static function () use ($schema, $repository, $projectKey): array { + $schema->ensureSchema(); + $repository->ensureProject($projectKey, strtoupper(str_replace('-', ' ', $projectKey))); + $result = (new \Modules\MiningChecker\Domain\SeedImporter($repository))->import($projectKey); + return [ + 'message' => 'Seed-Daten importiert. Eingefuegt: ' . (int) ($result['inserted'] ?? 0) . '.', + ]; + })(), + default => throw new RuntimeException('Unbekannte Setup-Aktion.'), + }; +}); + +$mm->registerFunction('mining-checker', 'sql_import_target', static function (): array { + $moduleBasePath = __DIR__; + $config = ModuleConfig::load($moduleBasePath); + + return [ + 'pdo' => ConnectionFactory::make($config), + 'label' => 'Mining-Checker Projekt-Datenbank', + ]; +}); diff --git a/temp/nexus-module-import/modules/mining-checker/config/example.config.php b/temp/nexus-module-import/modules/mining-checker/config/example.config.php new file mode 100644 index 00000000..2507a7cf --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/config/example.config.php @@ -0,0 +1,24 @@ + 'doge-main', + 'MINING_CHECKER_OCR_PROVIDERS' => 'ocrspace,tesseract', + 'MINING_CHECKER_OCR_SPACE_URL' => 'https://api.ocr.space/parse/image', + 'MINING_CHECKER_OCR_SPACE_API_KEY' => 'K83150278888957', + 'MINING_CHECKER_OCR_SPACE_LANGUAGE' => 'eng', + 'MINING_CHECKER_OCR_SPACE_ENGINE' => '2', + 'MINING_CHECKER_OCR_SPACE_SCALE' => 'true', + 'MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION' => 'true', + 'MINING_CHECKER_OCR_SPACE_IS_TABLE' => 'false', + 'MINING_CHECKER_OCR_SPACE_TIMEOUT' => '25', + 'MINING_CHECKER_TESSERACT_BIN' => '/usr/bin/tesseract', + 'MINING_CHECKER_TESSERACT_LANG' => 'eng', + 'MINING_CHECKER_FX_PROVIDER' => 'currencyapi', + 'MINING_CHECKER_FX_URL' => 'https://currencyapi.net', + 'MINING_CHECKER_FX_CURRENCIES_URL' => 'https://currencyapi.net', + 'MINING_CHECKER_FX_API_KEY' => 'eb18ce459ffb0461c59229b478f2e00388d1', + 'MINING_CHECKER_FX_TIMEOUT' => '10', + 'MINING_CHECKER_FX_CACHE_TTL' => '21600', + 'MINING_CHECKER_FX_AUTO_FETCH_ON_MISS' => 'false', +]; diff --git a/temp/nexus-module-import/modules/mining-checker/config/module.php b/temp/nexus-module-import/modules/mining-checker/config/module.php new file mode 100644 index 00000000..b04af692 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/config/module.php @@ -0,0 +1,46 @@ + getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main', + 'use_project_database' => true, + 'table_prefix' => 'miningcheck_', + 'uploads_dir' => dirname(__DIR__, 3) . '/data/mining-checker/uploads', + 'uploads_public_prefix' => '/data/mining-checker/uploads', + 'ocr' => [ + 'providers' => array_values(array_filter(array_map( + static fn (string $provider): string => trim(strtolower($provider)), + explode(',', getenv('MINING_CHECKER_OCR_PROVIDERS') ?: 'ocrspace,tesseract') + ))), + 'ocrspace' => [ + 'url' => getenv('MINING_CHECKER_OCR_SPACE_URL') ?: 'https://api.ocr.space/parse/image', + 'api_key' => getenv('MINING_CHECKER_OCR_SPACE_API_KEY') ?: 'K83150278888957', + 'language' => getenv('MINING_CHECKER_OCR_SPACE_LANGUAGE') ?: 'eng', + 'engine' => (int) (getenv('MINING_CHECKER_OCR_SPACE_ENGINE') ?: 2), + 'scale' => getenv('MINING_CHECKER_OCR_SPACE_SCALE') ?: 'true', + 'detect_orientation' => getenv('MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION') ?: 'true', + 'is_table' => getenv('MINING_CHECKER_OCR_SPACE_IS_TABLE') ?: 'false', + 'timeout' => (int) (getenv('MINING_CHECKER_OCR_SPACE_TIMEOUT') ?: 25), + ], + 'tesseract' => [ + 'binary' => getenv('MINING_CHECKER_TESSERACT_BIN') ?: 'tesseract', + 'language' => getenv('MINING_CHECKER_TESSERACT_LANG') ?: 'eng', + ], + ], + 'fx' => [ + 'provider' => getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi', + 'url' => getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net', + 'currencies_url' => getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: 'https://currencyapi.net', + 'api_key' => getenv('MINING_CHECKER_FX_API_KEY') ?: 'eb18ce459ffb0461c59229b478f2e00388d1', + 'timeout' => (int) (getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10), + 'cache_ttl' => (int) (getenv('MINING_CHECKER_FX_CACHE_TTL') ?: 21600), + 'auto_fetch_on_miss' => filter_var( + getenv('MINING_CHECKER_FX_AUTO_FETCH_ON_MISS') ?: 'false', + FILTER_VALIDATE_BOOL + ), + ], + 'debug' => [ + 'enabled' => filter_var(getenv('MINING_CHECKER_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL), + 'dir' => getenv('MINING_CHECKER_DEBUG_DIR') ?: dirname(__DIR__, 3) . '/data/mining-checker/debug', + ], +]; diff --git a/temp/nexus-module-import/modules/mining-checker/design.json b/temp/nexus-module-import/modules/mining-checker/design.json new file mode 100644 index 00000000..a0335736 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/design.json @@ -0,0 +1,17 @@ +{ + "eyebrow": "Modul", + "title": "Mining-Checker", + "description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.", + "actions": [ + { "label": "Nexus Übersicht", "href": "/modules" }, + { "label": "Setup", "href": "/modules/setup/mining-checker", "variant": "secondary" } + ], + "sections": [ + { "key": "overview", "label": "Übersicht" }, + { "key": "upload", "label": "Upload" }, + { "key": "measurements", "label": "Mining-History" }, + { "key": "wallet", "label": "Wallet" }, + { "key": "mining", "label": "Miner-Daten" }, + { "key": "dashboards", "label": "Dashboards" } + ] +} diff --git a/temp/nexus-module-import/modules/mining-checker/docs/README.md b/temp/nexus-module-import/modules/mining-checker/docs/README.md new file mode 100644 index 00000000..89af70a6 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/docs/README.md @@ -0,0 +1,114 @@ +# Mining-Checker Modul + +## Zweck + +Das Modul erfasst DOGE-Mining-Messpunkte, analysiert OCR-Vorschlaege aus Screenshots, speichert Messreihen projektbezogen und berechnet Performance-, Kurs- und Zielmetriken. + +## Ordnerstruktur + +```text +modules/mining-checker/ +|-- api/ +|-- assets/ +| |-- css/ +| `-- js/ +|-- config/ +|-- docs/ +|-- pages/ +|-- partials/ +|-- sql/ +| `-- migrations/ +|-- src/ +| |-- Api/ +| |-- Domain/ +| |-- Infrastructure/ +| `-- Support/ +|-- storage/uploads/ +|-- bootstrap.php +`-- module.json +``` + +## API-Endpunkte + +- `GET /api/mining-checker/v1/health` +- `GET /api/mining-checker/v1/projects/{projectKey}/bootstrap` +- `GET /api/mining-checker/v1/projects/{projectKey}/measurements` +- `POST /api/mining-checker/v1/projects/{projectKey}/measurements` +- `POST /api/mining-checker/v1/projects/{projectKey}/ocr-preview` +- `GET /api/mining-checker/v1/projects/{projectKey}/settings` +- `PUT /api/mining-checker/v1/projects/{projectKey}/settings` +- `GET /api/mining-checker/v1/projects/{projectKey}/targets` +- `POST /api/mining-checker/v1/projects/{projectKey}/targets` +- `PATCH /api/mining-checker/v1/projects/{projectKey}/targets/{targetId}` +- `GET /api/mining-checker/v1/projects/{projectKey}/dashboards` +- `POST /api/mining-checker/v1/projects/{projectKey}/dashboards` +- `GET /api/mining-checker/v1/projects/{projectKey}/dashboard-data` +- `POST /api/mining-checker/v1/projects/{projectKey}/seed-import` +- `GET /api/mining-checker/v1/projects/{projectKey}/schema-status` +- `POST /api/mining-checker/v1/projects/{projectKey}/initialize` +- `POST /api/mining-checker/v1/projects/{projectKey}/upgrade` +- `GET /api/mining-checker/v1/projects/{projectKey}/connection-test` +- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history` +- `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` + +## Integration + +1. SQL aus dem passenden Dialekt-Schema ausfuehren: + - MySQL/MariaDB: `sql/schema.mysql.sql` + - PostgreSQL: `sql/schema.pgsql.sql` + - `sql/schema.sql` bleibt der Rueckfall fuer bestehende Setups +2. Das Modul nutzt bewusst dieselbe Projekt-Datenbank wie die Anwendung und legt seine Tabellen mit dem Praefix `miningcheck_` an. +3. Modulroute ueber `/module/mining-checker` aufrufen. +4. REST-API wird ueber `/api/mining-checker/...` vom Hauptprojekt geroutet. + +Hinweis: +Wenn beim ersten API-Zugriff noch keine `miningcheck_*` Tabellen vorhanden sind, importiert das Modul automatisch das zum aktiven PDO-Treiber passende Schema. +Seed-Daten werden dabei nicht automatisch eingespielt. +Fuer eine manuelle Initialisierung, ein inkrementelles Upgrade oder einen Reset gibt es zusaetzlich `schema-status`, `upgrade` und `initialize`. Mit `{ "drop_existing": true }` werden vorhandene `miningcheck_*` Tabellen inklusive Daten geloescht und das Schema neu angelegt. + +## OCR-Hinweis + +Das Modul unterstuetzt einen OCR-Provider-Stack. Standardmaessig wird zuerst `ocr.space` verwendet und danach optional auf lokales `tesseract` zurueckgefallen. + +Empfohlene Umgebungsvariablen: + +- `MINING_CHECKER_OCR_PROVIDERS=ocrspace,tesseract` +- `MINING_CHECKER_OCR_SPACE_URL=https://api.ocr.space/parse/image` +- `MINING_CHECKER_OCR_SPACE_API_KEY=...` +- `MINING_CHECKER_OCR_SPACE_LANGUAGE=eng` +- `MINING_CHECKER_OCR_SPACE_ENGINE=2` +- `MINING_CHECKER_OCR_SPACE_SCALE=true` +- `MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION=true` +- `MINING_CHECKER_OCR_SPACE_IS_TABLE=false` +- `MINING_CHECKER_OCR_SPACE_TIMEOUT=25` +- `MINING_CHECKER_TESSERACT_BIN=/usr/bin/tesseract` +- `MINING_CHECKER_TESSERACT_LANG=eng` + +Laut OCR.space-Doku wird `POST https://api.ocr.space/parse/image` mit `file`, Header-`apikey`, optional `language`, `scale`, `detectOrientation`, `isTable` und `OCREngine` verwendet. Der Modulparser wertet die OCR.space-Felder `ParsedResults`, `ParsedText`, `IsErroredOnProcessing`, `ErrorMessage` und `OCRExitCode` aus. Quellen: https://ocr.space/ocrapi + +## Wechselkurse und Waehrungen + +Der Mining-Checker speichert keine eigenen FX-Snapshots mehr, sondern referenziert die `fetch_id` aus `fx-rates`. +Auch der Waehrungskatalog und die bevorzugten Waehrungen kommen ausschliesslich aus `fx-rates`. Der Mining-Checker fuehrt keine eigene Waehrungstabelle und keine eigene Alias-Verwaltung mehr im Laufzeitpfad. +- `MINING_CHECKER_FX_AUTO_FETCH_ON_MISS=false` + +Optionaler JSON-Body: + +- `base`: Standard `EUR` +- `symbols`: wird aktuell ignoriert; der Mining-Checker speichert immer den kompletten Waehrungssatz des Fetches + +Beispiel: + +```json +{ + "base": "EUR" +} +``` + +`currencyapi.net` wird ueber das Modul `fx-rates` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die eigentlichen Fetches und Raten liegen im Modul `fx-rates`. + +Pro Abruf entsteht genau ein Datensatz in `fx-rates` mit Basiswaehrung, Provider und Stichtag. Neue Mining-Messpunkte pruefen beim Speichern, ob ein neuer FX-Fetch noetig ist; falls nicht, wird die letzte passende `fetch_id` wiederverwendet. + +Falls noch historische Mining-Checker-Fetches in `miningcheck_fx_fetches` und `miningcheck_fx_rates` liegen, kann `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` diese nach `fx-rates` ueberfuehren. Danach werden bestehende Messpunkte soweit moeglich auf die passende `fx_fetch_id` aktualisiert. + +Fuer Auswertungen, Berichte und Listen speichert der Mining-Checker pro Messpunkt die damals passende `fx_fetch_id`. Historische Umrechnungen laufen damit gegen genau den zugeordneten `fx-rates`-Snapshot. diff --git a/temp/nexus-module-import/modules/mining-checker/module.json b/temp/nexus-module-import/modules/mining-checker/module.json new file mode 100644 index 00000000..05fd56d5 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/module.json @@ -0,0 +1,30 @@ +{ + "name": "Mining-Checker", + "title": "Mining-Checker", + "version": "0.3.0", + "description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.", + "enabled_by_default": true, + "setup": { + "sections": { + "database": true + }, + "fields": [ + { "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." }, + { "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql oder mysql" }, + { "name": "db.host", "label": "DB Host", "type": "text", "required": false }, + { "name": "db.port", "label": "DB Port", "type": "number", "required": false }, + { "name": "db.dbname", "label": "DB Name", "type": "text", "required": false }, + { "name": "db.schema", "label": "DB Schema", "type": "text", "required": false }, + { "name": "db.user", "label": "DB User", "type": "text", "required": false }, + { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false }, + { "name": "baseline_measured_at", "label": "Baseline Zeitpunkt", "type": "datetime-local", "required": false, "help": "Referenzzeitpunkt fuer die erste Baseline-Messung." }, + { "name": "baseline_coins_total", "label": "Baseline Coins", "type": "number", "required": false, "help": "Coin-Bestand zum Baseline-Zeitpunkt." }, + { "name": "report_currency", "label": "Standard-Berichtswaehrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Kennzahlen und Berichte, z.B. EUR." } + ] + }, + "auth": { + "required": true, + "users": [], + "groups": [] + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/pages/index.php b/temp/nexus-module-import/modules/mining-checker/pages/index.php new file mode 100644 index 00000000..d3ff0109 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/pages/index.php @@ -0,0 +1,59 @@ + $label, + 'href' => '/module/mining-checker?view=' . rawurlencode($key), + 'active' => $activeView === $key, + ]; +} +if ($allowedViews === []) { + $allowedViews = ['overview']; +} +if (!in_array($activeView, $allowedViews, true)) { + $activeView = $allowedViews[0]; + foreach ($tabs as &$tab) { + $tab['active'] = str_ends_with((string) ($tab['href'] ?? ''), 'view=' . $activeView); + } + unset($tab); +} +$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +$moduleCss = file_get_contents(dirname(__DIR__) . '/assets/css/app.css') ?: ''; +$moduleJs = file_get_contents(dirname(__DIR__) . '/assets/js/app.js') ?: ''; +$moduleJs = str_replace('', '<\/script>', $moduleJs); +?> + 'DOGE Mining-Checker', + 'tabs' => $tabs, +]) ?> +
+
+
+ + + + + diff --git a/temp/nexus-module-import/modules/mining-checker/partials/.gitkeep b/temp/nexus-module-import/modules/mining-checker/partials/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/temp/nexus-module-import/modules/mining-checker/sql/migrations/001_init.sql b/temp/nexus-module-import/modules/mining-checker/sql/migrations/001_init.sql new file mode 100644 index 00000000..5a1fba97 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/sql/migrations/001_init.sql @@ -0,0 +1,124 @@ +CREATE TABLE IF NOT EXISTS miningcheck_projects ( + project_key VARCHAR(64) NOT NULL PRIMARY KEY, + project_name VARCHAR(160) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS miningcheck_settings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + baseline_measured_at DATETIME NOT NULL, + baseline_coins_total DECIMAL(20,6) NOT NULL, + daily_cost_amount DECIMAL(20,10) NOT NULL, + daily_cost_currency VARCHAR(10) NOT NULL, + report_currency VARCHAR(10) NULL, + crypto_currency VARCHAR(10) NULL, + display_timezone VARCHAR(64) NULL, + fx_max_age_hours DECIMAL(10,2) NULL, + module_theme_mode VARCHAR(16) NULL, + module_theme_accent VARCHAR(16) NULL, + preferred_currencies 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_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_settings_project UNIQUE (project_key) +); + +CREATE TABLE IF NOT EXISTS miningcheck_cost_plans ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + starts_at DATETIME NOT NULL, + runtime_months INT NOT NULL, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + base_price_amount DECIMAL(20,8) NULL, + payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', + total_cost_amount DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + note TEXT NULL, + 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_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX idx_miningcheck_cost_plans_project_start + ON miningcheck_cost_plans(project_key, starts_at); + +CREATE TABLE IF NOT EXISTS miningcheck_measurements ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + measured_at DATETIME NOT NULL, + coins_total DECIMAL(20,6) NOT NULL, + coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', + price_per_coin DECIMAL(20,8) NULL, + price_currency VARCHAR(10) 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_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total) +); + +CREATE INDEX idx_miningcheck_measurements_project_measured_at + ON miningcheck_measurements(project_key, measured_at); + +CREATE TABLE IF NOT EXISTS miningcheck_targets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + target_amount_fiat DECIMAL(20,2) NOT NULL, + currency VARCHAR(10) NOT NULL, + miner_offer_id BIGINT UNSIGNED NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL, + CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label) +); + +CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( + 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 miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) +); + +CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + name VARCHAR(160) NOT NULL, + chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL, + x_field VARCHAR(64) NOT NULL, + y_field VARCHAR(64) NOT NULL, + aggregation VARCHAR(32) NOT NULL DEFAULT 'none', + filters_json JSON NULL, + 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_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name) +); diff --git a/temp/nexus-module-import/modules/mining-checker/sql/migrations/002_seed_doge_main.sql b/temp/nexus-module-import/modules/mining-checker/sql/migrations/002_seed_doge_main.sql new file mode 100644 index 00000000..71dc8fad --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/sql/migrations/002_seed_doge_main.sql @@ -0,0 +1,75 @@ +INSERT INTO miningcheck_projects (project_key, project_name) +VALUES ('doge-main', 'DOGE Mining Main') +ON DUPLICATE KEY UPDATE project_name = VALUES(project_name); + +INSERT INTO miningcheck_settings ( + project_key, + baseline_measured_at, + baseline_coins_total, + daily_cost_amount, + daily_cost_currency, + preferred_currencies +) +VALUES ( + 'doge-main', + '2026-03-16 01:32:00', + 27.617864, + 0.3123287671, + 'EUR', + '["DOGE","USD","EUR"]' +) +ON DUPLICATE KEY UPDATE + baseline_measured_at = VALUES(baseline_measured_at), + baseline_coins_total = VALUES(baseline_coins_total), + daily_cost_amount = VALUES(daily_cost_amount), + daily_cost_currency = VALUES(daily_cost_currency), + preferred_currencies = VALUES(preferred_currencies); + +INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order) +VALUES + ('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10), + ('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20) +ON DUPLICATE KEY UPDATE + target_amount_fiat = VALUES(target_amount_fiat), + currency = VALUES(currency), + is_active = VALUES(is_active), + sort_order = VALUES(sort_order); + +INSERT INTO miningcheck_dashboard_definitions ( + project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active +) +VALUES + ('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1), + ('doge-main', 'Performance-Verlauf', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1), + ('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currency":"EUR"}', 1) +ON DUPLICATE KEY UPDATE + chart_type = VALUES(chart_type), + x_field = VALUES(x_field), + y_field = VALUES(y_field), + aggregation = VALUES(aggregation), + filters_json = VALUES(filters_json), + is_active = VALUES(is_active); + +INSERT INTO miningcheck_measurements ( + project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags +) +VALUES + ('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')), + ('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')), + ('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')), + ('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')) +ON DUPLICATE KEY UPDATE + price_per_coin = VALUES(price_per_coin), + price_currency = VALUES(price_currency), + note = VALUES(note), + source = VALUES(source); diff --git a/temp/nexus-module-import/modules/mining-checker/sql/migrations/003_timezone_utc.sql b/temp/nexus-module-import/modules/mining-checker/sql/migrations/003_timezone_utc.sql new file mode 100644 index 00000000..e31b622f --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/sql/migrations/003_timezone_utc.sql @@ -0,0 +1,34 @@ +BEGIN; + +ALTER TABLE miningcheck_settings +ADD COLUMN IF NOT EXISTS display_timezone VARCHAR(64); + +UPDATE miningcheck_settings +SET display_timezone = 'Europe/Berlin' +WHERE display_timezone IS NULL OR BTRIM(display_timezone) = ''; + +UPDATE miningcheck_settings +SET baseline_measured_at = ((baseline_measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') +WHERE baseline_measured_at IS NOT NULL; + +UPDATE miningcheck_cost_plans +SET starts_at = ((starts_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') +WHERE starts_at IS NOT NULL; + +UPDATE miningcheck_measurements +SET measured_at = ((measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') +WHERE measured_at IS NOT NULL; + +UPDATE miningcheck_payouts +SET payout_at = ((payout_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') +WHERE payout_at IS NOT NULL; + +UPDATE miningcheck_purchased_miners +SET purchased_at = ((purchased_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') +WHERE purchased_at IS NOT NULL; + +UPDATE miningcheck_fx_fetches +SET fetched_at = ((fetched_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC') +WHERE fetched_at IS NOT NULL; + +COMMIT; diff --git a/temp/nexus-module-import/modules/mining-checker/sql/migrations/004_merge_cost_plans_into_purchased_miners.sql b/temp/nexus-module-import/modules/mining-checker/sql/migrations/004_merge_cost_plans_into_purchased_miners.sql new file mode 100644 index 00000000..bc6e4bed --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/sql/migrations/004_merge_cost_plans_into_purchased_miners.sql @@ -0,0 +1,72 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS miningcheck_cost_plans_legacy AS +SELECT * +FROM miningcheck_cost_plans +WHERE 1 = 0; + +INSERT INTO miningcheck_cost_plans_legacy +SELECT cp.* +FROM miningcheck_cost_plans cp +LEFT JOIN miningcheck_cost_plans_legacy legacy + ON legacy.id = cp.id +WHERE legacy.id IS NULL; + +INSERT INTO miningcheck_purchased_miners ( + project_key, + miner_offer_id, + purchased_at, + label, + runtime_months, + mining_speed_value, + mining_speed_unit, + bonus_speed_value, + bonus_speed_unit, + total_cost_amount, + currency, + usd_reference_amount, + reference_price_amount, + reference_price_currency, + auto_renew, + note, + is_active +) +SELECT + cp.project_key, + NULL AS miner_offer_id, + cp.starts_at AS purchased_at, + cp.label, + cp.runtime_months, + cp.mining_speed_value, + cp.mining_speed_unit, + cp.bonus_speed_value, + cp.bonus_speed_unit, + cp.total_cost_amount, + cp.currency, + CASE + WHEN COALESCE(s.report_currency, 'EUR') = 'USD' THEN cp.base_price_amount + ELSE NULL + END AS usd_reference_amount, + cp.base_price_amount AS reference_price_amount, + COALESCE(s.report_currency, 'EUR') AS reference_price_currency, + cp.auto_renew, + CASE + WHEN cp.note IS NULL OR BTRIM(cp.note) = '' THEN 'Migriert aus miningcheck_cost_plans' + ELSE cp.note || ' | Migriert aus miningcheck_cost_plans' + END AS note, + cp.is_active +FROM miningcheck_cost_plans cp +LEFT JOIN miningcheck_settings s + ON s.project_key = cp.project_key +LEFT JOIN miningcheck_purchased_miners pm + ON pm.project_key = cp.project_key + AND pm.miner_offer_id IS NULL + AND pm.purchased_at = cp.starts_at + AND pm.label = cp.label + AND pm.total_cost_amount = cp.total_cost_amount + AND pm.currency = cp.currency +WHERE pm.id IS NULL; + +DELETE FROM miningcheck_cost_plans; + +COMMIT; diff --git a/temp/nexus-module-import/modules/mining-checker/sql/migrations/005_module_theme_settings.sql b/temp/nexus-module-import/modules/mining-checker/sql/migrations/005_module_theme_settings.sql new file mode 100644 index 00000000..1caf2fa0 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/sql/migrations/005_module_theme_settings.sql @@ -0,0 +1,15 @@ +BEGIN; + +ALTER TABLE miningcheck_settings +ADD COLUMN IF NOT EXISTS module_theme_mode VARCHAR(16), +ADD COLUMN IF NOT EXISTS module_theme_accent VARCHAR(16); + +UPDATE miningcheck_settings +SET module_theme_mode = 'inherit' +WHERE module_theme_mode IS NULL OR BTRIM(module_theme_mode) = ''; + +UPDATE miningcheck_settings +SET module_theme_accent = 'teal' +WHERE module_theme_accent IS NULL OR BTRIM(module_theme_accent) = ''; + +COMMIT; diff --git a/temp/nexus-module-import/modules/mining-checker/sql/migrations/006_user_scope_owner_sub.sql b/temp/nexus-module-import/modules/mining-checker/sql/migrations/006_user_scope_owner_sub.sql new file mode 100644 index 00000000..9bc3a1ea --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/sql/migrations/006_user_scope_owner_sub.sql @@ -0,0 +1,182 @@ +-- Bestehende benutzerspezifische Mining-Daten werden diesem Keycloak-Sub zugeordnet: +-- adea1766-5d1c-4c2e-98bd-5239861f745f +-- Die Keycloak-Sub ist stabiler als preferred_username und wird fuer alle benutzerspezifischen Mining-Daten genutzt. + +BEGIN; + +ALTER TABLE miningcheck_settings +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_cost_plans +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_measurements +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_measurement_rates +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_payouts +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_targets +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_dashboard_definitions +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +ALTER TABLE miningcheck_purchased_miners +ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128); + +UPDATE miningcheck_settings +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_cost_plans +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_measurements +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_measurement_rates mr +SET owner_sub = m.owner_sub +FROM miningcheck_measurements m +WHERE mr.measurement_id = m.id + AND (mr.owner_sub IS NULL OR BTRIM(mr.owner_sub) = ''); + +UPDATE miningcheck_measurement_rates +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_payouts +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_targets +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_dashboard_definitions +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +UPDATE miningcheck_purchased_miners +SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f' +WHERE owner_sub IS NULL OR BTRIM(owner_sub) = ''; + +ALTER TABLE miningcheck_settings +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_cost_plans +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_measurements +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_measurement_rates +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_payouts +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_targets +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_dashboard_definitions +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_purchased_miners +ALTER COLUMN owner_sub SET NOT NULL; + +ALTER TABLE miningcheck_settings +DROP CONSTRAINT IF EXISTS miningcheck_settings_project_key_key; + +ALTER TABLE miningcheck_measurements +DROP CONSTRAINT IF EXISTS uq_mining_measurements_unique; + +ALTER TABLE miningcheck_targets +DROP CONSTRAINT IF EXISTS uq_mining_targets_project_label; + +ALTER TABLE miningcheck_dashboard_definitions +DROP CONSTRAINT IF EXISTS uq_mining_dashboards_project_name; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = current_schema() + AND table_name = 'miningcheck_settings' + AND constraint_name = 'uq_mining_settings_project_owner' + ) THEN + ALTER TABLE miningcheck_settings + ADD CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = current_schema() + AND table_name = 'miningcheck_measurements' + AND constraint_name = 'uq_mining_measurements_unique' + ) THEN + ALTER TABLE miningcheck_measurements + ADD CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = current_schema() + AND table_name = 'miningcheck_targets' + AND constraint_name = 'uq_mining_targets_project_label' + ) THEN + ALTER TABLE miningcheck_targets + ADD CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label); + END IF; +END $$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_schema = current_schema() + AND table_name = 'miningcheck_dashboard_definitions' + AND constraint_name = 'uq_mining_dashboards_project_name' + ) THEN + ALTER TABLE miningcheck_dashboard_definitions + ADD CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_owner_start + ON miningcheck_cost_plans(project_key, owner_sub, starts_at); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_owner_measured_at + ON miningcheck_measurements(project_key, owner_sub, measured_at); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_owner_measurement + ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_owner_payout_at + ON miningcheck_payouts(project_key, owner_sub, payout_at); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_targets_project_owner + ON miningcheck_targets(project_key, owner_sub, sort_order, id); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_dashboards_project_owner + ON miningcheck_dashboard_definitions(project_key, owner_sub, id); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_purchased_miners_project_owner_purchased_at + ON miningcheck_purchased_miners(project_key, owner_sub, purchased_at); + +COMMIT; diff --git a/temp/nexus-module-import/modules/mining-checker/sql/schema.mysql.sql b/temp/nexus-module-import/modules/mining-checker/sql/schema.mysql.sql new file mode 100644 index 00000000..7f83b187 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/sql/schema.mysql.sql @@ -0,0 +1,223 @@ +CREATE TABLE IF NOT EXISTS miningcheck_projects ( + project_key VARCHAR(64) NOT NULL PRIMARY KEY, + project_name VARCHAR(160) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS miningcheck_settings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + baseline_measured_at DATETIME NOT NULL, + baseline_coins_total DECIMAL(20,6) NOT NULL, + daily_cost_amount DECIMAL(20,10) NOT NULL, + daily_cost_currency VARCHAR(10) NOT NULL, + report_currency VARCHAR(10) NULL, + crypto_currency VARCHAR(10) NULL, + display_timezone VARCHAR(64) NULL, + fx_max_age_hours DECIMAL(10,2) NULL, + module_theme_mode VARCHAR(16) NULL, + module_theme_accent VARCHAR(16) NULL, + preferred_currencies 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_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_settings_project UNIQUE (project_key) +); + +CREATE TABLE IF NOT EXISTS miningcheck_cost_plans ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + starts_at DATETIME NOT NULL, + runtime_months INT NOT 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, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + base_price_amount DECIMAL(20,8) NULL, + payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', + total_cost_amount DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + note TEXT NULL, + 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_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX idx_miningcheck_cost_plans_project_start + ON miningcheck_cost_plans(project_key, starts_at); + +CREATE TABLE IF NOT EXISTS miningcheck_measurements ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + measured_at DATETIME NOT NULL, + coins_total DECIMAL(20,6) NOT NULL, + coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', + price_per_coin DECIMAL(20,8) NULL, + price_currency VARCHAR(10) NULL, + fx_fetch_id BIGINT UNSIGNED 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_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total) +); + +CREATE INDEX idx_miningcheck_measurements_project_measured_at + ON miningcheck_measurements(project_key, measured_at); + +CREATE INDEX idx_miningcheck_measurements_fx_fetch + ON miningcheck_measurements(fx_fetch_id); + +CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates ( + 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 miningcheck_measurements(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) +); + +CREATE TABLE IF NOT EXISTS miningcheck_payouts ( + 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 miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at) +); + +CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( + 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 miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) +); + +CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( + 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 miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS miningcheck_targets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + target_amount_fiat DECIMAL(20,2) NOT NULL, + currency VARCHAR(10) NOT NULL, + miner_offer_id BIGINT UNSIGNED NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL, + CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label) +); + +CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + name VARCHAR(160) NOT NULL, + chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL, + x_field VARCHAR(64) NOT NULL, + y_field VARCHAR(64) NOT NULL, + aggregation VARCHAR(32) NOT NULL DEFAULT 'none', + filters_json JSON NULL, + 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_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name) +); + +CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners ( + 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 miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches ( + 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 miningcheck_fx_rates ( + 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 miningcheck_fx_fetches(id) ON DELETE CASCADE +); diff --git a/temp/nexus-module-import/modules/mining-checker/sql/schema.pgsql.sql b/temp/nexus-module-import/modules/mining-checker/sql/schema.pgsql.sql new file mode 100644 index 00000000..232489dc --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/sql/schema.pgsql.sql @@ -0,0 +1,243 @@ +CREATE TABLE IF NOT EXISTS miningcheck_projects ( + project_key VARCHAR(64) PRIMARY KEY, + project_name VARCHAR(160) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS miningcheck_settings ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + baseline_measured_at TIMESTAMP NOT NULL, + baseline_coins_total NUMERIC(20,6) NOT NULL, + daily_cost_amount NUMERIC(20,10) NOT NULL, + daily_cost_currency VARCHAR(10) NOT NULL, + report_currency VARCHAR(10), + crypto_currency VARCHAR(10), + display_timezone VARCHAR(64), + fx_max_age_hours NUMERIC(10,2), + module_theme_mode VARCHAR(16), + module_theme_accent VARCHAR(16), + preferred_currencies JSONB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub) +); + +CREATE TABLE IF NOT EXISTS miningcheck_cost_plans ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + label VARCHAR(120) NOT NULL, + starts_at TIMESTAMP NOT NULL, + runtime_months INTEGER NOT NULL, + mining_speed_value NUMERIC(20,4), + mining_speed_unit VARCHAR(8), + bonus_speed_value NUMERIC(20,4), + bonus_speed_unit VARCHAR(8), + auto_renew BOOLEAN NOT NULL DEFAULT FALSE, + base_price_amount NUMERIC(20,8), + payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', + total_cost_amount NUMERIC(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + 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_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_start + ON miningcheck_cost_plans(project_key, owner_sub, starts_at); + +CREATE TABLE IF NOT EXISTS miningcheck_measurements ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + measured_at TIMESTAMP NOT NULL, + coins_total NUMERIC(20,6) NOT NULL, + coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', + price_per_coin NUMERIC(20,8), + price_currency VARCHAR(10), + fx_fetch_id BIGINT, + note TEXT, + source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')), + 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_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total) +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_measured_at + ON miningcheck_measurements(project_key, owner_sub, measured_at); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch + ON miningcheck_measurements(fx_fetch_id); + +CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates ( + id BIGSERIAL PRIMARY KEY, + measurement_id BIGINT NOT NULL, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) 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 miningcheck_measurements(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 miningcheck_measurement_rates(project_key, owner_sub, measurement_id); + +CREATE TABLE IF NOT EXISTS miningcheck_payouts ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) 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 miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at + ON miningcheck_payouts(project_key, owner_sub, payout_at); + +CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( + 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 CHECK (source IN ('manual', 'image_ocr', 'seed_import')), + 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 miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at + ON miningcheck_wallet_snapshots(project_key, owner_sub, measured_at); + +CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( + 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 miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS miningcheck_targets ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + label VARCHAR(120) NOT NULL, + target_amount_fiat NUMERIC(20,2) NOT NULL, + currency VARCHAR(10) NOT NULL, + miner_offer_id BIGINT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL, + CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label) +); + +CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + name VARCHAR(160) NOT NULL, + chart_type VARCHAR(16) NOT NULL CHECK (chart_type IN ('line', 'bar', 'area', 'table')), + x_field VARCHAR(64) NOT NULL, + y_field VARCHAR(64) NOT NULL, + aggregation VARCHAR(32) NOT NULL DEFAULT 'none', + filters_json JSONB, + 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_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name) +); + +CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) 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 miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches ( + 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 miningcheck_fx_fetches(base_currency, fetched_at); + +CREATE TABLE IF NOT EXISTS miningcheck_fx_rates ( + 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 miningcheck_fx_fetches(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_fetch + ON miningcheck_fx_rates(fetch_id); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_currency + ON miningcheck_fx_rates(currency_code); diff --git a/temp/nexus-module-import/modules/mining-checker/sql/schema.sql b/temp/nexus-module-import/modules/mining-checker/sql/schema.sql new file mode 100644 index 00000000..7f83b187 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/sql/schema.sql @@ -0,0 +1,223 @@ +CREATE TABLE IF NOT EXISTS miningcheck_projects ( + project_key VARCHAR(64) NOT NULL PRIMARY KEY, + project_name VARCHAR(160) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS miningcheck_settings ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + baseline_measured_at DATETIME NOT NULL, + baseline_coins_total DECIMAL(20,6) NOT NULL, + daily_cost_amount DECIMAL(20,10) NOT NULL, + daily_cost_currency VARCHAR(10) NOT NULL, + report_currency VARCHAR(10) NULL, + crypto_currency VARCHAR(10) NULL, + display_timezone VARCHAR(64) NULL, + fx_max_age_hours DECIMAL(10,2) NULL, + module_theme_mode VARCHAR(16) NULL, + module_theme_accent VARCHAR(16) NULL, + preferred_currencies 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_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_settings_project UNIQUE (project_key) +); + +CREATE TABLE IF NOT EXISTS miningcheck_cost_plans ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + starts_at DATETIME NOT NULL, + runtime_months INT NOT 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, + auto_renew TINYINT(1) NOT NULL DEFAULT 0, + base_price_amount DECIMAL(20,8) NULL, + payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat', + total_cost_amount DECIMAL(20,8) NOT NULL, + currency VARCHAR(10) NOT NULL, + note TEXT NULL, + 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_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX idx_miningcheck_cost_plans_project_start + ON miningcheck_cost_plans(project_key, starts_at); + +CREATE TABLE IF NOT EXISTS miningcheck_measurements ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + measured_at DATETIME NOT NULL, + coins_total DECIMAL(20,6) NOT NULL, + coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE', + price_per_coin DECIMAL(20,8) NULL, + price_currency VARCHAR(10) NULL, + fx_fetch_id BIGINT UNSIGNED 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_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total) +); + +CREATE INDEX idx_miningcheck_measurements_project_measured_at + ON miningcheck_measurements(project_key, measured_at); + +CREATE INDEX idx_miningcheck_measurements_fx_fetch + ON miningcheck_measurements(fx_fetch_id); + +CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates ( + 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 miningcheck_measurements(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) +); + +CREATE TABLE IF NOT EXISTS miningcheck_payouts ( + 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 miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at) +); + +CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( + 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 miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) +); + +CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( + 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 miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS miningcheck_targets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + label VARCHAR(120) NOT NULL, + target_amount_fiat DECIMAL(20,2) NOT NULL, + currency VARCHAR(10) NOT NULL, + miner_offer_id BIGINT UNSIGNED NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL, + CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label) +); + +CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + name VARCHAR(160) NOT NULL, + chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL, + x_field VARCHAR(64) NOT NULL, + y_field VARCHAR(64) NOT NULL, + aggregation VARCHAR(32) NOT NULL DEFAULT 'none', + filters_json JSON NULL, + 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_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name) +); + +CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners ( + 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 miningcheck_projects(project_key) ON DELETE CASCADE, + CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches ( + 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 miningcheck_fx_rates ( + 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 miningcheck_fx_fetches(id) ON DELETE CASCADE +); diff --git a/temp/nexus-module-import/modules/mining-checker/sql/seed.sql b/temp/nexus-module-import/modules/mining-checker/sql/seed.sql new file mode 100644 index 00000000..79975651 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/sql/seed.sql @@ -0,0 +1,75 @@ +INSERT INTO miningcheck_projects (project_key, project_name) +VALUES ('doge-main', 'DOGE Mining Main') +ON DUPLICATE KEY UPDATE project_name = VALUES(project_name); + +INSERT INTO miningcheck_settings ( + project_key, + baseline_measured_at, + baseline_coins_total, + daily_cost_amount, + daily_cost_currency, + preferred_currencies +) +VALUES ( + 'doge-main', + '2026-03-16 01:32:00', + 27.617864, + 0.3123287671, + 'EUR', + '["DOGE","USD","EUR"]' +) +ON DUPLICATE KEY UPDATE + baseline_measured_at = VALUES(baseline_measured_at), + baseline_coins_total = VALUES(baseline_coins_total), + daily_cost_amount = VALUES(daily_cost_amount), + daily_cost_currency = VALUES(daily_cost_currency), + preferred_currencies = VALUES(preferred_currencies); + +INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order) +VALUES + ('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10), + ('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20) +ON DUPLICATE KEY UPDATE + target_amount_fiat = VALUES(target_amount_fiat), + currency = VALUES(currency), + is_active = VALUES(is_active), + sort_order = VALUES(sort_order); + +INSERT INTO miningcheck_dashboard_definitions ( + project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active +) +VALUES + ('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1), + ('doge-main', 'DOGE pro Tag', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1), + ('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currencies":["EUR","USD"]}', 1) +ON DUPLICATE KEY UPDATE + chart_type = VALUES(chart_type), + x_field = VALUES(x_field), + y_field = VALUES(y_field), + aggregation = VALUES(aggregation), + filters_json = VALUES(filters_json), + is_active = VALUES(is_active); + +INSERT INTO miningcheck_measurements ( + project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags +) +VALUES + ('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL), + ('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')), + ('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')), + ('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')), + ('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')) +ON DUPLICATE KEY UPDATE + price_per_coin = VALUES(price_per_coin), + price_currency = VALUES(price_currency), + note = VALUES(note), + source = VALUES(source); diff --git a/temp/nexus-module-import/modules/mining-checker/src/Api/Router.php b/temp/nexus-module-import/modules/mining-checker/src/Api/Router.php new file mode 100644 index 00000000..635b29f4 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Api/Router.php @@ -0,0 +1,2774 @@ + ['value' => 50.0, 'unit' => 'kH/s'], + 'crypto' => ['value' => 75.0, 'unit' => 'kH/s'], + ]; + + private string $moduleBasePath; + private ModuleConfig $config; + private ?PDO $pdo = null; + private ?MiningRepository $repository = null; + private ?AnalyticsService $analytics = null; + private OcrService $ocr; + private ?SeedImporter $seedImporter = null; + private ?SchemaManager $schemaManager = null; + private ?FxService $fx = null; + private ?array $fxRatesSettingsCache = null; + private ?array $currencyCatalogCache = null; + private DebugTrace $debug; + + public function __construct(string $moduleBasePath) + { + $this->moduleBasePath = rtrim($moduleBasePath, '/'); + $this->config = ModuleConfig::load($this->moduleBasePath); + $requestUri = (string) ($_SERVER['REQUEST_URI'] ?? ''); + $requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: ''); + $debugEnabled = function_exists('module_debug_enabled') ? module_debug_enabled('mining-checker') : false; + $latestDebugFilePath = rtrim($this->config->debugDir(), '/') . '/latest-server.json'; + $isLatestDebugRequest = str_ends_with($requestPath, '/api/mining-checker/v1/debug/latest') + || $requestPath === 'api/mining-checker/v1/debug/latest' + || $requestPath === '/api/mining-checker/v1/debug/latest'; + $debugFilePath = ($debugEnabled && !$isLatestDebugRequest) ? $latestDebugFilePath : null; + DebugState::setLatestFilePath($latestDebugFilePath); + $this->debug = new DebugTrace((bool) $debugEnabled, $debugFilePath); + $this->debug->add('router.init', [ + 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', + 'uri' => $requestUri, + ]); + $this->ocr = new OcrService($this->config); + } + + public function handle(string $relativePath): never + { + try { + $this->configureRuntimeGuards(); + $this->releaseSessionLock(); + $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + $path = trim($relativePath, '/'); + $this->debug->add('router.handle.start', [ + 'path' => $path, + 'method' => $method, + ]); + + if ($path === 'v1/health') { + $this->respond(['ok' => true, 'module' => 'mining-checker']); + } + + if ($path === 'v1/debug/runtime' && $method === 'GET') { + $this->respond(['data' => $this->debugRuntime()]); + } + + if ($path === 'v1/debug/pdo' && $method === 'GET') { + $this->respond(['data' => $this->debugPdo()]); + } + + if ($path === 'v1/debug/schema-meta' && $method === 'GET') { + $this->respond(['data' => $this->debugSchemaMeta()]); + } + + if ($path === 'v1/debug/latest' && $method === 'GET') { + $this->respond(['data' => $this->debugLatest()]); + } + + $matches = []; + if (!preg_match('~^v1/projects/([a-zA-Z0-9_-]+)(?:/(.*))?$~', $path, $matches)) { + throw new ApiException('Unbekannter API-Pfad.', 404, ['path' => $path]); + } + + $projectKey = $matches[1]; + $resource = trim((string) ($matches[2] ?? ''), '/'); + $this->applyRouteRuntimeGuards($resource); + + if ($resource === 'schema-status' && $method === 'GET') { + $this->respond(['data' => $this->simpleSchemaStatus()]); + } + + if ($resource === 'initialize' && $method === 'POST') { + $input = Http::input(); + $this->respond([ + 'data' => $this->schemaManager()->initializeSchema(!empty($input['drop_existing'])), + ], 201); + } + + if ($resource === 'sql-import' && $method === 'POST') { + if (!isset($_FILES['sql_file']) || !is_array($_FILES['sql_file'])) { + throw new ApiException('Feld sql_file fehlt.', 422); + } + + $this->respond([ + 'data' => $this->schemaManager()->importSqlFile($_FILES['sql_file']), + ], 201); + } + + if ($resource === 'upgrade' && $method === 'POST') { + $this->respond([ + 'data' => $this->schemaManager()->upgradeSchemaDirect(), + ], 201); + } + + if ($resource === 'rebuild-preserve-core' && $method === 'POST') { + $this->respond([ + 'data' => $this->rebuildPreservingCoreData($projectKey), + ], 201); + } + + if ($resource === 'connection-test' && $method === 'GET') { + $this->respond(['data' => $this->connectionStatus()]); + } + + if ($resource === 'fx-history' && $method === 'GET') { + $this->respond(['data' => $this->fxHistory()]); + } + + if ($resource === 'legacy-fx-migrate' && $method === 'POST') { + $this->respond(['data' => $this->migrateLegacyFxRates($projectKey)], 201); + } + + if ($resource === 'bootstrap' && $method === 'GET') { + $view = trim((string) ($_GET['view'] ?? 'overview')); + $this->respond(['data' => $this->bootstrap($projectKey, $view)]); + } + + if ($resource === 'measurements' && $method === 'GET') { + Http::json(['data' => $this->measurements($projectKey)]); + } + + if ($resource === 'measurements' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->createMeasurement($projectKey, Http::input())], 201); + } + + if (preg_match('~^measurements/(\d+)$~', $resource, $matches) && $method === 'DELETE') { + $this->deleteMeasurement($projectKey, (int) $matches[1]); + Http::json(['data' => ['deleted' => true]]); + } + + if ($resource === 'measurements-import' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->importMeasurements($projectKey, Http::input())], 201); + } + + if ($resource === 'ocr-preview' && $method === 'POST') { + if (!isset($_FILES['image'])) { + throw new ApiException('Feld image fehlt.', 422); + } + + $ocrStartedAt = microtime(true); + $this->debug->add('ocr.preview.start', [ + 'project_key' => $projectKey, + 'file_name' => $_FILES['image']['name'] ?? null, + 'file_size' => $_FILES['image']['size'] ?? null, + ]); + $preview = $this->ocr->preview($_FILES['image'], array_merge($_POST, ['project_key' => $projectKey])); + $this->debug->add('ocr.preview.end', [ + 'project_key' => $projectKey, + 'duration_ms' => round((microtime(true) - $ocrStartedAt) * 1000, 2), + 'confidence' => $preview['confidence'] ?? null, + 'flags' => is_array($preview['flags'] ?? null) ? $preview['flags'] : [], + ]); + Http::json(['data' => $preview], 201); + } + + if ($resource === 'settings' && $method === 'GET') { + Http::json(['data' => $this->settings($projectKey)]); + } + + if ($resource === 'settings' && $method === 'PUT') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveSettings($projectKey, Http::input())]); + } + + if ($resource === 'targets' && $method === 'GET') { + Http::json(['data' => $this->targets($projectKey)]); + } + + if ($resource === 'targets' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveTarget($projectKey, Http::input())], 201); + } + + if (preg_match('~^targets/(\d+)$~', $resource, $matches) && $method === 'PATCH') { + Http::json(['data' => $this->updateTarget($projectKey, (int) $matches[1], Http::input())]); + } + + if (preg_match('~^targets/(\d+)$~', $resource, $matches) && $method === 'DELETE') { + $this->deleteTarget($projectKey, (int) $matches[1]); + Http::json(['data' => ['deleted' => true]]); + } + + if ($resource === 'dashboards' && $method === 'GET') { + Http::json(['data' => $this->dashboards($projectKey)]); + } + + if ($resource === 'dashboards' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveDashboard($projectKey, Http::input())], 201); + } + + if ($resource === 'dashboard-data' && $method === 'GET') { + Http::json(['data' => $this->dashboardData($projectKey, $_GET)]); + } + + if ($resource === 'seed-import' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->seedImporter()->import($projectKey)], 201); + } + + if ($resource === 'cost-plans' && $method === 'GET') { + Http::json(['data' => $this->costPlans($projectKey)]); + } + + if ($resource === 'cost-plans' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveCostPlan($projectKey, Http::input())], 201); + } + + if ($resource === 'payouts' && $method === 'GET') { + Http::json(['data' => $this->payouts($projectKey)]); + } + + if ($resource === 'payouts' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->savePayout($projectKey, Http::input())], 201); + } + + if ($resource === 'wallet-snapshots' && $method === 'GET') { + Http::json(['data' => $this->walletSnapshots($projectKey)]); + } + + if ($resource === 'wallet-snapshots' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveWalletSnapshot($projectKey, Http::input())], 201); + } + + if ($resource === 'miner-offers' && $method === 'GET') { + Http::json(['data' => $this->minerOffers($projectKey)]); + } + + if ($resource === 'miner-offers' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveMinerOffer($projectKey, Http::input())], 201); + } + + if ($resource === 'purchased-miners' && $method === 'GET') { + Http::json(['data' => $this->purchasedMiners($projectKey)]); + } + + if (preg_match('~^purchased-miners/(\d+)$~', $resource, $matches) && $method === 'PATCH') { + Http::json(['data' => $this->updatePurchasedMiner($projectKey, (int) $matches[1], Http::input())]); + } + + if (preg_match('~^miner-offers/(\d+)/purchase$~', $resource, $matches) && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->purchaseMiner($projectKey, (int) $matches[1], Http::input())], 201); + } + + throw new ApiException('Ressource nicht gefunden.', 404, ['resource' => $resource, 'method' => $method]); + } catch (ApiException $exception) { + $this->debug->add('router.handle.api_exception', [ + 'status' => $exception->statusCode(), + 'message' => $exception->getMessage(), + 'context' => $exception->context(), + ]); + Http::json([ + 'error' => $exception->getMessage(), + 'context' => $exception->context(), + ], $exception->statusCode()); + } catch (\Throwable $exception) { + $this->debug->add('router.handle.error', [ + 'type' => get_debug_type($exception), + 'message' => $exception->getMessage(), + ]); + Http::json([ + 'error' => 'Unerwarteter Mining-Checker Fehler.', + 'context' => ['message' => $exception->getMessage()], + ], 500); + } + } + + private function bootstrap(string $projectKey, string $view = 'overview'): array + { + $startedAt = microtime(true); + $view = $this->normalizeBootstrapView($view); + $this->debug->add('bootstrap.start', [ + 'project_key' => $projectKey, + 'view' => $view, + 'measurement_limit' => self::BOOTSTRAP_MEASUREMENT_LIMIT, + 'snapshot_limit' => self::BOOTSTRAP_SNAPSHOT_LIMIT, + ]); + + $settings = $this->safeTimed('bootstrap.settings', fn () => $this->settings($projectKey, $this->bootstrapSettingsOptions($view)), [ + 'project_key' => $projectKey, + 'baseline_measured_at' => null, + 'baseline_coins_total' => null, + 'daily_cost_amount' => null, + 'daily_cost_currency' => 'EUR', + 'report_currency' => 'EUR', + 'crypto_currency' => 'DOGE', + 'display_timezone' => nexus_display_timezone_name(), + 'fx_max_age_hours' => self::FX_FETCH_MAX_AGE_HOURS, + 'module_theme_mode' => 'inherit', + 'module_theme_accent' => 'teal', + 'preferred_currencies' => ['DOGE', 'USD', 'EUR'], + 'cost_plans' => [], + 'currencies' => [], + 'payouts' => [], + 'miner_offers' => [], + 'purchased_miners' => [], + 'measurement_rates' => [], + ], ['project_key' => $projectKey]); + $walletSnapshots = $this->safeTimed('bootstrap.wallet_snapshots', fn () => $this->bootstrapWalletSnapshots($projectKey, $view), [], [ + 'project_key' => $projectKey, + 'view' => $view, + ]); + $measurements = $this->safeTimed('bootstrap.measurements', fn () => $this->bootstrapMeasurements($projectKey, $settings, $view), [], [ + 'project_key' => $projectKey, + 'view' => $view, + ]); + $targets = $this->safeTimed('bootstrap.targets', fn () => $this->bootstrapTargets($projectKey, $view), [], [ + 'project_key' => $projectKey, + 'view' => $view, + ]); + $dashboards = $this->safeTimed('bootstrap.dashboards', fn () => $this->bootstrapDashboards($projectKey, $view), [], [ + 'project_key' => $projectKey, + 'view' => $view, + ]); + $fxSnapshots = $this->safeTimed('bootstrap.fx_snapshots', fn () => $this->bootstrapFxSnapshots($measurements, $view), [], [ + 'view' => $view, + 'measurement_count' => is_array($measurements) ? count($measurements) : 0, + ]); + $summary = $this->safeTimed('bootstrap.summary', fn () => $this->bootstrapSummary($measurements, $settings, $targets, $walletSnapshots, $view), [ + 'latest_measurement' => $measurements !== [] ? $measurements[array_key_last($measurements)] : null, + 'baseline' => $settings, + 'targets' => [], + 'payouts' => [], + 'miner_offers' => [], + ], [ + 'view' => $view, + 'measurement_count' => is_array($measurements) ? count($measurements) : 0, + 'target_count' => is_array($targets) ? count($targets) : 0, + ]); + $measurementCount = is_array($measurements) ? count($measurements) : 0; + $this->debug->add('bootstrap.end', [ + 'project_key' => $projectKey, + 'duration_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'measurement_count' => $measurementCount, + 'target_count' => is_array($targets) ? count($targets) : 0, + 'dashboard_count' => is_array($dashboards) ? count($dashboards) : 0, + 'snapshot_count' => is_array($fxSnapshots) ? count($fxSnapshots) : 0, + ]); + + return [ + 'project' => $this->repository()->getProject($projectKey), + 'settings' => $settings, + 'wallet_snapshots' => $walletSnapshots, + 'measurements' => $measurements, + 'targets' => $targets, + 'dashboards' => $dashboards, + 'fx_snapshots' => $fxSnapshots, + 'summary' => $summary, + 'bootstrap_meta' => [ + 'degraded' => $measurementCount >= self::BOOTSTRAP_MEASUREMENT_LIMIT, + 'view' => $view, + 'overview_window_days' => self::OVERVIEW_WINDOW_DAYS, + 'measurement_limit' => self::BOOTSTRAP_MEASUREMENT_LIMIT, + 'snapshot_limit' => self::BOOTSTRAP_SNAPSHOT_LIMIT, + 'measurement_count' => $measurementCount, + 'duration_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ], + ]; + } + + private function connectionStatus(): array + { + $driver = (string) $this->pdo()->getAttribute(PDO::ATTR_DRIVER_NAME); + $statement = $this->pdo()->query('SELECT 1'); + $ok = (int) $statement->fetchColumn() === 1; + + return [ + 'ok' => $ok, + 'driver' => $driver, + 'database' => app()->config()->dbConfig['dbname'] ?? null, + 'table_prefix' => $this->config->tablePrefix(), + ]; + } + + private function debugRuntime(): array + { + return [ + 'ok' => true, + 'path' => $_SERVER['REQUEST_URI'] ?? null, + 'host' => gethostname() ?: null, + 'pid' => function_exists('getmypid') ? getmypid() : null, + 'memory_usage' => memory_get_usage(true), + 'memory_peak' => memory_get_peak_usage(true), + 'time' => date('c'), + ]; + } + + private function debugPdo(): array + { + $startedAt = microtime(true); + $pdo = $this->pdo(); + $connectedAt = microtime(true); + + $statement = $pdo->query('SELECT 1'); + $ok = (int) $statement->fetchColumn() === 1; + $finishedAt = microtime(true); + + return [ + 'ok' => $ok, + 'host' => gethostname() ?: null, + 'pid' => function_exists('getmypid') ? getmypid() : null, + 'driver' => (string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME), + 'connect_ms' => round(($connectedAt - $startedAt) * 1000, 2), + 'query_ms' => round(($finishedAt - $connectedAt) * 1000, 2), + 'memory_usage' => memory_get_usage(true), + 'memory_peak' => memory_get_peak_usage(true), + ]; + } + + private function debugSchemaMeta(): array + { + $startedAt = microtime(true); + $pdo = $this->pdo(); + $connectedAt = microtime(true); + + $driver = (string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $prefix = $this->config->tablePrefix(); + + if ($driver === 'pgsql') { + $sql = 'SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_name LIKE :prefix ORDER BY table_name'; + } else { + $sql = 'SELECT table_name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name LIKE :prefix ORDER BY table_name'; + } + + $statement = $pdo->prepare($sql); + $statement->execute(['prefix' => $prefix . '%']); + $tables = array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN) ?: []); + $finishedAt = microtime(true); + + return [ + 'ok' => true, + 'host' => gethostname() ?: null, + 'pid' => function_exists('getmypid') ? getmypid() : null, + 'driver' => $driver, + 'connect_ms' => round(($connectedAt - $startedAt) * 1000, 2), + 'query_ms' => round(($finishedAt - $connectedAt) * 1000, 2), + 'table_prefix' => $prefix, + 'tables' => $tables, + 'memory_usage' => memory_get_usage(true), + 'memory_peak' => memory_get_peak_usage(true), + ]; + } + + private function simpleSchemaStatus(): array + { + return $this->schemaManager()->schemaStatus(); + } + + private function rebuildPreservingCoreData(string $projectKey): array + { + $backup = [ + 'project' => $this->repository()->getProject($projectKey), + 'settings' => $this->safeRead(fn () => $this->repository()->getSettings($projectKey)), + 'cost_plans' => $this->safeRead(fn () => $this->repository()->listCostPlans($projectKey), []), + 'measurements' => $this->safeRead(fn () => $this->repository()->listAllMeasurements($projectKey), []), + 'measurement_rates' => $this->safeRead(fn () => $this->repository()->listMeasurementRates($projectKey), []), + 'payouts' => $this->safeRead(fn () => $this->repository()->listPayouts($projectKey), []), + 'wallet_snapshots' => $this->safeRead(fn () => $this->repository()->listWalletSnapshots($projectKey, 500), []), + 'targets' => $this->safeRead(fn () => $this->repository()->listTargets($projectKey), []), + 'dashboards' => $this->safeRead(fn () => $this->repository()->listDashboards($projectKey), []), + 'miner_offers' => $this->safeRead(fn () => $this->repository()->listMinerOffers($projectKey), []), + 'purchased_miners' => $this->safeRead(fn () => $this->repository()->listPurchasedMiners($projectKey), []), + 'fx_rates' => $this->safeRead(fn () => $this->repository()->listAllFxRates(), []), + ]; + + $result = $this->schemaManager()->rebuildSchemaDirect(); + + $this->pdo = null; + $this->repository = null; + $this->schemaManager = null; + $this->fx = null; + $this->analytics = null; + $this->seedImporter = null; + + $projectName = is_array($backup['project']) ? ($backup['project']['project_name'] ?? null) : null; + $this->repository()->ensureProject($projectKey, is_string($projectName) ? $projectName : null); + + if (is_array($backup['settings'])) { + $this->repository()->saveSettings($projectKey, [ + 'baseline_measured_at' => $backup['settings']['baseline_measured_at'], + 'baseline_coins_total' => $backup['settings']['baseline_coins_total'], + 'daily_cost_amount' => $backup['settings']['daily_cost_amount'], + 'daily_cost_currency' => $backup['settings']['daily_cost_currency'], + 'report_currency' => $backup['settings']['report_currency'] ?? 'EUR', + 'crypto_currency' => $backup['settings']['crypto_currency'] ?? 'DOGE', + 'display_timezone' => $backup['settings']['display_timezone'] ?? nexus_display_timezone_name(), + 'fx_max_age_hours' => self::FX_FETCH_MAX_AGE_HOURS, + 'module_theme_mode' => $backup['settings']['module_theme_mode'] ?? 'inherit', + 'module_theme_accent' => $backup['settings']['module_theme_accent'] ?? 'teal', + 'preferred_currencies' => $backup['settings']['preferred_currencies'] ?? ['DOGE', 'USD', 'EUR'], + ]); + } + + foreach ($backup['cost_plans'] as $plan) { + $this->repository()->saveCostPlan($projectKey, [ + 'label' => $plan['label'], + 'starts_at' => $plan['starts_at'], + 'runtime_months' => $plan['runtime_months'], + 'mining_speed_value' => $plan['mining_speed_value'] ?? null, + 'mining_speed_unit' => $plan['mining_speed_unit'] ?? null, + 'bonus_speed_value' => $plan['bonus_speed_value'] ?? null, + 'bonus_speed_unit' => $plan['bonus_speed_unit'] ?? null, + 'auto_renew' => !empty($plan['auto_renew']) ? 1 : 0, + 'total_cost_amount' => $plan['total_cost_amount'], + 'currency' => $plan['currency'], + 'note' => $plan['note'] ?? null, + 'is_active' => !empty($plan['is_active']) ? 1 : 0, + ]); + } + + $measurementRatesByOldId = []; + foreach ($backup['measurement_rates'] as $rate) { + $oldMeasurementId = (int) ($rate['measurement_id'] ?? 0); + if ($oldMeasurementId > 0) { + $measurementRatesByOldId[$oldMeasurementId][] = [ + 'base_currency' => $rate['base_currency'] ?? null, + 'quote_currency' => $rate['quote_currency'] ?? null, + 'rate' => $rate['rate'] ?? null, + 'provider' => $rate['provider'] ?? 'derived', + ]; + } + } + + $measurementIdMap = []; + $restoredMeasurementRates = 0; + foreach ($backup['measurements'] as $measurement) { + $created = $this->repository()->createMeasurementIfNotExists($projectKey, [ + 'measured_at' => $measurement['measured_at'], + 'coins_total' => $measurement['coins_total'], + 'price_per_coin' => $measurement['price_per_coin'] ?? null, + 'price_currency' => $measurement['price_currency'] ?? null, + 'note' => $measurement['note'] ?? null, + 'source' => $measurement['source'] ?? 'manual', + 'image_path' => $measurement['image_path'] ?? null, + 'ocr_raw_text' => $measurement['ocr_raw_text'] ?? null, + 'ocr_confidence' => $measurement['ocr_confidence'] ?? null, + 'ocr_flags' => $measurement['ocr_flags'] ?? null, + ]); + if (is_array($created)) { + $oldMeasurementId = (int) ($measurement['id'] ?? 0); + $newMeasurementId = (int) ($created['id'] ?? 0); + if ($oldMeasurementId > 0 && $newMeasurementId > 0) { + $measurementIdMap[$oldMeasurementId] = $newMeasurementId; + } + if ($oldMeasurementId > 0 && isset($measurementRatesByOldId[$oldMeasurementId])) { + $this->repository()->replaceMeasurementRates($newMeasurementId, $projectKey, $measurementRatesByOldId[$oldMeasurementId]); + $restoredMeasurementRates += count($measurementRatesByOldId[$oldMeasurementId]); + } else { + $this->captureMeasurementRates($projectKey, $created); + } + } + } + + foreach ($backup['payouts'] as $payout) { + $this->repository()->savePayout($projectKey, [ + 'payout_at' => $payout['payout_at'], + 'coins_amount' => $payout['coins_amount'], + 'payout_currency' => $payout['payout_currency'] ?? 'DOGE', + 'note' => $payout['note'] ?? null, + ]); + } + + foreach ($backup['wallet_snapshots'] as $snapshot) { + $this->repository()->saveWalletSnapshot($projectKey, [ + 'measured_at' => $snapshot['measured_at'], + 'total_value_amount' => $snapshot['total_value_amount'] ?? null, + 'total_value_currency' => $snapshot['total_value_currency'] ?? null, + 'wallet_balance' => $snapshot['wallet_balance'] ?? null, + 'wallet_currency' => $snapshot['wallet_currency'] ?? ($backup['settings']['crypto_currency'] ?? 'DOGE'), + 'balances_json' => $snapshot['balances_json'] ?? [], + 'note' => $snapshot['note'] ?? null, + 'source' => $snapshot['source'] ?? 'manual', + 'image_path' => $snapshot['image_path'] ?? null, + 'ocr_raw_text' => $snapshot['ocr_raw_text'] ?? null, + 'ocr_confidence' => $snapshot['ocr_confidence'] ?? null, + 'ocr_flags' => $snapshot['ocr_flags'] ?? [], + ]); + } + + $minerOfferIdMap = []; + foreach ($backup['miner_offers'] as $offer) { + $savedOffer = $this->repository()->saveMinerOffer($projectKey, [ + 'label' => $offer['label'], + 'runtime_months' => $offer['runtime_months'] ?? null, + 'mining_speed_value' => $offer['mining_speed_value'] ?? null, + 'mining_speed_unit' => $offer['mining_speed_unit'] ?? null, + 'bonus_speed_value' => $offer['bonus_speed_value'] ?? null, + 'bonus_speed_unit' => $offer['bonus_speed_unit'] ?? null, + 'base_price_amount' => $offer['base_price_amount'] ?? $offer['reference_price_amount'] ?? $offer['usd_reference_amount'] ?? $offer['price_amount'], + 'base_price_currency' => $offer['base_price_currency'] ?? $offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : ($offer['price_currency'] ?? null)), + 'payment_type' => $offer['payment_type'] ?? (!empty($offer['price_currency']) && in_array(strtoupper((string) $offer['price_currency']), ['ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP'], true) ? 'crypto' : 'fiat'), + 'auto_renew' => !empty($offer['auto_renew']) ? 1 : 0, + 'note' => $offer['note'] ?? null, + 'is_active' => !empty($offer['is_active']) ? 1 : 0, + ]); + $oldOfferId = (int) ($offer['id'] ?? 0); + $newOfferId = (int) ($savedOffer['id'] ?? 0); + if ($oldOfferId > 0 && $newOfferId > 0) { + $minerOfferIdMap[$oldOfferId] = $newOfferId; + } + } + + foreach ($backup['targets'] as $target) { + $oldOfferId = (int) ($target['miner_offer_id'] ?? 0); + $this->repository()->saveTarget($projectKey, [ + 'label' => $target['label'], + 'target_amount_fiat' => $target['target_amount_fiat'], + 'currency' => $target['currency'], + 'miner_offer_id' => $oldOfferId > 0 ? ($minerOfferIdMap[$oldOfferId] ?? null) : null, + 'is_active' => !empty($target['is_active']) ? 1 : 0, + 'sort_order' => (int) ($target['sort_order'] ?? 0), + ]); + } + + foreach ($backup['dashboards'] as $dashboard) { + $this->repository()->saveDashboard($projectKey, [ + 'name' => $dashboard['name'], + 'chart_type' => $dashboard['chart_type'], + 'x_field' => $dashboard['x_field'], + 'y_field' => $dashboard['y_field'], + 'aggregation' => $dashboard['aggregation'], + 'filters' => $dashboard['filters'] ?? (is_array($dashboard['filters_json'] ?? null) ? $dashboard['filters_json'] : []), + 'is_active' => !empty($dashboard['is_active']) ? 1 : 0, + ]); + } + + foreach ($backup['purchased_miners'] as $miner) { + $oldOfferId = (int) ($miner['miner_offer_id'] ?? 0); + $this->repository()->restorePurchasedMiner($projectKey, [ + 'miner_offer_id' => $oldOfferId > 0 ? ($minerOfferIdMap[$oldOfferId] ?? null) : null, + 'purchased_at' => $miner['purchased_at'], + 'label' => $miner['label'], + 'runtime_months' => $miner['runtime_months'] ?? null, + 'mining_speed_value' => $miner['mining_speed_value'] ?? null, + 'mining_speed_unit' => $miner['mining_speed_unit'] ?? null, + 'bonus_speed_value' => $miner['bonus_speed_value'] ?? null, + 'bonus_speed_unit' => $miner['bonus_speed_unit'] ?? null, + 'total_cost_amount' => $miner['total_cost_amount'], + 'currency' => $miner['currency'], + 'usd_reference_amount' => $miner['usd_reference_amount'] ?? null, + 'reference_price_amount' => $miner['reference_price_amount'] ?? null, + 'reference_price_currency' => $miner['reference_price_currency'] ?? null, + 'auto_renew' => !empty($miner['auto_renew']) ? 1 : 0, + 'note' => $miner['note'] ?? null, + 'is_active' => !empty($miner['is_active']) ? 1 : 0, + ]); + } + + $fxRatesByFetch = []; + foreach ($backup['fx_rates'] as $rate) { + $fetchId = (int) ($rate['fetch_id'] ?? 0); + $targetCurrency = strtoupper(trim((string) ($rate['target_currency'] ?? ''))); + if ($fetchId <= 0 || $targetCurrency === '' || !is_numeric($rate['rate'] ?? null)) { + continue; + } + if (!isset($fxRatesByFetch[$fetchId])) { + $fxRatesByFetch[$fetchId] = [ + 'base_currency' => $rate['base_currency'] ?? '', + 'provider' => $rate['provider'] ?? 'currencyapi', + 'rate_date' => $rate['rate_date'] ?? date('Y-m-d'), + 'fetched_at' => $rate['fetched_at'] ?? null, + 'rates' => [], + ]; + } + $fxRatesByFetch[$fetchId]['rates'][$targetCurrency] = (float) $rate['rate']; + } + + $restoredFxRates = 0; + foreach ($fxRatesByFetch as $fetch) { + $restored = $this->repository()->restoreFxFetch( + (string) $fetch['base_currency'], + (string) $fetch['provider'], + (string) $fetch['rate_date'], + is_string($fetch['fetched_at'] ?? null) ? $fetch['fetched_at'] : null, + $fetch['rates'] + ); + $restoredFxRates += count($restored['rates'] ?? []); + } + + return array_merge($result, [ + 'restored' => [ + 'measurements' => count($backup['measurements']), + 'measurement_rates' => $restoredMeasurementRates, + 'purchased_miners' => count($backup['purchased_miners']), + 'cost_plans' => count($backup['cost_plans']), + 'payouts' => count($backup['payouts']), + 'wallet_snapshots' => count($backup['wallet_snapshots']), + 'targets' => count($backup['targets']), + 'dashboards' => count($backup['dashboards']), + 'miner_offers' => count($backup['miner_offers']), + 'fx_rates' => $restoredFxRates, + ], + ]); + } + + private function safeRead(callable $callback, mixed $fallback = null): mixed + { + try { + return $callback(); + } catch (\Throwable) { + return $fallback; + } + } + + private function fxHistory(): array + { + if (!modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'recent_fetches')) { + return []; + } + + $fetches = module_fn('fx-rates', 'recent_fetches', 30); + if (!is_array($fetches)) { + return []; + } + + $rows = []; + foreach ($fetches as $fetch) { + $fetchId = is_numeric($fetch['id'] ?? null) ? (int) $fetch['id'] : 0; + if ($fetchId <= 0) { + continue; + } + + $snapshot = $this->fx()->snapshotByFetchId($fetchId, (string) ($fetch['base_currency'] ?? ''), null); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + foreach ($rates as $currencyCode => $rate) { + if (!is_numeric($rate)) { + continue; + } + + $rows[] = [ + 'fetch_id' => $fetchId, + 'base_currency' => strtoupper((string) ($snapshot['base_currency'] ?? $fetch['base_currency'] ?? '')), + 'target_currency' => strtoupper((string) $currencyCode), + 'rate' => (float) $rate, + 'rate_date' => (string) ($snapshot['rate_date'] ?? $fetch['rate_date'] ?? ''), + 'provider' => (string) ($snapshot['provider'] ?? $fetch['provider'] ?? ''), + 'fetched_at' => (string) ($snapshot['fetched_at'] ?? $fetch['fetched_at'] ?? ''), + ]; + } + } + + return $rows; + } + + private function migrateLegacyFxRates(string $projectKey): array + { + if (!modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'repository')) { + throw new ApiException('Das Modul fx-rates ist nicht verfuegbar.', 422); + } + + $startedAt = microtime(true); + $this->debug->add('legacy_fx_migrate.start', [ + 'project_key' => $projectKey, + ]); + module_fn('fx-rates', 'ensure_schema'); + $legacyRows = $this->repository()->listAllFxRates(); + $legacyFetches = $this->groupLegacyFxFetches($legacyRows); + $fxRepository = module_fn('fx-rates', 'repository'); + $this->debug->add('legacy_fx_migrate.loaded', [ + 'legacy_rows' => count($legacyRows), + 'legacy_fetches' => count($legacyFetches), + ]); + + $fetchIdMap = []; + $importedFetches = 0; + $reusedFetches = 0; + $migratedRates = 0; + + $legacyFetchIndex = 0; + foreach ($legacyFetches as $legacyFetchId => $legacyFetch) { + $this->assertRequestWithinBudget($startedAt, 'Legacy-FX-Migration dauert zu lange.'); + $legacyFetchIndex++; + $existing = $this->findExistingFxFetchForLegacy($fxRepository, $legacyFetch); + if (is_array($existing) && !empty($existing['id'])) { + $fetchIdMap[$legacyFetchId] = (int) $existing['id']; + $reusedFetches++; + if ($legacyFetchIndex % 50 === 0) { + $this->debug->add('legacy_fx_migrate.fetch_progress', [ + 'processed' => $legacyFetchIndex, + 'total' => count($legacyFetches), + 'reused_fetches' => $reusedFetches, + 'imported_fetches' => $importedFetches, + ]); + } + continue; + } + + $saved = $fxRepository->saveFetch( + (string) $legacyFetch['base_currency'], + (string) $legacyFetch['provider'], + (string) $legacyFetch['rate_date'], + (array) $legacyFetch['rates'], + is_string($legacyFetch['fetched_at'] ?? null) ? $legacyFetch['fetched_at'] : null, + 'migration' + ); + $newFetchId = is_numeric($saved['fetch']['id'] ?? null) ? (int) $saved['fetch']['id'] : 0; + if ($newFetchId > 0) { + $fetchIdMap[$legacyFetchId] = $newFetchId; + } + $importedFetches++; + $migratedRates += count($saved['rates'] ?? []); + if ($legacyFetchIndex % 50 === 0) { + $this->debug->add('legacy_fx_migrate.fetch_progress', [ + 'processed' => $legacyFetchIndex, + 'total' => count($legacyFetches), + 'reused_fetches' => $reusedFetches, + 'imported_fetches' => $importedFetches, + ]); + } + } + + $measurements = $this->repository()->listAllMeasurements($projectKey); + $measurementRatesById = $this->groupMeasurementRatesByMeasurementId( + $this->repository()->listMeasurementRates($projectKey) + ); + $this->debug->add('legacy_fx_migrate.measurements_loaded', [ + 'measurement_count' => count($measurements), + 'measurement_rate_groups' => count($measurementRatesById), + ]); + + $updatedMeasurements = 0; + $reusedMeasurements = 0; + $unresolvedMeasurements = 0; + + $measurementIndex = 0; + foreach ($measurements as $measurement) { + $this->assertRequestWithinBudget($startedAt, 'Legacy-FX-Migration dauert zu lange.'); + $measurementIndex++; + $measurementId = is_numeric($measurement['id'] ?? null) ? (int) $measurement['id'] : 0; + if ($measurementId <= 0) { + continue; + } + + $currentFetchId = is_numeric($measurement['fx_fetch_id'] ?? null) ? (int) $measurement['fx_fetch_id'] : 0; + if ($currentFetchId > 0 && $this->fx()->snapshotByFetchId($currentFetchId, null, null) !== null) { + $reusedMeasurements++; + if ($measurementIndex % 100 === 0) { + $this->debug->add('legacy_fx_migrate.measurement_progress', [ + 'processed' => $measurementIndex, + 'total' => count($measurements), + 'updated' => $updatedMeasurements, + 'reused' => $reusedMeasurements, + 'unresolved' => $unresolvedMeasurements, + ]); + } + continue; + } + + $legacyFetchId = $this->resolveLegacyMeasurementFetchId( + $measurement, + $measurementRatesById[$measurementId] ?? [], + $legacyFetches + ); + $resolvedFetchId = $legacyFetchId !== null ? ($fetchIdMap[$legacyFetchId] ?? null) : null; + + if (!is_numeric($resolvedFetchId) || (int) $resolvedFetchId <= 0) { + $unresolvedMeasurements++; + if ($measurementIndex % 100 === 0) { + $this->debug->add('legacy_fx_migrate.measurement_progress', [ + 'processed' => $measurementIndex, + 'total' => count($measurements), + 'updated' => $updatedMeasurements, + 'reused' => $reusedMeasurements, + 'unresolved' => $unresolvedMeasurements, + ]); + } + continue; + } + + $this->repository()->setMeasurementFxFetchId($projectKey, $measurementId, (int) $resolvedFetchId); + $updatedMeasurements++; + if ($measurementIndex % 100 === 0) { + $this->debug->add('legacy_fx_migrate.measurement_progress', [ + 'processed' => $measurementIndex, + 'total' => count($measurements), + 'updated' => $updatedMeasurements, + 'reused' => $reusedMeasurements, + 'unresolved' => $unresolvedMeasurements, + ]); + } + } + + $this->debug->add('legacy_fx_migrate.end', [ + 'duration_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'legacy_fetches_found' => count($legacyFetches), + 'legacy_rates_found' => count($legacyRows), + 'fx_fetches_imported' => $importedFetches, + 'fx_fetches_reused' => $reusedFetches, + 'fx_rates_imported' => $migratedRates, + 'measurements_checked' => count($measurements), + 'measurements_updated' => $updatedMeasurements, + 'measurements_reused' => $reusedMeasurements, + 'measurements_unresolved' => $unresolvedMeasurements, + ]); + + return [ + 'message' => 'Legacy-FX-Rates wurden nach fx-rates migriert und Messpunkte aktualisiert.', + 'legacy_fetches_found' => count($legacyFetches), + 'legacy_rates_found' => count($legacyRows), + 'fx_fetches_imported' => $importedFetches, + 'fx_fetches_reused' => $reusedFetches, + 'fx_rates_imported' => $migratedRates, + 'measurements_checked' => count($measurements), + 'measurements_updated' => $updatedMeasurements, + 'measurements_reused' => $reusedMeasurements, + 'measurements_unresolved' => $unresolvedMeasurements, + ]; + } + + private function groupLegacyFxFetches(array $rows): array + { + $grouped = []; + foreach ($rows as $row) { + $legacyFetchId = is_numeric($row['fetch_id'] ?? null) ? (int) $row['fetch_id'] : 0; + $baseCurrency = strtoupper(trim((string) ($row['base_currency'] ?? ''))); + $targetCurrency = strtoupper(trim((string) ($row['target_currency'] ?? ''))); + if ($legacyFetchId <= 0 || $baseCurrency === '' || $targetCurrency === '' || !is_numeric($row['rate'] ?? null)) { + continue; + } + + if (!isset($grouped[$legacyFetchId])) { + $grouped[$legacyFetchId] = [ + 'legacy_fetch_id' => $legacyFetchId, + 'base_currency' => $baseCurrency, + 'provider' => trim((string) ($row['provider'] ?? '')) !== '' ? (string) $row['provider'] : 'currencyapi', + 'rate_date' => trim((string) ($row['rate_date'] ?? '')) !== '' ? (string) $row['rate_date'] : date('Y-m-d'), + 'fetched_at' => $this->normalizeTimestamp((string) ($row['fetched_at'] ?? '')), + 'rates' => [], + ]; + } + + $grouped[$legacyFetchId]['rates'][$targetCurrency] = (float) $row['rate']; + } + + return $grouped; + } + + private function groupMeasurementRatesByMeasurementId(array $rows): array + { + $grouped = []; + foreach ($rows as $row) { + $measurementId = is_numeric($row['measurement_id'] ?? null) ? (int) $row['measurement_id'] : 0; + if ($measurementId <= 0) { + continue; + } + $grouped[$measurementId][] = $row; + } + + return $grouped; + } + + private function findExistingFxFetchForLegacy(object $fxRepository, array $legacyFetch): ?array + { + $baseCurrency = strtoupper(trim((string) ($legacyFetch['base_currency'] ?? ''))); + $fetchedAt = $this->normalizeTimestamp((string) ($legacyFetch['fetched_at'] ?? '')); + if ($baseCurrency === '' || $fetchedAt === null) { + return null; + } + + if (!method_exists($fxRepository, 'findFetchByBaseAndFetchedAt')) { + return null; + } + + $existing = $fxRepository->findFetchByBaseAndFetchedAt($baseCurrency, $fetchedAt); + return is_array($existing) ? $existing : null; + } + + private function resolveLegacyMeasurementFetchId(array $measurement, array $measurementRates, array $legacyFetches): ?int + { + $measuredAt = $this->normalizeTimestamp((string) ($measurement['measured_at'] ?? '')); + if ($measuredAt === null || $legacyFetches === []) { + return null; + } + + $matches = []; + foreach ($legacyFetches as $legacyFetchId => $legacyFetch) { + if (!$this->measurementRatesMatchLegacyFetch($measurementRates, $legacyFetch)) { + continue; + } + + $legacyFetchedAt = $this->normalizeTimestamp((string) ($legacyFetch['fetched_at'] ?? '')); + $distanceSeconds = $this->timestampDistanceSeconds($measuredAt, $legacyFetchedAt); + $matches[] = [ + 'legacy_fetch_id' => (int) $legacyFetchId, + 'distance_seconds' => $distanceSeconds ?? PHP_INT_MAX, + ]; + } + + if ($matches === []) { + return $this->nearestLegacyFetchId($measuredAt, $legacyFetches); + } + + usort($matches, static function (array $left, array $right): int { + return ((int) $left['distance_seconds']) <=> ((int) $right['distance_seconds']); + }); + + return (int) $matches[0]['legacy_fetch_id']; + } + + private function measurementRatesMatchLegacyFetch(array $measurementRates, array $legacyFetch): bool + { + if ($measurementRates === []) { + return true; + } + + foreach ($measurementRates as $measurementRate) { + $baseCurrency = strtoupper(trim((string) ($measurementRate['base_currency'] ?? ''))); + $quoteCurrency = strtoupper(trim((string) ($measurementRate['quote_currency'] ?? ''))); + $expectedRate = is_numeric($measurementRate['rate'] ?? null) ? (float) $measurementRate['rate'] : null; + if ($baseCurrency === '' || $quoteCurrency === '' || $expectedRate === null) { + return false; + } + + $resolvedRate = $this->resolveLegacyFetchRate($legacyFetch, $baseCurrency, $quoteCurrency); + if ($resolvedRate === null || !$this->ratesAreEquivalent($resolvedRate, $expectedRate)) { + return false; + } + } + + return true; + } + + private function resolveLegacyFetchRate(array $legacyFetch, string $baseCurrency, string $quoteCurrency): ?float + { + $baseCurrency = strtoupper(trim($baseCurrency)); + $quoteCurrency = strtoupper(trim($quoteCurrency)); + $fetchBase = strtoupper(trim((string) ($legacyFetch['base_currency'] ?? ''))); + $rates = is_array($legacyFetch['rates'] ?? null) ? $legacyFetch['rates'] : []; + + if ($baseCurrency === '' || $quoteCurrency === '' || $fetchBase === '') { + return null; + } + + if ($baseCurrency === $quoteCurrency) { + return 1.0; + } + + if ($baseCurrency === $fetchBase && array_key_exists($quoteCurrency, $rates) && is_numeric($rates[$quoteCurrency])) { + return (float) $rates[$quoteCurrency]; + } + + if ($quoteCurrency === $fetchBase && array_key_exists($baseCurrency, $rates) && is_numeric($rates[$baseCurrency]) && (float) $rates[$baseCurrency] != 0.0) { + return 1.0 / (float) $rates[$baseCurrency]; + } + + if ( + array_key_exists($baseCurrency, $rates) + && array_key_exists($quoteCurrency, $rates) + && is_numeric($rates[$baseCurrency]) + && is_numeric($rates[$quoteCurrency]) + && (float) $rates[$baseCurrency] != 0.0 + ) { + return (float) $rates[$quoteCurrency] / (float) $rates[$baseCurrency]; + } + + return null; + } + + private function nearestLegacyFetchId(string $measuredAt, array $legacyFetches): ?int + { + $nearestFetchId = null; + $nearestDistance = null; + foreach ($legacyFetches as $legacyFetchId => $legacyFetch) { + $legacyFetchedAt = $this->normalizeTimestamp((string) ($legacyFetch['fetched_at'] ?? '')); + $distance = $this->timestampDistanceSeconds($measuredAt, $legacyFetchedAt); + if ($distance === null) { + continue; + } + + if ($nearestDistance === null || $distance < $nearestDistance) { + $nearestDistance = $distance; + $nearestFetchId = (int) $legacyFetchId; + } + } + + return $nearestFetchId; + } + + private function normalizeTimestamp(string $value): ?string + { + $normalized = trim($value); + if ($normalized === '') { + return null; + } + + try { + return (new \DateTimeImmutable($normalized))->setTimezone($this->utcTimezone())->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return null; + } + } + + private function timestampDistanceSeconds(?string $left, ?string $right): ?int + { + if ($left === null || $right === null) { + return null; + } + + $leftTs = strtotime($left); + $rightTs = strtotime($right); + if ($leftTs === false || $rightTs === false) { + return null; + } + + return abs($leftTs - $rightTs); + } + + private function settings(string $projectKey, array $options = []): array + { + $includeCostPlans = !array_key_exists('cost_plans', $options) || (bool) $options['cost_plans']; + $includeCurrencies = !array_key_exists('currencies', $options) || (bool) $options['currencies']; + $includePayouts = !array_key_exists('payouts', $options) || (bool) $options['payouts']; + $includeMinerOffers = !array_key_exists('miner_offers', $options) || (bool) $options['miner_offers']; + $includePurchasedMiners = !array_key_exists('purchased_miners', $options) || (bool) $options['purchased_miners']; + + $settings = $this->repository()->getSettings($projectKey); + $base = is_array($settings) ? $settings : [ + 'project_key' => $projectKey, + 'baseline_measured_at' => null, + 'baseline_coins_total' => null, + 'daily_cost_amount' => null, + 'daily_cost_currency' => 'EUR', + 'report_currency' => 'EUR', + 'crypto_currency' => 'DOGE', + 'display_timezone' => nexus_display_timezone_name(), + 'module_theme_mode' => 'inherit', + 'module_theme_accent' => 'teal', + 'preferred_currencies' => $this->preferredCurrencies(), + ]; + if (!$this->isValidTimezone((string) ($base['display_timezone'] ?? ''))) { + $base['display_timezone'] = nexus_display_timezone_name(); + } + if (!in_array((string) ($base['module_theme_mode'] ?? ''), ['inherit', 'custom'], true)) { + $base['module_theme_mode'] = 'inherit'; + } + if (!in_array((string) ($base['module_theme_accent'] ?? ''), ['teal', 'logo', 'pink', 'cyan', 'orange', 'green'], true)) { + $base['module_theme_accent'] = 'teal'; + } + + $base['cost_plans'] = $includeCostPlans ? $this->costPlans($projectKey) : []; + $base['currencies'] = $includeCurrencies ? $this->currencies() : []; + $base['preferred_currencies'] = $this->preferredCurrencies($base['preferred_currencies'] ?? null); + $base['payouts'] = $includePayouts ? $this->payouts($projectKey) : []; + $base['miner_offers'] = $includeMinerOffers ? $this->minerOffers($projectKey) : []; + $base['purchased_miners'] = $includePurchasedMiners ? $this->purchasedMiners($projectKey) : []; + $base['measurement_rates'] = []; + return $base; + } + + private function saveSettings(string $projectKey, array $input): array + { + $existingSettings = $this->repository()->getSettings($projectKey) ?? []; + $displayTimezone = $this->requiredTimezone( + $input['display_timezone'] ?? ($existingSettings['display_timezone'] ?? nexus_display_timezone_name()), + 'display_timezone' + ); + $settings = [ + 'baseline_measured_at' => $this->requiredDateTime($input['baseline_measured_at'] ?? null, 'baseline_measured_at', $displayTimezone), + 'baseline_coins_total' => $this->requiredDecimal($input['baseline_coins_total'] ?? null, 'baseline_coins_total'), + 'daily_cost_amount' => $this->requiredDecimal($input['daily_cost_amount'] ?? null, 'daily_cost_amount'), + 'daily_cost_currency' => $this->requiredCurrency($input['daily_cost_currency'] ?? null, 'daily_cost_currency'), + 'report_currency' => $this->requiredCurrency($input['report_currency'] ?? 'EUR', 'report_currency'), + 'crypto_currency' => $this->requiredCurrency($input['crypto_currency'] ?? 'DOGE', 'crypto_currency'), + 'display_timezone' => $displayTimezone, + 'fx_max_age_hours' => self::FX_FETCH_MAX_AGE_HOURS, + 'module_theme_mode' => $this->requiredEnum($input['module_theme_mode'] ?? 'inherit', 'module_theme_mode', ['inherit', 'custom']), + 'module_theme_accent' => $this->requiredEnum($input['module_theme_accent'] ?? 'teal', 'module_theme_accent', ['teal', 'logo', 'pink', 'cyan', 'orange', 'green']), + 'preferred_currencies' => $this->optionalCurrencyList($input['preferred_currencies'] ?? []), + ]; + + $this->assertCurrencyType($settings['report_currency'], false, 'report_currency'); + $this->assertCurrencyType($settings['crypto_currency'], true, 'crypto_currency'); + + $this->repository()->saveSettings($projectKey, $settings); + $this->syncFxRatesPreferredCurrencies($settings['preferred_currencies']); + return $this->settings($projectKey); + } + + private function measurements(string $projectKey, ?array $settingsOverride = null, bool $resolveFxReferences = true): array + { + $settings = $settingsOverride ?? $this->settings($projectKey); + $rows = $this->repository()->listMeasurements($projectKey, 500); + if ($resolveFxReferences) { + $rows = $this->ensureMeasurementFxReferences($projectKey, $rows, $settings); + } + return $this->analytics()->enrichMeasurements($rows, $settings); + } + + private function bootstrapMeasurements(string $projectKey, array $settings, string $view): array + { + if (in_array($view, ['upload', 'dashboards', 'wallet'], true)) { + return []; + } + + if (in_array($view, ['overview', 'mining'], true)) { + $rows = $this->repository()->listRecentMeasurements($projectKey, self::BOOTSTRAP_MEASUREMENT_LIMIT); + } elseif ($view === 'measurements') { + $rows = $this->repository()->listRecentMeasurements($projectKey, 10); + } else { + $rows = $this->repository()->listMeasurements($projectKey, self::BOOTSTRAP_MEASUREMENT_LIMIT); + } + + if (in_array($view, ['overview', 'mining'], true)) { + $rows = $this->filterMeasurementsToRecentWindow($rows, self::OVERVIEW_WINDOW_DAYS); + } + + $this->debug->add('bootstrap.measurements.loaded', [ + 'project_key' => $projectKey, + 'view' => $view, + 'row_count' => count($rows), + 'limit' => self::BOOTSTRAP_MEASUREMENT_LIMIT, + ]); + return $this->analytics()->enrichMeasurements($rows, $settings, [ + 'full_latest_only' => in_array($view, ['overview', 'mining'], true), + ]); + } + + private function bootstrapWalletSnapshots(string $projectKey, string $view): array + { + if ($view === 'wallet') { + return $this->repository()->listWalletSnapshots($projectKey, 50); + } + + if (!in_array($view, ['overview', 'mining'], true)) { + return []; + } + + return $this->repository()->listWalletSnapshots($projectKey, 1); + } + + private function bootstrapTargets(string $projectKey, string $view): array + { + return in_array($view, ['overview', 'mining'], true) ? $this->targets($projectKey) : []; + } + + private function bootstrapDashboards(string $projectKey, string $view): array + { + return $view === 'dashboards' ? $this->dashboards($projectKey) : []; + } + + private function bootstrapFxSnapshots(array $measurements, string $view): array + { + if (!in_array($view, ['overview', 'mining'], true)) { + return []; + } + + $limit = match ($view) { + 'overview' => 1, + default => self::BOOTSTRAP_SNAPSHOT_LIMIT, + }; + + return $this->measurementFxSnapshots($measurements, $limit); + } + + private function bootstrapSummary(array $measurements, array $settings, array $targets, array $walletSnapshots, string $view): array + { + if (!in_array($view, ['overview', 'mining'], true)) { + return [ + 'latest_measurement' => $measurements !== [] ? $measurements[array_key_last($measurements)] : null, + 'baseline' => $settings, + 'targets' => [], + 'payouts' => [], + 'miner_offers' => [], + ]; + } + + return $this->analytics()->buildSummary($measurements, $settings, $targets, [ + 'include_offer_scenarios' => $view === 'mining', + 'include_long_term_projection' => $view === 'mining', + 'wallet_snapshots' => $walletSnapshots, + ]); + } + + private function filterMeasurementsToRecentWindow(array $rows, int $windowDays): array + { + if ($rows === [] || $windowDays <= 0) { + return $rows; + } + + $latest = $rows[array_key_last($rows)] ?? null; + $latestTs = is_array($latest) ? strtotime((string) ($latest['measured_at'] ?? '')) : false; + if ($latestTs === false) { + return $rows; + } + + $minTs = $latestTs - ($windowDays * 86400); + $filtered = array_values(array_filter($rows, static function (array $row) use ($minTs): bool { + $measuredTs = strtotime((string) ($row['measured_at'] ?? '')); + return $measuredTs !== false && $measuredTs >= $minTs; + })); + + $latestRow = $rows[array_key_last($rows)] ?? null; + return $filtered !== [] || !is_array($latestRow) ? $filtered : [$latestRow]; + } + + private function normalizeBootstrapView(string $view): string + { + $normalized = trim(strtolower($view)); + return in_array($normalized, ['overview', 'upload', 'measurements', 'wallet', 'dashboards', 'mining'], true) + ? $normalized + : 'overview'; + } + + private function bootstrapSettingsOptions(string $view): array + { + return match ($view) { + 'overview' => [ + 'cost_plans' => true, + 'currencies' => true, + 'payouts' => true, + 'miner_offers' => false, + 'purchased_miners' => true, + ], + 'upload' => [ + 'cost_plans' => false, + 'currencies' => true, + 'payouts' => false, + 'miner_offers' => false, + 'purchased_miners' => false, + ], + 'measurements' => [ + 'cost_plans' => false, + 'currencies' => false, + 'payouts' => true, + 'miner_offers' => false, + 'purchased_miners' => false, + ], + 'wallet' => [ + 'cost_plans' => false, + 'currencies' => false, + 'payouts' => false, + 'miner_offers' => false, + 'purchased_miners' => false, + ], + 'dashboards' => [ + 'cost_plans' => false, + 'currencies' => false, + 'payouts' => false, + 'miner_offers' => false, + 'purchased_miners' => false, + ], + default => [ + 'cost_plans' => true, + 'currencies' => true, + 'payouts' => true, + 'miner_offers' => true, + 'purchased_miners' => true, + ], + }; + } + + private function createMeasurement(string $projectKey, array $input): array + { + $projectTimezone = $this->projectTimezone($projectKey); + $source = $this->enumValue($input['source'] ?? 'manual', ['manual', 'image_ocr', 'seed_import'], 'source'); + $payload = [ + 'measured_at' => $source === 'seed_import' + ? $this->requiredDateTime($input['measured_at'] ?? null, 'measured_at', $projectTimezone) + : $this->currentTimestamp(), + 'coins_total' => $this->requiredDecimal($input['coins_total'] ?? null, 'coins_total'), + 'coin_currency' => $this->measurementCoinCurrency($projectKey, $input), + 'price_per_coin' => $this->optionalDecimal($input['price_per_coin'] ?? null), + 'price_currency' => $this->optionalCurrency($input['price_currency'] ?? null), + 'note' => $this->optionalString($input['note'] ?? null, 2000), + 'source' => $source, + 'image_path' => $this->optionalString($input['image_path'] ?? null, 255), + 'ocr_raw_text' => $this->optionalString($input['ocr_raw_text'] ?? null, 65535), + 'ocr_confidence' => $this->optionalDecimal($input['ocr_confidence'] ?? null), + 'ocr_flags' => $this->optionalArray($input['ocr_flags'] ?? null), + 'fx_fetch_id' => null, + ]; + + if (($payload['price_per_coin'] === null) xor ($payload['price_currency'] === null)) { + throw new ApiException('Kurs und Kurswaehrung muessen gemeinsam gesetzt oder beide leer sein.', 422); + } + + $this->syncCurrencyCatalogForMeasurement($payload); + $payload['fx_fetch_id'] = $this->resolveMeasurementFxFetchId($projectKey, $payload, true); + $created = $this->repository()->createMeasurement($projectKey, $payload); + $measurements = $this->measurements($projectKey); + return $measurements[array_key_last($measurements)]; + } + + private function importMeasurements(string $projectKey, array $input): array + { + $rawText = trim((string) ($input['rows_text'] ?? '')); + if ($rawText === '') { + throw new ApiException('rows_text ist erforderlich.', 422); + } + + $defaultCurrency = $this->optionalCurrency($input['default_currency'] ?? null); + $defaultSource = $this->enumValue($input['source'] ?? 'manual', ['manual', 'seed_import'], 'source'); + $lines = preg_split('/\R/', $rawText) ?: []; + + $imported = 0; + $duplicates = 0; + $errors = []; + + foreach ($lines as $index => $line) { + $lineNumber = $index + 1; + $trimmed = trim($line); + if ($trimmed === '' || str_starts_with($trimmed, '#') || str_starts_with($trimmed, '//')) { + continue; + } + + try { + $payload = $this->parseImportLine($projectKey, $trimmed, $defaultCurrency, $defaultSource, $this->projectTimezone($projectKey)); + $this->syncCurrencyCatalogForMeasurement($payload); + $payload['fx_fetch_id'] = $this->resolveMeasurementFxFetchId($projectKey, $payload, false); + $result = $this->repository()->createMeasurementIfNotExists($projectKey, $payload); + if ($result === null) { + $duplicates++; + } else { + $imported++; + } + } catch (\Throwable $exception) { + $errors[] = [ + 'line' => $lineNumber, + 'input' => $trimmed, + 'message' => $exception instanceof ApiException ? $exception->getMessage() : 'Importzeile konnte nicht verarbeitet werden.', + ]; + } + } + + return [ + 'imported' => $imported, + 'duplicates_ignored' => $duplicates, + 'errors' => $errors, + 'error_count' => count($errors), + 'accepted_format' => 'DD.MM.YYYY HH:MM | coins_total | price_per_coin | currency | note', + ]; + } + + private function deleteMeasurement(string $projectKey, int $measurementId): void + { + if ($measurementId <= 0) { + throw new ApiException('measurement_id ist ungueltig.', 422); + } + + $this->repository()->deleteMeasurement($projectKey, $measurementId); + } + + private function syncCurrencyCatalogForMeasurement(array $payload): void + { + $coinCurrency = strtoupper(trim((string) ($payload['coin_currency'] ?? ''))); + if ($coinCurrency !== '' && $this->currencyCatalogEntry($coinCurrency) === null) { + throw new ApiException( + 'Coin-Waehrung ist im fx-rates Katalog nicht vorhanden.', + 422, + ['currency' => $coinCurrency] + ); + } + + $priceCurrency = strtoupper(trim((string) ($payload['price_currency'] ?? ''))); + if ($priceCurrency === '') { + return; + } + + if ($this->currencyCatalogEntry($priceCurrency) === null) { + throw new ApiException( + 'Waehrung ist im fx-rates Katalog nicht vorhanden.', + 422, + ['currency' => $priceCurrency] + ); + } + } + + private function parseImportLine(string $projectKey, string $line, ?string $defaultCurrency, string $defaultSource, string $projectTimezone): array + { + $parts = array_map('trim', explode('|', $line)); + if (count($parts) < 2) { + throw new ApiException('Zu wenige Felder. Erwartet: Datum/Zeit | Coins | Kurs | Waehrung | Notiz', 422); + } + + $measuredAt = $this->parseImportDateTime($parts[0] ?? '', $projectTimezone); + $coinsTotal = $this->requiredDecimal($parts[1] ?? null, 'coins_total'); + $pricePerCoin = $this->optionalDecimal($parts[2] ?? null); + $priceCurrency = $this->optionalCurrency($parts[3] ?? null) ?? $defaultCurrency; + $note = $this->optionalString($parts[4] ?? null, 2000); + + if (($pricePerCoin === null) xor ($priceCurrency === null)) { + throw new ApiException('Kurs und Waehrung muessen gemeinsam gesetzt werden oder beide leer bleiben.', 422); + } + + return [ + 'measured_at' => $measuredAt, + 'coins_total' => $coinsTotal, + 'coin_currency' => $this->measurementCoinCurrency($projectKey), + 'price_per_coin' => $pricePerCoin, + 'price_currency' => $priceCurrency, + 'note' => $note, + 'source' => $defaultSource, + 'image_path' => null, + 'ocr_raw_text' => null, + 'ocr_confidence' => null, + 'ocr_flags' => ['paste_import'], + ]; + } + + private function parseImportDateTime(string $value, string $projectTimezone): string + { + $normalized = trim($value); + if ($normalized === '') { + throw new ApiException('Datum/Zeit fehlt.', 422); + } + + $patterns = [ + 'd.m.Y H:i:s', + 'd.m.Y H:i', + 'd.m.y H:i:s', + 'd.m.y H:i', + 'Y-m-d H:i:s', + 'Y-m-d H:i', + ]; + + $timezone = new \DateTimeZone($projectTimezone); + foreach ($patterns as $pattern) { + $date = \DateTimeImmutable::createFromFormat($pattern, $normalized, $timezone); + if ($date instanceof \DateTimeImmutable) { + return $date->setTimezone($this->utcTimezone())->format('Y-m-d H:i:s'); + } + } + + try { + $date = new \DateTimeImmutable($normalized, $timezone); + } catch (\Throwable) { + throw new ApiException('Datum/Zeit konnte nicht gelesen werden.', 422); + } + + return $date->setTimezone($this->utcTimezone())->format('Y-m-d H:i:s'); + } + + private function targets(string $projectKey): array + { + return $this->repository()->listTargets($projectKey); + } + + private function costPlans(string $projectKey): array + { + return $this->repository()->listCostPlans($projectKey); + } + + private function payouts(string $projectKey): array + { + return $this->repository()->listPayouts($projectKey); + } + + private function measurementRates(string $projectKey): array + { + return []; + } + + private function currencies(): array + { + return $this->currencyCatalog(); + } + + private function minerOffers(string $projectKey): array + { + return $this->repository()->listMinerOffers($projectKey); + } + + private function purchasedMiners(string $projectKey): array + { + return $this->repository()->listPurchasedMiners($projectKey); + } + + private function saveTarget(string $projectKey, array $input): array + { + $payload = [ + 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), + 'target_amount_fiat' => $this->requiredDecimal($input['target_amount_fiat'] ?? null, 'target_amount_fiat'), + 'currency' => $this->requiredCurrency($input['currency'] ?? null, 'currency'), + 'miner_offer_id' => $this->optionalPositiveInt($input['miner_offer_id'] ?? null), + 'is_active' => !empty($input['is_active']) ? 1 : 0, + 'sort_order' => isset($input['sort_order']) ? (int) $input['sort_order'] : 0, + ]; + + $this->assertTargetOfferExists($projectKey, $payload['miner_offer_id']); + + return $this->repository()->saveTarget($projectKey, $payload); + } + + private function updateTarget(string $projectKey, int $targetId, array $input): array + { + $payload = [ + 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), + 'target_amount_fiat' => $this->requiredDecimal($input['target_amount_fiat'] ?? null, 'target_amount_fiat'), + 'currency' => $this->requiredCurrency($input['currency'] ?? null, 'currency'), + 'miner_offer_id' => $this->optionalPositiveInt($input['miner_offer_id'] ?? null), + 'is_active' => !empty($input['is_active']) ? 1 : 0, + 'sort_order' => isset($input['sort_order']) ? (int) $input['sort_order'] : 0, + ]; + + $this->assertTargetOfferExists($projectKey, $payload['miner_offer_id']); + + return $this->repository()->updateTarget($projectKey, $targetId, $payload); + } + + private function deleteTarget(string $projectKey, int $targetId): void + { + $this->repository()->deleteTarget($projectKey, $targetId); + } + + private function assertTargetOfferExists(string $projectKey, ?int $offerId): void + { + if ($offerId === null) { + return; + } + + if ($this->repository()->getMinerOffer($projectKey, $offerId) === null) { + throw new ApiException( + 'Verknuepftes Miner-Angebot wurde nicht gefunden.', + 422, + ['field' => 'miner_offer_id', 'miner_offer_id' => $offerId] + ); + } + } + + private function dashboards(string $projectKey): array + { + $dashboards = $this->repository()->listDashboards($projectKey); + foreach ($dashboards as &$dashboard) { + if (is_array($dashboard['filters_json'] ?? null)) { + $dashboard['filters'] = $dashboard['filters_json']; + } elseif (is_string($dashboard['filters_json'] ?? null) && $dashboard['filters_json'] !== '') { + $decoded = json_decode($dashboard['filters_json'], true); + $dashboard['filters'] = is_array($decoded) ? $decoded : []; + } else { + $dashboard['filters'] = []; + } + } + + return $dashboards; + } + + private function saveCostPlan(string $projectKey, array $input): array + { + $projectTimezone = $this->projectTimezone($projectKey); + $miningSpeedValue = $this->optionalDecimal($input['mining_speed_value'] ?? null); + $miningSpeedUnit = $this->optionalSpeedUnit($input['mining_speed_unit'] ?? null); + $bonusPercent = $this->optionalDecimal($input['bonus_percent'] ?? null); + $payload = [ + 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), + 'purchased_at' => $this->requiredDateTime($input['starts_at'] ?? null, 'starts_at', $projectTimezone), + 'runtime_months' => $this->requiredPositiveInt($input['runtime_months'] ?? null, 'runtime_months'), + 'mining_speed_value' => $miningSpeedValue, + 'mining_speed_unit' => $miningSpeedUnit, + 'bonus_speed_value' => $this->bonusSpeedFromPercent($miningSpeedValue, $miningSpeedUnit, $bonusPercent), + 'bonus_speed_unit' => $bonusPercent !== null ? $miningSpeedUnit : null, + 'bonus_percent' => $bonusPercent, + 'auto_renew' => !empty($input['auto_renew']) ? 1 : 0, + 'base_price_amount' => $this->requiredDecimal($input['base_price_amount'] ?? ($input['total_cost_amount'] ?? null), 'base_price_amount'), + 'payment_type' => $this->enumValue($input['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'), + 'note' => $this->optionalString($input['note'] ?? null, 1000), + 'is_active' => !empty($input['is_active']) ? 1 : 0, + ]; + + if (($payload['mining_speed_value'] === null) xor ($payload['mining_speed_unit'] === null)) { + throw new ApiException('Mining-Geschwindigkeit und Einheit muessen gemeinsam gesetzt oder beide leer sein.', 422); + } + + if ($bonusPercent !== null && ($miningSpeedValue === null || $miningSpeedUnit === null)) { + throw new ApiException('Bonus-Prozent setzt eine Mining-Geschwindigkeit voraus.', 422); + } + if ($bonusPercent !== null && $bonusPercent < 0) { + throw new ApiException('Bonus-Prozent darf nicht negativ sein.', 422); + } + + $settings = $this->settings($projectKey); + $fiatCurrency = $this->requiredCurrency($settings['report_currency'] ?? 'EUR', 'report_currency'); + $cryptoCurrency = $this->requiredCurrency($settings['crypto_currency'] ?? 'DOGE', 'crypto_currency'); + $payload['currency'] = $payload['payment_type'] === 'crypto' ? $cryptoCurrency : $fiatCurrency; + $payload['total_cost_amount'] = $payload['base_price_amount']; + $payload['reference_price_amount'] = $payload['base_price_amount']; + $payload['reference_price_currency'] = $fiatCurrency; + $payload['usd_reference_amount'] = $fiatCurrency === 'USD' ? $payload['base_price_amount'] : null; + + if ($payload['payment_type'] === 'crypto') { + $converted = $this->fx()->convert((float) $payload['base_price_amount'], $fiatCurrency, $cryptoCurrency); + if ($converted === null) { + throw new ApiException( + 'Basispreis konnte nicht in die eingestellte Krypto-Waehrung umgerechnet werden.', + 422, + ['base_currency' => $fiatCurrency, 'crypto_currency' => $cryptoCurrency] + ); + } + $payload['total_cost_amount'] = $converted; + } + + return $this->repository()->restorePurchasedMiner($projectKey, [ + 'miner_offer_id' => null, + 'purchased_at' => $payload['purchased_at'], + 'label' => $payload['label'], + 'runtime_months' => $payload['runtime_months'], + 'mining_speed_value' => $payload['mining_speed_value'], + 'mining_speed_unit' => $payload['mining_speed_unit'], + 'bonus_speed_value' => $payload['bonus_speed_value'], + 'bonus_speed_unit' => $payload['bonus_speed_unit'], + 'total_cost_amount' => $payload['total_cost_amount'], + 'currency' => $payload['currency'], + 'usd_reference_amount' => $payload['usd_reference_amount'], + 'reference_price_amount' => $payload['reference_price_amount'], + 'reference_price_currency' => $payload['reference_price_currency'], + 'auto_renew' => $payload['auto_renew'], + 'note' => $payload['note'], + 'is_active' => $payload['is_active'], + ]); + } + + private function savePayout(string $projectKey, array $input): array + { + $payload = [ + 'payout_at' => $this->requiredDateTime($input['payout_at'] ?? null, 'payout_at', $this->projectTimezone($projectKey)), + 'coins_amount' => $this->requiredDecimal($input['coins_amount'] ?? null, 'coins_amount'), + 'payout_currency' => $this->requiredCurrency($input['payout_currency'] ?? 'DOGE', 'payout_currency'), + 'note' => $this->optionalString($input['note'] ?? null, 1000), + ]; + + return $this->repository()->savePayout($projectKey, $payload); + } + + private function walletSnapshots(string $projectKey): array + { + return $this->repository()->listWalletSnapshots($projectKey, 100); + } + + private function saveWalletSnapshot(string $projectKey, array $input): array + { + $balances = $input['balances_json'] ?? []; + if (!is_array($balances)) { + $balances = []; + } + + $payload = [ + 'measured_at' => $this->requiredDateTime($input['measured_at'] ?? null, 'measured_at', $this->projectTimezone($projectKey)), + 'total_value_amount' => $this->optionalDecimal($input['total_value_amount'] ?? null), + 'total_value_currency' => $this->optionalCurrency($input['total_value_currency'] ?? null), + 'wallet_balance' => $this->optionalDecimal($input['wallet_balance'] ?? null), + 'wallet_currency' => $this->requiredCurrency($input['wallet_currency'] ?? ($this->settings($projectKey)['crypto_currency'] ?? 'DOGE'), 'wallet_currency'), + 'balances_json' => $balances, + 'note' => $this->optionalString($input['note'] ?? null, 1000), + 'source' => $this->enumValue($input['source'] ?? 'manual', ['manual', 'image_ocr', 'seed_import'], 'source'), + 'image_path' => $this->optionalString($input['image_path'] ?? null, 255), + 'ocr_raw_text' => $this->optionalString($input['ocr_raw_text'] ?? null, 20000), + 'ocr_confidence' => $this->optionalDecimal($input['ocr_confidence'] ?? null), + 'ocr_flags' => is_array($input['ocr_flags'] ?? null) ? $input['ocr_flags'] : [], + ]; + + return $this->repository()->saveWalletSnapshot($projectKey, $payload); + } + + private function saveMinerOffer(string $projectKey, array $input): array + { + $paymentType = $this->enumValue($input['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'); + $baseSpeed = self::BASE_OFFER_SPEEDS[$paymentType] ?? self::BASE_OFFER_SPEEDS['fiat']; + $bonusPercent = $this->optionalDecimal($input['bonus_percent'] ?? null); + $basePriceCurrency = $paymentType === 'crypto' + ? 'USD' + : $this->requiredCurrency($input['base_price_currency'] ?? ($input['reference_price_currency'] ?? $input['price_currency'] ?? null), 'base_price_currency'); + $payload = [ + 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), + 'runtime_months' => $this->optionalPositiveInt($input['runtime_months'] ?? null), + 'mining_speed_value' => $baseSpeed['value'], + 'mining_speed_unit' => $baseSpeed['unit'], + 'bonus_speed_value' => $this->bonusSpeedFromPercent((float) $baseSpeed['value'], (string) $baseSpeed['unit'], $bonusPercent), + 'bonus_speed_unit' => $bonusPercent !== null ? (string) $baseSpeed['unit'] : null, + 'bonus_percent' => $bonusPercent, + 'base_price_amount' => $this->requiredDecimal($input['base_price_amount'] ?? ($input['reference_price_amount'] ?? $input['price_amount'] ?? null), 'base_price_amount'), + 'base_price_currency' => $basePriceCurrency, + 'payment_type' => $paymentType, + 'auto_renew' => !empty($input['auto_renew']) ? 1 : 0, + 'note' => $this->optionalString($input['note'] ?? null, 1000), + 'is_active' => !empty($input['is_active']) ? 1 : 0, + ]; + + if ($bonusPercent !== null && $bonusPercent < 0) { + throw new ApiException('Bonus-Prozent darf nicht negativ sein.', 422); + } + + $this->assertCurrencyType($payload['base_price_currency'], false, 'base_price_currency'); + + return $this->repository()->saveMinerOffer($projectKey, $payload); + } + + private function purchaseMiner(string $projectKey, int $offerId, array $input): array + { + $offer = $this->repository()->getMinerOffer($projectKey, $offerId); + if (!is_array($offer)) { + throw new ApiException('Miner-Angebot nicht gefunden.', 404); + } + + $settings = $this->settings($projectKey); + $cryptoCurrency = $this->requiredCurrency($settings['crypto_currency'] ?? 'DOGE', 'crypto_currency'); + $isAutoRenew = array_key_exists('auto_renew', $input) ? !empty($input['auto_renew']) : !empty($offer['auto_renew']); + $paymentType = $this->enumValue($offer['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'); + $selectedSpeedValue = $this->optionalDecimal($input['mining_speed_value'] ?? null); + $selectedSpeedUnit = $this->optionalSpeedUnit($input['mining_speed_unit'] ?? null); + if (($selectedSpeedValue === null) xor ($selectedSpeedUnit === null)) { + throw new ApiException('Mining-Geschwindigkeit und Einheit muessen gemeinsam gesetzt oder beide leer sein.', 422); + } + + $resolvedSpeedValue = $selectedSpeedValue ?? $this->optionalDecimal($offer['mining_speed_value'] ?? null); + $resolvedSpeedUnit = $selectedSpeedUnit ?? $this->optionalSpeedUnit($offer['mining_speed_unit'] ?? null); + $selectedBonusPercent = $this->optionalDecimal($input['bonus_percent'] ?? null); + if ($selectedBonusPercent !== null && $selectedBonusPercent < 0) { + throw new ApiException('Bonus-Prozent darf nicht negativ sein.', 422); + } + $resolvedBonusPercent = $selectedBonusPercent ?? $this->offerBonusPercent($offer); + $resolvedBonusValue = $this->bonusSpeedFromPercent($resolvedSpeedValue, $resolvedSpeedUnit, $resolvedBonusPercent); + $resolvedBonusUnit = $resolvedBonusValue !== null ? $resolvedSpeedUnit : null; + + $purchaseCurrency = $this->optionalCurrency($input['currency'] ?? null) ?? (string) ($offer['effective_price_currency'] ?? $offer['price_currency'] ?? $offer['base_price_currency'] ?? ''); + if ($paymentType === 'crypto' || !$isAutoRenew) { + $purchaseCurrency = $cryptoCurrency; + } + $purchaseCost = $this->optionalDecimal($input['total_cost_amount'] ?? null); + if ($purchaseCost === null) { + $purchaseCost = $this->resolveOfferPurchaseCost($projectKey, array_merge($offer, [ + 'mining_speed_value' => $resolvedSpeedValue, + 'mining_speed_unit' => $resolvedSpeedUnit, + 'bonus_speed_value' => $resolvedBonusValue, + 'bonus_speed_unit' => $resolvedBonusUnit, + 'base_price_amount' => $this->optionalDecimal($input['reference_price_amount'] ?? null) ?? ($offer['base_price_amount'] ?? null), + 'base_price_currency' => $this->optionalCurrency($input['reference_price_currency'] ?? null) ?? ($offer['base_price_currency'] ?? null), + 'price_currency' => $purchaseCurrency !== '' ? $purchaseCurrency : ($offer['price_currency'] ?? ''), + ])); + } + $referencePriceAmount = $this->optionalDecimal($input['reference_price_amount'] ?? ($offer['base_price_amount'] ?? $offer['reference_price_amount'] ?? $offer['usd_reference_amount'] ?? null)); + $referencePriceCurrency = $this->optionalCurrency($input['reference_price_currency'] ?? ($offer['base_price_currency'] ?? $offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : null))); + $purchasedAt = array_key_exists('purchased_at', $input) + ? $this->requiredDateTime($input['purchased_at'], 'purchased_at', $this->projectTimezone($projectKey)) + : $this->currentTimestamp(); + + return $this->repository()->purchaseMiner($projectKey, $offerId, [ + 'purchased_at' => $purchasedAt, + 'label' => $this->optionalString($input['label'] ?? ($offer['label'] ?? null), 120) ?? $offer['label'], + 'runtime_months' => $offer['runtime_months'] ?? null, + 'mining_speed_value' => $resolvedSpeedValue, + 'mining_speed_unit' => $resolvedSpeedUnit, + 'bonus_speed_value' => $resolvedBonusValue, + 'bonus_speed_unit' => $resolvedBonusUnit, + 'total_cost_amount' => $purchaseCost, + 'currency' => $purchaseCurrency !== '' ? $purchaseCurrency : $offer['price_currency'], + 'usd_reference_amount' => $offer['usd_reference_amount'] ?? null, + 'reference_price_amount' => $referencePriceAmount, + 'reference_price_currency' => $referencePriceCurrency, + 'auto_renew' => $isAutoRenew ? 1 : 0, + 'note' => $this->optionalString($input['note'] ?? ($offer['note'] ?? null), 1000), + 'is_active' => 1, + ]); + } + + private function updatePurchasedMiner(string $projectKey, int $minerId, array $input): array + { + $miner = $this->repository()->getPurchasedMiner($projectKey, $minerId); + if (!is_array($miner)) { + throw new ApiException('Gemieteter Miner nicht gefunden.', 404); + } + + if (!array_key_exists('auto_renew', $input)) { + throw new ApiException('Es kann aktuell nur auto_renew geaendert werden.', 422, ['field' => 'auto_renew']); + } + + $offerId = is_numeric($miner['miner_offer_id'] ?? null) ? (int) $miner['miner_offer_id'] : null; + if ($offerId === null) { + throw new ApiException('Dieser Miner kann nicht ueber ein Angebot aktualisiert werden.', 422); + } + + $offer = $this->repository()->getMinerOffer($projectKey, $offerId); + if (!is_array($offer) || empty($offer['auto_renew'])) { + throw new ApiException('Dieser Miner unterstuetzt keine automatische Verlaengerung.', 422); + } + + return $this->repository()->updatePurchasedMinerAutoRenew( + $projectKey, + $minerId, + !empty($input['auto_renew']) + ); + } + + private function saveDashboard(string $projectKey, array $input): array + { + $payload = [ + 'name' => $this->requiredString($input['name'] ?? null, 'name', 160), + 'chart_type' => $this->enumValue($input['chart_type'] ?? null, ['line', 'bar', 'area', 'table'], 'chart_type'), + 'x_field' => $this->requiredString($input['x_field'] ?? null, 'x_field', 64), + 'y_field' => $this->requiredString($input['y_field'] ?? null, 'y_field', 64), + 'aggregation' => $this->enumValue($input['aggregation'] ?? 'none', ['none', 'sum', 'avg', 'min', 'max', 'count', 'latest'], 'aggregation'), + 'filters' => $this->optionalArray($input['filters'] ?? []) ?? [], + 'is_active' => !empty($input['is_active']) ? 1 : 0, + ]; + + return $this->repository()->saveDashboard($projectKey, $payload); + } + + private function dashboardData(string $projectKey, array $query): array + { + $measurements = $this->measurements($projectKey); + return $this->analytics()->dashboardData( + $measurements, + (string) ($query['x_field'] ?? 'measured_at'), + (string) ($query['y_field'] ?? 'coins_total'), + (string) ($query['aggregation'] ?? 'none'), + [ + 'source' => $query['source'] ?? null, + 'currency' => $query['currency'] ?? null, + 'date_from' => $query['date_from'] ?? null, + 'date_to' => $query['date_to'] ?? null, + ] + ); + } + + private function requiredString(mixed $value, string $field, int $maxLength): string + { + $normalized = trim((string) $value); + if ($normalized === '') { + throw new ApiException("Feld {$field} ist erforderlich.", 422); + } + if (mb_strlen($normalized) > $maxLength) { + throw new ApiException("Feld {$field} ist zu lang.", 422); + } + return $normalized; + } + + private function optionalString(mixed $value, int $maxLength): ?string + { + $normalized = trim((string) $value); + if ($normalized === '') { + return null; + } + if (mb_strlen($normalized) > $maxLength) { + throw new ApiException('Textwert ist zu lang.', 422); + } + return $normalized; + } + + private function requiredDecimal(mixed $value, string $field): float + { + if ($value === null || $value === '') { + throw new ApiException("Feld {$field} ist erforderlich.", 422); + } + if (!is_numeric((string) $value)) { + throw new ApiException("Feld {$field} muss numerisch sein.", 422); + } + return (float) $value; + } + + private function optionalDecimal(mixed $value): ?float + { + if ($value === null || $value === '') { + return null; + } + if (!is_numeric((string) $value)) { + throw new ApiException('Dezimalwert ist ungueltig.', 422); + } + return (float) $value; + } + + private function requiredEnum(mixed $value, string $field, array $allowed): string + { + $normalized = strtolower(trim((string) $value)); + if (!in_array($normalized, $allowed, true)) { + throw new ApiException("Feld {$field} ist ungueltig.", 422, [ + 'field' => $field, + 'allowed' => $allowed, + ]); + } + return $normalized; + } + + private function requiredCurrency(mixed $value, string $field): string + { + $currency = strtoupper(trim((string) $value)); + if (!preg_match('/^[A-Z0-9]{3,10}$/', $currency)) { + throw new ApiException("Feld {$field} muss ein gueltiger Waehrungscode sein.", 422); + } + + $catalogEntry = $this->currencyCatalogEntry($currency); + if (is_array($catalogEntry)) { + return $currency; + } + + throw new ApiException( + "Feld {$field} verweist auf keinen vorhandenen fx-rates Waehrungscode.", + 422, + [ + 'field' => $field, + 'missing_currency' => $currency, + 'hint' => 'Synchronisiere zuerst den Waehrungskatalog im Modul fx-rates.', + 'available_currencies' => array_slice(array_map( + static fn (array $item): string => (string) ($item['code'] ?? ''), + $this->currencies() + ), 0, 50), + ] + ); + } + + private function optionalCurrency(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + return $this->requiredCurrency($value, 'currency'); + } + + private function assertCurrencyType(string $code, bool $expectedCrypto, string $field): void + { + if ($this->currencyCatalogEntry($code) === null) { + throw new ApiException( + "Feld {$field} verweist auf keinen vorhandenen fx-rates Waehrungscode.", + 422, + ['field' => $field, 'currency' => $code] + ); + } + + $isCrypto = $this->isCryptoCurrencyCode($code); + if ($isCrypto !== $expectedCrypto) { + throw new ApiException( + $expectedCrypto + ? "Feld {$field} muss auf eine Krypto-Waehrung zeigen." + : "Feld {$field} muss auf eine FIAT-Waehrung zeigen.", + 422, + ['field' => $field, 'currency' => $code, 'expected_crypto' => $expectedCrypto] + ); + } + } + + private function optionalSpeedUnit(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + $unit = trim((string) $value); + if (!in_array($unit, ['kH/s', 'MH/s'], true)) { + throw new ApiException('Geschwindigkeitseinheit ist ungueltig.', 422, ['allowed' => ['kH/s', 'MH/s']]); + } + + return $unit; + } + + private function requiredPositiveInt(mixed $value, string $field): int + { + if ($value === null || $value === '' || !is_numeric((string) $value)) { + throw new ApiException("Feld {$field} muss numerisch sein.", 422); + } + + $intValue = (int) $value; + if ($intValue <= 0) { + throw new ApiException("Feld {$field} muss groesser als 0 sein.", 422); + } + + return $intValue; + } + + private function requiredPositiveDecimal(mixed $value, string $field): float + { + if ($value === null || $value === '' || !is_numeric((string) $value)) { + throw new ApiException("Feld {$field} muss numerisch sein.", 422); + } + + $floatValue = (float) $value; + if ($floatValue <= 0) { + throw new ApiException("Feld {$field} muss groesser als 0 sein.", 422); + } + + return $floatValue; + } + + private function optionalPositiveInt(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + return $this->requiredPositiveInt($value, 'value'); + } + + private function requiredDateTime(mixed $value, string $field, ?string $timezone = null): string + { + $normalized = trim((string) $value); + if ($normalized === '') { + throw new ApiException("Feld {$field} ist erforderlich.", 422); + } + + $sourceTimezone = new \DateTimeZone($timezone ?: 'UTC'); + $formats = [ + 'Y-m-d H:i:s', + 'Y-m-d H:i', + 'Y-m-d\TH:i:s', + 'Y-m-d\TH:i', + ]; + + $date = null; + foreach ($formats as $format) { + $parsed = \DateTimeImmutable::createFromFormat($format, $normalized, $sourceTimezone); + if ($parsed instanceof \DateTimeImmutable) { + $date = $parsed; + break; + } + } + + if (!$date instanceof \DateTimeImmutable) { + try { + $date = new \DateTimeImmutable($normalized, $sourceTimezone); + } catch (\Throwable) { + $date = null; + } + } + + if (!$date instanceof \DateTimeImmutable) { + throw new ApiException("Feld {$field} muss ein gueltiges Datum sein.", 422); + } + + return $date->setTimezone($this->utcTimezone())->format('Y-m-d H:i:s'); + } + + private function currentTimestamp(): string + { + return (new \DateTimeImmutable('now', $this->utcTimezone()))->format('Y-m-d H:i:s'); + } + + private function requiredTimezone(mixed $value, string $field): string + { + $timezone = trim((string) $value); + if (!$this->isValidTimezone($timezone)) { + throw new ApiException("Feld {$field} enthaelt keine gueltige Zeitzone.", 422); + } + + return $timezone; + } + + private function isValidTimezone(string $timezone): bool + { + return $timezone !== '' && in_array($timezone, \DateTimeZone::listIdentifiers(), true); + } + + private function projectTimezone(string $projectKey): string + { + $settings = $this->repository()->getSettings($projectKey); + $timezone = is_array($settings) ? (string) ($settings['display_timezone'] ?? '') : ''; + return $this->isValidTimezone($timezone) ? $timezone : nexus_display_timezone_name(); + } + + private function utcTimezone(): \DateTimeZone + { + static $timezone = null; + if (!$timezone instanceof \DateTimeZone) { + $timezone = new \DateTimeZone('UTC'); + } + + return $timezone; + } + + private function enumValue(mixed $value, array $allowed, string $field): string + { + $normalized = trim((string) $value); + if (!in_array($normalized, $allowed, true)) { + throw new ApiException("Feld {$field} enthaelt einen ungueltigen Wert.", 422, ['allowed' => $allowed]); + } + return $normalized; + } + + private function optionalArray(mixed $value): ?array + { + if ($value === null || $value === '') { + return null; + } + + if (is_array($value)) { + return $value; + } + + if (is_string($value)) { + $decoded = json_decode($value, true); + if (is_array($decoded)) { + return $decoded; + } + } + + throw new ApiException('Array-Wert ist ungueltig.', 422); + } + + private function optionalCurrencyList(mixed $value): array + { + if ($value === null || $value === '') { + return []; + } + + if (!is_array($value)) { + throw new ApiException('preferred_currencies muss ein Array sein.', 422); + } + + $result = []; + foreach ($value as $item) { + $currency = $this->requiredCurrency($item, 'preferred_currencies'); + if (!in_array($currency, $result, true)) { + $result[] = $currency; + } + } + + return $result; + } + + private function fxRatesSettings(): array + { + if ($this->fxRatesSettingsCache !== null) { + return $this->fxRatesSettingsCache; + } + + if (!modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'settings')) { + return $this->fxRatesSettingsCache = []; + } + + $settings = module_fn('fx-rates', 'settings'); + return $this->fxRatesSettingsCache = is_array($settings) ? $settings : []; + } + + private function preferredCurrencies(?array $fallback = null): array + { + $settings = $this->fxRatesSettings(); + $preferred = $settings['preferred_currencies'] ?? $fallback ?? ['DOGE', 'USD', 'EUR']; + $normalized = []; + foreach (is_array($preferred) ? $preferred : [] as $code) { + $normalizedCode = strtoupper(trim((string) $code)); + if ($normalizedCode !== '' && !in_array($normalizedCode, $normalized, true)) { + $normalized[] = $normalizedCode; + } + } + + return $normalized !== [] ? $normalized : ['DOGE', 'USD', 'EUR']; + } + + private function currencyCatalog(): array + { + if ($this->currencyCatalogCache !== null) { + return $this->currencyCatalogCache; + } + + $settings = $this->fxRatesSettings(); + $catalog = []; + foreach (is_array($settings['currency_catalog'] ?? null) ? $settings['currency_catalog'] : [] as $entry) { + if (!is_array($entry)) { + continue; + } + $code = strtoupper(trim((string) ($entry['code'] ?? ''))); + $name = trim((string) ($entry['name'] ?? '')); + if ($code === '') { + continue; + } + $catalog[] = [ + 'code' => $code, + 'name' => $name !== '' ? $name : $code, + 'symbol' => $code, + 'is_active' => 1, + 'is_crypto' => $this->isCryptoCurrencyCode($code) ? 1 : 0, + 'sort_order' => 1000, + ]; + } + + return $this->currencyCatalogCache = $catalog; + } + + private function currencyCatalogEntry(string $code): ?array + { + $normalizedCode = strtoupper(trim($code)); + if ($normalizedCode === '') { + return null; + } + + foreach ($this->currencyCatalog() as $entry) { + if ($normalizedCode === strtoupper((string) ($entry['code'] ?? ''))) { + return $entry; + } + } + + return null; + } + + private function syncFxRatesPreferredCurrencies(array $preferredCurrencies): void + { + if (!modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'save_runtime_settings')) { + return; + } + + module_fn('fx-rates', 'save_runtime_settings', [ + 'preferred_currencies' => $preferredCurrencies, + ]); + $this->fxRatesSettingsCache = null; + $this->currencyCatalogCache = null; + } + + private function isCryptoCurrencyCode(string $code): bool + { + return in_array(strtoupper(trim($code)), [ + 'ADA', 'ARB', 'AVAX', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC', 'MATIC', 'SOL', 'TRX', 'USDC', 'USDT', 'XRP', 'XMR' + ], true); + } + + private function resolveMeasurementFxFetchId(string $projectKey, array $payload, bool $allowRefresh, ?float $maxAgeHoursOverride = null): ?int + { + $measuredAt = trim((string) ($payload['measured_at'] ?? '')); + $maxAgeHours = $maxAgeHoursOverride ?? self::FX_FETCH_MAX_AGE_HOURS; + + if ($allowRefresh && $measuredAt !== '' && $this->isRecentTimestamp($measuredAt, $maxAgeHours)) { + $fresh = $this->fx()->ensureFreshLatestRates($maxAgeHours, 'USD'); + if (is_array($fresh) && is_numeric($fresh['fetch_id'] ?? null)) { + return (int) $fresh['fetch_id']; + } + } + + if ($measuredAt !== '') { + $nearest = $this->fx()->nearestSnapshot('USD', $measuredAt, null, null); + if (is_array($nearest) && is_numeric($nearest['id'] ?? null)) { + return (int) $nearest['id']; + } + } + + $latest = $this->fx()->latestSnapshot('USD', null); + if (is_array($latest) && is_numeric($latest['id'] ?? null)) { + return (int) $latest['id']; + } + + if ($allowRefresh) { + $fresh = $this->fx()->refreshLatestRates(null, 'USD'); + if (is_array($fresh) && is_numeric($fresh['fetch_id'] ?? null)) { + return (int) $fresh['fetch_id']; + } + } + + return null; + } + + private function measurementCoinCurrency(string $projectKey, array $input = []): string + { + $requested = $this->optionalCurrency($input['coin_currency'] ?? null); + if ($requested !== null) { + return $requested; + } + + $settings = $this->settings($projectKey); + return $this->requiredCurrency($settings['crypto_currency'] ?? 'DOGE', 'crypto_currency'); + } + + private function ensureMeasurementFxReferences(string $projectKey, array $rows, ?array $settings = null): array + { + $maxAgeHours = self::FX_FETCH_MAX_AGE_HOURS; + $resolvedByTimestamp = []; + $resolved = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $fetchId = is_numeric($row['fx_fetch_id'] ?? null) ? (int) $row['fx_fetch_id'] : 0; + if ($fetchId <= 0) { + $measuredAt = trim((string) ($row['measured_at'] ?? '')); + if ($measuredAt !== '' && array_key_exists($measuredAt, $resolvedByTimestamp)) { + $resolvedFetchId = $resolvedByTimestamp[$measuredAt]; + } else { + $resolvedFetchId = $this->resolveMeasurementFxFetchId($projectKey, $row, false, $maxAgeHours); + if ($measuredAt !== '') { + $resolvedByTimestamp[$measuredAt] = $resolvedFetchId; + } + } + + if ($resolvedFetchId !== null) { + $row['fx_fetch_id'] = $resolvedFetchId; + } + } + + $resolved[] = $row; + } + + return $resolved; + } + + private function measurementFxSnapshots(array $measurements, ?int $limit = null): array + { + $snapshots = []; + $measurementPool = $measurements; + if ($limit !== null && $limit > 0 && count($measurementPool) > $limit) { + $measurementPool = array_slice($measurementPool, -$limit); + } + + foreach ($measurementPool as $measurement) { + $fetchId = is_numeric($measurement['fx_fetch_id'] ?? null) ? (int) $measurement['fx_fetch_id'] : 0; + if ($fetchId <= 0 || isset($snapshots[$fetchId])) { + continue; + } + + $snapshot = $this->fx()->snapshotByFetchId($fetchId, null, null); + if (!is_array($snapshot)) { + continue; + } + + $snapshots[(string) $fetchId] = [ + 'id' => is_numeric($snapshot['id'] ?? null) ? (int) $snapshot['id'] : $fetchId, + 'base_currency' => (string) ($snapshot['base_currency'] ?? ''), + 'rate_date' => (string) ($snapshot['rate_date'] ?? ''), + 'provider' => (string) ($snapshot['provider'] ?? ''), + 'fetched_at' => (string) ($snapshot['fetched_at'] ?? ''), + 'rates' => is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [], + ]; + } + + return $snapshots; + } + + private function isRecentTimestamp(string $timestamp, float $maxAgeHours): bool + { + $parsed = strtotime($timestamp); + if ($parsed === false) { + return false; + } + + return abs(time() - $parsed) <= (int) round(max(0.25, $maxAgeHours) * 3600); + } + + private function resolveOfferPurchaseCost(string $projectKey, array $offer): float + { + $purchaseCurrency = (string) ($offer['price_currency'] ?? ''); + $baseAmount = is_numeric($offer['base_price_amount'] ?? null) + ? (float) $offer['base_price_amount'] + : (is_numeric($offer['reference_price_amount'] ?? null) + ? (float) $offer['reference_price_amount'] + : (is_numeric($offer['usd_reference_amount'] ?? null) ? (float) $offer['usd_reference_amount'] : null)); + $baseCurrency = (string) ($offer['base_price_currency'] ?? $offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : '')); + + if ($purchaseCurrency !== '' && $baseAmount !== null && $baseAmount > 0 && $baseCurrency !== '') { + $latestConverted = $this->convertWithLatestMeasurement($projectKey, $baseAmount, $baseCurrency, $purchaseCurrency); + if ($latestConverted !== null && $latestConverted > 0) { + return $latestConverted; + } + + $converted = $this->fx()->convert($baseAmount, $baseCurrency, $purchaseCurrency); + if (is_numeric($converted) && (float) $converted > 0) { + return (float) $converted; + } + } + + return (float) ($baseAmount ?? $offer['price_amount'] ?? 0); + } + + private function convertWithLatestMeasurement(string $projectKey, float $amount, string $fromCurrency, string $toCurrency): ?float + { + $from = strtoupper(trim($fromCurrency)); + $to = strtoupper(trim($toCurrency)); + if ($from === '' || $to === '' || $amount <= 0) { + return null; + } + if ($from === $to) { + return $amount; + } + + $latestRows = $this->repository()->listRecentMeasurements($projectKey, 1); + $latest = $latestRows[0] ?? null; + if (!is_array($latest)) { + return null; + } + + $coinCurrency = strtoupper(trim((string) ($latest['coin_currency'] ?? $this->settings($projectKey)['crypto_currency'] ?? ''))); + $priceCurrency = strtoupper(trim((string) ($latest['price_currency'] ?? ''))); + $pricePerCoin = is_numeric($latest['price_per_coin'] ?? null) ? (float) $latest['price_per_coin'] : null; + if ($coinCurrency === '' || $priceCurrency === '' || $pricePerCoin === null || $pricePerCoin <= 0) { + return null; + } + + if ($from === $priceCurrency && $to === $coinCurrency) { + return $amount / $pricePerCoin; + } + + if ($from === $coinCurrency && $to === $priceCurrency) { + return $amount * $pricePerCoin; + } + + if ($to === $coinCurrency) { + $convertedFiat = $this->fx()->convert($amount, $from, $priceCurrency); + if (is_numeric($convertedFiat) && (float) $convertedFiat > 0) { + return (float) $convertedFiat / $pricePerCoin; + } + } + + if ($from === $coinCurrency) { + $convertedFiat = $amount * $pricePerCoin; + $convertedTarget = $this->fx()->convert($convertedFiat, $priceCurrency, $to); + if (is_numeric($convertedTarget)) { + return (float) $convertedTarget; + } + } + + return null; + } + + private function bonusSpeedFromPercent(?float $speedValue, ?string $speedUnit, ?float $bonusPercent): ?float + { + if ($speedValue === null || $speedUnit === null || $bonusPercent === null) { + return null; + } + + return round($speedValue * ($bonusPercent / 100), 4); + } + + private function offerBonusPercent(array $offer): ?float + { + $speedValue = $this->optionalDecimal($offer['mining_speed_value'] ?? null); + $speedUnit = $this->optionalSpeedUnit($offer['mining_speed_unit'] ?? null); + $bonusValue = $this->optionalDecimal($offer['bonus_speed_value'] ?? null); + $bonusUnit = $this->optionalSpeedUnit($offer['bonus_speed_unit'] ?? null); + if ($speedValue === null || $speedUnit === null || $bonusValue === null || $bonusUnit === null) { + return null; + } + + $speedMh = $this->hashrateToMh($speedValue, $speedUnit); + $bonusMh = $this->hashrateToMh($bonusValue, $bonusUnit); + if ($speedMh <= 0 || $bonusMh <= 0) { + return null; + } + + return ($bonusMh / $speedMh) * 100; + } + + private function hashrateToMh(float $value, string $unit): float + { + return $unit === 'kH/s' ? $value / 1000 : $value; + } + + private function pdo(): PDO + { + if ($this->pdo === null) { + $this->debug->add('db.connect.start'); + $this->pdo = ConnectionFactory::make($this->config); + $this->debug->add('db.connect.end', [ + 'driver' => (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME), + ]); + } + + return $this->pdo; + } + + private function repository(): MiningRepository + { + if ($this->repository === null) { + $this->repository = new MiningRepository($this->pdo(), $this->config->tablePrefix(), $this->debug, $this->ownerSub()); + } + + return $this->repository; + } + + private function ownerSub(): string + { + $user = app()->auth()->user(); + $sub = is_array($user) ? trim((string) ($user['sub'] ?? '')) : ''; + if ($sub !== '') { + return $sub; + } + + if (app()->auth()->isEnabled()) { + throw new ApiException('Keycloak-Sub fehlt. Bitte erneut anmelden.', 401); + } + + return 'local'; + } + + private function schemaManager(): SchemaManager + { + if ($this->schemaManager === null) { + $this->schemaManager = new SchemaManager($this->pdo(), $this->config->tablePrefix(), $this->moduleBasePath); + } + + return $this->schemaManager; + } + + private function fx(): FxService + { + if ($this->fx === null) { + $fxConfig = $this->config->fx(); + $this->fx = new FxService( + $this->repository(), + (string) ($fxConfig['url'] ?? 'https://currencyapi.net'), + (string) ($fxConfig['currencies_url'] ?? 'https://currencyapi.net'), + (int) ($fxConfig['timeout'] ?? 10), + (int) ($fxConfig['cache_ttl'] ?? 21600), + (bool) ($fxConfig['auto_fetch_on_miss'] ?? false), + (string) ($fxConfig['provider'] ?? 'currencyapi'), + (string) ($fxConfig['api_key'] ?? ''), + $this->debug + ); + } + + return $this->fx; + } + + private function respond(array $payload, int $statusCode = 200): never + { + $trace = $this->debug->export(); + if ($trace !== []) { + $payload['debug'] = $trace; + } + + Http::json($payload, $statusCode); + } + + private function configureRuntimeGuards(): void + { + if (function_exists('ignore_user_abort')) { + @ignore_user_abort(false); + } + if (function_exists('set_time_limit')) { + @set_time_limit(15); + } + $this->debug->add('runtime.guards', [ + 'time_limit_sec' => 15, + 'budget_sec' => self::LONG_REQUEST_BUDGET_SECONDS, + ]); + } + + private function applyRouteRuntimeGuards(string $resource): void + { + $normalized = trim(strtolower($resource)); + $timeLimit = match ($normalized) { + 'ocr-preview' => 45, + 'measurements-import', 'legacy-fx-migrate', 'sql-import' => 60, + default => 15, + }; + + if (function_exists('set_time_limit')) { + @set_time_limit($timeLimit); + } + + $this->debug->add('runtime.route_guards', [ + 'resource' => $resource, + 'time_limit_sec' => $timeLimit, + ]); + } + + private function releaseSessionLock(): void + { + if (PHP_SAPI === 'cli') { + return; + } + + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + } + + private function assertRequestWithinBudget(float $startedAt, string $message): void + { + $elapsed = microtime(true) - $startedAt; + if ($elapsed > self::LONG_REQUEST_BUDGET_SECONDS) { + $this->debug->add('request.budget_exceeded', [ + 'elapsed_ms' => round($elapsed * 1000, 2), + 'budget_ms' => round(self::LONG_REQUEST_BUDGET_SECONDS * 1000, 2), + 'message' => $message, + ]); + throw new ApiException($message, 503, ['timeout' => true, 'elapsed_ms' => round($elapsed * 1000, 2)]); + } + } + + private function safeTimed(string $event, callable $callback, mixed $fallback = null, array $context = []): mixed + { + $startedAt = microtime(true); + $this->debug->add($event . '.start', $context); + + try { + $result = $callback(); + $meta = [ + 'duration_ms' => round((microtime(true) - $startedAt) * 1000, 2), + ]; + if (is_array($result)) { + $meta['count'] = count($result); + } + $this->debug->add($event . '.end', $context + $meta); + return $result; + } catch (\Throwable $exception) { + $meta = [ + 'duration_ms' => round((microtime(true) - $startedAt) * 1000, 2), + 'fallback_used' => true, + 'error' => $exception->getMessage(), + 'type' => get_debug_type($exception), + ]; + $this->debug->add($event . '.error', $context + $meta); + return $fallback; + } + } + + private function debugLatest(): array + { + $filePath = DebugState::latestFilePath(); + if ($filePath === null || !is_file($filePath)) { + return [ + 'entries' => [], + 'file' => $filePath, + 'exists' => false, + ]; + } + + $raw = file_get_contents($filePath); + $entries = json_decode($raw ?: '[]', true); + + return [ + 'entries' => is_array($entries) ? $entries : [], + 'file' => $filePath, + 'exists' => true, + 'updated_at' => date('c', filemtime($filePath) ?: time()), + ]; + } + + private function analytics(): AnalyticsService + { + if ($this->analytics === null) { + $this->analytics = new AnalyticsService($this->fx()); + } + + return $this->analytics; + } + + private function seedImporter(): SeedImporter + { + if ($this->seedImporter === null) { + $this->seedImporter = new SeedImporter($this->repository()); + } + + return $this->seedImporter; + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/src/Domain/AnalyticsService.php b/temp/nexus-module-import/modules/mining-checker/src/Domain/AnalyticsService.php new file mode 100644 index 00000000..8fa2f0b6 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Domain/AnalyticsService.php @@ -0,0 +1,1619 @@ + ['value' => 50.0, 'unit' => 'kH/s'], + 'crypto' => ['value' => 75.0, 'unit' => 'kH/s'], + ]; + + private const OFFER_SPEED_MULTIPLIERS = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + ]; + + private ?FxService $fx; + + public function __construct(?FxService $fx = null) + { + $this->fx = $fx; + } + + public function enrichMeasurements(array $measurements, array $settings, array $options = []): array + { + $fullLatestOnly = !empty($options['full_latest_only']); + $baselineCoins = (float) ($settings['baseline_coins_total'] ?? 0.0); + $baselineAt = (string) ($settings['baseline_measured_at'] ?? ''); + $costPlans = is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : []; + $payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : []; + $purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : []; + $preferredPriceCurrencies = array_values(array_unique(array_filter([ + strtoupper(trim((string) ($settings['report_currency'] ?? ''))), + 'USD', + 'EUR', + 'DOGE', + ]))); + + $baselineTs = $this->utcTimestamp($baselineAt); + $previous = null; + $previousMeasuredTs = null; + $previousIntervalRate = null; + $previousCoinCurrency = null; + $result = []; + $payoutIndex = 0; + $payoutsByAsset = []; + $latestPriceByCurrency = []; + + $lastIndex = count($measurements) - 1; + foreach ($measurements as $index => $row) { + $measuredTs = $this->utcTimestamp((string) $row['measured_at']); + $coinCurrency = strtoupper(trim((string) ($row['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE'))); + $includeFullDetail = !$fullLatestOnly || $index === $lastIndex; + while (isset($payouts[$payoutIndex])) { + $payoutTs = $this->utcTimestamp((string) ($payouts[$payoutIndex]['payout_at'] ?? '')); + if ($payoutTs <= 0 || $payoutTs > $measuredTs) { + break; + } + + $payoutAsset = strtoupper(trim((string) ($payouts[$payoutIndex]['payout_currency'] ?? $coinCurrency))); + $payoutsByAsset[$payoutAsset] = ($payoutsByAsset[$payoutAsset] ?? 0.0) + (float) ($payouts[$payoutIndex]['coins_amount'] ?? 0); + $payoutIndex++; + } + + $cumulativePayouts = (float) ($payoutsByAsset[$coinCurrency] ?? 0.0); + $visibleCoinsTotal = (float) $row['coins_total']; + $effectiveCoinsTotal = $visibleCoinsTotal + $cumulativePayouts; + $growth = $effectiveCoinsTotal - $baselineCoins; + $hoursSinceBaseline = $baselineTs > 0 && $measuredTs > $baselineTs ? ($measuredTs - $baselineTs) / 3600 : 0.0; + $perHourSinceBaseline = $hoursSinceBaseline > 0 ? $growth / $hoursSinceBaseline : null; + $perDaySinceBaseline = $perHourSinceBaseline !== null ? $perHourSinceBaseline * 24 : null; + $activeHashrateMh = $this->measurementHashrateMh($costPlans, $purchasedMiners, $measuredTs > 0 ? $measuredTs : null); + + $intervalHours = null; + $intervalGrowth = null; + $perHourInterval = null; + $perDayInterval = null; + $perHourPerMhInterval = null; + $perDayPerMhInterval = null; + + if (is_array($previous) && $previousMeasuredTs !== null) { + $intervalStartTs = $previousMeasuredTs; + $intervalStartCoins = (float) ($previous['coins_total_effective'] ?? $previous['coins_total'] ?? 0.0); + if ($previousCoinCurrency !== null && $previousCoinCurrency !== $coinCurrency) { + $intervalStartCoins = $effectiveCoinsTotal; + } + + $intervalHours = max(0.0, ($measuredTs - $intervalStartTs) / 3600); + $intervalGrowth = $effectiveCoinsTotal - $intervalStartCoins; + $perHourInterval = $intervalHours > 0 ? $intervalGrowth / $intervalHours : null; + $perDayInterval = $perHourInterval !== null ? $perHourInterval * 24 : null; + if ($perHourInterval !== null && $activeHashrateMh > 0) { + $perHourPerMhInterval = $perHourInterval / $activeHashrateMh; + $perDayPerMhInterval = $perDayInterval !== null ? $perDayInterval / $activeHashrateMh : null; + } + } + + $trendLabel = 'stabil'; + if ($perHourInterval !== null && $previousIntervalRate !== null) { + $delta = $perHourInterval - $previousIntervalRate; + $threshold = max(abs($previousIntervalRate) * 0.05, 0.01); + if ($delta > $threshold) { + $trendLabel = 'steigend'; + } elseif ($delta < -$threshold) { + $trendLabel = 'fallend'; + } + } + + $rawPrice = isset($row['price_per_coin']) && $row['price_per_coin'] !== null ? (float) $row['price_per_coin'] : null; + $rawPriceCurrency = $row['price_currency'] ?: null; + + if ($rawPrice !== null && $rawPriceCurrency !== null) { + $latestPriceByCurrency[(string) $rawPriceCurrency] = $rawPrice; + } + + $measurementDerivedPrices = $this->measurementDerivedPrices($row, $preferredPriceCurrencies); + foreach ($measurementDerivedPrices as $derivedCurrency => $derivedPrice) { + $latestPriceByCurrency[$derivedCurrency] = $derivedPrice; + } + + $priceCurrency = $rawPriceCurrency !== null + ? (string) $rawPriceCurrency + : $this->preferredPriceCurrency($latestPriceByCurrency, $measurementDerivedPrices); + $price = $rawPrice; + if ($price === null && $priceCurrency !== null && isset($measurementDerivedPrices[$priceCurrency])) { + $price = (float) $measurementDerivedPrices[$priceCurrency]; + } + if ($price === null && $priceCurrency !== null && isset($latestPriceByCurrency[$priceCurrency])) { + $price = (float) $latestPriceByCurrency[$priceCurrency]; + } + if ($price === null) { + foreach (['USD', 'EUR'] as $fallbackCurrency) { + $fxPrice = $this->convertAmount(1.0, $coinCurrency, $fallbackCurrency, $row); + if ($fxPrice !== null && $fxPrice > 0) { + $latestPriceByCurrency[$fallbackCurrency] = $fxPrice; + } + } + $priceCurrency = $priceCurrency ?? $this->preferredPriceCurrency($latestPriceByCurrency, $measurementDerivedPrices); + if ($priceCurrency !== null && isset($latestPriceByCurrency[$priceCurrency])) { + $price = (float) $latestPriceByCurrency[$priceCurrency]; + } + } + + $effectiveDailyCost = $includeFullDetail ? $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency, $row) : null; + $currentValue = ($includeFullDetail && $price !== null) ? $visibleCoinsTotal * $price : null; + $currentValueEffective = ($includeFullDetail && $price !== null) ? $effectiveCoinsTotal * $price : null; + $theoreticalDailyRevenue = ($includeFullDetail && $price !== null && $perDayInterval !== null) ? $perDayInterval * $price : null; + $theoreticalDailyProfit = ( + $includeFullDetail && + $theoreticalDailyRevenue !== null && + $effectiveDailyCost !== null + ) ? $theoreticalDailyRevenue - $effectiveDailyCost : null; + $breakEvenPricePerCoin = ( + $includeFullDetail && + $effectiveDailyCost !== null && + $perDayInterval !== null && + $perDayInterval > 0 + ) ? $effectiveDailyCost / $perDayInterval : null; + $profitMarginPercent = ( + $includeFullDetail && + $theoreticalDailyRevenue !== null && + $theoreticalDailyRevenue > 0 && + $theoreticalDailyProfit !== null + ) ? ($theoreticalDailyProfit / $theoreticalDailyRevenue) * 100 : null; + + $normalizedFlags = $row['ocr_flags']; + if (is_string($normalizedFlags) && $normalizedFlags !== '') { + $decoded = json_decode($normalizedFlags, true); + $normalizedFlags = is_array($decoded) ? $decoded : [$normalizedFlags]; + } + + $enrichedRow = array_merge($row, [ + 'coins_total_visible' => $this->roundOrNull($visibleCoinsTotal, 6), + 'coins_total_effective' => $this->roundOrNull($effectiveCoinsTotal, 6), + 'coin_currency' => $coinCurrency, + 'payouts_cumulative' => $this->roundOrNull($cumulativePayouts, 6), + 'growth_since_baseline' => $this->roundOrNull($growth, 6), + 'hours_since_baseline' => $this->roundOrNull($hoursSinceBaseline, 4), + 'doge_per_hour_since_baseline' => $this->roundOrNull($perHourSinceBaseline, 6), + 'doge_per_day_since_baseline' => $this->roundOrNull($perDaySinceBaseline, 6), + 'active_hashrate_mh' => $this->roundOrNull($activeHashrateMh > 0 ? $activeHashrateMh : null, 4), + 'interval_hours' => $this->roundOrNull($intervalHours, 4), + 'interval_growth' => $this->roundOrNull($intervalGrowth, 6), + 'doge_per_hour_interval' => $this->roundOrNull($perHourInterval, 6), + 'doge_per_day_interval' => $this->roundOrNull($perDayInterval, 6), + 'doge_per_hour_per_mh_interval' => $this->roundOrNull($perHourPerMhInterval, 8), + 'doge_per_day_per_mh_interval' => $this->roundOrNull($perDayPerMhInterval, 8), + 'trend_label' => $trendLabel, + 'effective_price_per_coin' => $this->roundOrNull($price, 8), + 'effective_price_currency' => $priceCurrency, + 'price_is_fallback' => $rawPrice === null && $price !== null, + 'price_quotes' => $measurementDerivedPrices, + 'current_value' => $this->roundOrNull($currentValue, 8), + 'current_value_effective' => $this->roundOrNull($currentValueEffective, 8), + 'effective_daily_cost' => $this->roundOrNull($effectiveDailyCost, 8), + 'theoretical_daily_revenue' => $this->roundOrNull($theoreticalDailyRevenue, 8), + 'theoretical_daily_profit' => $this->roundOrNull($theoreticalDailyProfit, 8), + 'break_even_price_per_coin' => $this->roundOrNull($breakEvenPricePerCoin, 8), + 'profit_margin_percent' => $this->roundOrNull($profitMarginPercent, 4), + 'measured_date' => substr((string) $row['measured_at'], 0, 10), + 'ocr_flags' => is_array($normalizedFlags) ? $normalizedFlags : [], + ]); + $result[] = $enrichedRow; + + if ($perHourInterval !== null) { + $previousIntervalRate = $perHourInterval; + } + $previous = $enrichedRow; + $previousMeasuredTs = $measuredTs > 0 ? $measuredTs : null; + $previousCoinCurrency = $coinCurrency; + } + + return $result; + } + + public function buildSummary(array $measurements, array $settings, array $targets, array $options = []): array + { + $includeOfferScenarios = !array_key_exists('include_offer_scenarios', $options) || (bool) $options['include_offer_scenarios']; + $includeLongTermProjection = !array_key_exists('include_long_term_projection', $options) || (bool) $options['include_long_term_projection']; + + if ($measurements === []) { + return [ + 'latest_measurement' => null, + 'baseline' => $settings, + 'targets' => [], + 'payouts' => [], + 'miner_offers' => [], + ]; + } + + $latest = $measurements[array_key_last($measurements)]; + $latestPriceByCurrency = []; + foreach ($measurements as $measurement) { + $quotes = is_array($measurement['price_quotes'] ?? null) ? $measurement['price_quotes'] : []; + foreach ($quotes as $currency => $price) { + $normalizedCurrency = strtoupper(trim((string) $currency)); + if ($normalizedCurrency !== '' && is_numeric($price)) { + $latestPriceByCurrency[$normalizedCurrency] = (float) $price; + } + } + } + $latestEffectiveCurrency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''))); + if ($latestEffectiveCurrency !== '' && is_numeric($latest['effective_price_per_coin'] ?? null)) { + $latestPriceByCurrency[$latestEffectiveCurrency] = (float) $latest['effective_price_per_coin']; + } + + $payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : []; + $purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : []; + $minerOffers = is_array($settings['miner_offers'] ?? null) ? $settings['miner_offers'] : []; + $currentHashrateMh = $this->totalHashrateMh(array_merge( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners + )); + $offerSummary = []; + foreach ($this->expandOfferVariants($minerOffers, $settings) as $offer) { + $offerSummary[] = $this->evaluateMinerOffer($offer, $latest, $latestPriceByCurrency, $currentHashrateMh, $settings); + } + + $targetSummary = []; + foreach ($targets as $target) { + $currency = (string) $target['currency']; + $targetAmount = is_numeric($target['target_amount_fiat'] ?? null) ? (float) $target['target_amount_fiat'] : null; + $linkedOffer = null; + if (is_numeric($target['miner_offer_id'] ?? null)) { + foreach ($offerSummary as $offer) { + if ((int) ($offer['base_offer_id'] ?? $offer['id'] ?? 0) === (int) $target['miner_offer_id']) { + $linkedOffer = $offer; + break; + } + } + } + + if (is_array($linkedOffer)) { + $currency = (string) ($linkedOffer['reference_price_currency'] ?? $linkedOffer['effective_price_currency'] ?? $currency); + $targetAmount = is_numeric($linkedOffer['reference_price_amount'] ?? null) + ? (float) $linkedOffer['reference_price_amount'] + : (is_numeric($linkedOffer['effective_price_amount'] ?? null) ? (float) $linkedOffer['effective_price_amount'] : $targetAmount); + } + + $price = $latestPriceByCurrency[$currency] ?? $this->convertLatestPrice($latestPriceByCurrency, $currency, $latest); + $requiredDoge = ($price && $targetAmount !== null) ? $targetAmount / $price : null; + $remainingDoge = $requiredDoge !== null ? $requiredDoge - (float) ($latest['coins_total_effective'] ?? $latest['coins_total']) : null; + $remainingDays = ( + $remainingDoge !== null && + $latest['doge_per_day_interval'] !== null && + (float) $latest['doge_per_day_interval'] > 0 + ) ? $remainingDoge / (float) $latest['doge_per_day_interval'] : null; + $targetEtaAt = null; + if ($remainingDays !== null) { + if ($remainingDays <= 0) { + $targetEtaAt = (string) ($latest['measured_at'] ?? ''); + } elseif (!empty($latest['measured_at'])) { + try { + $targetEtaAt = $this->formatUtcTimestamp( + $this->utcTimestamp((string) $latest['measured_at']) + (int) round($remainingDays * 86400) + ); + } catch (\Throwable) { + $targetEtaAt = null; + } + } + } + + $targetSummary[] = array_merge($target, [ + 'effective_target_amount_fiat' => $this->roundOrNull($targetAmount, 2), + 'effective_currency' => $currency, + 'linked_offer_id' => $linkedOffer['id'] ?? ($target['miner_offer_id'] ?? null), + 'linked_offer_label' => $linkedOffer['label'] ?? null, + 'latest_price_for_currency' => $price, + 'required_doge' => $this->roundOrNull($requiredDoge, 6), + 'remaining_doge' => $this->roundOrNull($remainingDoge, 6), + 'remaining_days' => $this->roundOrNull($remainingDays, 4), + 'target_eta_at' => $targetEtaAt, + 'status' => $remainingDoge !== null && $remainingDoge <= 0 ? 'reached' : 'open', + ]); + } + + $latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''); + $latestMeasuredTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); + $cashInvestedCapital = $latestCurrency !== '' + ? $this->totalInvestmentBasis( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners, + $latestMeasuredTs, + $latestCurrency, + $latest, + 'cash' + ) + : null; + $reinvestedCapital = $latestCurrency !== '' + ? $this->totalInvestmentBasis( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners, + $latestMeasuredTs, + $latestCurrency, + $latest, + 'reinvest' + ) + : null; + $walletBalances = $this->walletBalances($payouts, $purchasedMiners, $latestMeasuredTs); + $latestWalletSnapshot = $this->latestWalletSnapshot(is_array($options['wallet_snapshots'] ?? null) ? $options['wallet_snapshots'] : []); + if (is_array($latestWalletSnapshot)) { + $snapshotBalances = $this->walletSnapshotBalances($latestWalletSnapshot); + if ($snapshotBalances !== []) { + $walletBalances = $snapshotBalances; + } + } + $walletBalanceCurrentAsset = (float) ($walletBalances[strtoupper(trim((string) ($latest['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE')))] ?? 0.0); + $holdingsCurrentAsset = $walletBalanceCurrentAsset + (float) ($latest['coins_total_visible'] ?? $latest['coins_total'] ?? 0); + $walletValue = $latestCurrency !== '' + ? ( + is_array($latestWalletSnapshot) + ? $this->walletSnapshotValue($latestWalletSnapshot, $latestCurrency, $latest) + : $this->walletBalanceValue($walletBalances, $latestCurrency, $latest) + ) + : null; + $currentVisibleValue = is_numeric($latest['current_value'] ?? null) ? (float) $latest['current_value'] : null; + $totalHoldingsValue = ($walletValue !== null || $currentVisibleValue !== null) + ? (float) ($walletValue ?? 0.0) + (float) ($currentVisibleValue ?? 0.0) + : null; + $currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null; + $breakEvenRemainingAmount = ($cashInvestedCapital !== null && $totalHoldingsValue !== null) + ? max(0.0, $cashInvestedCapital - $totalHoldingsValue) + : $cashInvestedCapital; + $breakEvenProjection = ( + $cashInvestedCapital !== null && + $breakEvenRemainingAmount !== null + ) ? $this->projectBreakEvenDate( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners, + $latest, + $breakEvenRemainingAmount + ) : ['days' => null, 'eta' => null]; + $breakEvenDaysOverall = is_numeric($breakEvenProjection['days'] ?? null) ? (float) $breakEvenProjection['days'] : null; + $latestSummary = array_merge($latest, [ + 'invested_capital' => $this->roundOrNull($cashInvestedCapital, 8), + 'cash_invested_capital' => $this->roundOrNull($cashInvestedCapital, 8), + 'reinvested_capital' => $this->roundOrNull($reinvestedCapital, 8), + 'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6), + 'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6), + 'wallet_value' => $this->roundOrNull($walletValue, 8), + 'wallet_snapshot_measured_at' => is_array($latestWalletSnapshot) ? (string) ($latestWalletSnapshot['measured_at'] ?? '') : null, + 'total_holdings_value' => $this->roundOrNull($totalHoldingsValue, 8), + 'break_even_remaining_amount' => $this->roundOrNull($breakEvenRemainingAmount, 8), + 'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4), + 'break_even_eta_at' => $breakEvenProjection['eta'] ?? null, + ]); + if ($includeLongTermProjection) { + $currentProjection = $this->projectPerformance( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners, + $latestSummary, + 730 + ); + $latestSummary = array_merge($latestSummary, [ + 'projection_days' => $currentProjection['days'], + 'projection_two_year_revenue' => $this->roundOrNull($currentProjection['revenue'], 8), + 'projection_two_year_cost' => $this->roundOrNull($currentProjection['cost'], 8), + 'projection_two_year_profit' => $this->roundOrNull($currentProjection['profit'], 8), + ]); + } + + if ($includeOfferScenarios) { + $offerSummary = array_map( + fn (array $offer): array => $this->enrichOfferScenario( + $offer, + $latestSummary, + $currentHashrateMh, + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners + ), + $offerSummary + ); + } else { + $offerSummary = []; + } + + return [ + 'latest_measurement' => $latestSummary, + 'baseline' => $settings, + 'targets' => $targetSummary, + 'payouts' => [ + 'total_count' => count($payouts), + 'total_coins' => $this->roundOrNull(array_sum(array_map(static fn (array $payout): float => (float) ($payout['coins_amount'] ?? 0), $payouts)), 6), + 'current_visible_coins' => $this->roundOrNull((float) ($latest['coins_total_visible'] ?? $latest['coins_total']), 6), + 'current_effective_coins' => $this->roundOrNull((float) ($latest['coins_total_effective'] ?? $latest['coins_total']), 6), + 'wallet_balances' => $walletBalances, + 'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6), + 'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6), + ], + 'current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4), + 'miner_offers' => $offerSummary, + ]; + } + + public function dashboardData(array $measurements, string $xField, string $yField, string $aggregation, array $filters = []): array + { + $allowedX = ['measured_at', 'measured_date', 'source', 'price_currency', 'trend_label']; + $allowedY = [ + 'coins_total', + 'price_per_coin', + 'growth_since_baseline', + 'doge_per_hour_since_baseline', + 'doge_per_day_since_baseline', + 'doge_per_hour_interval', + 'doge_per_day_interval', + 'current_value', + 'theoretical_daily_revenue', + 'theoretical_daily_profit', + ]; + + if (!in_array($xField, $allowedX, true) || !in_array($yField, $allowedY, true)) { + throw new ApiException('Dashboard-Felder sind nicht erlaubt.', 422, ['x_field' => $xField, 'y_field' => $yField]); + } + + $filtered = array_values(array_filter($measurements, static function (array $row) use ($filters): bool { + if (!empty($filters['source']) && $row['source'] !== $filters['source']) { + return false; + } + + if (!empty($filters['currency']) && $row['price_currency'] !== $filters['currency']) { + return false; + } + + if (!empty($filters['date_from']) && (string) $row['measured_at'] < (string) $filters['date_from']) { + return false; + } + + if (!empty($filters['date_to']) && (string) $row['measured_at'] > (string) $filters['date_to']) { + return false; + } + + return true; + })); + + if ($aggregation === 'none') { + return array_map(static fn (array $row): array => [ + 'x' => $row[$xField] ?? null, + 'y' => $row[$yField] ?? null, + 'row' => $row, + ], $filtered); + } + + $groups = []; + foreach ($filtered as $row) { + $key = (string) ($row[$xField] ?? 'unknown'); + $groups[$key][] = $row; + } + + $result = []; + foreach ($groups as $key => $rows) { + $values = array_values(array_filter(array_map(static fn (array $row) => $row[$yField] ?? null, $rows), static fn ($value): bool => $value !== null)); + $aggregated = match ($aggregation) { + 'avg' => $values === [] ? null : array_sum($values) / count($values), + 'sum' => $values === [] ? null : array_sum($values), + 'min' => $values === [] ? null : min($values), + 'max' => $values === [] ? null : max($values), + 'count' => count($rows), + 'latest' => $rows[array_key_last($rows)][$yField] ?? null, + default => null, + }; + + $result[] = [ + 'x' => $key, + 'y' => $this->roundOrNull(is_numeric($aggregated) ? (float) $aggregated : null, 6), + 'points' => count($rows), + ]; + } + + return $result; + } + + private function roundOrNull(?float $value, int $precision): ?float + { + return $value === null ? null : round($value, $precision); + } + + private function effectiveDailyCost(array $costPlans, int $measurementTs, ?string $currency, ?array $fxContext = null): ?float + { + if ($currency === null) { + return null; + } + + $dailyTotal = 0.0; + $matched = false; + foreach ($costPlans as $plan) { + if (empty($plan['is_active'])) { + continue; + } + + $startTs = $this->utcTimestamp((string) ($plan['starts_at'] ?? '')); + $runtimeMonths = (int) ($plan['runtime_months'] ?? 0); + if ($startTs <= 0 || $runtimeMonths <= 0 || $measurementTs < $startTs) { + continue; + } + + $runtimeDays = $runtimeMonths * 30.4375; + $endTs = (int) round($startTs + ($runtimeDays * 86400)); + $isCovered = !empty($plan['auto_renew']) || $measurementTs <= $endTs; + if (!$isCovered) { + continue; + } + + $planDailyCost = (float) $plan['total_cost_amount'] / $runtimeDays; + $convertedDailyCost = $this->convertAmount( + $planDailyCost, + (string) ($plan['currency'] ?? ''), + $currency, + $fxContext + ); + if ($convertedDailyCost === null) { + continue; + } + + $matched = true; + $dailyTotal += $convertedDailyCost; + } + + return $matched ? $dailyTotal : null; + } + + private function convertLatestPrice(array $latestPriceByCurrency, string $targetCurrency, ?array $fxContext = null): ?float + { + foreach ($latestPriceByCurrency as $sourceCurrency => $price) { + $converted = $this->convertAmount((float) $price, (string) $sourceCurrency, $targetCurrency, $fxContext); + if ($converted !== null) { + return $converted; + } + } + + return null; + } + + private function preferredPriceCurrency(array $latestPriceByCurrency, array $measurementDerivedPrices = []): ?string + { + foreach (['USD', 'EUR', 'DOGE'] as $preferredCurrency) { + if (array_key_exists($preferredCurrency, $latestPriceByCurrency)) { + return $preferredCurrency; + } + if (array_key_exists($preferredCurrency, $measurementDerivedPrices)) { + return $preferredCurrency; + } + } + + $first = array_key_first($latestPriceByCurrency); + if (is_string($first)) { + return $first; + } + + $derivedFirst = array_key_first($measurementDerivedPrices); + return is_string($derivedFirst) ? $derivedFirst : null; + } + + private function measurementDerivedPrices(array $measurement, array $targetCurrencies): array + { + $rawPrice = is_numeric($measurement['price_per_coin'] ?? null) ? (float) $measurement['price_per_coin'] : null; + $rawCurrency = strtoupper(trim((string) ($measurement['price_currency'] ?? ''))); + + if ($rawPrice === null || $rawPrice <= 0 || $rawCurrency === '') { + return []; + } + + $prices = [$rawCurrency => $rawPrice]; + foreach ($targetCurrencies as $targetCurrency) { + $targetCurrency = strtoupper(trim((string) $targetCurrency)); + if ($targetCurrency === '' || isset($prices[$targetCurrency])) { + continue; + } + + $converted = $this->convertAmount($rawPrice, $rawCurrency, $targetCurrency, $measurement); + if ($converted !== null && $converted > 0) { + $prices[$targetCurrency] = $converted; + } + } + + return $prices; + } + + private function convertAmount(?float $amount, ?string $fromCurrency, ?string $toCurrency, ?array $fxContext = null): ?float + { + if ($amount === null || $fromCurrency === null || $toCurrency === null) { + return null; + } + + $from = strtoupper(trim($fromCurrency)); + $to = strtoupper(trim($toCurrency)); + if ($from === '' || $to === '') { + return null; + } + + if ($from === $to) { + return $amount; + } + + $measurementConverted = $this->convertAmountFromMeasurementPrice($amount, $from, $to, $fxContext); + if ($measurementConverted !== null) { + return $measurementConverted; + } + + if ($this->fx === null) { + return null; + } + + $fetchId = isset($fxContext['fx_fetch_id']) && is_numeric($fxContext['fx_fetch_id']) + ? (int) $fxContext['fx_fetch_id'] + : null; + $at = is_string($fxContext['measured_at'] ?? null) ? (string) $fxContext['measured_at'] : null; + + return $this->fx->convertAt($amount, $from, $to, $at, null, $fetchId); + } + + private function convertAmountFromMeasurementPrice(float $amount, string $from, string $to, ?array $fxContext = null): ?float + { + if (!is_array($fxContext)) { + return null; + } + + $coinCurrency = strtoupper(trim((string) ($fxContext['coin_currency'] ?? ''))); + $priceCurrency = strtoupper(trim((string) ($fxContext['effective_price_currency'] ?? $fxContext['price_currency'] ?? ''))); + $pricePerCoin = is_numeric($fxContext['effective_price_per_coin'] ?? null) + ? (float) $fxContext['effective_price_per_coin'] + : (is_numeric($fxContext['price_per_coin'] ?? null) ? (float) $fxContext['price_per_coin'] : null); + + if ($coinCurrency === '' || $priceCurrency === '' || $pricePerCoin === null || $pricePerCoin <= 0) { + return null; + } + + if ($from === $coinCurrency && $to === $priceCurrency) { + return $amount * $pricePerCoin; + } + + if ($from === $priceCurrency && $to === $coinCurrency) { + return $amount / $pricePerCoin; + } + + if ($to === $coinCurrency && $this->fx !== null) { + $convertedFiat = $this->fx->convertAt( + $amount, + $from, + $priceCurrency, + is_string($fxContext['measured_at'] ?? null) ? (string) $fxContext['measured_at'] : null, + null, + isset($fxContext['fx_fetch_id']) && is_numeric($fxContext['fx_fetch_id']) ? (int) $fxContext['fx_fetch_id'] : null + ); + if (is_numeric($convertedFiat) && (float) $convertedFiat > 0) { + return (float) $convertedFiat / $pricePerCoin; + } + } + + if ($from === $coinCurrency && $this->fx !== null) { + $convertedFiat = $amount * $pricePerCoin; + $convertedTarget = $this->fx->convertAt( + $convertedFiat, + $priceCurrency, + $to, + is_string($fxContext['measured_at'] ?? null) ? (string) $fxContext['measured_at'] : null, + null, + isset($fxContext['fx_fetch_id']) && is_numeric($fxContext['fx_fetch_id']) ? (int) $fxContext['fx_fetch_id'] : null + ); + if (is_numeric($convertedTarget)) { + return (float) $convertedTarget; + } + } + + return null; + } + + private function totalHashrateMh(array $entries): float + { + $total = 0.0; + foreach ($entries as $entry) { + if (array_key_exists('is_active', $entry) && empty($entry['is_active'])) { + continue; + } + if (!$this->entryIsCovered($entry, null)) { + continue; + } + + $total += $this->normalizeHashrateMh($entry['mining_speed_value'] ?? null, $entry['mining_speed_unit'] ?? null); + $total += $this->normalizeHashrateMh($entry['bonus_speed_value'] ?? null, $entry['bonus_speed_unit'] ?? null); + } + + return $total; + } + + private function measurementHashrateMh(array $costPlans, array $purchasedMiners, ?int $measurementTs = null): float + { + $total = 0.0; + + foreach ($costPlans as $plan) { + if (array_key_exists('is_active', $plan) && empty($plan['is_active'])) { + continue; + } + if (!$this->entryIsCovered($plan, $measurementTs)) { + continue; + } + + $total += $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null); + $total += $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null); + } + + foreach ($purchasedMiners as $miner) { + if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { + continue; + } + if (!$this->entryIsCovered($miner, $measurementTs, 'purchased_at')) { + continue; + } + + $total += $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null); + $total += $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null); + } + + return $total; + } + + private function totalPurchasedMinerCost(array $purchasedMiners, string $targetCurrency, ?int $measurementTs = null, ?array $fxContext = null): ?float + { + $target = strtoupper(trim($targetCurrency)); + if ($target === '') { + return null; + } + + $total = 0.0; + $matched = false; + foreach ($purchasedMiners as $miner) { + if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { + continue; + } + if ($measurementTs > 0 && $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')) > $measurementTs) { + continue; + } + + $amount = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_amount'] : null; + $currency = strtoupper(trim((string) ($miner['currency'] ?? ''))); + if ($amount === null || $amount <= 0 || $currency === '') { + continue; + } + + $converted = $this->convertAmount($amount, $currency, $target, $fxContext); + if ($converted === null) { + continue; + } + + $matched = true; + $total += $converted; + } + + return $matched ? $total : null; + } + + private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency, ?array $fxContext = null, ?string $fundingSource = null): ?float + { + $target = strtoupper(trim($targetCurrency)); + if ($target === '') { + return null; + } + + $total = 0.0; + $matched = false; + + $purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs, $fxContext); + if ($fundingSource === null && $purchasedTotal !== null) { + $matched = true; + $total += $purchasedTotal; + } + + foreach ($costPlans as $plan) { + if ($measurementTs > 0 && $this->utcTimestamp((string) ($plan['starts_at'] ?? '')) > $measurementTs) { + continue; + } + if ($fundingSource !== null && $this->entryFundingSource($plan) !== $fundingSource) { + continue; + } + + $amount = is_numeric($plan['total_cost_amount'] ?? null) ? (float) $plan['total_cost_amount'] : null; + $currency = strtoupper(trim((string) ($plan['currency'] ?? ''))); + if ($amount === null || $amount <= 0 || $currency === '') { + continue; + } + + $converted = $this->convertAmount($amount, $currency, $target, $fxContext); + if ($converted === null) { + continue; + } + + $matched = true; + $total += $converted; + } + + if ($fundingSource !== null) { + foreach ($purchasedMiners as $miner) { + if ($measurementTs > 0 && $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')) > $measurementTs) { + continue; + } + if ($this->entryFundingSource($miner) !== $fundingSource) { + continue; + } + + $amount = $this->investmentBasisAmount($miner); + $currency = strtoupper(trim((string) ($miner['reference_price_currency'] ?? $miner['currency'] ?? ''))); + if ($amount === null || $amount <= 0 || $currency === '') { + continue; + } + + $converted = $this->convertAmount($amount, $currency, $target, $fxContext); + if ($converted === null) { + continue; + } + + $matched = true; + $total += $converted; + } + } + + return $matched ? $total : null; + } + + private function entryIsCovered(array $entry, ?int $measurementTs = null, string $startField = 'starts_at'): bool + { + $runtimeMonths = (int) ($entry['runtime_months'] ?? 0); + if ($runtimeMonths <= 0) { + return true; + } + + $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); + if ($startTs <= 0) { + return true; + } + + $checkTs = $measurementTs ?? time(); + if ($checkTs < $startTs) { + return false; + } + + $runtimeDays = $runtimeMonths * 30.4375; + $endTs = (int) round($startTs + ($runtimeDays * 86400)); + return !empty($entry['auto_renew']) || $checkTs <= $endTs; + } + + private function normalizeHashrateMh(mixed $value, mixed $unit): float + { + if (!is_numeric($value) || !is_string($unit) || trim($unit) === '') { + return 0.0; + } + + $numeric = (float) $value; + return match (trim($unit)) { + 'MH/s' => $numeric, + 'kH/s' => $numeric / 1000, + default => 0.0, + }; + } + + private function expandOfferVariants(array $offers, array $settings): array + { + $expanded = []; + foreach ($offers as $offer) { + $paymentType = $this->normalizeOfferPaymentType($offer); + $baseSpeed = self::BASE_OFFER_SPEEDS[$paymentType] ?? self::BASE_OFFER_SPEEDS['fiat']; + $baseSpeedValue = (float) $baseSpeed['value']; + $baseSpeedUnit = (string) $baseSpeed['unit']; + $baseBonusPercent = $this->deriveBonusPercent($offer, $baseSpeedValue, $baseSpeedUnit); + $basePriceAmount = is_numeric($offer['base_price_amount'] ?? null) + ? (float) $offer['base_price_amount'] + : (is_numeric($offer['reference_price_amount'] ?? null) + ? (float) $offer['reference_price_amount'] + : (is_numeric($offer['usd_reference_amount'] ?? null) + ? (float) $offer['usd_reference_amount'] + : (is_numeric($offer['price_amount'] ?? null) ? (float) $offer['price_amount'] : null))); + + foreach (self::OFFER_SPEED_MULTIPLIERS as $multiplier) { + $derivedSpeedValue = $baseSpeedValue * $multiplier; + $derivedBonusValue = $baseBonusPercent !== null + ? round($derivedSpeedValue * ($baseBonusPercent / 100), 4) + : null; + $derivedPriceAmount = $basePriceAmount !== null + ? round($basePriceAmount * $multiplier, 8) + : null; + + $expanded[] = array_merge($offer, [ + 'id' => sprintf('%s:%s:%s', (string) ($offer['id'] ?? 'offer'), $paymentType, rtrim(rtrim(number_format($derivedSpeedValue, 4, '.', ''), '0'), '.')), + 'base_offer_id' => $offer['id'] ?? null, + 'base_offer_label' => $offer['label'] ?? null, + 'payment_type' => $paymentType, + 'label' => trim(((string) ($offer['label'] ?? 'Miner-Angebot')) . ' · ' . $this->formatSpeedLabel($derivedSpeedValue, $baseSpeedUnit)), + 'mining_speed_value' => $derivedSpeedValue, + 'mining_speed_unit' => $baseSpeedUnit, + 'bonus_speed_value' => $derivedBonusValue, + 'bonus_speed_unit' => $derivedBonusValue !== null ? $baseSpeedUnit : null, + 'bonus_percent' => $baseBonusPercent, + 'speed_factor' => $multiplier, + 'base_speed_value' => $baseSpeedValue, + 'base_speed_unit' => $baseSpeedUnit, + 'base_price_amount' => $derivedPriceAmount, + 'reference_price_amount' => $derivedPriceAmount, + ]); + } + } + + usort($expanded, static function (array $left, array $right): int { + $paymentCompare = strcmp((string) ($left['payment_type'] ?? ''), (string) ($right['payment_type'] ?? '')); + if ($paymentCompare !== 0) { + return $paymentCompare; + } + + $runtimeCompare = ((int) ($left['runtime_months'] ?? 0)) <=> ((int) ($right['runtime_months'] ?? 0)); + if ($runtimeCompare !== 0) { + return $runtimeCompare; + } + + return ((int) ($left['speed_factor'] ?? 0)) <=> ((int) ($right['speed_factor'] ?? 0)); + }); + + return $expanded; + } + + private function normalizeOfferPaymentType(array $offer): string + { + $paymentType = strtolower(trim((string) ($offer['payment_type'] ?? ''))); + if (in_array($paymentType, ['fiat', 'crypto'], true)) { + return $paymentType; + } + + return !empty($offer['price_currency']) && in_array(strtoupper((string) $offer['price_currency']), ['ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP'], true) + ? 'crypto' + : 'fiat'; + } + + private function deriveBonusPercent(array $offer, float $speedValue, string $speedUnit): ?float + { + if (is_numeric($offer['bonus_percent'] ?? null)) { + $numeric = (float) $offer['bonus_percent']; + return $numeric >= 0 ? $numeric : null; + } + + $baseSpeedMh = $this->normalizeHashrateMh($speedValue, $speedUnit); + $bonusMh = $this->normalizeHashrateMh($offer['bonus_speed_value'] ?? null, $offer['bonus_speed_unit'] ?? null); + if ($baseSpeedMh <= 0 || $bonusMh <= 0) { + return null; + } + + return ($bonusMh / $baseSpeedMh) * 100; + } + + private function formatSpeedLabel(float $value, string $unit): string + { + return rtrim(rtrim(number_format($value, 4, '.', ''), '0'), '.') . ' ' . $unit; + } + + private function evaluateMinerOffer(array $offer, array $latest, array $latestPriceByCurrency, float $currentHashrateMh, array $settings): array + { + $offerHashrateMh = $this->normalizeHashrateMh($offer['mining_speed_value'] ?? null, $offer['mining_speed_unit'] ?? null) + + $this->normalizeHashrateMh($offer['bonus_speed_value'] ?? null, $offer['bonus_speed_unit'] ?? null); + + $paymentType = (string) ($offer['payment_type'] ?? ''); + if ($paymentType === '') { + $paymentType = !empty($offer['price_currency']) && in_array(strtoupper((string) $offer['price_currency']), ['ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP'], true) + ? 'crypto' + : 'fiat'; + } + $basePriceAmount = is_numeric($offer['base_price_amount'] ?? null) + ? (float) $offer['base_price_amount'] + : (is_numeric($offer['reference_price_amount'] ?? null) + ? (float) $offer['reference_price_amount'] + : (is_numeric($offer['usd_reference_amount'] ?? null) + ? (float) $offer['usd_reference_amount'] + : (is_numeric($offer['price_amount'] ?? null) ? (float) $offer['price_amount'] : null))); + $basePriceCurrency = (string) ($offer['base_price_currency'] ?? $offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : ($offer['price_currency'] ?? ''))); + $effectivePriceCurrency = $paymentType === 'crypto' + ? (string) ($settings['crypto_currency'] ?? 'DOGE') + : $basePriceCurrency; + $effectivePriceAmount = $basePriceAmount; + if ($basePriceAmount !== null && $basePriceAmount > 0 && $basePriceCurrency !== '' && $effectivePriceCurrency !== '' && strtoupper($basePriceCurrency) !== strtoupper($effectivePriceCurrency)) { + $convertedReference = $this->convertAmount($basePriceAmount, $basePriceCurrency, $effectivePriceCurrency, $latest); + if ($convertedReference !== null && $convertedReference > 0) { + $effectivePriceAmount = $convertedReference; + } else { + $effectivePriceAmount = null; + } + } + $referencePriceAmount = $basePriceAmount; + $referencePriceCurrency = $basePriceCurrency !== '' ? $basePriceCurrency : null; + + $expectedDogePerDay = null; + if ($currentHashrateMh > 0 && $offerHashrateMh > 0 && is_numeric($latest['doge_per_day_interval'] ?? null)) { + $expectedDogePerDay = ((float) $latest['doge_per_day_interval'] / $currentHashrateMh) * $offerHashrateMh; + } + + $offerCurrencyPrice = $effectivePriceCurrency !== '' ? ($latestPriceByCurrency[$effectivePriceCurrency] ?? $this->convertLatestPrice($latestPriceByCurrency, $effectivePriceCurrency, $latest)) : null; + $expectedDailyRevenue = ($expectedDogePerDay !== null && $offerCurrencyPrice !== null) + ? $expectedDogePerDay * $offerCurrencyPrice + : null; + $breakEvenDays = ($effectivePriceAmount !== null && $expectedDailyRevenue !== null && $expectedDailyRevenue > 0) + ? $effectivePriceAmount / $expectedDailyRevenue + : null; + + $recommendation = 'keine Basis'; + if ($breakEvenDays !== null) { + if ($breakEvenDays <= 180) { + $recommendation = 'lohnt eher'; + } elseif ($breakEvenDays <= 365) { + $recommendation = 'abwaegen'; + } else { + $recommendation = 'eher warten'; + } + } + + return array_merge($offer, [ + 'payment_type' => $paymentType, + 'base_price_amount' => $this->roundOrNull($basePriceAmount, 8), + 'base_price_currency' => $basePriceCurrency !== '' ? $basePriceCurrency : null, + 'offer_hashrate_mh' => $this->roundOrNull($offerHashrateMh, 4), + 'effective_price_amount' => $this->roundOrNull($effectivePriceAmount, 8), + 'effective_price_currency' => $effectivePriceCurrency, + 'reference_price_amount' => $this->roundOrNull($referencePriceAmount, 8), + 'reference_price_currency' => $referencePriceCurrency !== '' ? $referencePriceCurrency : null, + 'expected_doge_per_day' => $this->roundOrNull($expectedDogePerDay, 6), + 'expected_daily_revenue' => $this->roundOrNull($expectedDailyRevenue, 8), + 'break_even_days' => $this->roundOrNull($breakEvenDays, 2), + 'recommendation' => $recommendation, + ]); + } + + private function enrichOfferScenario(array $offer, array $latest, float $currentHashrateMh, array $costPlans, array $purchasedMiners): array + { + $latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''); + $currentDogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null; + $currentDailyProfit = is_numeric($latest['theoretical_daily_profit'] ?? null) ? (float) $latest['theoretical_daily_profit'] : null; + $currentDailyCost = is_numeric($latest['effective_daily_cost'] ?? null) ? (float) $latest['effective_daily_cost'] : null; + $investedCapital = is_numeric($latest['invested_capital'] ?? null) ? (float) $latest['invested_capital'] : null; + $effectivePricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null; + $expectedDogePerDay = is_numeric($offer['expected_doge_per_day'] ?? null) ? (float) $offer['expected_doge_per_day'] : null; + $offerPriceAmount = is_numeric($offer['effective_price_amount'] ?? null) ? (float) $offer['effective_price_amount'] : null; + $offerPriceCurrency = (string) ($offer['effective_price_currency'] ?? ''); + + $scenarioOfferCost = ( + $offerPriceAmount !== null && + $offerPriceCurrency !== '' && + $latestCurrency !== '' + ) ? $this->convertAmount($offerPriceAmount, $offerPriceCurrency, $latestCurrency, $latest) : null; + $scenarioDogePerDay = ( + $currentDogePerDay !== null && + $expectedDogePerDay !== null + ) ? ($currentDogePerDay + $expectedDogePerDay) : null; + $scenarioDailyRevenue = ( + $scenarioDogePerDay !== null && + $effectivePricePerCoin !== null + ) ? ($scenarioDogePerDay * $effectivePricePerCoin) : null; + $scenarioDailyProfit = ( + $scenarioDailyRevenue !== null && + $currentDailyCost !== null + ) ? ($scenarioDailyRevenue - $currentDailyCost) : null; + $scenarioInvestedCapital = ( + $investedCapital !== null && + $scenarioOfferCost !== null + ) ? ($investedCapital + $scenarioOfferCost) : null; + $scenarioRemainingAmount = $scenarioInvestedCapital; + $scenarioBreakEvenDays = ( + $scenarioInvestedCapital !== null && + $scenarioDailyRevenue !== null && + $scenarioDailyRevenue > 0 + ) ? ($scenarioInvestedCapital / $scenarioDailyRevenue) : null; + $scenarioDate = null; + $measuredTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); + if ($measuredTs > 0 && $scenarioBreakEvenDays !== null) { + $scenarioDate = $this->formatUtcTimestamp((int) round($measuredTs + ($scenarioBreakEvenDays * 86400))); + } + + $scenarioPurchasedMiners = $purchasedMiners; + if ($scenarioOfferCost !== null && $latestCurrency !== '') { + $scenarioPurchasedMiners[] = [ + 'purchased_at' => $latest['measured_at'] ?? $this->currentUtcDateTime(), + 'runtime_months' => $offer['runtime_months'] ?? null, + 'mining_speed_value' => $offer['mining_speed_value'] ?? null, + 'mining_speed_unit' => $offer['mining_speed_unit'] ?? null, + 'bonus_speed_value' => $offer['bonus_speed_value'] ?? null, + 'bonus_speed_unit' => $offer['bonus_speed_unit'] ?? null, + 'total_cost_amount' => $scenarioOfferCost, + 'currency' => $latestCurrency, + 'auto_renew' => !empty($offer['auto_renew']) ? 1 : 0, + 'is_active' => 1, + ]; + } + + $currentProjection = $this->projectPerformance($costPlans, $purchasedMiners, $latest, 730); + $scenarioProjection = $this->projectPerformance($costPlans, $scenarioPurchasedMiners, $latest, 730); + + return array_merge($offer, [ + 'scenario_currency' => $latestCurrency !== '' ? $latestCurrency : null, + 'scenario_current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4), + 'scenario_hashrate_mh' => $this->roundOrNull($currentHashrateMh + (float) ($offer['offer_hashrate_mh'] ?? 0), 4), + 'scenario_current_doge_per_day' => $this->roundOrNull($currentDogePerDay, 6), + 'scenario_doge_per_day' => $this->roundOrNull($scenarioDogePerDay, 6), + 'scenario_current_daily_profit' => $this->roundOrNull($currentDailyProfit, 8), + 'scenario_daily_profit' => $this->roundOrNull($scenarioDailyProfit, 8), + 'scenario_daily_profit_delta' => ( + $scenarioDailyProfit !== null && + $currentDailyProfit !== null + ) ? $this->roundOrNull($scenarioDailyProfit - $currentDailyProfit, 8) : null, + 'scenario_current_invested_capital' => $this->roundOrNull($investedCapital, 8), + 'scenario_invested_capital' => $this->roundOrNull($scenarioInvestedCapital, 8), + 'scenario_offer_cost' => $this->roundOrNull($scenarioOfferCost, 8), + 'scenario_break_even_remaining_amount' => $this->roundOrNull($scenarioRemainingAmount, 8), + 'scenario_break_even_days' => $this->roundOrNull($scenarioBreakEvenDays, 4), + 'scenario_break_even_date' => $scenarioDate, + 'scenario_projection_days' => $scenarioProjection['days'], + 'scenario_current_two_year_profit' => $this->roundOrNull($currentProjection['profit'], 8), + 'scenario_two_year_profit' => $this->roundOrNull($scenarioProjection['profit'], 8), + 'scenario_two_year_profit_delta' => ( + $scenarioProjection['profit'] !== null && + $currentProjection['profit'] !== null + ) ? $this->roundOrNull($scenarioProjection['profit'] - $currentProjection['profit'], 8) : null, + 'scenario_two_year_revenue' => $this->roundOrNull($scenarioProjection['revenue'], 8), + 'scenario_two_year_cost' => $this->roundOrNull($scenarioProjection['cost'], 8), + ]); + } + + private function projectPerformance(array $costPlans, array $purchasedMiners, array $latest, int $days): array + { + $currency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''))); + $pricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null; + $dogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null; + $currentHashrateMh = $this->totalHashrateMh(array_merge($costPlans, $purchasedMiners)); + $baseTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); + if ($baseTs <= 0) { + $baseTs = $this->utcTimestamp($this->currentUtcDateTime()); + } + + if ($currency === '' || $pricePerCoin === null || $dogePerDay === null || $currentHashrateMh <= 0 || $days <= 0) { + return ['days' => $days, 'revenue' => null, 'cost' => null, 'profit' => null]; + } + + $dogePerDayPerMh = $dogePerDay / $currentHashrateMh; + $revenue = 0.0; + $cost = 0.0; + $hashrateDayTotal = 0.0; + + foreach ($costPlans as $plan) { + if (empty($plan['is_active'])) { + continue; + } + + $coveredDays = $this->entryCoveredDayCount($plan, $baseTs, $days); + if ($coveredDays <= 0) { + continue; + } + + $entryHashrateMh = $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null) + + $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null); + $hashrateDayTotal += $entryHashrateMh * $coveredDays; + + $runtimeMonths = (int) ($plan['runtime_months'] ?? 0); + if ($runtimeMonths > 0 && is_numeric($plan['total_cost_amount'] ?? null)) { + $runtimeDays = $runtimeMonths * 30.4375; + $dailyCost = $this->convertAmount((float) $plan['total_cost_amount'] / $runtimeDays, (string) ($plan['currency'] ?? ''), $currency, $latest); + if ($dailyCost !== null) { + $cost += $dailyCost * $coveredDays; + } + } + } + + foreach ($purchasedMiners as $miner) { + if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { + continue; + } + + $coveredDays = $this->entryCoveredDayCount($miner, $baseTs, $days, 'purchased_at'); + if ($coveredDays <= 0) { + continue; + } + + $entryHashrateMh = $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null) + + $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null); + $hashrateDayTotal += $entryHashrateMh * $coveredDays; + + $runtimeMonths = (int) ($miner['runtime_months'] ?? 0); + if ($runtimeMonths > 0 && is_numeric($miner['total_cost_amount'] ?? null)) { + $runtimeDays = $runtimeMonths * 30.4375; + $dailyCost = $this->convertAmount((float) $miner['total_cost_amount'] / $runtimeDays, (string) ($miner['currency'] ?? ''), $currency, $latest); + if ($dailyCost !== null) { + $cost += $dailyCost * $coveredDays; + } + } + } + + $revenue = $hashrateDayTotal * $dogePerDayPerMh * $pricePerCoin; + + return [ + 'days' => $days, + 'revenue' => $revenue, + 'cost' => $cost, + 'profit' => $revenue - $cost, + ]; + } + + private function entryCoveredDayCount(array $entry, int $baseTs, int $days, string $startField = 'starts_at'): int + { + if ($days <= 0) { + return 0; + } + + $runtimeMonths = (int) ($entry['runtime_months'] ?? 0); + $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); + if ($startTs <= 0) { + return $days; + } + + $startIndex = max(0, (int) ceil(($startTs - $baseTs) / 86400)); + if ($startIndex >= $days) { + return 0; + } + + if (!empty($entry['auto_renew']) || $runtimeMonths <= 0) { + return $days - $startIndex; + } + + $runtimeDays = $runtimeMonths * 30.4375; + $endTs = (int) round($startTs + ($runtimeDays * 86400)); + $endIndex = (int) floor(($endTs - $baseTs) / 86400); + $endIndex = min($days - 1, $endIndex); + if ($endIndex < $startIndex) { + return 0; + } + + return $endIndex - $startIndex + 1; + } + + private function walletBalances(array $payouts, array $purchasedMiners, int $measurementTs): array + { + $balances = []; + + foreach ($payouts as $payout) { + $payoutTs = $this->utcTimestamp((string) ($payout['payout_at'] ?? '')); + if ($measurementTs > 0 && $payoutTs > $measurementTs) { + continue; + } + + $currency = strtoupper(trim((string) ($payout['payout_currency'] ?? ''))); + $amount = is_numeric($payout['coins_amount'] ?? null) ? (float) $payout['coins_amount'] : null; + if ($currency === '' || $amount === null) { + continue; + } + + $balances[$currency] = ($balances[$currency] ?? 0.0) + $amount; + } + + foreach ($purchasedMiners as $miner) { + $purchaseTs = $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')); + if ($measurementTs > 0 && $purchaseTs > $measurementTs) { + continue; + } + if ($this->entryFundingSource($miner) !== 'reinvest') { + continue; + } + + $currency = strtoupper(trim((string) ($miner['currency'] ?? ''))); + $amount = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_amount'] : null; + if ($currency === '' || $amount === null) { + continue; + } + + $balances[$currency] = ($balances[$currency] ?? 0.0) - $amount; + } + + ksort($balances); + return array_map(fn (float $value): float => round($value, 8), $balances); + } + + private function latestWalletSnapshot(array $walletSnapshots): ?array + { + foreach ($walletSnapshots as $snapshot) { + if (is_array($snapshot)) { + return $snapshot; + } + } + + return null; + } + + private function walletSnapshotBalances(array $snapshot): array + { + $balances = []; + $rawBalances = is_array($snapshot['balances_json'] ?? null) ? $snapshot['balances_json'] : []; + foreach ($rawBalances as $code => $asset) { + $normalizedCode = strtoupper(trim((string) $code)); + if ($normalizedCode === '') { + continue; + } + + $balance = is_array($asset) + ? ($asset['balance'] ?? null) + : $asset; + if (!is_numeric($balance)) { + continue; + } + + $balances[$normalizedCode] = round((float) $balance, 8); + } + + ksort($balances); + return $balances; + } + + private function walletSnapshotValue(array $snapshot, string $targetCurrency, ?array $fxContext = null): ?float + { + $target = strtoupper(trim($targetCurrency)); + if ($target === '') { + return null; + } + + $balances = is_array($snapshot['balances_json'] ?? null) ? $snapshot['balances_json'] : []; + if ($balances === []) { + return null; + } + + $total = 0.0; + $matched = false; + foreach ($balances as $code => $asset) { + $currency = strtoupper(trim((string) $code)); + if ($currency === '') { + continue; + } + + $balance = is_array($asset) ? ($asset['balance'] ?? null) : $asset; + if (!is_numeric($balance)) { + continue; + } + + $numericBalance = (float) $balance; + if ($currency === $target) { + $total += $numericBalance; + $matched = true; + continue; + } + + $snapshotPrice = is_array($asset) && is_numeric($asset['price_amount'] ?? null) + ? (float) $asset['price_amount'] + : null; + $snapshotPriceCurrency = is_array($asset) + ? strtoupper(trim((string) ($asset['price_currency'] ?? ''))) + : ''; + + if ($snapshotPrice !== null && $snapshotPrice > 0 && $snapshotPriceCurrency !== '') { + $convertedPrice = $snapshotPriceCurrency === $target + ? $snapshotPrice + : $this->convertAmount($snapshotPrice, $snapshotPriceCurrency, $target, $fxContext); + if ($convertedPrice !== null) { + $total += $numericBalance * $convertedPrice; + $matched = true; + continue; + } + } + + $convertedBalance = $this->convertAmount($numericBalance, $currency, $target, $fxContext); + if ($convertedBalance !== null) { + $total += $convertedBalance; + $matched = true; + } + } + + return $matched ? $total : null; + } + + private function walletBalanceValue(array $balances, string $targetCurrency, ?array $fxContext = null): ?float + { + $target = strtoupper(trim($targetCurrency)); + if ($target === '' || $balances === []) { + return null; + } + + $total = 0.0; + $matched = false; + foreach ($balances as $currency => $amount) { + if (!is_numeric($amount)) { + continue; + } + $numericAmount = (float) $amount; + if (strtoupper((string) $currency) === $target) { + $matched = true; + $total += $numericAmount; + continue; + } + + $converted = $this->convertAmount($numericAmount, (string) $currency, $target, $fxContext); + if ($converted === null) { + continue; + } + + $matched = true; + $total += $converted; + } + + return $matched ? $total : null; + } + + private function entryFundingSource(array $entry): string + { + return !empty($entry['auto_renew']) ? 'cash' : 'reinvest'; + } + + private function investmentBasisAmount(array $entry): ?float + { + if (is_numeric($entry['reference_price_amount'] ?? null)) { + return (float) $entry['reference_price_amount']; + } + if (is_numeric($entry['base_price_amount'] ?? null)) { + return (float) $entry['base_price_amount']; + } + if (is_numeric($entry['total_cost_amount'] ?? null)) { + return (float) $entry['total_cost_amount']; + } + return null; + } + + private function projectBreakEvenDate(array $costPlans, array $purchasedMiners, array $latest, float $remainingAmount): array + { + if ($remainingAmount <= 0) { + return ['days' => 0.0, 'eta' => (string) ($latest['measured_at'] ?? null)]; + } + + $currency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? ''))); + $pricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null; + $dogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null; + $currentHashrateMh = $this->totalHashrateMh(array_merge($costPlans, $purchasedMiners)); + $baseTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? '')); + + if ($currency === '' || $pricePerCoin === null || $dogePerDay === null || $currentHashrateMh <= 0 || $baseTs <= 0) { + return ['days' => null, 'eta' => null]; + } + + $dogePerDayPerMh = $dogePerDay / $currentHashrateMh; + $maxDays = 3650; + $horizonTs = $baseTs + ($maxDays * 86400); + $entries = []; + + foreach ($costPlans as $plan) { + if (empty($plan['is_active'])) { + continue; + } + $entries[] = ['data' => $plan, 'start_field' => 'starts_at']; + } + + foreach ($purchasedMiners as $miner) { + if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) { + continue; + } + $entries[] = ['data' => $miner, 'start_field' => 'purchased_at']; + } + + $events = [$baseTs, $horizonTs]; + foreach ($entries as $entryMeta) { + $entry = $entryMeta['data']; + $startField = $entryMeta['start_field']; + $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); + if ($startTs > $baseTs && $startTs < $horizonTs) { + $events[] = $startTs; + } + + $endTs = $this->entryCoverageEndTimestamp($entry, $startField); + if ($endTs !== null) { + $afterEndTs = $endTs + 1; + if ($afterEndTs > $baseTs && $afterEndTs < $horizonTs) { + $events[] = $afterEndTs; + } + } + } + + $events = array_values(array_unique(array_map('intval', $events))); + sort($events, SORT_NUMERIC); + + $cumulativeRevenue = 0.0; + for ($index = 0, $maxIndex = count($events) - 1; $index < $maxIndex; $index++) { + $segmentStartTs = $events[$index]; + $segmentEndTs = $events[$index + 1]; + if ($segmentEndTs <= $segmentStartTs) { + continue; + } + + $segmentHashrate = 0.0; + foreach ($entries as $entryMeta) { + $entry = $entryMeta['data']; + $startField = $entryMeta['start_field']; + if (!$this->entryIsCovered($entry, $segmentStartTs, $startField)) { + continue; + } + + $segmentHashrate += $this->normalizeHashrateMh($entry['mining_speed_value'] ?? null, $entry['mining_speed_unit'] ?? null); + $segmentHashrate += $this->normalizeHashrateMh($entry['bonus_speed_value'] ?? null, $entry['bonus_speed_unit'] ?? null); + } + + if ($segmentHashrate <= 0) { + continue; + } + + $segmentDays = ($segmentEndTs - $segmentStartTs) / 86400; + if ($segmentDays <= 0) { + continue; + } + + $segmentRevenuePerDay = $segmentHashrate * $dogePerDayPerMh * $pricePerCoin; + if ($segmentRevenuePerDay <= 0) { + continue; + } + + $segmentRevenue = $segmentRevenuePerDay * $segmentDays; + if ($cumulativeRevenue + $segmentRevenue >= $remainingAmount) { + $remainingSegmentAmount = $remainingAmount - $cumulativeRevenue; + $segmentOffsetDays = $remainingSegmentAmount / $segmentRevenuePerDay; + $etaTs = (int) round($segmentStartTs + ($segmentOffsetDays * 86400)); + return [ + 'days' => round(($etaTs - $baseTs) / 86400, 4), + 'eta' => $this->formatUtcTimestamp($etaTs), + ]; + } + + $cumulativeRevenue += $segmentRevenue; + } + + return ['days' => null, 'eta' => null]; + } + + private function entryCoverageEndTimestamp(array $entry, string $startField = 'starts_at'): ?int + { + if (!empty($entry['auto_renew'])) { + return null; + } + + $runtimeMonths = (int) ($entry['runtime_months'] ?? 0); + if ($runtimeMonths <= 0) { + return null; + } + + $startTs = $this->utcTimestamp((string) ($entry[$startField] ?? '')); + if ($startTs <= 0) { + return null; + } + + $runtimeDays = $runtimeMonths * 30.4375; + return (int) round($startTs + ($runtimeDays * 86400)); + } + + private function utcTimestamp(?string $value): int + { + $normalized = trim((string) $value); + if ($normalized === '') { + return 0; + } + + $utc = new \DateTimeZone('UTC'); + $formats = ['Y-m-d H:i:s', 'Y-m-d H:i', \DateTimeInterface::ATOM]; + foreach ($formats as $format) { + $date = \DateTimeImmutable::createFromFormat($format, $normalized, $utc); + if ($date instanceof \DateTimeImmutable) { + return $date->getTimestamp(); + } + } + + try { + return (new \DateTimeImmutable($normalized, $utc))->getTimestamp(); + } catch (\Throwable) { + return 0; + } + } + + private function formatUtcTimestamp(int $timestamp): string + { + return (new \DateTimeImmutable('@' . $timestamp)) + ->setTimezone(new \DateTimeZone('UTC')) + ->format('Y-m-d H:i:s'); + } + + private function currentUtcDateTime(): string + { + return $this->formatUtcTimestamp(time()); + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/src/Domain/FxService.php b/temp/nexus-module-import/modules/mining-checker/src/Domain/FxService.php new file mode 100644 index 00000000..089cc532 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Domain/FxService.php @@ -0,0 +1,950 @@ +repository = $repository; + $this->provider = trim(strtolower($provider)) !== '' ? trim(strtolower($provider)) : 'currencyapi'; + $this->apiBaseUrl = rtrim($apiBaseUrl, '/'); + $this->currenciesApiBaseUrl = rtrim($currenciesApiBaseUrl, '/'); + $this->apiKey = trim($apiKey); + $this->timeout = max(2, $timeout); + $this->cacheTtl = max(60, $cacheTtl); + $this->autoFetchOnMiss = $autoFetchOnMiss; + $this->debug = $debug; + } + + public function convert(?float $amount, ?string $from, ?string $to): ?float + { + return $this->convertAt($amount, $from, $to, null, null, null); + } + + public function convertAt(?float $amount, ?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float + { + if ($amount === null || $from === null || $to === null) { + return null; + } + + $normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null; + $shared = $this->sharedFxService(); + if ($shared !== null && $normalizedFetchId !== null) { + $snapshot = $this->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]); + if (is_array($snapshot)) { + $resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to))); + if ($resolved !== null) { + return $amount * $resolved; + } + } + } + + if ($shared !== null && method_exists($shared, 'convert')) { + $converted = $shared->convert($amount, $from, $to, $at, $windowMinutes); + return is_numeric($converted) ? (float) $converted : null; + } + + $rate = $this->rateAt($from, $to, $at, $windowMinutes, $normalizedFetchId); + return $rate === null ? null : $amount * $rate; + } + + public function rate(?string $from, ?string $to): ?float + { + return $this->rateAt($from, $to, null, null, null); + } + + public function rateAt(?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float + { + $base = strtoupper(trim((string) $from)); + $target = strtoupper(trim((string) $to)); + $normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null; + + if ($base === '' || $target === '') { + return null; + } + + if ($base === $target) { + return 1.0; + } + + $shared = $this->sharedFxService(); + if ($shared !== null && $normalizedFetchId !== null) { + $snapshot = $this->snapshotByFetchId($normalizedFetchId, $base, [$target]); + if (is_array($snapshot)) { + $resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target); + if ($resolved !== null) { + return $resolved; + } + } + } + if ($shared !== null && method_exists($shared, 'findRate')) { + $resolved = $shared->findRate($from, $to, $at, $windowMinutes); + return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null; + } + + $cacheKey = implode(':', [$base, $target, $at ?? '', (string) ($windowMinutes ?? 0), (string) ($normalizedFetchId ?? 0)]); + if (array_key_exists($cacheKey, $this->memoryCache)) { + return $this->memoryCache[$cacheKey]; + } + + $stored = $this->storedRate($base, $target); + if ($stored !== null) { + $this->memoryCache[$cacheKey] = $stored; + return $stored; + } + + $cached = $this->readFileCache($cacheKey); + if ($cached !== null) { + $this->memoryCache[$cacheKey] = $cached; + return $cached; + } + + if (!$this->autoFetchOnMiss) { + return null; + } + + $rate = $this->fetchAndPersistRate($base, $target); + $this->memoryCache[$cacheKey] = $rate; + if ($rate !== null) { + $this->writeFileCache($cacheKey, $rate); + } + + return $rate; + } + + public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array + { + if ($fetchId <= 0) { + return null; + } + + $cacheKey = $this->snapshotCacheKey('fetch', [ + $fetchId, + strtoupper(trim((string) ($baseCurrency ?? ''))), + $this->normalizeSymbolsForCache($symbols), + ]); + if (array_key_exists($cacheKey, $this->snapshotCache)) { + return $this->snapshotCache[$cacheKey]; + } + + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) { + $snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols); + return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null); + } + + return $this->snapshotCache[$cacheKey] = null; + } + + public function latestSnapshot(?string $baseCurrency = null, ?array $symbols = null): ?array + { + $cacheKey = $this->snapshotCacheKey('latest', [ + strtoupper(trim((string) ($baseCurrency ?? ''))), + $this->normalizeSymbolsForCache($symbols), + ]); + if (array_key_exists($cacheKey, $this->snapshotCache)) { + return $this->snapshotCache[$cacheKey]; + } + + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'snapshot')) { + $snapshot = $shared->snapshot($baseCurrency, null, $symbols, null); + return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null); + } + + return $this->snapshotCache[$cacheKey] = null; + } + + public function nearestSnapshot(?string $baseCurrency, string $at, ?array $symbols = null, ?int $windowMinutes = null): ?array + { + $cacheKey = $this->snapshotCacheKey('nearest', [ + strtoupper(trim((string) ($baseCurrency ?? ''))), + trim($at), + $windowMinutes ?? 0, + $this->normalizeSymbolsForCache($symbols), + ]); + if (array_key_exists($cacheKey, $this->snapshotCache)) { + return $this->snapshotCache[$cacheKey]; + } + + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'nearestSnapshot')) { + $snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes); + return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null); + } + + return $this->snapshotCache[$cacheKey] = null; + } + + public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array + { + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'refreshLatestRates')) { + return $shared->refreshLatestRates($currencies, $base); + } + + $normalizedBase = strtoupper(trim($base)); + $targets = $currencies === null + ? null + : array_values(array_unique(array_filter(array_map( + static fn ($code): string => strtoupper(trim((string) $code)), + $currencies + ), static fn (string $code): bool => $code !== '' && $code !== $normalizedBase))); + + $payload = $this->fetchLatestPayload($normalizedBase, $targets); + $rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : []; + $rateDate = $this->normalizeRateDate($payload['date'] ?? null); + $forwardRates = []; + foreach ($rates as $target => $rate) { + if (!is_numeric($rate)) { + continue; + } + $targetCode = strtoupper((string) $target); + if ($targetCode === '' || $targetCode === $normalizedBase) { + continue; + } + $forwardRates[$targetCode] = (float) $rate; + } + + $updated = $this->persistRateSet($normalizedBase, $forwardRates, $rateDate); + + return [ + 'base' => $normalizedBase, + 'rate_date' => $rateDate, + 'updated_count' => count($updated), + 'rates' => $updated, + ]; + } + + public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array + { + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'ensureFreshLatestRates')) { + return $shared->ensureFreshLatestRates($maxAgeHours, $base, null); + } + + $normalizedBase = strtoupper(trim($base)); + $maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0; + + if ($this->repository === null) { + return $this->refreshLatestRates(null, $normalizedBase); + } + + $latestFetch = $this->repository->getLatestFxFetch($normalizedBase); + $latestFetchedAt = is_array($latestFetch) ? $this->parseStoredUtcTimestamp((string) ($latestFetch['fetched_at'] ?? '')) : null; + $ageSeconds = $latestFetchedAt !== null ? (time() - $latestFetchedAt) : null; + $maxAgeSeconds = (int) round($maxAgeHours * 3600); + + if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) { + $this->debug?->add('fx.latest.reuse', [ + 'base' => $normalizedBase, + 'fetched_at' => $latestFetch['fetched_at'] ?? null, + 'age_seconds' => $ageSeconds, + 'max_age_seconds' => $maxAgeSeconds, + ]); + + return [ + 'base' => $normalizedBase, + 'rate_date' => $latestFetch['rate_date'] ?? null, + 'updated_count' => 0, + 'rates' => [], + 'reused' => true, + 'fetched_at' => $latestFetch['fetched_at'] ?? null, + ]; + } + + $this->debug?->add('fx.latest.refresh_required', [ + 'base' => $normalizedBase, + 'previous_fetched_at' => $latestFetch['fetched_at'] ?? null, + 'age_seconds' => $ageSeconds, + 'max_age_seconds' => $maxAgeSeconds, + ]); + + $result = $this->refreshLatestRates(null, $normalizedBase); + $result['reused'] = false; + return $result; + } + + public function probeLatestRates(string $base = 'EUR'): array + { + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'probeLatestRates')) { + return $shared->probeLatestRates($base); + } + + $normalizedBase = strtoupper(trim($base)); + return $this->fetchLatestProbe($normalizedBase); + } + + public function refreshCurrencyCatalog(): array + { + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'refreshCurrencyCatalog')) { + return $shared->refreshCurrencyCatalog(); + } + + $payload = $this->fetchCurrenciesPayload(); + $items = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : []; + if ($items === []) { + return [ + 'synced_count' => 0, + 'currencies' => [], + ]; + } + + $synced = []; + $sortOrder = 1000; + + foreach ($items as $code => $name) { + $normalizedCode = strtoupper(trim((string) $code)); + $normalizedName = trim((string) $name); + if ($normalizedCode === '' || $normalizedName === '') { + continue; + } + + $currency = [ + 'code' => substr($normalizedCode, 0, 10), + 'name' => function_exists('mb_substr') ? mb_substr($normalizedName, 0, 64) : substr($normalizedName, 0, 64), + 'symbol' => substr($normalizedCode, 0, 8), + 'is_active' => 1, + 'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0, + 'sort_order' => $this->catalogSortOrder($normalizedCode, $sortOrder), + ]; + + $synced[] = $currency; + $sortOrder++; + } + + usort($synced, static function (array $left, array $right): int { + return [$left['sort_order'], $left['code']] <=> [$right['sort_order'], $right['code']]; + }); + + return [ + 'synced_count' => count($synced), + 'currencies' => $synced, + ]; + } + + public function probeCurrencyCatalog(): array + { + $shared = $this->sharedFxService(); + if ($shared !== null && method_exists($shared, 'probeCurrencyCatalog')) { + return $shared->probeCurrencyCatalog(); + } + + return $this->fetchCurrenciesProbe(); + } + + private function fetchAndPersistRate(string $base, string $target): ?float + { + $payload = $this->fetchLatestPayload($base, [$target]); + $rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : []; + $rate = $rates[$target] ?? null; + if (!is_numeric($rate)) { + return null; + } + + $numericRate = (float) $rate; + $rateDate = $this->normalizeRateDate($payload['date'] ?? null); + $this->persistRateSet($base, [$target => $numericRate], $rateDate); + return $numericRate; + } + + private function fetchLatestPayload(string $base, ?array $targets = null): array + { + if (!function_exists('curl_init')) { + return []; + } + + $url = $this->buildLatestUrl($base, $targets); + if ($url === null) { + $this->debug?->add('fx.latest.skip', ['reason' => 'missing_url_or_key', 'base' => $base]); + return []; + } + + $this->debug?->add('fx.latest.request', [ + 'base' => $base, + 'url' => $this->maskUrl($url), + 'targets' => $targets, + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + + $response = curl_exec($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + $this->debug?->add('fx.latest.response', [ + 'http_status' => $httpStatus, + 'curl_error' => $curlError, + 'response_bytes' => is_string($response) ? strlen($response) : 0, + 'response_preview' => is_string($response) ? substr($response, 0, 1200) : null, + ]); + + if ($response === false || $curlError !== '' || $httpStatus >= 400) { + return []; + } + + $payload = json_decode((string) $response, true); + return $this->normalizePayload($payload, $base, $targets); + } + + private function fetchLatestProbe(string $base): array + { + if (!function_exists('curl_init')) { + return ['ok' => false, 'message' => 'curl_init ist nicht verfuegbar.']; + } + + $url = $this->buildLatestUrl($base, null); + if ($url === null) { + return ['ok' => false, 'message' => 'FX-URL oder API-Key fehlt.']; + } + + $this->debug?->add('fx.latest.probe.request', [ + 'base' => $base, + 'url' => $this->maskUrl($url), + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + + $response = curl_exec($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $curlError = curl_error($ch); + curl_close($ch); + + $rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : ''; + $body = is_string($response) ? substr($response, $headerSize) : ''; + + $result = [ + 'ok' => $response !== false && $curlError === '' && $httpStatus < 400, + 'url' => $this->maskUrl($url), + 'http_status' => $httpStatus, + 'curl_error' => $curlError, + 'response_headers' => $rawHeaders, + 'response_body' => substr($body, 0, 4000), + ]; + + $this->debug?->add('fx.latest.probe.response', $result); + return $result; + } + + private function fetchCurrenciesPayload(): array + { + if (!function_exists('curl_init') || $this->apiKey === '') { + return []; + } + + $url = sprintf( + '%s/api/v2/currencies?output=json&key=%s', + $this->currenciesApiBaseUrl, + rawurlencode($this->apiKey) + ); + + $this->debug?->add('fx.currencies.request', [ + 'url' => $this->maskUrl($url), + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + + $response = curl_exec($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + $this->debug?->add('fx.currencies.response', [ + 'http_status' => $httpStatus, + 'curl_error' => $curlError, + 'response_bytes' => is_string($response) ? strlen($response) : 0, + 'response_preview' => is_string($response) ? substr($response, 0, 1200) : null, + ]); + + if ($response === false || $curlError !== '' || $httpStatus >= 400) { + return []; + } + + $payload = json_decode((string) $response, true); + if (!is_array($payload)) { + throw new \RuntimeException('Waehrungskatalog konnte nicht gelesen werden.'); + } + + if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) { + throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.')); + } + + return $payload; + } + + private function fetchCurrenciesProbe(): array + { + if (!function_exists('curl_init') || $this->apiKey === '') { + return ['ok' => false, 'message' => 'curl_init oder API-Key fehlt.']; + } + + $url = sprintf( + '%s/api/v2/currencies?output=json&key=%s', + $this->currenciesApiBaseUrl, + rawurlencode($this->apiKey) + ); + + $this->debug?->add('fx.currencies.probe.request', [ + 'url' => $this->maskUrl($url), + ]); + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => $this->timeout, + CURLOPT_HEADER => true, + CURLOPT_HTTPHEADER => ['Accept: application/json'], + ]); + + $response = curl_exec($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $curlError = curl_error($ch); + curl_close($ch); + + $rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : ''; + $body = is_string($response) ? substr($response, $headerSize) : ''; + + $result = [ + 'ok' => $response !== false && $curlError === '' && $httpStatus < 400, + 'url' => $this->maskUrl($url), + 'http_status' => $httpStatus, + 'curl_error' => $curlError, + 'response_headers' => $rawHeaders, + 'response_body' => substr($body, 0, 4000), + ]; + + $this->debug?->add('fx.currencies.probe.response', $result); + return $result; + } + + private function storedRate(string $base, string $target): ?float + { + if ($this->repository === null) { + return null; + } + + try { + $direct = $this->repository->getLatestFxRate($base, $target); + if (is_array($direct) && is_numeric($direct['rate'] ?? null)) { + return (float) $direct['rate']; + } + + $inverse = $this->repository->getLatestFxRate($target, $base); + if (is_array($inverse) && is_numeric($inverse['rate'] ?? null) && (float) $inverse['rate'] > 0) { + return 1 / (float) $inverse['rate']; + } + + $measurementRate = $this->repository->getLatestMeasurementRate($base, $target); + if (is_array($measurementRate) && is_numeric($measurementRate['rate'] ?? null)) { + return (float) $measurementRate['rate']; + } + + $inverseMeasurementRate = $this->repository->getLatestMeasurementRate($target, $base); + if ( + is_array($inverseMeasurementRate) && + is_numeric($inverseMeasurementRate['rate'] ?? null) && + (float) $inverseMeasurementRate['rate'] > 0 + ) { + return 1 / (float) $inverseMeasurementRate['rate']; + } + + foreach (['USD', 'EUR'] as $viaBase) { + if ($base === $viaBase || $target === $viaBase) { + continue; + } + + $fromVia = $this->repository->getLatestFxRate($viaBase, $base); + $toVia = $this->repository->getLatestFxRate($viaBase, $target); + if ( + is_array($fromVia) && is_numeric($fromVia['rate'] ?? null) && + is_array($toVia) && is_numeric($toVia['rate'] ?? null) && + (float) $fromVia['rate'] > 0 + ) { + return (float) $toVia['rate'] / (float) $fromVia['rate']; + } + + $fromViaInverse = $this->repository->getLatestFxRate($base, $viaBase); + $toViaInverse = $this->repository->getLatestFxRate($target, $viaBase); + if ( + is_array($fromViaInverse) && is_numeric($fromViaInverse['rate'] ?? null) && + is_array($toViaInverse) && is_numeric($toViaInverse['rate'] ?? null) && + (float) $toViaInverse['rate'] > 0 + ) { + return (1 / (float) $fromViaInverse['rate']) / (1 / (float) $toViaInverse['rate']); + } + } + } catch (\Throwable) { + return null; + } + + return null; + } + + private function persistRateSet(string $base, array $rates, string $rateDate): array + { + $normalizedBase = strtoupper($base); + $normalizedRates = []; + foreach ($rates as $target => $rate) { + if (!is_numeric($rate)) { + continue; + } + + $normalizedTarget = strtoupper((string) $target); + $normalizedRates[$normalizedTarget] = (float) $rate; + $this->memoryCache[$normalizedBase . ':' . $normalizedTarget] = (float) $rate; + $this->writeFileCache($normalizedBase . ':' . $normalizedTarget, (float) $rate); + } + + if ($this->repository === null) { + $result = []; + foreach ($normalizedRates as $target => $rate) { + $result[] = [ + 'base_currency' => $normalizedBase, + 'target_currency' => $target, + 'rate' => $rate, + 'rate_date' => $rateDate, + 'provider' => $this->provider, + ]; + } + return $result; + } + + try { + $saved = $this->repository->saveFxFetch($normalizedBase, $this->provider, $rateDate, $normalizedRates); + return is_array($saved['rates'] ?? null) ? $saved['rates'] : []; + } catch (\Throwable) { + $result = []; + foreach ($normalizedRates as $target => $rate) { + $result[] = [ + 'base_currency' => $normalizedBase, + 'target_currency' => $target, + 'rate' => $rate, + 'rate_date' => $rateDate, + 'provider' => $this->provider, + ]; + } + return $result; + } + } + + private function buildLatestUrl(string $base, ?array $targets = null): ?string + { + if ($this->provider === 'currencyapi') { + if ($this->apiKey === '') { + return null; + } + + return sprintf( + '%s/api/v2/rates?base=%s&output=json&key=%s', + $this->apiBaseUrl, + rawurlencode($base), + rawurlencode($this->apiKey) + ); + } + + $targets = $targets ?? $this->defaultCurrencies(); + return sprintf( + '%s/latest?base=%s&symbols=%s', + $this->apiBaseUrl, + rawurlencode($base), + rawurlencode(implode(',', $targets)) + ); + } + + private function normalizePayload(mixed $payload, string $base, ?array $targets = null): array + { + if (!is_array($payload)) { + return []; + } + + if ($this->provider === 'currencyapi') { + if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) { + throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.')); + } + + $allRates = $payload['rates']; + $filteredRates = []; + if ($targets === null) { + foreach ($allRates as $target => $rate) { + $targetCode = strtoupper((string) $target); + if ($targetCode === $base || !is_numeric($rate)) { + continue; + } + $filteredRates[$targetCode] = (float) $rate; + } + } else { + foreach ($targets as $target) { + $targetCode = strtoupper((string) $target); + if ($targetCode === $base) { + continue; + } + + $rate = $allRates[$targetCode] ?? null; + if (is_numeric($rate)) { + $filteredRates[$targetCode] = (float) $rate; + } + } + } + + return [ + 'base' => strtoupper((string) ($payload['base'] ?? $base)), + 'date' => $payload['updated'] ?? null, + 'rates' => $filteredRates, + ]; + } + + if (!is_array($payload['rates'] ?? null)) { + return []; + } + + if (array_key_exists('success', $payload) && $payload['success'] !== true) { + return []; + } + + return $payload; + } + + private function extractProviderError(array $payload, string $fallback): string + { + foreach (['error', 'message', 'msg'] as $field) { + $value = $payload[$field] ?? null; + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + } + + $errors = $payload['errors'] ?? null; + if (is_array($errors)) { + $flat = []; + array_walk_recursive($errors, static function ($value) use (&$flat): void { + if (is_string($value) && trim($value) !== '') { + $flat[] = trim($value); + } + }); + if ($flat !== []) { + return implode(' | ', array_values(array_unique($flat))); + } + } + + return $fallback; + } + + private function defaultCurrencies(): array + { + return ['EUR', 'USD']; + } + + private function normalizeRateDate(mixed $value): string + { + if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit(trim($value)))) { + $timestamp = (int) $value; + if ($timestamp > 0) { + return date('Y-m-d', $timestamp); + } + } + + if (is_string($value) && trim($value) !== '') { + $timestamp = strtotime($value); + if ($timestamp !== false) { + return date('Y-m-d', $timestamp); + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) { + return $matches[0]; + } + } + + return date('Y-m-d'); + } + + private function parseStoredUtcTimestamp(string $value): ?int + { + $normalized = trim($value); + if ($normalized === '') { + return null; + } + + try { + if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $normalized) === 1) { + $date = new \DateTimeImmutable(str_replace(' ', 'T', $normalized), new \DateTimeZone('UTC')); + } else { + $date = new \DateTimeImmutable($normalized); + } + return $date->setTimezone(new \DateTimeZone('UTC'))->getTimestamp(); + } catch (\Throwable) { + return null; + } + } + + private function catalogSortOrder(string $code, int $fallback): int + { + return match (strtoupper($code)) { + 'EUR' => 10, + 'USD' => 20, + 'DOGE' => 30, + 'BTC' => 40, + 'ETH' => 50, + 'USDT' => 60, + 'USDC' => 70, + default => $fallback, + }; + } + + private function isCryptoCode(string $code): bool + { + return in_array(strtoupper($code), [ + 'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC', + 'SOL', 'USDC', 'USDT', 'XRP', + ], true); + } + + private function cacheFile(string $cacheKey): string + { + return rtrim(sys_get_temp_dir(), '/') . '/mining-checker-fx-' . md5($cacheKey) . '.json'; + } + + private function readFileCache(string $cacheKey): ?float + { + $file = $this->cacheFile($cacheKey); + if (!is_file($file) || (time() - filemtime($file)) > $this->cacheTtl) { + return null; + } + + $payload = json_decode((string) file_get_contents($file), true); + $rate = $payload['rate'] ?? null; + return is_numeric($rate) ? (float) $rate : null; + } + + private function writeFileCache(string $cacheKey, float $rate): void + { + @file_put_contents($this->cacheFile($cacheKey), json_encode([ + 'rate' => $rate, + 'cached_at' => time(), + ], JSON_UNESCAPED_UNICODE)); + } + + private function maskUrl(string $url): string + { + return preg_replace_callback('/([?&]key=)([^&]+)/i', static function (array $matches): string { + $key = $matches[2] ?? ''; + if (strlen($key) <= 8) { + return $matches[1] . $key; + } + + return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4); + }, $url) ?: $url; + } + + private function sharedFxService(): ?object + { + if (!function_exists('modules') || !modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'service')) { + return null; + } + + try { + $service = module_fn('fx-rates', 'service'); + return is_object($service) ? $service : null; + } catch (\Throwable) { + return null; + } + } + + private function resolveRateFromSnapshot(array $snapshot, string $from, string $to): ?float + { + $base = strtoupper(trim((string) ($snapshot['base_currency'] ?? ''))); + $rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : []; + + if ($base === '' || $from === '' || $to === '') { + return null; + } + + if ($base === $from && is_numeric($rates[$to] ?? null)) { + return (float) $rates[$to]; + } + + if ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) { + return 1 / (float) $rates[$from]; + } + + if (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) { + return (float) $rates[$to] / (float) $rates[$from]; + } + + return null; + } + + private function snapshotCacheKey(string $prefix, array $parts): string + { + return $prefix . ':' . implode(':', array_map(static fn (mixed $part): string => (string) $part, $parts)); + } + + private function normalizeSymbolsForCache(?array $symbols): string + { + if (!is_array($symbols) || $symbols === []) { + return '*'; + } + + $normalized = array_values(array_unique(array_filter(array_map( + static fn (mixed $symbol): string => strtoupper(trim((string) $symbol)), + $symbols + )))); + sort($normalized); + return implode(',', $normalized); + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/src/Domain/OcrService.php b/temp/nexus-module-import/modules/mining-checker/src/Domain/OcrService.php new file mode 100644 index 00000000..10dae3e1 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Domain/OcrService.php @@ -0,0 +1,657 @@ +config = $config; + } + + public function preview(array $file, array $input): array + { + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) { + throw new ApiException('Screenshot-Upload fehlt oder ist fehlerhaft.', 422); + } + + $mime = mime_content_type($file['tmp_name']) ?: ''; + if (!in_array($mime, ['image/png', 'image/jpeg', 'image/webp'], true)) { + throw new ApiException('Nur PNG, JPEG und WEBP werden akzeptiert.', 422, ['mime' => $mime]); + } + + $projectKey = (string) ($input['project_key'] ?? $this->config->defaultProjectKey()); + $uploadDir = $this->resolveUploadDir($projectKey); + + $extension = pathinfo((string) ($file['name'] ?? 'upload.png'), PATHINFO_EXTENSION) ?: 'png'; + $filename = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . strtolower($extension); + $targetFile = $uploadDir . '/' . $filename; + if (!move_uploaded_file($file['tmp_name'], $targetFile)) { + throw new ApiException('Bild konnte nicht gespeichert werden.', 500); + } + + $rawText = trim((string) ($input['ocr_hint_text'] ?? '')); + $flags = []; + + if ($rawText === '') { + ['text' => $rawText, 'flags' => $providerFlags] = $this->extractRawText($targetFile); + $flags = array_merge($flags, $providerFlags); + } else { + $flags[] = 'ocr_hint_text_used'; + } + + $parsed = $this->parseText( + $rawText, + (string) ($input['date_context'] ?? date('Y-m-d')), + strtoupper(trim((string) ($input['wallet_currency_hint'] ?? ''))) + ); + $parsed['image_path'] = $targetFile; + $parsed['raw_text'] = $rawText; + $parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags']))); + + return $parsed; + } + + private function resolveUploadDir(string $projectKey): string + { + $safeProjectKey = preg_replace('~[^a-zA-Z0-9_-]~', '-', $projectKey) ?: 'default'; + $candidates = [ + rtrim($this->config->uploadsDir(), '/') . '/' . $safeProjectKey, + rtrim(sys_get_temp_dir(), '/') . '/mining-checker/uploads/' . $safeProjectKey, + ]; + + foreach ($candidates as $candidate) { + if ($this->ensureWritableDirectory($candidate)) { + return $candidate; + } + } + + throw new ApiException('Upload-Verzeichnis konnte nicht erstellt werden.', 500, [ + 'candidates' => $candidates, + ]); + } + + private function ensureWritableDirectory(string $directory): bool + { + if (is_dir($directory)) { + return is_writable($directory); + } + + return @mkdir($directory, 0775, true) || is_dir($directory); + } + + private function extractRawText(string $imagePath): array + { + $ocrConfig = $this->config->ocr(); + $providers = $ocrConfig['providers'] ?? ['tesseract']; + $flags = []; + + if (!is_array($providers) || $providers === []) { + $providers = ['tesseract']; + } + + foreach ($providers as $provider) { + $providerName = strtolower(trim((string) $provider)); + if ($providerName === '') { + continue; + } + + if ($providerName === 'ocrspace') { + $result = $this->runOcrSpace((array) ($ocrConfig['ocrspace'] ?? []), $imagePath); + } elseif ($providerName === 'tesseract') { + $result = $this->runTesseract((array) ($ocrConfig['tesseract'] ?? []), $imagePath); + } else { + $flags[] = 'ocr_provider_unsupported:' . $providerName; + continue; + } + + $flags = array_merge($flags, $result['flags']); + if (($result['text'] ?? '') !== '') { + return [ + 'text' => (string) $result['text'], + 'flags' => array_values(array_unique(array_merge( + $flags, + ['ocr_provider:' . $providerName] + ))), + ]; + } + } + + return [ + 'text' => '', + 'flags' => array_values(array_unique(array_merge($flags, ['ocr_engine_missing']))), + ]; + } + + private function runOcrSpace(array $providerConfig, string $imagePath): array + { + if (!function_exists('curl_init') || !class_exists(\CURLFile::class)) { + return [ + 'text' => '', + 'flags' => ['ocr_provider_missing:ocrspace', 'ocr_transport_missing:curl'], + ]; + } + + $url = trim((string) ($providerConfig['url'] ?? '')); + if ($url === '') { + return [ + 'text' => '', + 'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_url_missing'], + ]; + } + + $apiKey = trim((string) ($providerConfig['api_key'] ?? '')); + if ($apiKey === '') { + return [ + 'text' => '', + 'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_api_key_missing'], + ]; + } + + $postFields = [ + 'file' => new \CURLFile($imagePath), + 'language' => (string) ($providerConfig['language'] ?? 'eng'), + 'OCREngine' => (string) ((int) ($providerConfig['engine'] ?? 2)), + 'scale' => (string) ($providerConfig['scale'] ?? 'true'), + 'detectOrientation' => (string) ($providerConfig['detect_orientation'] ?? 'true'), + 'isTable' => (string) ($providerConfig['is_table'] ?? 'false'), + 'isOverlayRequired' => 'false', + ]; + + $ch = curl_init(); + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $postFields, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => max(5, (int) ($providerConfig['timeout'] ?? 25)), + CURLOPT_HTTPHEADER => [ + 'Accept: application/json', + 'apikey: ' . $apiKey, + ], + ]); + + $response = curl_exec($ch); + $curlError = curl_error($ch); + $httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + + if ($response === false || $curlError !== '') { + return [ + 'text' => '', + 'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_request_failed'], + ]; + } + + $payload = json_decode((string) $response, true); + if (!is_array($payload)) { + return [ + 'text' => '', + 'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_invalid_response'], + ]; + } + + $flags = []; + $rawText = ''; + $parsedResults = $payload['ParsedResults'] ?? null; + if (is_array($parsedResults)) { + $texts = []; + foreach ($parsedResults as $result) { + if (!is_array($result)) { + continue; + } + $fileExitCode = (string) ($result['FileParseExitCode'] ?? ''); + if ($fileExitCode !== '') { + $flags[] = 'ocrspace_file_exit_code:' . $fileExitCode; + } + $parsedText = trim((string) ($result['ParsedText'] ?? '')); + if ($parsedText !== '') { + $texts[] = $parsedText; + } + $resultError = trim((string) ($result['ErrorMessage'] ?? '')); + if ($resultError !== '') { + $flags[] = 'ocrspace_result_error'; + } + } + $rawText = trim(implode("\n", $texts)); + } + + $ocrExitCode = (string) ($payload['OCRExitCode'] ?? ''); + $isErroredOnProcessing = !empty($payload['IsErroredOnProcessing']); + $errorMessage = trim((string) ($payload['ErrorMessage'] ?? '')); + $errorDetails = trim((string) ($payload['ErrorDetails'] ?? '')); + + if ($httpStatus >= 400) { + $flags[] = 'ocrspace_http_error'; + } + + if ($ocrExitCode !== '') { + $flags[] = 'ocrspace_exit_code:' . $ocrExitCode; + } + + $flags[] = 'ocrspace_engine:' . (string) ((int) ($providerConfig['engine'] ?? 2)); + + if ($isErroredOnProcessing) { + $flags[] = 'ocrspace_processing_error'; + } + + if ($errorMessage !== '' || $errorDetails !== '') { + $flags[] = 'ocrspace_error'; + } + + return [ + 'text' => $rawText, + 'flags' => $rawText === '' ? array_values(array_unique(array_merge($flags, ['ocr_provider_empty:ocrspace']))) : array_values(array_unique($flags)), + ]; + } + + private function runTesseract(array $providerConfig, string $imagePath): array + { + $binary = (string) ($providerConfig['binary'] ?? 'tesseract'); + if (!$this->binaryExists($binary)) { + return [ + 'text' => '', + 'flags' => ['ocr_provider_missing:tesseract'], + ]; + } + + $language = (string) ($providerConfig['language'] ?? 'eng'); + $tmpBase = tempnam(sys_get_temp_dir(), 'mc-ocr-'); + if ($tmpBase === false) { + return [ + 'text' => '', + 'flags' => ['ocr_tempfile_failed:tesseract'], + ]; + } + + @unlink($tmpBase); + $command = sprintf( + '%s %s %s -l %s 2>/dev/null', + escapeshellcmd($binary), + escapeshellarg($imagePath), + escapeshellarg($tmpBase), + escapeshellarg($language) + ); + shell_exec($command); + + $txtFile = $tmpBase . '.txt'; + $text = is_file($txtFile) ? (string) file_get_contents($txtFile) : ''; + @unlink($txtFile); + return [ + 'text' => trim($text), + 'flags' => trim($text) === '' ? ['ocr_provider_empty:tesseract'] : [], + ]; + } + + private function binaryExists(string $binary): bool + { + return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== ''; + } + + private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array + { + $measurement = $this->parseMeasurementText($rawText, $dateContext); + $wallet = $this->parseWalletText($rawText, $dateContext, $walletCurrencyHint); + + $isWallet = ($wallet['score'] ?? 0) > ($measurement['score'] ?? 0) + && ( + ($wallet['suggested_wallet']['wallet_balance'] ?? null) !== null + || ($wallet['suggested_wallet']['total_value_amount'] ?? null) !== null + ); + + return [ + 'kind' => $isWallet ? 'wallet' : 'measurement', + 'suggested' => $measurement['suggested'], + 'suggested_wallet' => $wallet['suggested_wallet'], + 'confidence' => round((float) ($isWallet ? ($wallet['confidence'] ?? 0.0) : ($measurement['confidence'] ?? 0.0)), 4), + 'flags' => $isWallet ? $wallet['flags'] : $measurement['flags'], + ]; + } + + private function parseMeasurementText(string $rawText, string $dateContext): array + { + $flags = []; + $suggestedTime = null; + $coinsTotal = null; + $price = null; + $currency = null; + $normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: ''; + $lines = array_values(array_filter(array_map( + static fn (string $line): string => trim($line), + preg_split('/\R/u', $rawText) ?: [] + ), static fn (string $line): bool => $line !== '')); + + if ($normalizedText === '') { + $flags[] = 'ocr_raw_text_empty'; + } + + if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) { + $suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]); + } + + preg_match_all('/\b\d+(?:[.,]\d+)?\b/', $normalizedText, $numberMatches); + $decimalCandidates = []; + foreach ($numberMatches[0] ?? [] as $candidate) { + $normalized = (float) str_replace(',', '.', $candidate); + if ($normalized <= 0) { + continue; + } + $decimalCandidates[] = [ + 'raw' => $candidate, + 'value' => $normalized, + 'precision' => str_contains($candidate, ',') || str_contains($candidate, '.') + ? strlen((string) preg_replace('/^\d+[.,]/', '', $candidate)) + : 0, + ]; + } + + if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) { + $currency = strtoupper((string) $pairMatch[1]); + } elseif (preg_match('/\b(EUR|USD|USDT|USDC|BTC|ETH|LTC)\b/i', $normalizedText, $currencyMatch)) { + $currency = strtoupper((string) $currencyMatch[1]); + } elseif (str_contains($normalizedText, '$')) { + $currency = 'USD'; + } else { + $flags[] = 'currency_missing'; + } + + if (preg_match('/DOGE\s*\/\s*(?:USD|EUR|USDT|USDC|BTC|ETH|LTC)[^\d]{0,20}(\d+[.,]\d{3,8})/i', $normalizedText, $priceMatch)) { + $price = round((float) str_replace(',', '.', $priceMatch[1]), 8); + } + + if ($coinsTotal === null) { + foreach ($lines as $line) { + if (!preg_match('/MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE/i', $line)) { + continue; + } + + if (preg_match('/(\d+[.,]\d{4,8})/', $line, $lineCoinsMatch)) { + $coinsTotal = round((float) str_replace(',', '.', $lineCoinsMatch[1]), 6); + $flags[] = 'coins_from_balance_line'; + break; + } + } + } + + if ($coinsTotal === null && preg_match('/(\d+[.,]\d{4,8})\s*(?:DOGE)?\s*(?:MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE)/i', $normalizedText, $coinsMatch)) { + $coinsTotal = round((float) str_replace(',', '.', $coinsMatch[1]), 6); + $flags[] = 'coins_from_balance_context'; + } + + if ($coinsTotal === null) { + $coinsCandidates = array_values(array_filter($decimalCandidates, static function (array $item) use ($price): bool { + if ($item['precision'] < 4) { + return false; + } + if ($item['value'] <= 0 || $item['value'] >= 1000000) { + return false; + } + if ($price !== null && abs($item['value'] - $price) < 0.0000005) { + return false; + } + + return true; + })); + + if ($coinsCandidates !== []) { + usort($coinsCandidates, static function (array $a, array $b): int { + return [$b['precision'], $b['value']] <=> [$a['precision'], $a['value']]; + }); + $coinsTotal = round((float) $coinsCandidates[0]['value'], 6); + if (count($coinsCandidates) > 1) { + $flags[] = 'coins_ambiguous'; + } + } else { + $flags[] = 'coins_missing'; + } + } + + $priceCandidates = array_values(array_filter( + $decimalCandidates, + static fn (array $item): bool => $item['value'] > 0 && $item['value'] < 1 + )); + + if ($price === null && $priceCandidates !== []) { + usort($priceCandidates, static function (array $a, array $b): int { + return [$b['precision'], $a['value']] <=> [$a['precision'], $b['value']]; + }); + $price = round((float) $priceCandidates[0]['value'], 8); + if (count($priceCandidates) > 1 && count(array_filter($priceCandidates, static fn (array $item): bool => $item['precision'] >= 4)) > 1) { + $flags[] = 'price_ambiguous'; + } + } + + if ($price === null && $coinsTotal !== null && preg_match('/~\s*(\d+[.,]\d+)\s*\$/', $normalizedText, $fiatMatch)) { + $fiatValue = (float) str_replace(',', '.', $fiatMatch[1]); + if ($fiatValue > 0) { + $price = round($fiatValue / $coinsTotal, 8); + $flags[] = 'price_derived_from_balance_value'; + $currency = $currency ?? 'USD'; + } + } + + $measurementIndicators = 0; + $normalizedLower = strtolower($normalizedText); + foreach (['mining-guthaben', 'mining guthaben', 'mining-balance', 'mining balance', 'doge /', 'bonus', 'verlauf'] as $indicator) { + if (str_contains($normalizedLower, $indicator)) { + $measurementIndicators++; + } + } + + $matchedFields = 0; + foreach ([$coinsTotal, $price, $currency] as $field) { + if ($field !== null) { + $matchedFields++; + } + } + + $score = $matchedFields + min(3, $measurementIndicators); + $confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $measurementIndicators) * 0.08) - (count($flags) * 0.04))); + + return [ + 'suggested' => [ + 'measured_at' => $suggestedTime, + 'coins_total' => $coinsTotal, + 'price_per_coin' => $price, + 'price_currency' => $currency, + 'note' => null, + 'source' => 'image_ocr', + ], + 'confidence' => round($confidence, 4), + 'flags' => $flags, + 'score' => $score, + ]; + } + + private function parseWalletText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array + { + $flags = []; + $suggestedTime = null; + $totalValueAmount = null; + $totalValueCurrency = null; + $walletBalance = null; + $walletCurrency = $walletCurrencyHint !== '' ? $walletCurrencyHint : 'DOGE'; + $balances = []; + + $normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: ''; + $lines = array_values(array_filter(array_map( + static fn (string $line): string => trim($line), + preg_split('/\R/u', $rawText) ?: [] + ), static fn (string $line): bool => $line !== '')); + + if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) { + $suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]); + } + + if ( + preg_match('/GESAMTSALDO[^\d]{0,24}(\d+(?:[.,]\d+)?)\s*(USD|EUR|USDT|USDC|BTC|ETH|DOGE|CTC|HSH)/i', $normalizedText, $totalMatch) + || preg_match('/(\d+(?:[.,]\d+)?)\s*(USD|EUR)\b.*GESAMTSALDO/i', $normalizedText, $totalMatch) + ) { + $totalValueAmount = round((float) str_replace(',', '.', $totalMatch[1]), 8); + $totalValueCurrency = strtoupper((string) $totalMatch[2]); + } else { + $flags[] = 'wallet_total_missing'; + } + + $assetRows = []; + foreach ($lines as $lineIndex => $line) { + if (!preg_match('/(\d+(?:[.,]\d+)?)\s*([A-Z]{2,10})\b/u', $line, $match)) { + continue; + } + $amount = round((float) str_replace(',', '.', $match[1]), 10); + $currency = strtoupper((string) $match[2]); + if ($amount <= 0 || $currency === '' || in_array($currency, ['USD', 'EUR'], true)) { + continue; + } + + $assetRows[] = [ + 'index' => $lineIndex, + 'currency' => $currency, + 'balance' => $amount, + ]; + } + + foreach ($assetRows as $assetIndex => $assetRow) { + $currency = (string) $assetRow['currency']; + $balanceAmount = (float) $assetRow['balance']; + $startIndex = (int) $assetRow['index']; + $endIndex = isset($assetRows[$assetIndex + 1]['index']) + ? (int) $assetRows[$assetIndex + 1]['index'] + : count($lines); + + $usdCandidates = []; + for ($i = $startIndex; $i < $endIndex; $i++) { + if (!isset($lines[$i])) { + continue; + } + if (preg_match_all('/(\d+(?:[.,]\d+)?)\s*USD\b/u', $lines[$i], $usdMatches, PREG_SET_ORDER)) { + foreach ($usdMatches as $usdMatch) { + $usdCandidates[] = round((float) str_replace(',', '.', $usdMatch[1]), 8); + } + } + } + + $priceAmount = $this->pickWalletUnitPrice($balanceAmount, $usdCandidates); + $balances[$currency] = [ + 'balance' => $balanceAmount, + 'price_amount' => $priceAmount, + 'price_currency' => $priceAmount !== null ? 'USD' : null, + ]; + } + + if ($walletCurrencyHint !== '' && array_key_exists($walletCurrencyHint, $balances)) { + $walletCurrency = $walletCurrencyHint; + $walletBalance = is_array($balances[$walletCurrencyHint]) + ? (float) ($balances[$walletCurrencyHint]['balance'] ?? 0.0) + : (float) $balances[$walletCurrencyHint]; + } elseif ($balances !== []) { + foreach (['DOGE', 'BTC', 'ETH', 'CTC', 'HSH', 'LTC', 'USDT', 'USDC'] as $preferredCurrency) { + if (array_key_exists($preferredCurrency, $balances)) { + $walletCurrency = $preferredCurrency; + $walletBalance = is_array($balances[$preferredCurrency]) + ? (float) ($balances[$preferredCurrency]['balance'] ?? 0.0) + : (float) $balances[$preferredCurrency]; + break; + } + } + if ($walletBalance === null) { + $firstCurrency = array_key_first($balances); + if (is_string($firstCurrency)) { + $walletCurrency = $firstCurrency; + $walletBalance = is_array($balances[$firstCurrency]) + ? (float) ($balances[$firstCurrency]['balance'] ?? 0.0) + : (float) $balances[$firstCurrency]; + } + } + } else { + $flags[] = 'wallet_balance_missing'; + } + + $walletIndicators = 0; + $normalizedLower = strtolower($normalizedText); + foreach (['wallets', 'gesamtsaldo', 'alle münzen', 'alle munzen', 'letzte transaktion'] as $indicator) { + if (str_contains($normalizedLower, $indicator)) { + $walletIndicators++; + } + } + + $matchedFields = 0; + foreach ([$totalValueAmount, $walletBalance, $walletCurrency] as $field) { + if ($field !== null && $field !== '') { + $matchedFields++; + } + } + $score = $matchedFields + ($walletIndicators * 2); + $confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $walletIndicators) * 0.12) - (count($flags) * 0.03))); + + ksort($balances); + + return [ + 'suggested_wallet' => [ + 'measured_at' => $suggestedTime, + 'total_value_amount' => $totalValueAmount, + 'total_value_currency' => $totalValueCurrency, + 'wallet_balance' => $walletBalance, + 'wallet_currency' => $walletCurrency, + 'balances_json' => $balances, + 'note' => null, + 'source' => 'image_ocr', + ], + 'confidence' => round($confidence, 4), + 'flags' => array_values(array_unique($flags)), + 'score' => $score, + ]; + } + + /** + * @param list $candidates + */ + private function pickWalletUnitPrice(float $balance, array $candidates): ?float + { + $candidates = array_values(array_filter(array_map( + static fn (mixed $value): float => round((float) $value, 8), + $candidates + ), static fn (float $value): bool => $value > 0)); + + if ($balance <= 0 || $candidates === []) { + return null; + } + if (count($candidates) === 1) { + return $candidates[0]; + } + + $bestPrice = null; + $bestError = null; + $candidateCount = count($candidates); + + for ($i = 0; $i < $candidateCount; $i++) { + $priceCandidate = $candidates[$i]; + for ($j = 0; $j < $candidateCount; $j++) { + if ($i === $j) { + continue; + } + $totalCandidate = $candidates[$j]; + $estimatedTotal = $balance * $priceCandidate; + $denominator = max(abs($totalCandidate), 0.00000001); + $error = abs($estimatedTotal - $totalCandidate) / $denominator; + if ($bestError === null || $error < $bestError) { + $bestError = $error; + $bestPrice = $priceCandidate; + } + } + } + + if ($bestPrice !== null && $bestError !== null && $bestError <= 0.2) { + return round($bestPrice, 8); + } + + return round($candidates[count($candidates) - 1], 8); + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/src/Domain/SeedData.php b/temp/nexus-module-import/modules/mining-checker/src/Domain/SeedData.php new file mode 100644 index 00000000..5f778264 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Domain/SeedData.php @@ -0,0 +1,79 @@ + '2026-03-16 01:32:00', + 'baseline_coins_total' => 27.617864, + 'daily_cost_amount' => 0.3123287671, + 'daily_cost_currency' => 'EUR', + 'preferred_currencies' => ['DOGE', 'USD', 'EUR'], + ]; + } + + public static function currencies(): array + { + return [ + ['code' => 'EUR', 'name' => 'Euro', 'symbol' => 'EUR', 'is_active' => 1, 'sort_order' => 10], + ['code' => 'USD', 'name' => 'US-Dollar', 'symbol' => 'USD', 'is_active' => 1, 'sort_order' => 20], + ['code' => 'DOGE', 'name' => 'Dogecoin', 'symbol' => 'DOGE', 'is_active' => 1, 'sort_order' => 100], + ['code' => 'BTC', 'name' => 'Bitcoin', 'symbol' => 'BTC', 'is_active' => 1, 'sort_order' => 110], + ['code' => 'ETH', 'name' => 'Ethereum', 'symbol' => 'ETH', 'is_active' => 1, 'sort_order' => 120], + ['code' => 'LTC', 'name' => 'Litecoin', 'symbol' => 'LTC', 'is_active' => 1, 'sort_order' => 130], + ['code' => 'USDT', 'name' => 'Tether', 'symbol' => 'USDT', 'is_active' => 1, 'sort_order' => 140], + ['code' => 'USDC', 'name' => 'USD Coin', 'symbol' => 'USDC', 'is_active' => 1, 'sort_order' => 150], + ]; + } + + public static function measurements(): array + { + return [ + ['measured_at' => '2026-03-16 01:32:00', 'coins_total' => 27.617864, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Basiswert', 'source' => 'seed_import'], + ['measured_at' => '2026-03-17 02:41:00', 'coins_total' => 33.751904, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'source' => 'seed_import'], + ['measured_at' => '2026-03-17 07:15:00', 'coins_total' => 34.825695, 'price_per_coin' => 0.10037, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-17 13:21:00', 'coins_total' => 36.328140, 'price_per_coin' => 0.10002, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-17 18:53:00', 'coins_total' => 37.682757, 'price_per_coin' => 0.10062, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-18 00:08:00', 'coins_total' => 38.934351, 'price_per_coin' => 0.10097, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-18 07:40:00', 'coins_total' => 40.782006, 'price_per_coin' => 0.10040, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-18 13:32:00', 'coins_total' => 42.223449, 'price_per_coin' => 0.09607, 'price_currency' => 'EUR', 'note' => 'Originaleingabe im Chat: 18.6.2026', 'source' => 'seed_import'], + ['measured_at' => '2026-03-18 21:15:00', 'coins_total' => 44.191018, 'price_per_coin' => 0.09446, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-19 00:09:00', 'coins_total' => 44.908500, 'price_per_coin' => 0.09507, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'], + ['measured_at' => '2026-03-19 02:33:00', 'coins_total' => 45.546924, 'price_per_coin' => 0.09499, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']], + ['measured_at' => '2026-03-19 07:01:00', 'coins_total' => 46.694127, 'price_per_coin' => 0.09460, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']], + ['measured_at' => '2026-03-19 12:24:00', 'coins_total' => 48.056494, 'price_per_coin' => 0.09419, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']], + ['measured_at' => '2026-03-19 21:39:00', 'coins_total' => 50.427943, 'price_per_coin' => 0.09361, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']], + ]; + } + + public static function targets(): array + { + return [ + ['label' => 'Ziel A', 'target_amount_fiat' => 10.82, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 10], + ['label' => 'Ziel B', 'target_amount_fiat' => 19.50, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 20], + ]; + } + + public static function dashboards(): array + { + return [ + ['name' => 'Mining-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'coins_total', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1], + ['name' => 'Performance-Verlauf', 'chart_type' => 'area', 'x_field' => 'measured_date', 'y_field' => 'doge_per_day_interval', 'aggregation' => 'avg', 'filters' => [], 'is_active' => 1], + ['name' => 'Kurs-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'price_per_coin', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1], + ]; + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/src/Domain/SeedImporter.php b/temp/nexus-module-import/modules/mining-checker/src/Domain/SeedImporter.php new file mode 100644 index 00000000..b533e2b7 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Domain/SeedImporter.php @@ -0,0 +1,62 @@ +repository = $repository; + } + + public function import(string $projectKey): array + { + $seedProjectKey = SeedData::projectKey(); + if ($projectKey !== $seedProjectKey) { + return ['inserted' => 0, 'project_key' => $projectKey, 'warning' => 'Seed-Daten sind nur fuer doge-main definiert.']; + } + + $this->repository->ensureProject($projectKey, SeedData::projectName()); + $this->repository->saveSettings($projectKey, SeedData::settings()); + + $insertedMeasurements = 0; + foreach (SeedData::measurements() as $measurement) { + try { + $this->repository->createMeasurement($projectKey, array_merge([ + 'image_path' => null, + 'ocr_raw_text' => null, + 'ocr_confidence' => null, + 'ocr_flags' => null, + ], $measurement)); + $insertedMeasurements++; + } catch (\Throwable $exception) { + // Duplicate seeds are expected on repeated imports. + } + } + + $targetCount = 0; + foreach (SeedData::targets() as $target) { + $this->repository->saveTarget($projectKey, $target); + $targetCount++; + } + + $dashboardCount = 0; + foreach (SeedData::dashboards() as $dashboard) { + $this->repository->saveDashboard($projectKey, $dashboard); + $dashboardCount++; + } + + return [ + 'project_key' => $projectKey, + 'imported_measurements' => $insertedMeasurements, + 'historical_rows_total' => count(SeedData::measurements()), + 'targets_synced' => $targetCount, + 'dashboards_synced' => $dashboardCount, + ]; + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/src/Infrastructure/ConnectionFactory.php b/temp/nexus-module-import/modules/mining-checker/src/Infrastructure/ConnectionFactory.php new file mode 100644 index 00000000..cf28e6f2 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Infrastructure/ConnectionFactory.php @@ -0,0 +1,65 @@ +settings('mining-checker'); + $useSeparateDb = self::usesSeparateDatabase($moduleSettings); + + if ($useSeparateDb) { + $dbConfig = is_array($moduleSettings['db'] ?? null) ? $moduleSettings['db'] : []; + if ($dbConfig === []) { + throw new ApiException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.', 500); + } + self::assertSupportedDriver($dbConfig); + if (method_exists(AppDatabase::class, 'connectFromConfig')) { + return AppDatabase::connectFromConfig($dbConfig); + } + return AppDatabase::createFromArray($dbConfig); + } + + $dbConfig = app()->config()->dbConfig; + if ($dbConfig === []) { + throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500); + } + + self::assertSupportedDriver($dbConfig); + + if (method_exists(AppDatabase::class, 'connectFromConfig')) { + return AppDatabase::connectFromConfig($dbConfig); + } + + return AppDatabase::createFromArray($dbConfig); + } + + private static function usesSeparateDatabase(array $moduleSettings): bool + { + $raw = $moduleSettings['use_separate_db'] ?? false; + if (is_bool($raw)) { + return $raw; + } + + $normalized = strtolower(trim((string) $raw)); + return in_array($normalized, ['1', 'true', 'yes', 'on', 'custom'], true); + } + + private static function assertSupportedDriver(array $dbConfig): void + { + $driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? ''))); + if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:')) { + throw new ApiException( + 'Mining-Checker unterstuetzt aktuell MySQL/MariaDB und PostgreSQL. Stelle den Driver auf mysql oder pgsql.', + 500, + ['driver' => $dbConfig['driver'] ?? 'unknown'] + ); + } + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/src/Infrastructure/MiningRepository.php b/temp/nexus-module-import/modules/mining-checker/src/Infrastructure/MiningRepository.php new file mode 100644 index 00000000..78caae71 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Infrastructure/MiningRepository.php @@ -0,0 +1,1401 @@ +pdo = $pdo; + $this->prefix = $prefix; + $this->driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + $this->debug = $debug; + $this->ownerSub = trim((string) $ownerSub) !== '' ? trim((string) $ownerSub) : 'local'; + } + + public function ensureProject(string $projectKey, ?string $projectName = null): void + { + $stmt = $this->pdo->prepare($this->driver === 'pgsql' + ? 'INSERT INTO ' . $this->table('projects') . ' (project_key, project_name) + VALUES (:project_key, :project_name) + ON CONFLICT (project_key) DO UPDATE + SET project_name = COALESCE(' . $this->table('projects') . '.project_name, EXCLUDED.project_name)' + : 'INSERT INTO ' . $this->table('projects') . ' (project_key, project_name) + VALUES (:project_key, :project_name) + ON DUPLICATE KEY UPDATE project_name = COALESCE(project_name, VALUES(project_name))' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'project_name' => $projectName ?: strtoupper($projectKey), + ]); + } + + public function getProject(string $projectKey): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->table('projects') . ' WHERE project_key = :project_key LIMIT 1'); + $stmt->execute(['project_key' => $projectKey]); + $project = $stmt->fetch(); + return is_array($project) ? $this->normalizeRow($project) : null; + } + + public function getSettings(string $projectKey): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->table('settings') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub LIMIT 1'); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function saveSettings(string $projectKey, array $settings): void + { + $stmt = $this->pdo->prepare($this->driver === 'pgsql' + ? 'INSERT INTO ' . $this->table('settings') . ' ( + project_key, owner_sub, baseline_measured_at, baseline_coins_total, daily_cost_amount, daily_cost_currency, report_currency, crypto_currency, display_timezone, fx_max_age_hours, module_theme_mode, module_theme_accent, preferred_currencies + ) VALUES ( + :project_key, :owner_sub, :baseline_measured_at, :baseline_coins_total, :daily_cost_amount, :daily_cost_currency, :report_currency, :crypto_currency, :display_timezone, :fx_max_age_hours, :module_theme_mode, :module_theme_accent, CAST(:preferred_currencies AS jsonb) + ) + ON CONFLICT (project_key, owner_sub) DO UPDATE SET + baseline_measured_at = EXCLUDED.baseline_measured_at, + baseline_coins_total = EXCLUDED.baseline_coins_total, + daily_cost_amount = EXCLUDED.daily_cost_amount, + daily_cost_currency = EXCLUDED.daily_cost_currency, + report_currency = EXCLUDED.report_currency, + crypto_currency = EXCLUDED.crypto_currency, + display_timezone = EXCLUDED.display_timezone, + fx_max_age_hours = EXCLUDED.fx_max_age_hours, + module_theme_mode = EXCLUDED.module_theme_mode, + module_theme_accent = EXCLUDED.module_theme_accent, + preferred_currencies = EXCLUDED.preferred_currencies' + : 'INSERT INTO ' . $this->table('settings') . ' ( + project_key, owner_sub, baseline_measured_at, baseline_coins_total, daily_cost_amount, daily_cost_currency, report_currency, crypto_currency, display_timezone, fx_max_age_hours, module_theme_mode, module_theme_accent, preferred_currencies + ) VALUES ( + :project_key, :owner_sub, :baseline_measured_at, :baseline_coins_total, :daily_cost_amount, :daily_cost_currency, :report_currency, :crypto_currency, :display_timezone, :fx_max_age_hours, :module_theme_mode, :module_theme_accent, :preferred_currencies + ) + ON DUPLICATE KEY UPDATE + baseline_measured_at = VALUES(baseline_measured_at), + baseline_coins_total = VALUES(baseline_coins_total), + daily_cost_amount = VALUES(daily_cost_amount), + daily_cost_currency = VALUES(daily_cost_currency), + report_currency = VALUES(report_currency), + crypto_currency = VALUES(crypto_currency), + display_timezone = VALUES(display_timezone), + fx_max_age_hours = VALUES(fx_max_age_hours), + module_theme_mode = VALUES(module_theme_mode), + module_theme_accent = VALUES(module_theme_accent), + preferred_currencies = VALUES(preferred_currencies)' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'baseline_measured_at' => $settings['baseline_measured_at'], + 'baseline_coins_total' => $settings['baseline_coins_total'], + 'daily_cost_amount' => $settings['daily_cost_amount'], + 'daily_cost_currency' => $settings['daily_cost_currency'], + 'report_currency' => $settings['report_currency'] ?? 'EUR', + 'crypto_currency' => $settings['crypto_currency'] ?? 'DOGE', + 'display_timezone' => $settings['display_timezone'] ?? nexus_display_timezone_name(), + 'fx_max_age_hours' => $settings['fx_max_age_hours'] ?? 3, + 'module_theme_mode' => $settings['module_theme_mode'] ?? 'inherit', + 'module_theme_accent' => $settings['module_theme_accent'] ?? 'teal', + 'preferred_currencies' => json_encode($settings['preferred_currencies'] ?? [], JSON_UNESCAPED_UNICODE), + ]); + } + + public function tableExists(string $logicalName): bool + { + $tableName = $this->table($logicalName); + $schemaCondition = $this->driver === 'pgsql' + ? 'table_schema = current_schema()' + : 'table_schema = DATABASE()'; + $stmt = $this->pdo->prepare( + 'SELECT table_name + FROM information_schema.tables + WHERE ' . $schemaCondition . ' AND table_name = :table_name + LIMIT 1' + ); + $stmt->execute(['table_name' => $tableName]); + return (bool) $stmt->fetchColumn(); + } + + public function listCostPlans(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('cost_plans') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY starts_at DESC, id DESC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function saveCostPlan(string $projectKey, array $payload): array + { + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('cost_plans') . ' ( + project_key, owner_sub, label, starts_at, runtime_months, mining_speed_value, mining_speed_unit, + bonus_speed_value, bonus_speed_unit, auto_renew, base_price_amount, payment_type, total_cost_amount, currency, note, is_active + ) VALUES ( + :project_key, :owner_sub, :label, :starts_at, :runtime_months, :mining_speed_value, :mining_speed_unit, + :bonus_speed_value, :bonus_speed_unit, :auto_renew, :base_price_amount, :payment_type, :total_cost_amount, :currency, :note, :is_active + ) + RETURNING *' + ); + $stmt->execute($this->normalizeInsertPayload($projectKey, $payload)); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('cost_plans') . ' ( + project_key, owner_sub, label, starts_at, runtime_months, mining_speed_value, mining_speed_unit, + bonus_speed_value, bonus_speed_unit, auto_renew, base_price_amount, payment_type, total_cost_amount, currency, note, is_active + ) VALUES ( + :project_key, :owner_sub, :label, :starts_at, :runtime_months, :mining_speed_value, :mining_speed_unit, + :bonus_speed_value, :bonus_speed_unit, :auto_renew, :base_price_amount, :payment_type, :total_cost_amount, :currency, :note, :is_active + )' + ); + $stmt->execute($this->normalizeInsertPayload($projectKey, $payload)); + + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('cost_plans') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function listMeasurements(string $projectKey, int $limit = 200): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('measurements') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub + ORDER BY measured_at ASC + LIMIT :limit' + ); + $stmt->bindValue(':project_key', $projectKey, PDO::PARAM_STR); + $stmt->bindValue(':owner_sub', $this->ownerSub, PDO::PARAM_STR); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function listRecentMeasurements(string $projectKey, int $limit = 200): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('measurements') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub + ORDER BY measured_at DESC + LIMIT :limit' + ); + $stmt->bindValue(':project_key', $projectKey, PDO::PARAM_STR); + $stmt->bindValue(':owner_sub', $this->ownerSub, PDO::PARAM_STR); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + $rows = $this->normalizeRows($stmt->fetchAll() ?: []); + return array_reverse($rows); + } + + public function listAllMeasurements(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('measurements') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub + ORDER BY measured_at ASC' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + ]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function createMeasurement(string $projectKey, array $payload): array + { + $this->debug?->add('db.createMeasurement.start', [ + 'project_key' => $projectKey, + 'measured_at' => $payload['measured_at'] ?? null, + 'price_currency' => $payload['price_currency'] ?? null, + 'fx_fetch_id' => $payload['fx_fetch_id'] ?? null, + ]); + $params = [ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'measured_at' => $payload['measured_at'], + 'coins_total' => $payload['coins_total'], + 'coin_currency' => $payload['coin_currency'] ?? 'DOGE', + 'price_per_coin' => $payload['price_per_coin'], + 'price_currency' => $payload['price_currency'], + 'fx_fetch_id' => $payload['fx_fetch_id'] ?? null, + 'note' => $payload['note'], + 'source' => $payload['source'], + 'image_path' => $payload['image_path'], + 'ocr_raw_text' => $payload['ocr_raw_text'], + 'ocr_confidence' => $payload['ocr_confidence'], + 'ocr_flags' => $payload['ocr_flags'] === null ? null : json_encode($payload['ocr_flags'], JSON_UNESCAPED_UNICODE), + ]; + + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('measurements') . ' ( + project_key, owner_sub, measured_at, coins_total, coin_currency, price_per_coin, price_currency, fx_fetch_id, note, + source, image_path, ocr_raw_text, ocr_confidence, ocr_flags + ) VALUES ( + :project_key, :owner_sub, :measured_at, :coins_total, :coin_currency, :price_per_coin, :price_currency, :fx_fetch_id, :note, + :source, :image_path, :ocr_raw_text, :ocr_confidence, CAST(:ocr_flags AS jsonb) + ) + RETURNING *' + ); + $stmt->execute($params); + $row = $this->normalizeRow($stmt->fetch() ?: []); + $this->debug?->add('db.createMeasurement.end', ['id' => $row['id'] ?? null]); + return $row; + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('measurements') . ' ( + project_key, owner_sub, measured_at, coins_total, coin_currency, price_per_coin, price_currency, fx_fetch_id, note, + source, image_path, ocr_raw_text, ocr_confidence, ocr_flags + ) VALUES ( + :project_key, :owner_sub, :measured_at, :coins_total, :coin_currency, :price_per_coin, :price_currency, :fx_fetch_id, :note, + :source, :image_path, :ocr_raw_text, :ocr_confidence, :ocr_flags + )' + ); + + $stmt->execute($params); + + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('measurements') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + $row = $this->normalizeRow($fetch->fetch() ?: []); + $this->debug?->add('db.createMeasurement.end', ['id' => $row['id'] ?? null]); + return $row; + } + + public function createMeasurementIfNotExists(string $projectKey, array $payload): ?array + { + try { + return $this->createMeasurement($projectKey, $payload); + } catch (\PDOException $exception) { + $sqlState = (string) ($exception->getCode() ?? ''); + if (in_array($sqlState, ['23000', '23505'], true)) { + return null; + } + + throw $exception; + } + } + + public function setMeasurementFxFetchId(string $projectKey, int $measurementId, ?int $fxFetchId): ?array + { + if ($measurementId <= 0) { + return null; + } + + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->table('measurements') . ' + SET fx_fetch_id = :fx_fetch_id + WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id' + ); + $stmt->execute([ + 'fx_fetch_id' => $fxFetchId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $measurementId, + ]); + + $fetch = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('measurements') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id LIMIT 1' + ); + $fetch->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $measurementId, + ]); + + $row = $fetch->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function deleteMeasurement(string $projectKey, int $measurementId): void + { + if ($measurementId <= 0) { + return; + } + + $deleteRates = $this->pdo->prepare( + 'DELETE FROM ' . $this->table('measurement_rates') . ' + WHERE measurement_id = :measurement_id AND owner_sub = :owner_sub' + ); + $deleteRates->execute([ + 'measurement_id' => $measurementId, + 'owner_sub' => $this->ownerSub, + ]); + + $deleteMeasurement = $this->pdo->prepare( + 'DELETE FROM ' . $this->table('measurements') . ' + WHERE id = :id AND project_key = :project_key AND owner_sub = :owner_sub' + ); + $deleteMeasurement->execute([ + 'id' => $measurementId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + ]); + } + + public function replaceMeasurementRates(int $measurementId, string $projectKey, array $rates): void + { + $delete = $this->pdo->prepare('DELETE FROM ' . $this->table('measurement_rates') . ' WHERE measurement_id = :measurement_id AND owner_sub = :owner_sub'); + $delete->execute(['measurement_id' => $measurementId, 'owner_sub' => $this->ownerSub]); + + if ($rates === []) { + return; + } + + $insert = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('measurement_rates') . ' ( + measurement_id, project_key, owner_sub, base_currency, quote_currency, rate, provider + ) VALUES ( + :measurement_id, :project_key, :owner_sub, :base_currency, :quote_currency, :rate, :provider + )' + ); + + foreach ($rates as $rate) { + $insert->execute([ + 'measurement_id' => $measurementId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'base_currency' => strtoupper((string) $rate['base_currency']), + 'quote_currency' => strtoupper((string) $rate['quote_currency']), + 'rate' => $rate['rate'], + 'provider' => $rate['provider'] ?? 'derived', + ]); + } + } + + public function listMeasurementRates(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('measurement_rates') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY measurement_id ASC, base_currency ASC, quote_currency ASC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function listPayouts(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('payouts') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY payout_at ASC, id ASC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function savePayout(string $projectKey, array $payload): array + { + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('payouts') . ' ( + project_key, owner_sub, payout_at, coins_amount, payout_currency, note + ) VALUES ( + :project_key, :owner_sub, :payout_at, :coins_amount, :payout_currency, :note + ) + RETURNING *' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'payout_at' => $payload['payout_at'], + 'coins_amount' => $payload['coins_amount'], + 'payout_currency' => $payload['payout_currency'], + 'note' => $payload['note'], + ]); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('payouts') . ' ( + project_key, owner_sub, payout_at, coins_amount, payout_currency, note + ) VALUES ( + :project_key, :owner_sub, :payout_at, :coins_amount, :payout_currency, :note + )' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'payout_at' => $payload['payout_at'], + 'coins_amount' => $payload['coins_amount'], + 'payout_currency' => $payload['payout_currency'], + 'note' => $payload['note'], + ]); + + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('payouts') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function listWalletSnapshots(string $projectKey, int $limit = 100): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('wallet_snapshots') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub + ORDER BY measured_at DESC, id DESC + LIMIT :limit' + ); + $stmt->bindValue(':project_key', $projectKey, PDO::PARAM_STR); + $stmt->bindValue(':owner_sub', $this->ownerSub, PDO::PARAM_STR); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function saveWalletSnapshot(string $projectKey, array $payload): array + { + $params = [ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'measured_at' => $payload['measured_at'], + 'total_value_amount' => $payload['total_value_amount'] ?? null, + 'total_value_currency' => $payload['total_value_currency'] ?? null, + 'wallet_balance' => $payload['wallet_balance'] ?? null, + 'wallet_currency' => $payload['wallet_currency'], + 'balances_json' => json_encode($payload['balances_json'] ?? [], JSON_UNESCAPED_UNICODE), + 'note' => $payload['note'] ?? null, + 'source' => $payload['source'] ?? 'manual', + 'image_path' => $payload['image_path'] ?? null, + 'ocr_raw_text' => $payload['ocr_raw_text'] ?? null, + 'ocr_confidence' => $payload['ocr_confidence'] ?? null, + 'ocr_flags' => json_encode($payload['ocr_flags'] ?? [], JSON_UNESCAPED_UNICODE), + ]; + + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('wallet_snapshots') . ' ( + project_key, owner_sub, measured_at, total_value_amount, total_value_currency, wallet_balance, + wallet_currency, balances_json, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags + ) VALUES ( + :project_key, :owner_sub, :measured_at, :total_value_amount, :total_value_currency, :wallet_balance, + :wallet_currency, CAST(:balances_json AS jsonb), :note, :source, :image_path, :ocr_raw_text, :ocr_confidence, CAST(:ocr_flags AS jsonb) + ) + RETURNING *' + ); + $stmt->execute($params); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('wallet_snapshots') . ' ( + project_key, owner_sub, measured_at, total_value_amount, total_value_currency, wallet_balance, + wallet_currency, balances_json, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags + ) VALUES ( + :project_key, :owner_sub, :measured_at, :total_value_amount, :total_value_currency, :wallet_balance, + :wallet_currency, :balances_json, :note, :source, :image_path, :ocr_raw_text, :ocr_confidence, :ocr_flags + )' + ); + $stmt->execute($params); + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('wallet_snapshots') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function listTargets(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('targets') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY sort_order ASC, id ASC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function saveTarget(string $projectKey, array $payload): array + { + $stmt = $this->pdo->prepare($this->driver === 'pgsql' + ? 'INSERT INTO ' . $this->table('targets') . ' (project_key, owner_sub, label, target_amount_fiat, currency, miner_offer_id, is_active, sort_order) + VALUES (:project_key, :owner_sub, :label, :target_amount_fiat, :currency, :miner_offer_id, :is_active, :sort_order) + ON CONFLICT (project_key, owner_sub, label) DO UPDATE SET + target_amount_fiat = EXCLUDED.target_amount_fiat, + currency = EXCLUDED.currency, + miner_offer_id = EXCLUDED.miner_offer_id, + is_active = EXCLUDED.is_active, + sort_order = EXCLUDED.sort_order' + : 'INSERT INTO ' . $this->table('targets') . ' (project_key, owner_sub, label, target_amount_fiat, currency, miner_offer_id, is_active, sort_order) + VALUES (:project_key, :owner_sub, :label, :target_amount_fiat, :currency, :miner_offer_id, :is_active, :sort_order) + ON DUPLICATE KEY UPDATE + target_amount_fiat = VALUES(target_amount_fiat), + currency = VALUES(currency), + miner_offer_id = VALUES(miner_offer_id), + is_active = VALUES(is_active), + sort_order = VALUES(sort_order)' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'label' => $payload['label'], + 'target_amount_fiat' => $payload['target_amount_fiat'], + 'currency' => $payload['currency'], + 'miner_offer_id' => $payload['miner_offer_id'] ?? null, + 'is_active' => $payload['is_active'], + 'sort_order' => $payload['sort_order'], + ]); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('targets') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub AND label = :label LIMIT 1'); + $fetch->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub, 'label' => $payload['label']]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function updateTarget(string $projectKey, int $targetId, array $payload): array + { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->table('targets') . ' + SET label = :label, target_amount_fiat = :target_amount_fiat, currency = :currency, miner_offer_id = :miner_offer_id, + is_active = :is_active, sort_order = :sort_order + WHERE id = :id AND project_key = :project_key AND owner_sub = :owner_sub' + ); + $stmt->execute([ + 'id' => $targetId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'label' => $payload['label'], + 'target_amount_fiat' => $payload['target_amount_fiat'], + 'currency' => $payload['currency'], + 'miner_offer_id' => $payload['miner_offer_id'] ?? null, + 'is_active' => $payload['is_active'], + 'sort_order' => $payload['sort_order'], + ]); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('targets') . ' WHERE id = :id AND owner_sub = :owner_sub LIMIT 1'); + $fetch->execute(['id' => $targetId, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function deleteTarget(string $projectKey, int $targetId): void + { + $stmt = $this->pdo->prepare( + 'DELETE FROM ' . $this->table('targets') . ' WHERE id = :id AND project_key = :project_key AND owner_sub = :owner_sub' + ); + $stmt->execute([ + 'id' => $targetId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + ]); + } + + public function listDashboards(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('dashboard_definitions') . ' + WHERE project_key = :project_key AND owner_sub = :owner_sub + ORDER BY id ASC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function saveDashboard(string $projectKey, array $payload): array + { + $stmt = $this->pdo->prepare($this->driver === 'pgsql' + ? 'INSERT INTO ' . $this->table('dashboard_definitions') . ' ( + project_key, owner_sub, name, chart_type, x_field, y_field, aggregation, filters_json, is_active + ) VALUES ( + :project_key, :owner_sub, :name, :chart_type, :x_field, :y_field, :aggregation, CAST(:filters_json AS jsonb), :is_active + ) + ON CONFLICT (project_key, owner_sub, name) DO UPDATE SET + chart_type = EXCLUDED.chart_type, + x_field = EXCLUDED.x_field, + y_field = EXCLUDED.y_field, + aggregation = EXCLUDED.aggregation, + filters_json = EXCLUDED.filters_json, + is_active = EXCLUDED.is_active' + : 'INSERT INTO ' . $this->table('dashboard_definitions') . ' ( + project_key, owner_sub, name, chart_type, x_field, y_field, aggregation, filters_json, is_active + ) VALUES ( + :project_key, :owner_sub, :name, :chart_type, :x_field, :y_field, :aggregation, :filters_json, :is_active + ) + ON DUPLICATE KEY UPDATE + chart_type = VALUES(chart_type), + x_field = VALUES(x_field), + y_field = VALUES(y_field), + aggregation = VALUES(aggregation), + filters_json = VALUES(filters_json), + is_active = VALUES(is_active)' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'name' => $payload['name'], + 'chart_type' => $payload['chart_type'], + 'x_field' => $payload['x_field'], + 'y_field' => $payload['y_field'], + 'aggregation' => $payload['aggregation'], + 'filters_json' => json_encode($payload['filters'], JSON_UNESCAPED_UNICODE), + 'is_active' => $payload['is_active'], + ]); + + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('dashboard_definitions') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub AND name = :name LIMIT 1'); + $fetch->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub, 'name' => $payload['name']]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function listMinerOffers(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('miner_offers') . ' WHERE project_key = :project_key ORDER BY created_at DESC, id DESC' + ); + $stmt->execute(['project_key' => $projectKey]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function saveMinerOffer(string $projectKey, array $payload): array + { + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('miner_offers') . ' ( + project_key, label, runtime_months, mining_speed_value, mining_speed_unit, + bonus_speed_value, bonus_speed_unit, base_price_amount, base_price_currency, + payment_type, auto_renew, note, is_active + ) VALUES ( + :project_key, :label, :runtime_months, :mining_speed_value, :mining_speed_unit, + :bonus_speed_value, :bonus_speed_unit, :base_price_amount, :base_price_currency, + :payment_type, :auto_renew, :note, :is_active + ) + RETURNING *' + ); + $stmt->execute($this->normalizeOfferPayload($projectKey, $payload)); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('miner_offers') . ' ( + project_key, label, runtime_months, mining_speed_value, mining_speed_unit, + bonus_speed_value, bonus_speed_unit, base_price_amount, base_price_currency, + payment_type, auto_renew, note, is_active + ) VALUES ( + :project_key, :label, :runtime_months, :mining_speed_value, :mining_speed_unit, + :bonus_speed_value, :bonus_speed_unit, :base_price_amount, :base_price_currency, + :payment_type, :auto_renew, :note, :is_active + )' + ); + $stmt->execute($this->normalizeOfferPayload($projectKey, $payload)); + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('miner_offers') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function listPurchasedMiners(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY purchased_at DESC, id DESC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function getPurchasedMiner(string $projectKey, int $minerId): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id LIMIT 1' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $minerId, + ]); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function purchaseMiner(string $projectKey, int $offerId, array $payload): array + { + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('purchased_miners') . ' ( + project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months, + mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit, + total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active + ) VALUES ( + :project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months, + :mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit, + :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active + ) + RETURNING *' + ); + $stmt->execute($this->normalizePurchasedPayload($projectKey, $offerId, $payload)); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('purchased_miners') . ' ( + project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months, + mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit, + total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active + ) VALUES ( + :project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months, + :mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit, + :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active + )' + ); + $stmt->execute($this->normalizePurchasedPayload($projectKey, $offerId, $payload)); + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function restorePurchasedMiner(string $projectKey, array $payload): array + { + $params = [ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'miner_offer_id' => $payload['miner_offer_id'] ?? null, + 'purchased_at' => $payload['purchased_at'], + 'label' => $payload['label'], + 'runtime_months' => $payload['runtime_months'] ?? null, + 'mining_speed_value' => $payload['mining_speed_value'] ?? null, + 'mining_speed_unit' => $payload['mining_speed_unit'] ?? null, + 'bonus_speed_value' => $payload['bonus_speed_value'] ?? null, + 'bonus_speed_unit' => $payload['bonus_speed_unit'] ?? null, + 'total_cost_amount' => $payload['total_cost_amount'], + 'currency' => $payload['currency'], + 'usd_reference_amount' => $payload['usd_reference_amount'] ?? null, + 'reference_price_amount' => $payload['reference_price_amount'] ?? null, + 'reference_price_currency' => $payload['reference_price_currency'] ?? null, + 'auto_renew' => $payload['auto_renew'] ?? 0, + 'note' => $payload['note'] ?? null, + 'is_active' => $payload['is_active'] ?? 1, + ]; + + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('purchased_miners') . ' ( + project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months, + mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit, + total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active + ) VALUES ( + :project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months, + :mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit, + :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active + ) + RETURNING *' + ); + $stmt->execute($params); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('purchased_miners') . ' ( + project_key, owner_sub, miner_offer_id, purchased_at, label, runtime_months, + mining_speed_value, mining_speed_unit, bonus_speed_value, bonus_speed_unit, + total_cost_amount, currency, usd_reference_amount, reference_price_amount, reference_price_currency, auto_renew, note, is_active + ) VALUES ( + :project_key, :owner_sub, :miner_offer_id, :purchased_at, :label, :runtime_months, + :mining_speed_value, :mining_speed_unit, :bonus_speed_value, :bonus_speed_unit, + :total_cost_amount, :currency, :usd_reference_amount, :reference_price_amount, :reference_price_currency, :auto_renew, :note, :is_active + )' + ); + $stmt->execute($params); + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function updatePurchasedMinerAutoRenew(string $projectKey, int $minerId, bool $autoRenew): array + { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->table('purchased_miners') . ' + SET auto_renew = :auto_renew + WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id' + ); + $stmt->execute([ + 'auto_renew' => $autoRenew ? 1 : 0, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $minerId, + ]); + + $fetch = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('purchased_miners') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id LIMIT 1' + ); + $fetch->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $minerId, + ]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function getMinerOffer(string $projectKey, int $offerId): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM ' . $this->table('miner_offers') . ' WHERE project_key = :project_key AND id = :id LIMIT 1'); + $stmt->execute(['project_key' => $projectKey, 'id' => $offerId]); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function getLatestFxRate(string $baseCurrency, string $targetCurrency): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT + r.id, + f.id AS fetch_id, + f.base_currency, + r.currency_code AS target_currency, + r.current_value AS rate, + f.rate_date, + f.provider, + f.fetched_at + FROM ' . $this->table('fx_rates') . ' r + INNER JOIN ' . $this->table('fx_fetches') . ' f ON f.id = r.fetch_id + WHERE f.base_currency = :base_currency AND r.currency_code = :target_currency + ORDER BY f.rate_date DESC, f.fetched_at DESC, r.id DESC + LIMIT 1' + ); + $stmt->execute([ + 'base_currency' => strtoupper($baseCurrency), + 'target_currency' => strtoupper($targetCurrency), + ]); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function getLatestFxFetch(?string $baseCurrency = null): ?array + { + $sql = 'SELECT id, provider, base_currency, rate_date, fetched_at + FROM ' . $this->table('fx_fetches'); + $params = []; + + if ($baseCurrency !== null && trim($baseCurrency) !== '') { + $sql .= ' WHERE base_currency = :base_currency'; + $params['base_currency'] = strtoupper(trim($baseCurrency)); + } + + $sql .= ' ORDER BY fetched_at DESC, id DESC LIMIT 1'; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function listFxRates(int $limit = 30): array + { + $stmt = $this->pdo->prepare( + 'SELECT + r.id, + r.fetch_id, + f.base_currency, + r.currency_code AS target_currency, + r.current_value AS rate, + f.rate_date, + f.provider, + f.fetched_at + FROM ' . $this->table('fx_rates') . ' r + INNER JOIN ' . $this->table('fx_fetches') . ' f ON f.id = r.fetch_id + WHERE f.id IN ( + SELECT id + FROM ' . $this->table('fx_fetches') . ' + ORDER BY fetched_at DESC, id DESC + LIMIT :limit + ) + ORDER BY f.fetched_at DESC, f.id DESC, r.currency_code ASC' + ); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function listAllFxRates(): array + { + $stmt = $this->pdo->query( + 'SELECT + r.id, + r.fetch_id, + f.base_currency, + r.currency_code AS target_currency, + r.current_value AS rate, + f.rate_date, + f.provider, + f.fetched_at + FROM ' . $this->table('fx_rates') . ' r + INNER JOIN ' . $this->table('fx_fetches') . ' f ON f.id = r.fetch_id + ORDER BY f.fetched_at ASC, f.id ASC, r.currency_code ASC' + ); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function restoreFxFetch(string $baseCurrency, string $provider, string $rateDate, ?string $fetchedAt, array $rates): array + { + $baseCurrency = strtoupper(trim($baseCurrency)); + $provider = trim($provider) !== '' ? trim($provider) : 'currencyapi'; + $fetchedAt = trim((string) $fetchedAt) !== '' ? (string) $fetchedAt : $this->currentUtcTimestamp(); + + if ($rates === []) { + return ['fetch' => null, 'rates' => []]; + } + + if ($this->driver === 'pgsql') { + $fetchStmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('fx_fetches') . ' ( + provider, base_currency, rate_date, fetched_at + ) VALUES ( + :provider, :base_currency, :rate_date, :fetched_at + ) + RETURNING *' + ); + $fetchStmt->execute([ + 'provider' => $provider, + 'base_currency' => $baseCurrency, + 'rate_date' => $rateDate, + 'fetched_at' => $fetchedAt, + ]); + $fetch = $this->normalizeRow($fetchStmt->fetch() ?: []); + } else { + $fetchStmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('fx_fetches') . ' ( + provider, base_currency, rate_date, fetched_at + ) VALUES ( + :provider, :base_currency, :rate_date, :fetched_at + )' + ); + $fetchStmt->execute([ + 'provider' => $provider, + 'base_currency' => $baseCurrency, + 'rate_date' => $rateDate, + 'fetched_at' => $fetchedAt, + ]); + $fetchId = (int) $this->pdo->lastInsertId(); + $fetchLookup = $this->pdo->prepare('SELECT * FROM ' . $this->table('fx_fetches') . ' WHERE id = :id LIMIT 1'); + $fetchLookup->execute(['id' => $fetchId]); + $fetch = $this->normalizeRow($fetchLookup->fetch() ?: []); + } + + $placeholders = []; + $params = ['fetch_id' => $fetch['id']]; + $savedRates = []; + $index = 0; + foreach ($rates as $currencyCode => $rate) { + if (!is_numeric($rate)) { + continue; + } + $codeKey = 'currency_code_' . $index; + $valueKey = 'current_value_' . $index; + $targetCurrency = strtoupper(trim((string) $currencyCode)); + if ($targetCurrency === '') { + continue; + } + $placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})"; + $params[$codeKey] = $targetCurrency; + $params[$valueKey] = (float) $rate; + $savedRates[] = [ + 'fetch_id' => $fetch['id'], + 'base_currency' => $baseCurrency, + 'target_currency' => $targetCurrency, + 'rate' => (float) $rate, + 'rate_date' => $rateDate, + 'provider' => $provider, + 'fetched_at' => $fetch['fetched_at'] ?? $fetchedAt, + ]; + $index++; + } + + if ($placeholders !== []) { + $insert = $this->pdo->prepare('INSERT INTO ' . $this->table('fx_rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders)); + $insert->execute($params); + } + + return [ + 'fetch' => $fetch, + 'rates' => $savedRates, + ]; + } + + public function getLatestMeasurementRate(string $baseCurrency, string $targetCurrency): ?array + { + $stmt = $this->pdo->prepare( + 'SELECT + id, + measurement_id, + base_currency, + quote_currency AS target_currency, + rate, + provider, + created_at + FROM ' . $this->table('measurement_rates') . ' + WHERE owner_sub = :owner_sub AND base_currency = :base_currency AND quote_currency = :target_currency + ORDER BY measurement_id DESC, id DESC + LIMIT 1' + ); + $stmt->execute([ + 'owner_sub' => $this->ownerSub, + 'base_currency' => strtoupper($baseCurrency), + 'target_currency' => strtoupper($targetCurrency), + ]); + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : null; + } + + public function saveFxRate(string $baseCurrency, string $targetCurrency, float $rate, string $rateDate, string $provider = 'currencyapi'): array + { + $result = $this->saveFxFetch($baseCurrency, $provider, $rateDate, [ + strtoupper($targetCurrency) => $rate, + ]); + return $result['rates'][0] ?? [ + 'fetch_id' => $result['fetch']['id'] ?? null, + 'base_currency' => strtoupper($baseCurrency), + 'target_currency' => strtoupper($targetCurrency), + 'rate' => $rate, + 'rate_date' => $rateDate, + 'provider' => $provider, + ]; + } + + public function saveFxFetch(string $baseCurrency, string $provider, string $rateDate, array $rates): array + { + $baseCurrency = strtoupper(trim($baseCurrency)); + $provider = trim($provider) !== '' ? trim($provider) : 'currencyapi'; + $fetchedAt = $this->currentUtcTimestamp(); + $normalizedRates = []; + + foreach ($rates as $currencyCode => $rate) { + if (!is_numeric($rate)) { + continue; + } + + $normalizedCurrencyCode = strtoupper(trim((string) $currencyCode)); + if ($normalizedCurrencyCode === '' || $normalizedCurrencyCode === $baseCurrency) { + continue; + } + + $normalizedRates[$normalizedCurrencyCode] = (float) $rate; + } + + $this->debug?->add('db.saveFxFetch.start', [ + 'base_currency' => $baseCurrency, + 'provider' => $provider, + 'rate_date' => $rateDate, + 'rate_count' => count($normalizedRates), + 'fetched_at' => $fetchedAt, + ]); + + $startedTransaction = false; + if (!$this->pdo->inTransaction()) { + $this->debug?->add('db.saveFxFetch.transaction.begin.start', [ + 'already_in_transaction' => false, + ]); + $this->pdo->beginTransaction(); + $startedTransaction = true; + $this->debug?->add('db.saveFxFetch.transaction.begin.end', [ + 'started_transaction' => true, + ]); + } else { + $this->debug?->add('db.saveFxFetch.transaction.reuse', [ + 'already_in_transaction' => true, + ]); + } + + try { + if ($this->driver === 'pgsql') { + $this->debug?->add('db.saveFxFetch.insertFetch.start', [ + 'driver' => $this->driver, + ]); + $fetchStmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('fx_fetches') . ' ( + provider, base_currency, rate_date, fetched_at + ) VALUES ( + :provider, :base_currency, :rate_date, :fetched_at + ) + RETURNING *' + ); + $fetchStmt->execute([ + 'provider' => $provider, + 'base_currency' => $baseCurrency, + 'rate_date' => $rateDate, + 'fetched_at' => $fetchedAt, + ]); + $fetch = $this->normalizeRow($fetchStmt->fetch() ?: []); + $this->debug?->add('db.saveFxFetch.insertFetch.end', [ + 'driver' => $this->driver, + 'fetch_id' => $fetch['id'] ?? null, + ]); + } else { + $this->debug?->add('db.saveFxFetch.insertFetch.start', [ + 'driver' => $this->driver, + ]); + $fetchStmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('fx_fetches') . ' ( + provider, base_currency, rate_date, fetched_at + ) VALUES ( + :provider, :base_currency, :rate_date, :fetched_at + )' + ); + $fetchStmt->execute([ + 'provider' => $provider, + 'base_currency' => $baseCurrency, + 'rate_date' => $rateDate, + 'fetched_at' => $fetchedAt, + ]); + $fetchId = (int) $this->pdo->lastInsertId(); + $fetchLookup = $this->pdo->prepare('SELECT * FROM ' . $this->table('fx_fetches') . ' WHERE id = :id LIMIT 1'); + $fetchLookup->execute(['id' => $fetchId]); + $fetch = $this->normalizeRow($fetchLookup->fetch() ?: []); + $this->debug?->add('db.saveFxFetch.insertFetch.end', [ + 'driver' => $this->driver, + 'fetch_id' => $fetch['id'] ?? null, + ]); + } + + if ($normalizedRates === []) { + if ($startedTransaction) { + $this->debug?->add('db.saveFxFetch.commit.start', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => 0, + ]); + $this->pdo->commit(); + $this->debug?->add('db.saveFxFetch.commit.end', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => 0, + ]); + } + + $this->debug?->add('db.saveFxFetch.end', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => 0, + ]); + + return [ + 'fetch' => $fetch, + 'rates' => [], + ]; + } + + $placeholders = []; + $params = ['fetch_id' => $fetch['id']]; + $savedRates = []; + $index = 0; + foreach ($normalizedRates as $currencyCode => $rate) { + $codeKey = 'currency_code_' . $index; + $valueKey = 'current_value_' . $index; + $placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})"; + $params[$codeKey] = $currencyCode; + $params[$valueKey] = $rate; + $savedRates[] = [ + 'fetch_id' => $fetch['id'], + 'base_currency' => $baseCurrency, + 'target_currency' => $currencyCode, + 'rate' => $rate, + 'rate_date' => $rateDate, + 'provider' => $provider, + 'fetched_at' => $fetch['fetched_at'] ?? null, + ]; + $index++; + } + + $this->debug?->add('db.saveFxFetch.insertRates.start', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => count($savedRates), + ]); + $sql = 'INSERT INTO ' . $this->table('fx_rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders); + $insert = $this->pdo->prepare($sql); + $insert->execute($params); + $this->debug?->add('db.saveFxFetch.insertRates.end', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => count($savedRates), + ]); + + if ($startedTransaction) { + $this->debug?->add('db.saveFxFetch.commit.start', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => count($savedRates), + ]); + $this->pdo->commit(); + $this->debug?->add('db.saveFxFetch.commit.end', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => count($savedRates), + ]); + } + + $this->debug?->add('db.saveFxFetch.end', [ + 'fetch_id' => $fetch['id'] ?? null, + 'rate_count' => count($savedRates), + ]); + + return [ + 'fetch' => $fetch, + 'rates' => $savedRates, + ]; + } catch (\Throwable $exception) { + if ($startedTransaction && $this->pdo->inTransaction()) { + $this->debug?->add('db.saveFxFetch.rollback.start', [ + 'message' => $exception->getMessage(), + ]); + $this->pdo->rollBack(); + $this->debug?->add('db.saveFxFetch.rollback.end', [ + 'message' => $exception->getMessage(), + ]); + } + + $this->debug?->add('db.saveFxFetch.error', [ + 'message' => $exception->getMessage(), + ]); + throw $exception; + } + } + + private function table(string $logicalName): string + { + return match ($logicalName) { + 'projects' => $this->prefix . 'projects', + 'settings' => $this->prefix . 'settings', + 'cost_plans' => $this->prefix . 'cost_plans', + 'measurements' => $this->prefix . 'measurements', + 'targets' => $this->prefix . 'targets', + 'dashboard_definitions' => $this->prefix . 'dashboard_definitions', + 'fx_fetches' => $this->prefix . 'fx_fetches', + 'fx_rates' => $this->prefix . 'fx_rates', + 'measurement_rates' => $this->prefix . 'measurement_rates', + 'payouts' => $this->prefix . 'payouts', + 'wallet_snapshots' => $this->prefix . 'wallet_snapshots', + 'miner_offers' => $this->prefix . 'miner_offers', + 'purchased_miners' => $this->prefix . 'purchased_miners', + default => throw new \RuntimeException('Unknown mining table: ' . $logicalName), + }; + } + + private function normalizeInsertPayload(string $projectKey, array $payload): array + { + return [ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'label' => $payload['label'], + 'starts_at' => $payload['starts_at'], + 'runtime_months' => $payload['runtime_months'], + 'mining_speed_value' => $payload['mining_speed_value'], + 'mining_speed_unit' => $payload['mining_speed_unit'], + 'bonus_speed_value' => $payload['bonus_speed_value'], + 'bonus_speed_unit' => $payload['bonus_speed_unit'], + 'auto_renew' => $payload['auto_renew'], + 'base_price_amount' => $payload['base_price_amount'] ?? $payload['total_cost_amount'], + 'payment_type' => $payload['payment_type'] ?? 'fiat', + 'total_cost_amount' => $payload['total_cost_amount'], + 'currency' => $payload['currency'], + 'note' => $payload['note'], + 'is_active' => $payload['is_active'], + ]; + } + + private function normalizeOfferPayload(string $projectKey, array $payload): array + { + return [ + 'project_key' => $projectKey, + 'label' => $payload['label'], + 'runtime_months' => $payload['runtime_months'], + 'mining_speed_value' => $payload['mining_speed_value'], + 'mining_speed_unit' => $payload['mining_speed_unit'], + 'bonus_speed_value' => $payload['bonus_speed_value'], + 'bonus_speed_unit' => $payload['bonus_speed_unit'], + 'base_price_amount' => $payload['base_price_amount'], + 'base_price_currency' => $payload['base_price_currency'], + 'payment_type' => $payload['payment_type'] ?? 'fiat', + 'auto_renew' => $payload['auto_renew'] ?? 0, + 'note' => $payload['note'], + 'is_active' => $payload['is_active'], + ]; + } + + private function normalizePurchasedPayload(string $projectKey, int $offerId, array $payload): array + { + return [ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'miner_offer_id' => $offerId, + 'purchased_at' => $payload['purchased_at'], + 'label' => $payload['label'], + 'runtime_months' => $payload['runtime_months'], + 'mining_speed_value' => $payload['mining_speed_value'], + 'mining_speed_unit' => $payload['mining_speed_unit'], + 'bonus_speed_value' => $payload['bonus_speed_value'], + 'bonus_speed_unit' => $payload['bonus_speed_unit'], + 'total_cost_amount' => $payload['total_cost_amount'], + 'currency' => $payload['currency'], + 'usd_reference_amount' => $payload['usd_reference_amount'], + 'reference_price_amount' => $payload['reference_price_amount'] ?? null, + 'reference_price_currency' => $payload['reference_price_currency'] ?? null, + 'auto_renew' => $payload['auto_renew'] ?? 0, + 'note' => $payload['note'], + 'is_active' => $payload['is_active'], + ]; + } + + private function normalizeRows(array $rows): array + { + return array_map(fn (array $row): array => $this->normalizeRow($row), $rows); + } + + private function normalizeRow(array $row): array + { + foreach (['ocr_flags', 'filters_json', 'preferred_currencies', 'balances_json'] as $jsonField) { + if (array_key_exists($jsonField, $row) && is_string($row[$jsonField]) && trim($row[$jsonField]) !== '') { + $decoded = json_decode($row[$jsonField], true); + if (json_last_error() === JSON_ERROR_NONE) { + $row[$jsonField] = $decoded; + } + } + } + + foreach (['is_active', 'auto_renew'] as $booleanField) { + if (array_key_exists($booleanField, $row)) { + $row[$booleanField] = $this->normalizeBoolean($row[$booleanField]); + } + } + + if (array_key_exists('is_crypto', $row)) { + $row['is_crypto'] = (bool) $this->normalizeBoolean($row['is_crypto']); + } + + return $row; + } + + private function isCryptoCode(string $code): bool + { + return in_array(strtoupper($code), [ + 'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC', + 'SOL', 'USDC', 'USDT', 'XRP', + ], true); + } + + private function normalizeBoolean(mixed $value): bool|int|null + { + if ($value === null) { + return null; + } + + if (is_bool($value) || is_int($value)) { + return $value; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + if (in_array($normalized, ['t', 'true', '1', 'y', 'yes'], true)) { + return true; + } + if (in_array($normalized, ['f', 'false', '0', 'n', 'no'], true)) { + return false; + } + } + + return (int) $value; + } + + private function currentUtcTimestamp(): string + { + $timezone = new \DateTimeZone('UTC'); + return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s'); + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/src/Infrastructure/ModuleConfig.php b/temp/nexus-module-import/modules/mining-checker/src/Infrastructure/ModuleConfig.php new file mode 100644 index 00000000..261f0722 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Infrastructure/ModuleConfig.php @@ -0,0 +1,66 @@ +config = $config; + } + + public static function load(string $moduleBasePath): self + { + $config = require $moduleBasePath . '/config/module.php'; + return new self(is_array($config) ? $config : []); + } + + public function defaultProjectKey(): string + { + return (string) ($this->config['default_project_key'] ?? 'doge-main'); + } + + public function useProjectDatabase(): bool + { + return (bool) ($this->config['use_project_database'] ?? true); + } + + public function tablePrefix(): string + { + return (string) ($this->config['table_prefix'] ?? 'miningcheck_'); + } + + public function uploadsDir(): string + { + return (string) ($this->config['uploads_dir'] ?? sys_get_temp_dir()); + } + + public function uploadsPublicPrefix(): string + { + return rtrim((string) ($this->config['uploads_public_prefix'] ?? '/uploads'), '/'); + } + + public function ocr(): array + { + return (array) ($this->config['ocr'] ?? []); + } + + public function fx(): array + { + return (array) ($this->config['fx'] ?? []); + } + + public function debug(): array + { + return (array) ($this->config['debug'] ?? []); + } + + public function debugDir(): string + { + $debug = $this->debug(); + return (string) ($debug['dir'] ?? (dirname($this->uploadsDir()) . '/debug')); + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/src/Infrastructure/SchemaManager.php b/temp/nexus-module-import/modules/mining-checker/src/Infrastructure/SchemaManager.php new file mode 100644 index 00000000..c4c40256 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Infrastructure/SchemaManager.php @@ -0,0 +1,1482 @@ +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', + ]; + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/src/Support/ApiException.php b/temp/nexus-module-import/modules/mining-checker/src/Support/ApiException.php new file mode 100644 index 00000000..665ac027 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Support/ApiException.php @@ -0,0 +1,29 @@ +statusCode = $statusCode; + $this->context = $context; + } + + public function statusCode(): int + { + return $this->statusCode; + } + + public function context(): array + { + return $this->context; + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/src/Support/DebugState.php b/temp/nexus-module-import/modules/mining-checker/src/Support/DebugState.php new file mode 100644 index 00000000..7ccda2b7 --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Support/DebugState.php @@ -0,0 +1,35 @@ +enabled = $enabled; + $this->filePath = $enabled ? $filePath : null; + DebugState::replace([]); + if ($this->enabled && $this->filePath !== null) { + $this->persist(); + } + } + + public function enabled(): bool + { + return $this->enabled; + } + + public function add(string $event, array $context = []): void + { + if (!$this->enabled) { + return; + } + + $this->entries[] = [ + 'time' => date('c'), + 'event' => $event, + 'context' => $context, + ]; + DebugState::replace($this->entries); + $this->persist(); + } + + public function export(): array + { + return $this->enabled ? $this->entries : []; + } + + private function persist(): void + { + if (!$this->enabled || $this->filePath === null) { + return; + } + + $directory = dirname($this->filePath); + if (!is_dir($directory)) { + @mkdir($directory, 0775, true); + } + + @file_put_contents($this->filePath, json_encode($this->entries, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } +} diff --git a/temp/nexus-module-import/modules/mining-checker/src/Support/Http.php b/temp/nexus-module-import/modules/mining-checker/src/Support/Http.php new file mode 100644 index 00000000..e90d3bbb --- /dev/null +++ b/temp/nexus-module-import/modules/mining-checker/src/Support/Http.php @@ -0,0 +1,27 @@ + { + const form = document.querySelector('[data-command-form]'); + const list = document.querySelector('[data-command-list]'); + if (!form) return; + + const idInput = form.querySelector('input[name="id"]'); + const labelInput = form.querySelector('input[name="label"]'); + const commandInput = form.querySelector('textarea[name="command"]'); + const timeoutInput = form.querySelector('input[name="timeout_sec"]'); + const adminInput = form.querySelector('input[name="admin_only"]'); + const submitBtn = form.querySelector('[data-command-submit]'); + const cancelBtn = form.querySelector('[data-command-cancel]'); + const modal = document.querySelector('[data-command-modal]'); + const modalTitle = document.querySelector('[data-command-modal-title]'); + const closeBtn = document.querySelector('[data-command-close]'); + const newBtn = document.querySelector('[data-command-new]'); + const unsavedBar = document.querySelector('[data-command-unsaved]'); + const discardBtn = document.querySelector('[data-command-discard]'); + + let initialSnapshot = ''; + + const resetForm = () => { + if (idInput) idInput.value = ''; + if (labelInput) labelInput.value = ''; + if (commandInput) commandInput.value = ''; + if (timeoutInput) timeoutInput.value = ''; + if (adminInput) adminInput.checked = false; + if (submitBtn) submitBtn.textContent = 'Speichern'; + if (unsavedBar) unsavedBar.style.display = 'none'; + }; + + const snapshot = () => { + return JSON.stringify({ + id: idInput ? idInput.value : '', + label: labelInput ? labelInput.value : '', + command: commandInput ? commandInput.value : '', + timeout: timeoutInput ? timeoutInput.value : '', + admin: adminInput ? adminInput.checked : false, + }); + }; + + const isDirty = () => snapshot() !== initialSnapshot; + + const openModal = () => { + if (!modal) return; + modal.classList.add('is-open'); + modal.setAttribute('aria-hidden', 'false'); + initialSnapshot = snapshot(); + }; + + const closeModal = (force = false) => { + if (!modal) return; + if (!force && isDirty()) { + if (unsavedBar) unsavedBar.style.display = 'flex'; + return; + } + modal.classList.remove('is-open'); + modal.setAttribute('aria-hidden', 'true'); + if (unsavedBar) unsavedBar.style.display = 'none'; + }; + + document.querySelectorAll('[data-command-edit]').forEach((btn) => { + btn.addEventListener('click', () => { + const item = btn.closest('.command-item'); + if (!item) return; + if (idInput) idInput.value = item.dataset.commandId || ''; + if (labelInput) labelInput.value = item.dataset.label || ''; + if (commandInput) commandInput.value = item.dataset.command || ''; + if (timeoutInput) timeoutInput.value = item.dataset.timeout || ''; + if (adminInput) adminInput.checked = item.dataset.admin === '1'; + if (submitBtn) submitBtn.textContent = 'Aktualisieren'; + if (modalTitle) modalTitle.textContent = 'Befehl bearbeiten'; + const details = btn.closest('details'); + if (details) details.removeAttribute('open'); + openModal(); + }); + }); + + if (cancelBtn) { + cancelBtn.addEventListener('click', (e) => { + e.preventDefault(); + resetForm(); + }); + } + + if (newBtn) { + newBtn.addEventListener('click', () => { + resetForm(); + if (modalTitle) modalTitle.textContent = 'Neuer Befehl'; + openModal(); + }); + } + + if (closeBtn) { + closeBtn.addEventListener('click', () => closeModal(false)); + } + + if (discardBtn) { + discardBtn.addEventListener('click', () => { + resetForm(); + closeModal(true); + }); + } + + form.addEventListener('input', () => { + if (unsavedBar && isDirty()) { + unsavedBar.style.display = 'flex'; + } + }); + + if (!list) return; + + let dragging = null; + + list.querySelectorAll('.command-item').forEach((item) => { + item.addEventListener('dragstart', () => { + dragging = item; + item.classList.add('is-dragging'); + }); + item.addEventListener('dragend', () => { + item.classList.remove('is-dragging'); + dragging = null; + saveOrder(); + }); + }); + + list.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!dragging) return; + const after = getDragAfterElement(list, e.clientY); + if (after == null) { + list.appendChild(dragging); + } else if (after !== dragging) { + list.insertBefore(dragging, after); + } + }); + + const getDragAfterElement = (container, y) => { + const elements = [...container.querySelectorAll('.command-item:not(.is-dragging)')]; + return elements.reduce( + (closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + if (offset < 0 && offset > closest.offset) { + return { offset, element: child }; + } + return closest; + }, + { offset: Number.NEGATIVE_INFINITY, element: null } + ).element; + }; + + const saveOrder = () => { + const order = [...list.querySelectorAll('.command-item')].map((el) => el.dataset.commandId); + fetch(window.location.pathname + '?reorder_json=1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order }), + cache: 'no-store', + }).catch(() => {}); + }; +})(); diff --git a/temp/nexus-module-import/modules/pi_control/assets/console.js b/temp/nexus-module-import/modules/pi_control/assets/console.js new file mode 100644 index 00000000..eb63a25c --- /dev/null +++ b/temp/nexus-module-import/modules/pi_control/assets/console.js @@ -0,0 +1,417 @@ +(() => { + // Disable any beforeunload prompts on this page + window.onbeforeunload = null; + window.addEventListener( + 'beforeunload', + (e) => { + e.stopImmediatePropagation(); + }, + { capture: true } + ); + const tabBar = document.querySelector('[data-console-tab-bar]'); + const tabPanels = document.querySelector('[data-console-tab-panels]'); + if (!tabBar || !tabPanels) return; + + const consoleFab = document.querySelector('[data-console-fab]'); + const consoleModal = document.querySelector('[data-console-modal]'); + const consoleClose = document.querySelector('[data-console-close]'); + + let tabCount = 0; + const idleMs = 5 * 60 * 1000; + const idleTimers = new Map(); + const storageKey = 'pi_control_console_tabs'; + + const saveTabs = () => { + const tabs = []; + tabBar.querySelectorAll('.console-tab').forEach((btn) => { + const id = btn.dataset.tabId; + const panel = tabPanels.querySelector(`.console-panel[data-tab-id="${id}"]`); + const iframe = panel ? panel.querySelector('iframe') : null; + if (iframe && iframe.src) { + tabs.push({ + label: btn.firstChild ? btn.firstChild.textContent : btn.textContent, + url: iframe.src, + openedAt: Date.now(), + }); + } + }); + localStorage.setItem(storageKey, JSON.stringify(tabs)); + if (consoleFab) { + consoleFab.classList.toggle('is-visible', tabs.length > 0); + } + }; + + const activateTab = (id) => { + tabBar.querySelectorAll('.console-tab').forEach((btn) => { + btn.classList.toggle('is-active', btn.dataset.tabId === id); + }); + tabPanels.querySelectorAll('.console-panel').forEach((panel) => { + panel.classList.toggle('is-active', panel.dataset.tabId === id); + }); + }; + + const getActiveIframe = () => { + const activePanel = tabPanels.querySelector('.console-panel.is-active'); + if (!activePanel) return null; + return activePanel.querySelector('iframe'); + }; + + const trySendToIframe = (iframe, command) => { + if (!iframe) return false; + try { + const win = iframe.contentWindow; + if (!win) return false; + const term = win.term || win.xterm || win.terminal; + if (term && typeof term.write === 'function') { + term.write(command + '\r\n'); + return true; + } + } catch (e) { + return false; + } + return false; + }; + + const openModal = () => { + if (!consoleModal) return; + consoleModal.classList.add('is-open'); + consoleModal.setAttribute('aria-hidden', 'false'); + }; + const closeModal = () => { + if (!consoleModal) return; + consoleModal.classList.remove('is-open'); + consoleModal.setAttribute('aria-hidden', 'true'); + }; + + const openTab = (label, url, persist = true) => { + const id = `tab-${++tabCount}`; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'console-tab'; + btn.textContent = label || 'Konsole'; + btn.dataset.tabId = id; + btn.addEventListener('click', () => activateTab(id)); + + const closeBtn = document.createElement('span'); + closeBtn.className = 'console-tab-close'; + closeBtn.textContent = '×'; + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const panel = tabPanels.querySelector(`.console-panel[data-tab-id="${id}"]`); + if (panel) panel.remove(); + btn.remove(); + idleTimers.delete(id); + saveTabs(); + const next = tabBar.querySelector('.console-tab'); + if (next) activateTab(next.dataset.tabId); + }); + btn.appendChild(closeBtn); + + const panel = document.createElement('div'); + panel.className = 'console-panel'; + panel.dataset.tabId = id; + panel.innerHTML = ``; + + tabBar.appendChild(btn); + tabPanels.appendChild(panel); + activateTab(id); + if (persist) saveTabs(); + openModal(); + + const iframe = panel.querySelector('iframe'); + const markActive = () => { + idleTimers.set(id, Date.now()); + }; + idleTimers.set(id, Date.now()); + + iframe.addEventListener('load', () => { + try { + const disableBeforeUnload = (win) => { + if (!win) return; + try { + win.onbeforeunload = null; + win.addEventListener( + 'beforeunload', + (e) => { + e.stopImmediatePropagation(); + }, + { capture: true } + ); + if (win.document) { + win.document.onbeforeunload = null; + win.document.addEventListener( + 'beforeunload', + (e) => { + e.stopImmediatePropagation(); + }, + { capture: true } + ); + } + } catch (err) { + // ignore + } + }; + disableBeforeUnload(iframe.contentWindow); + setInterval(() => disableBeforeUnload(iframe.contentWindow), 2000); + const doc = iframe.contentWindow.document; + ['keydown', 'mousedown', 'wheel', 'touchstart'].forEach((evt) => { + doc.addEventListener(evt, markActive, { passive: true }); + }); + const observer = new MutationObserver(markActive); + observer.observe(doc.body, { childList: true, subtree: true, characterData: true }); + } catch (e) { + // cross-origin or blocked; rely on timer only + } + }); + + const idleCheck = setInterval(() => { + const last = idleTimers.get(id) || 0; + if (Date.now() - last > idleMs) { + const panelEl = tabPanels.querySelector(`.console-panel[data-tab-id="${id}"]`); + const btnEl = tabBar.querySelector(`.console-tab[data-tab-id="${id}"]`); + if (panelEl) panelEl.remove(); + if (btnEl) btnEl.remove(); + idleTimers.delete(id); + saveTabs(); + clearInterval(idleCheck); + const next = tabBar.querySelector('.console-tab'); + if (next) activateTab(next.dataset.tabId); + } + }, 10000); + }; + + document.querySelectorAll('.console-launch').forEach((el) => { + const url = el.dataset.url; + const host = el.dataset.host || 'Konsole'; + if (url) { + openTab(host, url); + } + el.remove(); + }); + + const showRestoreNotice = (text) => { + const notice = document.querySelector('[data-console-notice]'); + if (!notice) return; + notice.textContent = text; + notice.style.display = 'block'; + }; + + try { + const raw = localStorage.getItem(storageKey); + if (raw) { + const tabs = JSON.parse(raw); + if (Array.isArray(tabs)) { + const now = Date.now(); + tabs.forEach((t) => { + if (t && t.url) { + if (t.openedAt && now - t.openedAt > (10 * 60 * 1000)) { + showRestoreNotice('Token abgelaufen, bitte Konsole neu öffnen.'); + return; + } + openTab(t.label || 'Konsole', t.url, false); + } + }); + if (consoleFab && tabs.length > 0) { + consoleFab.classList.add('is-visible'); + } + } + } + } catch (e) { + // ignore + } + + if (consoleFab) { + consoleFab.addEventListener('click', openModal); + } + if (consoleClose) { + consoleClose.addEventListener('click', closeModal); + } + if (consoleModal) { + consoleModal.addEventListener('click', (e) => { + if (e.target === consoleModal) closeModal(); + }); + } + + const queueBody = document.querySelector('[data-queue-body]'); + const countdownEl = document.querySelector('[data-queue-countdown]'); + const refreshBtn = document.querySelector('[data-queue-refresh]'); + const queueBtn = document.querySelector('[data-queue-button]'); + const queueCount = document.querySelector('[data-queue-count]'); + const queueModal = document.querySelector('[data-queue-modal]'); + const queueClose = document.querySelector('[data-queue-close]'); + if (!queueBody || !countdownEl) return; + + let remaining = 10; + + const fetchQueue = async () => { + const url = new URL(window.location.href); + url.searchParams.set('queue_json', '1'); + try { + const res = await fetch(url.toString(), { cache: 'no-store' }); + if (!res.ok) return; + const data = await res.json(); + if (data && data.html) { + queueBody.innerHTML = data.html; + } + if (queueCount && typeof data.count === 'number') { + queueCount.textContent = String(data.count); + queueCount.style.display = data.count > 0 ? 'inline-flex' : 'none'; + } + remaining = data && data.next ? data.next : 10; + } catch (e) { + // ignore + } + }; + + const tick = () => { + remaining -= 1; + if (remaining <= 0) { + fetchQueue(); + remaining = 10; + } + countdownEl.textContent = String(remaining); + }; + + setInterval(tick, 1000); + fetchQueue(); + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { + fetchQueue(); + remaining = 10; + countdownEl.textContent = String(remaining); + }); + } + + if (queueBtn && queueModal) { + queueBtn.addEventListener('click', () => { + queueModal.classList.add('is-open'); + queueModal.setAttribute('aria-hidden', 'false'); + fetchQueue(); + }); + } + if (queueClose && queueModal) { + queueClose.addEventListener('click', () => { + queueModal.classList.remove('is-open'); + queueModal.setAttribute('aria-hidden', 'true'); + }); + } + if (queueModal) { + queueModal.addEventListener('click', (e) => { + if (e.target === queueModal) { + queueModal.classList.remove('is-open'); + queueModal.setAttribute('aria-hidden', 'true'); + } + }); + } + + queueBody.addEventListener('click', async (e) => { + const btn = e.target.closest('[data-queue-action]'); + if (!btn) return; + const runId = btn.getAttribute('data-run-id'); + const action = btn.getAttribute('data-queue-action'); + if (!runId || !action) return; + const url = new URL(window.location.href); + url.searchParams.set('queue_action_json', '1'); + const formData = new FormData(); + formData.set('run_id', runId); + formData.set('action', action); + try { + const res = await fetch(url.toString(), { method: 'POST', body: formData, cache: 'no-store' }); + const data = await res.json(); + if (!data.ok) { + if (consoleNotice) { + consoleNotice.textContent = data.error || 'Aktion fehlgeschlagen.'; + consoleNotice.style.display = 'block'; + } + } + } catch (err) { + if (consoleNotice) { + consoleNotice.textContent = 'Aktion fehlgeschlagen.'; + consoleNotice.style.display = 'block'; + } + } + fetchQueue(); + remaining = 10; + countdownEl.textContent = String(remaining); + }); + + const consoleForm = document.querySelector('[data-console-form]'); + const consoleError = document.querySelector('[data-console-error]'); + const consoleNotice = document.querySelector('[data-console-notice]'); + const tokenEl = document.querySelector('[data-console-token]'); + if (consoleForm) { + consoleForm.addEventListener('submit', (e) => e.preventDefault()); + + const presetSelect = consoleForm.querySelector('select[name="terminal_preset_id"]'); + const commandTextarea = consoleForm.querySelector('textarea[name="terminal_command_text"]'); + if (presetSelect && commandTextarea) { + presetSelect.addEventListener('change', () => { + const opt = presetSelect.selectedOptions[0]; + const cmd = opt && opt.dataset && opt.dataset.command ? opt.dataset.command : ''; + if (cmd) { + commandTextarea.value = cmd; + } + }); + } + + const submitOpen = async () => { + const formData = new FormData(consoleForm); + if (presetSelect) formData.set('terminal_preset_id', ''); + const url = new URL(window.location.href); + url.searchParams.set('open_console_json', '1'); + const res = await fetch(url.toString(), { method: 'POST', body: formData, cache: 'no-store' }); + const data = await res.json(); + if (!data.ok) { + if (consoleError) { + consoleError.textContent = data.error || 'Fehler beim Öffnen der Konsole.'; + consoleError.style.display = 'block'; + } + return; + } + if (consoleError) consoleError.style.display = 'none'; + if (consoleNotice) consoleNotice.style.display = 'none'; + if (tokenEl) tokenEl.textContent = data.token || ''; + if (data.url) openTab(data.host || 'Konsole', data.url); + }; + + const submitRun = async () => { + const formData = new FormData(consoleForm); + if (presetSelect) formData.set('terminal_preset_id', ''); + const url = new URL(window.location.href); + url.searchParams.set('run_command_json', '1'); + const res = await fetch(url.toString(), { method: 'POST', body: formData, cache: 'no-store' }); + const data = await res.json(); + if (!data.ok) { + if (consoleError) { + consoleError.textContent = data.error || 'Fehler beim Ausführen.'; + consoleError.style.display = 'block'; + } + return; + } + if (consoleError) consoleError.style.display = 'none'; + if (consoleNotice) { + consoleNotice.textContent = data.notice || ''; + consoleNotice.style.display = 'block'; + } + fetchQueue(); + remaining = 10; + countdownEl.textContent = String(remaining); + }; + + const openBtn = consoleForm.querySelector('[data-open-console]'); + const runBtn = consoleForm.querySelector('[data-run-command]'); + if (openBtn) { + openBtn.addEventListener('click', (e) => { + e.preventDefault(); + submitOpen(); + }); + } + if (runBtn) { + runBtn.addEventListener('click', (e) => { + e.preventDefault(); + submitRun(); + }); + } + // send-active removed + } +})(); diff --git a/temp/nexus-module-import/modules/pi_control/assets/hosts.js b/temp/nexus-module-import/modules/pi_control/assets/hosts.js new file mode 100644 index 00000000..3c587a3c --- /dev/null +++ b/temp/nexus-module-import/modules/pi_control/assets/hosts.js @@ -0,0 +1,286 @@ +(() => { + const form = document.querySelector('[data-host-form]'); + if (!form) return; + + const idInput = form.querySelector('input[name="id"]'); + const nameInput = form.querySelector('input[name="name"]'); + const hostInput = form.querySelector('input[name="host"]'); + const portInput = form.querySelector('input[name="port"]'); + const userInput = form.querySelector('input[name="username"]'); + const authSelect = form.querySelector('select[name="auth_type"]'); + const keyInput = form.querySelector('input[name="key_path"]'); + const passInput = form.querySelector('input[name="password"]'); + const imageInput = form.querySelector('input[name="image_url"]'); + const submitBtn = form.querySelector('[data-host-submit]'); + const modal = document.querySelector('[data-host-modal]'); + const modalTitle = document.querySelector('[data-host-modal-title]'); + const closeBtn = document.querySelector('[data-host-close]'); + const newBtn = document.querySelector('[data-host-new]'); + const checkAllBtn = document.querySelector('[data-host-check-all]'); + const cancelBtn = form.querySelector('[data-host-cancel]'); + + let initialSnapshot = ''; + + const resetForm = () => { + if (idInput) idInput.value = ''; + if (nameInput) nameInput.value = ''; + if (hostInput) hostInput.value = ''; + if (portInput) portInput.value = '22'; + if (userInput) userInput.value = ''; + if (authSelect) authSelect.value = 'key'; + if (keyInput) keyInput.value = ''; + if (passInput) passInput.value = ''; + if (imageInput) imageInput.value = ''; + if (submitBtn) submitBtn.textContent = 'Speichern'; + if (modal && modal.classList.contains('is-open')) { + initialSnapshot = snapshot(); + } + }; + + const snapshot = () => { + return JSON.stringify({ + id: idInput ? idInput.value : '', + name: nameInput ? nameInput.value : '', + host: hostInput ? hostInput.value : '', + port: portInput ? portInput.value : '', + user: userInput ? userInput.value : '', + auth: authSelect ? authSelect.value : '', + key: keyInput ? keyInput.value : '', + pass: passInput ? passInput.value : '', + image: imageInput ? imageInput.value : '', + }); + }; + + const isDirty = () => snapshot() !== initialSnapshot; + + const openModal = () => { + if (!modal) return; + modal.classList.add('is-open'); + modal.setAttribute('aria-hidden', 'false'); + initialSnapshot = snapshot(); + }; + + const closeModal = (force = false) => { + if (!modal) return; + if (!force && isDirty()) { + const ok = window.confirm('Änderungen nicht gespeichert. Ohne Speichern schließen?'); + if (!ok) return; + } + modal.classList.remove('is-open'); + modal.setAttribute('aria-hidden', 'true'); + }; + + document.querySelectorAll('[data-host-edit]').forEach((btn) => { + btn.addEventListener('click', () => { + const card = btn.closest('.host-card'); + if (!card) return; + if (idInput) idInput.value = card.dataset.hostId || ''; + if (nameInput) nameInput.value = card.dataset.name || ''; + if (hostInput) hostInput.value = card.dataset.host || ''; + if (portInput) portInput.value = card.dataset.port || '22'; + if (userInput) userInput.value = card.dataset.username || ''; + if (authSelect) authSelect.value = card.dataset.auth || 'key'; + if (keyInput) keyInput.value = card.dataset.keyPath || ''; + if (passInput) passInput.value = ''; + if (imageInput) imageInput.value = card.dataset.imageUrl || ''; + if (submitBtn) submitBtn.textContent = 'Aktualisieren'; + if (modalTitle) modalTitle.textContent = 'Host bearbeiten'; + const details = btn.closest('details'); + if (details) details.removeAttribute('open'); + openModal(); + }); + }); + + if (newBtn) { + newBtn.addEventListener('click', () => { + resetForm(); + if (modalTitle) modalTitle.textContent = 'Neuer Host'; + openModal(); + }); + } + + if (closeBtn) { + closeBtn.addEventListener('click', () => closeModal(false)); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', (e) => { + e.preventDefault(); + resetForm(); + }); + } + + const updateStatus = (card, status) => { + const dot = card.querySelector('[data-host-status]'); + if (!dot) return; + dot.classList.remove('status-ok', 'status-auth', 'status-down'); + if (status === 'ok') dot.classList.add('status-ok'); + else if (status === 'down') dot.classList.add('status-down'); + else dot.classList.add('status-auth'); + }; + + const fetchStatus = (card) => { + const id = card.dataset.hostId; + if (!id) return; + fetch(`${window.location.pathname}?status_json=1&id=${encodeURIComponent(id)}`, { cache: 'no-store' }) + .then((res) => res.json()) + .then((data) => { + if (data && data.ok) { + updateStatus(card, data.status); + } + }) + .catch(() => {}); + }; + + document.querySelectorAll('.host-card').forEach((card) => { + fetchStatus(card); + }); + + const setUpdateUi = (card, data) => { + const upd = card.querySelector('[data-update-badge]'); + const upg = card.querySelector('[data-upgrade-badge]'); + const time = card.querySelector('[data-update-time]'); + const updDebug = card.querySelector('[data-update-debug]'); + const upgDebug = card.querySelector('[data-upgrade-debug]'); + if (upd) { + upd.classList.remove('badge-warn', 'badge-ok', 'badge-error'); + if (data.updates && data.updates.error) { + upd.textContent = 'Updates: Fehler'; + upd.classList.add('badge-error'); + upd.setAttribute('title', data.updates.error); + } else if (data.updates && typeof data.updates.count === 'number') { + upd.textContent = `Updates: ${data.updates.count}`; + upd.classList.toggle('badge-warn', data.updates.count > 0); + upd.classList.toggle('badge-ok', data.updates.count === 0); + if (data.updates.preview || data.updates.raw) { + upd.setAttribute('title', data.updates.preview || data.updates.raw); + } + } else { + upd.textContent = 'Updates: –'; + if (data.updates && (data.updates.preview || data.updates.raw)) { + upd.setAttribute('title', data.updates.preview || data.updates.raw); + } + } + } + if (upg) { + upg.classList.remove('badge-warn', 'badge-ok', 'badge-error'); + if (data.os && data.os.error) { + upg.textContent = 'OS: Fehler'; + upg.classList.add('badge-error'); + upg.setAttribute('title', data.os.error); + } else if (data.os && typeof data.os.available === 'boolean') { + upg.textContent = data.os.available ? 'OS: Upgrade verfügbar' : 'OS: OK'; + upg.classList.toggle('badge-warn', data.os.available); + upg.classList.toggle('badge-ok', !data.os.available); + if (data.os.raw) upg.setAttribute('title', data.os.raw); + } else { + upg.textContent = 'OS: –'; + if (data.os && data.os.raw) { + upg.setAttribute('title', data.os.raw); + } + } + } + if (updDebug) { + const raw = (data.updates && (data.updates.raw || data.updates.preview)) || ''; + updDebug.textContent = raw ? `Update Debug: ${raw}` : 'Update Debug: –'; + } + if (upgDebug) { + const raw = (data.os && data.os.raw) || ''; + upgDebug.textContent = raw ? `Upgrade Debug: ${raw}` : 'Upgrade Debug: –'; + } + if (time && data.checked_at) { + const dt = new Date(data.checked_at); + time.textContent = isNaN(dt.getTime()) ? data.checked_at : dt.toLocaleString(); + } + }; + + const checkHostUpdates = (card) => { + const id = card.dataset.hostId; + if (!id) return Promise.resolve(); + const btn = card.querySelector('[data-host-check]'); + if (btn) { + btn.disabled = true; + btn.textContent = 'Prüfe...'; + } + return fetch(`${window.location.pathname}?update_json=1&id=${encodeURIComponent(id)}`, { cache: 'no-store' }) + .then((res) => res.json()) + .then((data) => { + if (data && data.ok) { + setUpdateUi(card, data); + } + }) + .finally(() => { + if (btn) { + btn.disabled = false; + btn.textContent = 'Updates prüfen'; + } + }); + }; + + document.querySelectorAll('[data-host-check]').forEach((btn) => { + btn.addEventListener('click', () => { + const card = btn.closest('.host-card'); + if (card) checkHostUpdates(card); + }); + }); + + if (checkAllBtn) { + checkAllBtn.addEventListener('click', async () => { + const cards = Array.from(document.querySelectorAll('.host-card')); + for (const card of cards) { + await checkHostUpdates(card); + } + }); + } + + const applyStoredUpdate = (card) => { + const checkedAt = card.dataset.updateChecked || ''; + const updateCount = card.dataset.updateCount; + const updateError = card.dataset.updateError || ''; + const upgradeAvailable = card.dataset.upgradeAvailable; + const upgradeRaw = card.dataset.upgradeRaw || ''; + const upgradeError = card.dataset.upgradeError || ''; + + const payload = { + updates: {}, + os: {}, + checked_at: checkedAt || '', + }; + if (updateError) { + payload.updates.error = updateError; + } else if (updateCount !== undefined && updateCount !== '') { + payload.updates.count = Number(updateCount); + payload.updates.preview = card.dataset.updatePreview || ''; + payload.updates.raw = card.dataset.updatePreview || ''; + } else { + payload.updates.preview = card.dataset.updatePreview || ''; + payload.updates.raw = card.dataset.updatePreview || ''; + } + + if (upgradeError) { + payload.os.error = upgradeError; + } else if (upgradeAvailable !== undefined && upgradeAvailable !== '') { + payload.os.available = upgradeAvailable === '1' || upgradeAvailable === 'true'; + payload.os.raw = upgradeRaw; + } else { + payload.os.raw = upgradeRaw; + } + + setUpdateUi(card, payload); + }; + + const isStale = (checkedAt) => { + if (!checkedAt) return true; + const dt = new Date(checkedAt); + if (isNaN(dt.getTime())) return true; + const ageMs = Date.now() - dt.getTime(); + return ageMs > 24 * 60 * 60 * 1000; + }; + + document.querySelectorAll('.host-card').forEach((card) => { + applyStoredUpdate(card); + if (isStale(card.dataset.updateChecked || '')) { + checkHostUpdates(card); + } + }); +})(); diff --git a/temp/nexus-module-import/modules/pi_control/assets/pi_control.css b/temp/nexus-module-import/modules/pi_control/assets/pi_control.css new file mode 100644 index 00000000..b54a4f98 --- /dev/null +++ b/temp/nexus-module-import/modules/pi_control/assets/pi_control.css @@ -0,0 +1,279 @@ +.form-card { padding: 14px; } + +.notice-card { + padding: 12px 14px; + line-height: 1.45; + word-break: break-word; +} +.form-grid { display: grid; gap: 12px; } +.form-field { display: grid; gap: 6px; } +.form-field input, +.form-field select, +.form-field textarea { width: 100%; } + +.icon-button { + border: 1px solid var(--line); + background: var(--panel-2); + border-radius: 999px; + padding: 4px 10px; + cursor: pointer; + font-weight: 700; +} +.icon-button:hover { background: var(--panel); } + +.console-tabs { + border: 1px solid var(--line); + border-radius: 14px; + overflow: hidden; + background: #0b0f17; +} +.console-tab-bar { + display: flex; + gap: 6px; + flex-wrap: wrap; + padding: 8px; + background: #0f1624; + border-bottom: 1px solid rgba(255,255,255,0.08); +} +.console-tab { + background: transparent; + color: #c9d3e3; + border: 1px solid transparent; + border-radius: 10px; + padding: 6px 10px; + cursor: pointer; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 8px; +} +.console-tab.is-active { + border-color: rgba(255,255,255,0.2); + background: rgba(255,255,255,0.08); + color: #ffffff; +} +.console-tab-panels { + min-height: 420px; +} +.console-tab-close { + display: inline-flex; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; + border-radius: 999px; + background: rgba(255,255,255,0.12); + color: #ffffff; + font-size: 0.8rem; + line-height: 1; +} +.console-tab-close:hover { + background: rgba(255,255,255,0.25); +} +.console-panel { display: none; } +.console-panel.is-active { display: block; } +.console-panel iframe { + width: 100%; + height: 520px; + border: 0; + background: #0b0f17; +} + +.host-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} +.host-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 16px; + overflow: visible; + display: grid; + grid-template-rows: 120px 1fr; + box-shadow: var(--shadow); + position: relative; + z-index: 1; +} +.host-card-image { + background: linear-gradient(135deg, #2b3a67 0%, #3b2f5c 45%, #1c2b3f 100%); + background-size: cover; + background-position: center; + position: relative; + border-top-left-radius: 16px; + border-top-right-radius: 16px; +} +.host-card-overlay { + position: absolute; + inset: 0; + background: rgba(10, 16, 28, 0.35); +} +.host-card-body { + padding: 12px 14px 14px; + display: grid; + gap: 6px; +} +.host-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.host-card-title { + display: inline-flex; + align-items: center; + gap: 8px; +} +.status-dot { + width: 10px; + height: 10px; + border-radius: 999px; + display: inline-block; +} +.status-ok { background: #31c48d; } +.status-auth { background: #fbbf24; } +.status-down { background: #ef4444; } + +.host-update-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.update-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--panel-2); + font-size: 0.75rem; + font-weight: 600; +} +.badge-ok { + border-color: rgba(49,196,141,0.5); + background: rgba(49,196,141,0.15); +} +.badge-warn { + border-color: rgba(251,191,36,0.6); + background: rgba(251,191,36,0.2); +} +.badge-error { + border-color: rgba(239,68,68,0.6); + background: rgba(239,68,68,0.15); +} + +.action-menu { + position: relative; +} +.action-menu summary { + list-style: none; + cursor: pointer; + border-radius: 10px; + padding: 2px 6px; + border: 1px solid var(--line); + background: var(--panel-2); +} +.action-menu summary::-webkit-details-marker { display: none; } +.action-menu[open] summary { + background: var(--panel); +} +.action-menu-panel { + position: absolute; + right: 0; + top: calc(100% + 6px); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + padding: 6px; + min-width: 160px; + display: grid; + gap: 4px; + z-index: 200; + box-shadow: var(--shadow); +} +.action-menu-panel form { margin: 0; } + +.host-unsaved { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid #f3c3b8; + background: #fff5f3; + color: #7a2114; + border-radius: 12px; +} + +.host-debug { + margin: 6px 0 0; + padding: 6px 8px; + background: #0b1020; + color: #c7d2fe; + border-radius: 8px; + font-size: 0.7rem; + white-space: pre-wrap; + word-break: break-word; + max-height: 120px; + overflow: auto; +} + +.command-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 10px; +} +.command-item { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 10px; + align-items: start; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--panel); +} +.command-item.is-dragging { + opacity: 0.6; +} +.command-drag { + cursor: grab; + color: var(--muted); + font-size: 1.1rem; + padding-top: 4px; +} +.command-body code { + display: inline-block; + word-break: break-word; +} + +.queue-button { + display: inline-flex; + align-items: center; + gap: 8px; +} +.queue-badge { + display: inline-flex; + min-width: 22px; + height: 22px; + align-items: center; + justify-content: center; + border-radius: 999px; + background: #ff5a3c; + color: #fff; + font-weight: 700; + font-size: 0.75rem; + padding: 0 6px; +} + +.modal-card { + width: min(1100px, 96vw); +} +.modal-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} diff --git a/temp/nexus-module-import/modules/pi_control/bootstrap.php b/temp/nexus-module-import/modules/pi_control/bootstrap.php new file mode 100644 index 00000000..b00aca83 --- /dev/null +++ b/temp/nexus-module-import/modules/pi_control/bootstrap.php @@ -0,0 +1,304 @@ +registerFunction($moduleName, 'table', function (string $name): string { + $prefix = 'picontrol_'; + $sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name); + return $prefix . $sanitized; +}); + +$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): \PDO { + $settings = modules()->settings($moduleName); + $useSeparate = !empty($settings['use_separate_db']); + + if ($useSeparate) { + // Uses module-specific DB config + $module = modules()->get($moduleName); + $fallback = $module['db_defaults'] ?? []; + return modules()->modulePdo($moduleName, $fallback); + } + + $base = app()->basePdo(); + if (!$base) { + throw new ModuleConfigException( + $moduleName, + 'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eigene Modul-DB konfigurieren.' + ); + } + + return $base; +}); + +$mm->registerFunction($moduleName, 'redis', function () use ($moduleName) { + $settings = modules()->settings($moduleName); + $redis = (array)($settings['redis'] ?? []); + $host = (string)($redis['host'] ?? ($settings['redis.host'] ?? getenv('PI_CONTROL_REDIS_HOST') ?: 'redis')); + $port = (int)($redis['port'] ?? ($settings['redis.port'] ?? (getenv('PI_CONTROL_REDIS_PORT') !== false ? (int)getenv('PI_CONTROL_REDIS_PORT') : 6379))); + $password = (string)($redis['password'] ?? ($settings['redis.password'] ?? getenv('PI_CONTROL_REDIS_PASSWORD') ?: '')); + $db = (int)($redis['db'] ?? ($settings['redis.db'] ?? (getenv('PI_CONTROL_REDIS_DB') !== false ? (int)getenv('PI_CONTROL_REDIS_DB') : 0))); + + return new \App\RedisClient($host, $port, $password !== '' ? $password : null, $db); +}); + +$mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName): void { + $pdo = module_fn($moduleName, 'pdo'); + $table = fn(string $name) => module_fn($moduleName, 'table', $name); + + $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + + $hostTable = $table('hosts'); + $cmdTable = $table('commands'); + $runTable = $table('runs'); + $sessionTable = $table('sessions'); + + if ($driver === 'pgsql') { + $pdo->exec("CREATE TABLE IF NOT EXISTS {$hostTable} ( + id SERIAL PRIMARY KEY, + name VARCHAR(120) NOT NULL, + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + username VARCHAR(120) NOT NULL, + auth_type VARCHAR(20) NOT NULL DEFAULT 'key', + key_path TEXT NULL, + password TEXT NULL, + image_url TEXT NULL, + update_checked_at TIMESTAMP NULL, + update_count INTEGER NULL, + update_preview TEXT NULL, + update_error TEXT NULL, + upgrade_available BOOLEAN NULL, + upgrade_raw TEXT NULL, + upgrade_error TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$cmdTable} ( + id SERIAL PRIMARY KEY, + label VARCHAR(160) NOT NULL, + command TEXT NOT NULL, + admin_only BOOLEAN NOT NULL DEFAULT false, + timeout_sec INTEGER NULL, + sort_order INTEGER NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$runTable} ( + id SERIAL PRIMARY KEY, + host_id INTEGER NULL, + command_id INTEGER NULL, + command_text TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + output TEXT NULL, + error TEXT NULL, + exit_code INTEGER NULL, + timeout_sec INTEGER NULL, + created_by VARCHAR(120) NULL, + started_at TIMESTAMP NULL, + finished_at TIMESTAMP NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$sessionTable} ( + id SERIAL PRIMARY KEY, + token VARCHAR(64) NOT NULL UNIQUE, + host_id INTEGER NOT NULL, + provider VARCHAR(20) NOT NULL DEFAULT 'ttyd', + command_text TEXT NULL, + created_by VARCHAR(120) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + last_used_at TIMESTAMP NULL + )"); + } else { + $pdo->exec("CREATE TABLE IF NOT EXISTS {$hostTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(120) NOT NULL, + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL DEFAULT 22, + username VARCHAR(120) NOT NULL, + auth_type VARCHAR(20) NOT NULL DEFAULT 'key', + key_path TEXT NULL, + password TEXT NULL, + image_url TEXT NULL, + update_checked_at DATETIME NULL, + update_count INTEGER NULL, + update_preview TEXT NULL, + update_error TEXT NULL, + upgrade_available INTEGER NULL, + upgrade_raw TEXT NULL, + upgrade_error TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$cmdTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label VARCHAR(160) NOT NULL, + command TEXT NOT NULL, + admin_only INTEGER NOT NULL DEFAULT 0, + timeout_sec INTEGER NULL, + sort_order INTEGER NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$runTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + host_id INTEGER NULL, + command_id INTEGER NULL, + command_text TEXT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + output TEXT NULL, + error TEXT NULL, + exit_code INTEGER NULL, + timeout_sec INTEGER NULL, + created_by VARCHAR(120) NULL, + started_at DATETIME NULL, + finished_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )"); + $pdo->exec("CREATE TABLE IF NOT EXISTS {$sessionTable} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token VARCHAR(64) NOT NULL UNIQUE, + host_id INTEGER NOT NULL, + provider VARCHAR(20) NOT NULL DEFAULT 'ttyd', + command_text TEXT NULL, + created_by VARCHAR(120) NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + last_used_at DATETIME NULL + )"); + } + + // Schema migrations for existing tables + if ($driver === 'pgsql') { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS image_url TEXT NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS update_checked_at TIMESTAMP NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS update_count INTEGER NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS update_preview TEXT NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS update_error TEXT NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS upgrade_available BOOLEAN NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS upgrade_raw TEXT NULL"); + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN IF NOT EXISTS upgrade_error TEXT NULL"); + $pdo->exec("ALTER TABLE {$cmdTable} ADD COLUMN IF NOT EXISTS timeout_sec INTEGER NULL"); + $pdo->exec("ALTER TABLE {$cmdTable} ADD COLUMN IF NOT EXISTS sort_order INTEGER NULL"); + $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS error TEXT NULL"); + $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS exit_code INTEGER NULL"); + $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS timeout_sec INTEGER NULL"); + $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS started_at TIMESTAMP NULL"); + $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN IF NOT EXISTS finished_at TIMESTAMP NULL"); + $pdo->exec("ALTER TABLE {$sessionTable} ADD COLUMN IF NOT EXISTS command_text TEXT NULL"); + } else { + $columns = []; + $stmt = $pdo->query('PRAGMA table_info(' . $hostTable . ')'); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) { + $columns[$col['name']] = true; + } + if (empty($columns['image_url'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN image_url TEXT NULL"); + } + if (empty($columns['update_checked_at'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN update_checked_at DATETIME NULL"); + } + if (empty($columns['update_count'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN update_count INTEGER NULL"); + } + if (empty($columns['update_preview'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN update_preview TEXT NULL"); + } + if (empty($columns['update_error'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN update_error TEXT NULL"); + } + if (empty($columns['upgrade_available'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN upgrade_available INTEGER NULL"); + } + if (empty($columns['upgrade_raw'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN upgrade_raw TEXT NULL"); + } + if (empty($columns['upgrade_error'])) { + $pdo->exec("ALTER TABLE {$hostTable} ADD COLUMN upgrade_error TEXT NULL"); + } + + $columns = []; + $stmt = $pdo->query('PRAGMA table_info(' . $cmdTable . ')'); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) { + $columns[$col['name']] = true; + } + if (empty($columns['timeout_sec'])) { + $pdo->exec("ALTER TABLE {$cmdTable} ADD COLUMN timeout_sec INTEGER NULL"); + } + if (empty($columns['sort_order'])) { + $pdo->exec("ALTER TABLE {$cmdTable} ADD COLUMN sort_order INTEGER NULL"); + } + + $columns = []; + $stmt = $pdo->query('PRAGMA table_info(' . $runTable . ')'); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) { + $columns[$col['name']] = true; + } + if (empty($columns['error'])) { + $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN error TEXT NULL"); + } + if (empty($columns['exit_code'])) { + $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN exit_code INTEGER NULL"); + } + if (empty($columns['timeout_sec'])) { + $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN timeout_sec INTEGER NULL"); + } + if (empty($columns['started_at'])) { + $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN started_at DATETIME NULL"); + } + if (empty($columns['finished_at'])) { + $pdo->exec("ALTER TABLE {$runTable} ADD COLUMN finished_at DATETIME NULL"); + } + + $columns = []; + $stmt = $pdo->query('PRAGMA table_info(' . $sessionTable . ')'); + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $col) { + $columns[$col['name']] = true; + } + if (empty($columns['command_text'])) { + $pdo->exec("ALTER TABLE {$sessionTable} ADD COLUMN command_text TEXT NULL"); + } + } + + // Seed default commands (only when empty) + $count = (int)$pdo->query('SELECT COUNT(*) FROM ' . $cmdTable)->fetchColumn(); + if ($count === 0) { + $defaults = [ + ['Speicherplatz auf Dateisystem', 'df -T -h', false], + ['Netzwerkdaten', 'ip -s addr show', false], + ['CPU-Information', 'cat /proc/cpuinfo', false], + ['Informationen über USB-Bus', 'lsusb', false], + ['Uptime', 'uptime -p', false], + ['Scannen Sie einen I2C-Bus nach Geräten', '/usr/sbin/i2cdetect -y 1', true], + ['Prozesse (Gib zum Beenden \"q\" ein)', 'top', false], + ['SSH status', 'systemctl status ssh', false], + ['Konfigurationsprogramm in Raspberry OS', 'sudo raspi-config', true], + ['Pinbelegung', 'pinout', false], + ['Exportiere GPIO (Für Nutzung vorbereiten)', 'echo 22 > /sys/class/gpio/export', true], + ['Lese GPIO-Wert', 'echo in > /sys/class/gpio/gpio22/direction && cat /sys/class/gpio/gpio22/value', true], + ['Schreibe \"Low\" GPIO Wert', 'echo out > /sys/class/gpio/gpio22/direction && echo 0 > /sys/class/gpio/gpio22/value', true], + ['Schreibe \"High\" GPIO Wert', 'echo out > /sys/class/gpio/gpio22/direction && echo 1 > /sys/class/gpio/gpio22/value', true], + ['Export entfernen (Ressource freigeben)', 'echo 22 > /sys/class/gpio/unexport', true], + ['Alternative Funktionen von GPIO', 'raspi-gpio funcs', false], + ['Gerät herunterfahren', 'sudo systemctl poweroff', true], + ['Gerät neu starten', 'sudo systemctl reboot', true], + ['Aktualisiere die Betriebssystem-Pakete', 'sudo apt update && sudo apt full-upgrade', true], + ['Befehle, die zuvor ausgeführt wurden (Quellsprache)', 'history 30', false], + ['Liste der zuletzt angemeldeten Nutzer', 'last -30 -F', false], + ['Liste der aktuell angemeldeten Nutzer', 'w', false], + ['Aktualisierung', 'sudo apt update && sudo apt upgrade -y && sudo apt-get autoremove --purge && sudo apt-get clean && sudo rm -rf /var/lib/apt/lists/* && sudo apt-get update && sudo apt-get remove texlive-*-doc', true], + ['Update OS', 'sudo apt update && sudo apt full-upgrade -y && sudo apt-get autoremove --purge && sudo apt-get clean && sudo rm -rf /var/lib/apt/lists/* && sudo apt-get update && sudo apt-get remove texlive-*-doc', true], + ['Paketlisten laden', 'sudo apt-get update; sudo apt-get dist-upgrade -y;exit', true], + ['Gerät herunterfahren', 'sudo /sbin/shutdown -h now', true], + ['Gerät neu starten', 'sudo /sbin/reboot', true], + ]; + + $stmt = $pdo->prepare( + 'INSERT INTO ' . $cmdTable . ' (label, command, admin_only) VALUES (:label, :command, :admin_only)' + ); + foreach ($defaults as [$label, $command, $adminOnly]) { + $stmt->bindValue(':label', $label, PDO::PARAM_STR); + $stmt->bindValue(':command', $command, PDO::PARAM_STR); + $stmt->bindValue(':admin_only', (bool)$adminOnly, PDO::PARAM_BOOL); + $stmt->execute(); + } + } +}); diff --git a/temp/nexus-module-import/modules/pi_control/design.json b/temp/nexus-module-import/modules/pi_control/design.json new file mode 100644 index 00000000..6f1ed148 --- /dev/null +++ b/temp/nexus-module-import/modules/pi_control/design.json @@ -0,0 +1,14 @@ +{ + "eyebrow": "Modul", + "title": "Pi Control", + "description": "Verwaltung und Steuerung von Raspberry Pis per SSH, Presets und Konsole.", + "actions": [ + { "label": "Setup", "href": "/modules/setup/pi_control", "variant": "secondary" } + ], + "tabs": [ + { "label": "Ueberblick", "href": "/module/pi_control", "match_prefixes": ["/module/pi_control"] }, + { "label": "Hosts", "href": "/module/pi_control/hosts", "match_prefixes": ["/module/pi_control/hosts"] }, + { "label": "Befehle", "href": "/module/pi_control/commands", "match_prefixes": ["/module/pi_control/commands"] }, + { "label": "Konsole", "href": "/module/pi_control/console", "match_prefixes": ["/module/pi_control/console"] } + ] +} diff --git a/temp/nexus-module-import/modules/pi_control/module.json b/temp/nexus-module-import/modules/pi_control/module.json new file mode 100644 index 00000000..8a257a4b --- /dev/null +++ b/temp/nexus-module-import/modules/pi_control/module.json @@ -0,0 +1,38 @@ +{ + "title": "Pi Control", + "version": "0.1.0", + "description": "Verwaltung und Steuerung von Raspberry Pis (SSH/Commands/Presets).", + "setup": { + "fields": [ + { "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Base-DB genutzt." }, + { "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" }, + { "name": "db.host", "label": "DB Host", "type": "text", "required": false }, + { "name": "db.port", "label": "DB Port", "type": "number", "required": false }, + { "name": "db.dbname", "label": "DB Name", "type": "text", "required": false }, + { "name": "db.schema", "label": "DB Schema", "type": "text", "required": false }, + { "name": "db.user", "label": "DB User", "type": "text", "required": false }, + { "name": "db.password", "label": "DB Passwort", "type": "password", "required": false }, + { "name": "ttyd_url", "label": "ttyd URL", "type": "text", "required": false, "help": "z.B. https://staging.nexus.kusche.berlin/ttyd" }, + { "name": "terminal_token_ttl", "label": "Token TTL (Minuten)", "type": "number", "required": false, "help": "Gültigkeit der Konsole-Token, z.B. 10" }, + { "name": "terminal_shared_secret", "label": "Terminal Shared Secret", "type": "password", "required": false, "help": "Zusätzliche Absicherung für terminal_info (Header X-Terminal-Secret)" }, + { "name": "terminal_tmux_session", "label": "tmux Session-Name", "type": "text", "required": false, "help": "Session-Name für bestehende Konsole (Standard: nexus)" }, + { "name": "terminal_strict_hostkey", "label": "Strict Host-Key Checking", "type": "checkbox", "required": false, "help": "Aktiviert StrictHostKeyChecking (accept-new) statt Insecure." }, + { "name": "exec_default_timeout", "label": "Command-Timeout (Sek.)", "type": "number", "required": false, "help": "Default-Timeout für Befehle, z.B. 300" }, + { "name": "settings_reload_sec", "label": "Settings Reload (Sek.)", "type": "number", "required": false, "help": "Wie oft der Worker Settings neu lädt (Standard 30s)" }, + { "name": "redis.host", "label": "Redis Host", "type": "text", "required": false, "help": "Service-Name, z.B. redis" }, + { "name": "redis.port", "label": "Redis Port", "type": "number", "required": false, "help": "Standard 6379" }, + { "name": "redis.password", "label": "Redis Passwort", "type": "password", "required": false }, + { "name": "redis.db", "label": "Redis DB", "type": "number", "required": false, "help": "Standard 0" }, + { "name": "redis.queue", "label": "Redis Queue", "type": "text", "required": false, "help": "z.B. pi_control:queue" } + ] + }, + "db_defaults": { + "driver": "pgsql", + "host": "localhost", + "port": 5432, + "dbname": "", + "schema": "public", + "user": "", + "password": "" + } +} diff --git a/temp/nexus-module-import/modules/pi_control/pages/asset.php b/temp/nexus-module-import/modules/pi_control/pages/asset.php new file mode 100644 index 00000000..4461c276 --- /dev/null +++ b/temp/nexus-module-import/modules/pi_control/pages/asset.php @@ -0,0 +1,32 @@ + $base . '/pi_control.css', + 'console.js' => $base . '/console.js', + 'hosts.js' => $base . '/hosts.js', + 'commands.js' => $base . '/commands.js', +]; + +if (!isset($map[$file])) { + http_response_code(404); + exit('Not found'); +} + +$path = $map[$file]; +if (!$base || !is_file($path) || !str_starts_with($path, $base)) { + http_response_code(404); + exit('Not found'); +} + +$ext = pathinfo($path, PATHINFO_EXTENSION); +if ($ext === 'css') { + header('Content-Type: text/css; charset=utf-8'); +} elseif ($ext === 'js') { + header('Content-Type: application/javascript; charset=utf-8'); +} else { + header('Content-Type: application/octet-stream'); +} + +readfile($path); +exit; diff --git a/temp/nexus-module-import/modules/pi_control/pages/commands.php b/temp/nexus-module-import/modules/pi_control/pages/commands.php new file mode 100644 index 00000000..3f418d67 --- /dev/null +++ b/temp/nexus-module-import/modules/pi_control/pages/commands.php @@ -0,0 +1,181 @@ + module_fn('pi_control', 'table', $name); +$assets = app()->assets(); +if ($assets) { + $assets->addStyle('/module/pi_control/asset?file=pi_control.css'); + $assets->addScript('/module/pi_control/asset?file=commands.js', 'footer', true); +} + +$notice = null; +$error = null; + +if (isset($_GET['reorder_json'])) { + require_admin(); + $payload = json_decode(file_get_contents('php://input'), true); + $order = is_array($payload['order'] ?? null) ? $payload['order'] : []; + if ($order) { + $stmt = $pdo->prepare('UPDATE ' . $table('commands') . ' SET sort_order = :sort_order WHERE id = :id'); + $pos = 1; + foreach ($order as $id) { + $stmt->execute([ + 'sort_order' => $pos++, + 'id' => (int)$id, + ]); + } + } + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => true]); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_admin(); + $deleteId = (int)($_POST['delete_id'] ?? 0); + $editId = (int)($_POST['id'] ?? 0); + $label = trim((string)($_POST['label'] ?? '')); + $command = trim((string)($_POST['command'] ?? '')); + $adminOnly = !empty($_POST['admin_only']) ? 1 : 0; + $timeoutSec = (int)($_POST['timeout_sec'] ?? 0); + + if ($deleteId > 0) { + $stmt = $pdo->prepare('DELETE FROM ' . $table('commands') . ' WHERE id = :id'); + $stmt->execute(['id' => $deleteId]); + $notice = 'Befehl gelöscht.'; + } else { + if ($label === '' || $command === '') { + $error = 'Bitte Label und Command angeben.'; + } else { + if ($editId > 0) { + $stmt = $pdo->prepare( + 'UPDATE ' . $table('commands') . ' SET label = :label, command = :command, admin_only = :admin_only, timeout_sec = :timeout_sec WHERE id = :id' + ); + $stmt->execute([ + 'id' => $editId, + 'label' => $label, + 'command' => $command, + 'admin_only' => $adminOnly, + 'timeout_sec' => $timeoutSec > 0 ? $timeoutSec : null, + ]); + $notice = 'Befehl aktualisiert.'; + } else { + $nextSort = (int)$pdo->query('SELECT COALESCE(MAX(sort_order), 0) + 1 FROM ' . $table('commands'))->fetchColumn(); + $stmt = $pdo->prepare( + 'INSERT INTO ' . $table('commands') . ' (label, command, admin_only, timeout_sec, sort_order) VALUES (:label, :command, :admin_only, :timeout_sec, :sort_order)' + ); + $stmt->execute([ + 'label' => $label, + 'command' => $command, + 'admin_only' => $adminOnly, + 'timeout_sec' => $timeoutSec > 0 ? $timeoutSec : null, + 'sort_order' => $nextSort, + ]); + $notice = 'Befehl gespeichert.'; + } + } + } +} + +$commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY COALESCE(sort_order, id) ASC, id ASC')->fetchAll(PDO::FETCH_ASSOC); +?> + 'Befehle', +]) ?> +
+
+
+
+

Befehle

+

Verwalte vordefinierte SSH-Befehle.

+
+ +
+ + +
+ +
+ +
+ +
+ + +
+ Vorhandene Befehle + +
Keine Befehle vorhanden.
+ +
    + +
  • +
    ⋮⋮
    +
    +
    + + + Admin + +
    +
    + +
    +
    + Timeout: +
    +
    +
    + +
    + +
    + + +
    +
    +
    +
  • + +
+

Reihenfolge per Drag & Drop ändern.

+ +
+
+
+ + + diff --git a/temp/nexus-module-import/modules/pi_control/pages/console.php b/temp/nexus-module-import/modules/pi_control/pages/console.php new file mode 100644 index 00000000..dcd87216 --- /dev/null +++ b/temp/nexus-module-import/modules/pi_control/pages/console.php @@ -0,0 +1,527 @@ + module_fn('pi_control', 'table', $name); + +$notice = null; +$error = null; +$terminalNotice = null; +$terminalError = null; +$terminalUrl = null; +$terminalToken = null; +$terminalHostLabel = null; + +$settings = modules()->settings('pi_control'); +$assets = app()->assets(); +if ($assets) { + $assets->addStyle('/module/pi_control/asset?file=pi_control.css'); + $assets->addScript('/module/pi_control/asset?file=console.js', 'footer', true); +} +$ttydUrl = trim((string)($settings['ttyd_url'] ?? '/ttyd')); +$defaultProvider = 'ttyd'; +$defaultTimeout = (int)($settings['exec_default_timeout'] ?? (getenv('PI_CONTROL_EXEC_DEFAULT_TIMEOUT') !== false ? (int)getenv('PI_CONTROL_EXEC_DEFAULT_TIMEOUT') : 300)); +$defaultTimeout = $defaultTimeout > 0 ? $defaultTimeout : 300; +$queueName = (string)($settings['redis']['queue'] ?? ($settings['redis.queue'] ?? (getenv('PI_CONTROL_REDIS_QUEUE') ?: 'pi_control:queue'))); +$tokenTtl = (int)($settings['terminal_token_ttl'] ?? 10); +$tokenTtl = $tokenTtl > 0 ? $tokenTtl : 10; + +$hosts = $pdo->query('SELECT * FROM ' . $table('hosts') . ' ORDER BY name ASC')->fetchAll(PDO::FETCH_ASSOC); +$commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY COALESCE(sort_order, id) ASC, id ASC')->fetchAll(PDO::FETCH_ASSOC); + +$renderRuns = function (array $runs): string { + ob_start(); + if (!$runs) { + echo 'Noch keine Runs.'; + return ob_get_clean(); + } + foreach ($runs as $r) { + $out = (string)($r['output'] ?? ''); + $err = (string)($r['error'] ?? ''); + $snippet = $out !== '' ? $out : $err; + if (strlen($snippet) > 140) { + $snippet = substr($snippet, 0, 140) . '…'; + } + echo ''; + echo '' . e((string)$r['id']) . ''; + echo '' . e((string)($r['status'] ?? '')) . ''; + echo '' . e((string)($r['host_name'] ?? $r['host_addr'] ?? '')) . ''; + echo '' . e((string)($r['command_text'] ?? '')) . ''; + echo '' . e((string)($r['created_by'] ?? '')) . ''; + echo '' . e($snippet) . ''; + echo '' . (!empty($r['timeout_sec']) ? e((string)$r['timeout_sec']) . 's' : 'default') . ''; + $status = (string)($r['status'] ?? ''); + echo ''; + if ($status === 'queued') { + echo ''; + echo ''; + } elseif ($status === 'running') { + echo 'läuft'; + } else { + echo ''; + } + echo ''; + echo ''; + } + return ob_get_clean(); +}; + +if (isset($_GET['queue_json'])) { + $runs = $pdo->query( + 'SELECT r.*, h.name AS host_name, h.host AS host_addr + FROM ' . $table('runs') . ' r + LEFT JOIN ' . $table('hosts') . ' h ON h.id = r.host_id + ORDER BY r.id DESC LIMIT 20' + )->fetchAll(PDO::FETCH_ASSOC); + $count = (int)$pdo->query( + "SELECT COUNT(*) FROM " . $table('runs') . " WHERE status IN ('queued','running','cancel_requested')" + )->fetchColumn(); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => true, + 'next' => 10, + 'count' => $count, + 'html' => $renderRuns($runs), + ]); + exit; +} + +if (isset($_GET['queue_action_json'])) { + $runId = (int)($_POST['run_id'] ?? 0); + $action = (string)($_POST['action'] ?? ''); + $error = null; + + if ($runId <= 0 || !in_array($action, ['cancel', 'delete'], true)) { + $error = 'Ungültige Anfrage.'; + } else { + $stmt = $pdo->prepare('SELECT * FROM ' . $table('runs') . ' WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $runId]); + $run = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$run) { + $error = 'Run nicht gefunden.'; + } else { + $status = (string)($run['status'] ?? ''); + $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $nowExpr = $driver === 'pgsql' ? 'NOW()' : "DATETIME('now')"; + + if ($action === 'cancel') { + if ($status !== 'queued') { + $error = 'Nur queued Runs können gestoppt werden.'; + } else { + $pdo->exec('UPDATE ' . $table('runs') . ' SET status = \'canceled\', finished_at = ' . $nowExpr . ' WHERE id = ' . (int)$runId); + try { + $redis = module_fn('pi_control', 'redis'); + $payload = json_encode(['run_id' => $runId]); + $redis->command(['LREM', $queueName, '0', $payload]); + } catch (\Throwable $e) { + // ignore redis cleanup errors + } + } + } elseif ($action === 'delete') { + if ($status === 'running') { + $error = 'Laufende Runs können nicht gelöscht werden.'; + } else { + $pdo->prepare('DELETE FROM ' . $table('runs') . ' WHERE id = :id')->execute(['id' => $runId]); + try { + $redis = module_fn('pi_control', 'redis'); + $payload = json_encode(['run_id' => $runId]); + $redis->command(['LREM', $queueName, '0', $payload]); + } catch (\Throwable $e) { + // ignore redis cleanup errors + } + } + } + } + } + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => $error === null, + 'error' => $error, + ]); + exit; +} + +if (isset($_GET['open_console_json'])) { + $hostId = (int)($_POST['terminal_host_id'] ?? 0); + $presetId = (int)($_POST['terminal_preset_id'] ?? 0); + $rawCommand = trim((string)($_POST['terminal_command_text'] ?? '')); + $terminalError = null; + $terminalUrl = null; + $terminalToken = null; + $terminalHostLabel = null; + + if ($hostId <= 0) { + $terminalError = 'Bitte einen Host wählen.'; + } elseif ($ttydUrl === '') { + $terminalError = 'ttyd URL fehlt. Bitte im Setup setzen.'; + } else { + $presetCommand = ''; + if ($presetId > 0) { + foreach ($commands as $c) { + if ((int)$c['id'] === $presetId) { + if (!auth_is_admin() && !empty($c['admin_only'])) { + $terminalError = 'Diese Vorlage ist nur für Admins.'; + } else { + $presetCommand = (string)$c['command']; + } + break; + } + } + } + if (!$terminalError) { + $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $expiresSql = $driver === 'pgsql' + ? "NOW() + INTERVAL '{$tokenTtl} minutes'" + : "DATETIME('now', '+{$tokenTtl} minutes')"; + + $token = bin2hex(random_bytes(24)); + $terminalToken = $token; + $stmt = $pdo->prepare( + 'INSERT INTO ' . $table('sessions') . ' (token, host_id, provider, command_text, created_by, expires_at) + VALUES (:token, :host_id, :provider, :command_text, :created_by, ' . $expiresSql . ')' + ); + $commandToRun = $presetCommand !== '' ? $presetCommand : $rawCommand; + $stmt->execute([ + 'token' => $token, + 'host_id' => $hostId, + 'provider' => 'ttyd', + 'command_text' => $commandToRun !== '' ? $commandToRun : null, + 'created_by' => auth_display_name() ?: null, + ]); + + $sep = str_contains($ttydUrl, '?') ? '&' : '?'; + $terminalUrl = rtrim($ttydUrl, '/') . '/' . $sep . 'arg=' . rawurlencode($token); + foreach ($hosts as $h) { + if ((int)$h['id'] === $hostId) { + $terminalHostLabel = (string)($h['name'] ?? $h['host']); + break; + } + } + } + } + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => $terminalError === null, + 'error' => $terminalError, + 'url' => $terminalUrl, + 'token' => $terminalToken, + 'host' => $terminalHostLabel, + ]); + exit; +} + +if (isset($_GET['run_command_json'])) { + $hostId = (int)($_POST['terminal_host_id'] ?? 0); + $commandId = (int)($_POST['terminal_preset_id'] ?? 0); + $rawCommand = trim((string)($_POST['terminal_command_text'] ?? '')); + $timeoutSec = $defaultTimeout; + $error = null; + $notice = null; + + if ($hostId <= 0) { + $error = 'Bitte einen Host wählen.'; + } elseif ($commandId <= 0 && $rawCommand === '') { + $error = 'Bitte einen Befehl wählen oder einen Befehl eingeben.'; + } else { + $selectedCommand = ''; + if ($commandId > 0) { + foreach ($commands as $c) { + if ((int)$c['id'] === $commandId) { + if (!auth_is_admin() && !empty($c['admin_only'])) { + $error = 'Dieser Befehl ist nur für Admins.'; + } else { + $selectedCommand = (string)$c['command']; + if (!empty($c['timeout_sec'])) { + $timeoutSec = (int)$c['timeout_sec']; + } + } + break; + } + } + } + + if (!$error) { + $commandText = $selectedCommand !== '' ? $selectedCommand : $rawCommand; + $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + if ($driver === 'pgsql') { + $stmt = $pdo->prepare( + 'INSERT INTO ' . $table('runs') . ' (host_id, command_id, command_text, status, timeout_sec, created_by) + VALUES (:host_id, :command_id, :command_text, :status, :timeout_sec, :created_by) + RETURNING id' + ); + $stmt->execute([ + 'host_id' => $hostId, + 'command_id' => $commandId > 0 ? $commandId : null, + 'command_text' => $commandText, + 'status' => 'queued', + 'timeout_sec' => $timeoutSec, + 'created_by' => auth_display_name() ?: null, + ]); + $runId = (int)$stmt->fetchColumn(); + } else { + $stmt = $pdo->prepare( + 'INSERT INTO ' . $table('runs') . ' (host_id, command_id, command_text, status, timeout_sec, created_by) + VALUES (:host_id, :command_id, :command_text, :status, :timeout_sec, :created_by)' + ); + $stmt->execute([ + 'host_id' => $hostId, + 'command_id' => $commandId > 0 ? $commandId : null, + 'command_text' => $commandText, + 'status' => 'queued', + 'timeout_sec' => $timeoutSec, + 'created_by' => auth_display_name() ?: null, + ]); + $runId = (int)$pdo->lastInsertId(); + } + try { + $redis = module_fn('pi_control', 'redis'); + $payload = json_encode(['run_id' => $runId], JSON_THROW_ON_ERROR); + $redis->command(['RPUSH', $queueName, $payload]); + $notice = 'Befehl wurde in die Queue gestellt.'; + } catch (\Throwable $e) { + $pdo->exec('UPDATE ' . $table('runs') . ' SET status = \'queue_error\' WHERE id = ' . (int)$runId); + $notice = 'Befehl gespeichert, aber Queue nicht erreichbar.'; + } + } + } + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => $error === null, + 'error' => $error, + 'notice' => $notice, + ]); + exit; +} + +if (isset($_GET['send_active_json'])) { + $hostId = (int)($_POST['terminal_host_id'] ?? 0); + $commandId = (int)($_POST['terminal_preset_id'] ?? 0); + $rawCommand = trim((string)($_POST['terminal_command_text'] ?? '')); + $error = null; + $notice = null; + + if ($hostId <= 0) { + $error = 'Bitte einen Host wählen.'; + } elseif ($commandId <= 0 && $rawCommand === '') { + $error = 'Bitte einen Befehl wählen oder einen Befehl eingeben.'; + } else { + $selectedCommand = ''; + if ($commandId > 0) { + foreach ($commands as $c) { + if ((int)$c['id'] === $commandId) { + if (!auth_is_admin() && !empty($c['admin_only'])) { + $error = 'Dieser Befehl ist nur für Admins.'; + } else { + $selectedCommand = (string)$c['command']; + } + break; + } + } + } + if (!$error) { + $commandText = $selectedCommand !== '' ? $selectedCommand : $rawCommand; + $stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $hostId]); + $host = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$host) { + $error = 'Host nicht gefunden.'; + } else { + $settings = modules()->settings('pi_control'); + $strictHostKey = !empty($settings['terminal_strict_hostkey']) || getenv('PI_CONTROL_STRICT_HOSTKEY') === '1'; + [$ok, $sendError] = sendToActiveConsole($host, $commandText, $strictHostKey); + if ($ok) { + $notice = 'Befehl wurde in der bestehenden Konsole ausgeführt.'; + } else { + $error = $sendError ?: 'Bestehende Konsole nicht verfügbar.'; + } + } + } + } + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => $error === null, + 'error' => $error, + 'notice' => $notice, + ]); + exit; +} + +// Form submits are handled via AJAX to avoid reloads. + +$runs = $pdo->query( + 'SELECT r.*, h.name AS host_name, h.host AS host_addr + FROM ' . $table('runs') . ' r + LEFT JOIN ' . $table('hosts') . ' h ON h.id = r.host_id + ORDER BY r.id DESC LIMIT 20' +)->fetchAll(PDO::FETCH_ASSOC); + +function sendToActiveConsole(array $host, string $command, bool $strictHostKey): array +{ + $hostAddr = (string)($host['host'] ?? ''); + $user = (string)($host['username'] ?? ''); + $port = (int)($host['port'] ?? 22); + $authType = (string)($host['auth_type'] ?? 'key'); + $keyPath = (string)($host['key_path'] ?? ''); + $password = (string)($host['password'] ?? ''); + + if ($hostAddr === '' || $user === '') { + return [false, 'Hostdaten unvollständig.']; + } + + $opts = $strictHostKey + ? '-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts' + : '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; + + $target = escapeshellarg($user . '@' . $hostAddr); + $cmdB64 = base64_encode($command); + $remote = 'CMD_B64="$0"; CMD="$(printf "%s" "$CMD_B64" | base64 -d)"; ' . + 'command -v tmux >/dev/null 2>&1 || exit 2; ' . + 'tmux has-session -t nexus 2>/dev/null || exit 3; ' . + 'tmux send-keys -t nexus "$CMD" C-m'; + + $cmd = 'ssh ' . $opts . ' -p ' . (int)$port . ' '; + if ($authType === 'key' && $keyPath !== '') { + $cmd .= '-i ' . escapeshellarg($keyPath) . ' '; + } + $cmd .= $target . ' -- /bin/bash -lc ' . escapeshellarg($remote) . ' ' . escapeshellarg($cmdB64); + if ($authType === 'pass' && $password !== '') { + $cmd = 'sshpass -p ' . escapeshellarg($password) . ' ' . $cmd; + } + + $descriptors = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open($cmd, $descriptors, $pipes); + if (!is_resource($process)) { + return [false, 'proc_open failed']; + } + $out = stream_get_contents($pipes[1]); + $err = stream_get_contents($pipes[2]); + $status = proc_get_status($process); + $exitCode = (int)($status['exitcode'] ?? 1); + proc_close($process); + + if ($exitCode === 0) { + return [true, null]; + } + if ($exitCode === 2) { + return [false, 'tmux ist auf dem Host nicht installiert.']; + } + if ($exitCode === 3) { + return [false, 'Keine aktive Konsole gefunden.']; + } + $msg = trim($err !== '' ? $err : $out); + return [false, $msg !== '' ? $msg : 'Befehl konnte nicht gesendet werden.']; +} +?> + 'Konsole', +]) ?> +
+
+
+
+

Konsole

+

Wähle einen Host und führe einen Befehl aus.

+
+
+ + +
+ +
+ +
+ +
+ + +
+ Live-Konsole + + +
+ + + +
+ + + +
+
+
+ Token: +
+ + +
+ +
+ + +
+
+ diff --git a/temp/nexus-module-import/modules/pi_control/pages/hosts.php b/temp/nexus-module-import/modules/pi_control/pages/hosts.php new file mode 100644 index 00000000..dea2d711 --- /dev/null +++ b/temp/nexus-module-import/modules/pi_control/pages/hosts.php @@ -0,0 +1,479 @@ + module_fn('pi_control', 'table', $name); +$assets = app()->assets(); +if ($assets) { + $assets->addStyle('/module/pi_control/asset?file=pi_control.css'); + $assets->addScript('/module/pi_control/asset?file=hosts.js', 'footer', true); +} + +$notice = null; +$error = null; + +if (isset($_GET['status_json'])) { + require_admin(); + $id = (int)($_GET['id'] ?? 0); + $stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $id]); + $host = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$host) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'not_found']); + exit; + } + $settings = modules()->settings('pi_control'); + $strictHostKey = !empty($settings['terminal_strict_hostkey']); + $reachable = hostReachable((string)($host['host'] ?? ''), (int)($host['port'] ?? 22)); + $authOk = $reachable ? hostAuthOk($host, $strictHostKey) : false; + $status = !$reachable ? 'down' : ($authOk ? 'ok' : 'auth'); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => true, 'status' => $status]); + exit; +} + +if (isset($_GET['update_json'])) { + require_admin(); + $id = (int)($_GET['id'] ?? 0); + $stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $id]); + $host = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$host) { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => 'not_found']); + exit; + } + $settings = modules()->settings('pi_control'); + $strictHostKey = !empty($settings['terminal_strict_hostkey']); + + $updateCmd = <<<'SH' +if ! command -v apt-get >/dev/null 2>&1; then echo "__ERR__NO_APT"; exit 2; fi; +if sudo -n apt update -qq >/dev/null 2>&1; then echo "__APT_UPDATE__=1"; else echo "__APT_UPDATE__=0"; fi; +count=$(apt-get -s dist-upgrade 2>/dev/null | grep -c "^Inst "); +echo "__COUNT__=$count" +SH; + $upgradeCmd = <<<'SH' +id="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")"; +current="$(. /etc/os-release 2>/dev/null && echo "${VERSION_CODENAME:-}")"; +if [ "$id" != "debian" ] || [ -z "$current" ]; then echo "__UPGRADE__=0"; exit 0; fi; +latest="$( (command -v curl >/dev/null 2>&1 && curl -fsSL https://deb.debian.org/debian/dists/stable/Release) || (command -v wget >/dev/null 2>&1 && wget -qO- https://deb.debian.org/debian/dists/stable/Release) )"; +latest="$(printf "%s" "$latest" | awk -F": " "/^Codename:/{print $2}")"; +if [ -z "$latest" ]; then echo "__UPGRADE__=0"; echo "__RAW__=NO_FETCH"; exit 0; fi; +if [ "$current" != "$latest" ]; then echo "__UPGRADE__=1"; else echo "__UPGRADE__=0"; fi +SH; + + [$updExit, $updOut, $updErr] = runSshCommandCapture($host, $updateCmd, $strictHostKey, 20); + $updOutStr = (string)$updOut; + $updErrStr = (string)$updErr; + $updateCount = null; + $updatePreview = ''; + $updateErr = str_contains($updOutStr, '__ERR__NO_APT'); + if (preg_match('/^__COUNT__=(\d+)$/m', $updOutStr, $m)) { + $updateCount = (int)$m[1]; + } + $updatePreview = trim($updOutStr); + if (strlen($updatePreview) > 1200) { + $updatePreview = substr($updatePreview, 0, 1200); + } + + [$upgExit, $upgOut, $upgErr] = runSshCommandCapture($host, $upgradeCmd, $strictHostKey, 25); + $upgOutStr = (string)$upgOut; + $upgErrStr = (string)$upgErr; + $upgradeAvailable = null; + $upgradeErr = str_contains($upgOutStr, '__ERR__'); + if ($upgExit === 0 && !$upgradeErr) { + if (preg_match('/^__UPGRADE__=(0|1)$/m', $upgOutStr, $m)) { + $upgradeAvailable = $m[1] === '1'; + } + } + + $driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $nowExpr = $driver === 'pgsql' ? 'NOW()' : "DATETIME('now')"; + $updCountVal = $updateCount; + $updErrVal = (!$updateErr && $updateCount !== null) ? null : trim($updErrStr ?: $updOutStr); + $upgAvailVal = $upgradeAvailable; + $upgErrVal = $upgExit === 0 && !$upgradeErr ? null : trim($upgErrStr ?: $upgOutStr); + $stmt = $pdo->prepare( + 'UPDATE ' . $table('hosts') . ' SET update_checked_at = ' . $nowExpr . ', + update_count = :update_count, + update_preview = :update_preview, + update_error = :update_error, + upgrade_available = :upgrade_available, + upgrade_raw = :upgrade_raw, + upgrade_error = :upgrade_error + WHERE id = :id' + ); + $stmt->execute([ + 'update_count' => $updCountVal, + 'update_preview' => $updatePreview !== '' ? $updatePreview : null, + 'update_error' => $updErrVal, + 'upgrade_available' => $upgAvailVal === null ? null : ($upgAvailVal ? 1 : 0), + 'upgrade_raw' => $upgExit === 0 ? trim($upgOutStr) : null, + 'upgrade_error' => $upgErrVal, + 'id' => $id, + ]); + + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'ok' => true, + 'updates' => [ + 'count' => $updateCount, + 'preview' => $updatePreview, + 'raw' => $updatePreview, + 'error' => (!$updateErr && $updateCount !== null) ? '' : trim($updErrStr ?: $updOutStr), + ], + 'os' => [ + 'available' => $upgradeAvailable, + 'raw' => $upgExit === 0 ? trim($upgOutStr) : '', + 'error' => $upgExit === 0 && !$upgradeErr ? '' : trim($upgErrStr ?: $upgOutStr), + ], + 'checked_at' => date('c'), + ]); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + require_admin(); + $deleteId = (int)($_POST['delete_id'] ?? 0); + $editId = (int)($_POST['id'] ?? 0); + $name = trim((string)($_POST['name'] ?? '')); + $host = trim((string)($_POST['host'] ?? '')); + $port = (int)($_POST['port'] ?? 22); + $username = trim((string)($_POST['username'] ?? '')); + $authType = trim((string)($_POST['auth_type'] ?? 'key')); + $keyPath = trim((string)($_POST['key_path'] ?? '')); + $password = trim((string)($_POST['password'] ?? '')); + $imageUrl = trim((string)($_POST['image_url'] ?? '')); + + if ($deleteId > 0) { + $stmt = $pdo->prepare('DELETE FROM ' . $table('hosts') . ' WHERE id = :id'); + $stmt->execute(['id' => $deleteId]); + $notice = 'Host gelöscht.'; + } else { + if ($name === '' || $host === '' || $username === '') { + $error = 'Bitte Name, Host und Benutzer angeben.'; + } else { + if ($editId > 0) { + $stmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1'); + $stmt->execute(['id' => $editId]); + $existing = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; + $passwordToStore = $password !== '' ? $password : ($existing['password'] ?? null); + $stmt = $pdo->prepare( + 'UPDATE ' . $table('hosts') . ' SET name = :name, host = :host, port = :port, username = :username, auth_type = :auth_type, key_path = :key_path, password = :password, image_url = :image_url WHERE id = :id' + ); + $stmt->execute([ + 'id' => $editId, + 'name' => $name, + 'host' => $host, + 'port' => $port > 0 ? $port : 22, + 'username' => $username, + 'auth_type' => $authType !== '' ? $authType : 'key', + 'key_path' => $keyPath !== '' ? $keyPath : null, + 'password' => $passwordToStore, + 'image_url' => $imageUrl !== '' ? $imageUrl : null, + ]); + $notice = 'Host aktualisiert.'; + } else { + $stmt = $pdo->prepare( + 'INSERT INTO ' . $table('hosts') . ' (name, host, port, username, auth_type, key_path, password, image_url) VALUES (:name, :host, :port, :username, :auth_type, :key_path, :password, :image_url)' + ); + $stmt->execute([ + 'name' => $name, + 'host' => $host, + 'port' => $port > 0 ? $port : 22, + 'username' => $username, + 'auth_type' => $authType !== '' ? $authType : 'key', + 'key_path' => $keyPath !== '' ? $keyPath : null, + 'password' => $password !== '' ? $password : null, + 'image_url' => $imageUrl !== '' ? $imageUrl : null, + ]); + $notice = 'Host gespeichert.'; + } + } + } +} + +$hosts = $pdo->query('SELECT * FROM ' . $table('hosts') . ' ORDER BY id DESC')->fetchAll(PDO::FETCH_ASSOC); +$settings = modules()->settings('pi_control'); +$strictHostKey = !empty($settings['terminal_strict_hostkey']); + +function hostReachable(string $host, int $port): bool +{ + $errno = 0; + $errstr = ''; + $fp = @fsockopen($host, $port, $errno, $errstr, 1.2); + if ($fp) { + fclose($fp); + return true; + } + return false; +} + +function runSshCommand(string $cmd, int $timeoutSec): int +{ + $descriptors = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open($cmd, $descriptors, $pipes); + if (!is_resource($process)) { + return 255; + } + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $start = time(); + while (true) { + $status = proc_get_status($process); + if (!$status['running']) { + $code = (int)$status['exitcode']; + proc_close($process); + return $code; + } + if (time() - $start > $timeoutSec) { + proc_terminate($process, 9); + proc_close($process); + return 124; + } + usleep(100000); + } +} + +function runSshCommandCapture(array $host, string $command, bool $strictHostKey, int $timeoutSec): array +{ + $hostAddr = (string)($host['host'] ?? ''); + $user = (string)($host['username'] ?? ''); + $port = (int)($host['port'] ?? 22); + $authType = (string)($host['auth_type'] ?? 'key'); + $keyPath = (string)($host['key_path'] ?? ''); + $password = (string)($host['password'] ?? ''); + + $opts = $strictHostKey + ? '-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts' + : '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; + $opts .= ' -o ConnectTimeout=6 -o NumberOfPasswordPrompts=1 -o LogLevel=ERROR -o RequestTTY=no'; + + $target = escapeshellarg($user . '@' . $hostAddr); + $remote = '/bin/sh -c ' . escapeshellarg($command); + $cmd = 'ssh -T ' . $opts . ' -p ' . (int)$port . ' '; + if ($authType === 'key' && $keyPath !== '') { + $cmd .= '-i ' . escapeshellarg($keyPath) . ' -o BatchMode=yes '; + } elseif ($authType === 'key') { + $cmd .= '-o BatchMode=yes '; + } + $cmd .= $target . ' -- ' . $remote; + if ($authType === 'pass' && $password !== '') { + $cmd = 'sshpass -p ' . escapeshellarg($password) . ' ' . $cmd; + } + + $descriptors = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $process = proc_open($cmd, $descriptors, $pipes); + if (!is_resource($process)) { + return [255, '', 'proc_open failed']; + } + stream_set_blocking($pipes[1], false); + stream_set_blocking($pipes[2], false); + $out = ''; + $err = ''; + $start = time(); + while (true) { + $status = proc_get_status($process); + $out .= stream_get_contents($pipes[1]); + $err .= stream_get_contents($pipes[2]); + if (!$status['running']) { + $exit = (int)$status['exitcode']; + proc_close($process); + return [$exit, $out, $err]; + } + if (time() - $start > $timeoutSec) { + proc_terminate($process, 9); + proc_close($process); + return [124, $out, $err]; + } + usleep(100000); + } +} + +function hostAuthOk(array $host, bool $strictHostKey): bool +{ + $hostAddr = (string)($host['host'] ?? ''); + $user = (string)($host['username'] ?? ''); + $port = (int)($host['port'] ?? 22); + $authType = (string)($host['auth_type'] ?? 'key'); + $keyPath = (string)($host['key_path'] ?? ''); + $password = (string)($host['password'] ?? ''); + if ($hostAddr === '' || $user === '') { + return false; + } + + $opts = $strictHostKey + ? '-o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/root/.ssh/known_hosts' + : '-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null'; + $opts .= ' -o ConnectTimeout=2 -o NumberOfPasswordPrompts=1'; + + $target = escapeshellarg($user . '@' . $hostAddr); + $cmd = 'ssh ' . $opts . ' -p ' . (int)$port . ' '; + if ($authType === 'key' && $keyPath !== '') { + $cmd .= '-i ' . escapeshellarg($keyPath) . ' -o BatchMode=yes '; + } elseif ($authType === 'key') { + $cmd .= '-o BatchMode=yes '; + } + $cmd .= $target . ' -- true'; + + if ($authType === 'pass') { + if ($password === '') { + return false; + } + $cmd = 'sshpass -p ' . escapeshellarg($password) . ' ' . $cmd; + } + + $exitCode = runSshCommand($cmd, 3); + return $exitCode === 0; +} +?> + 'Hosts', +]) ?> +
+
+
+
+

Hosts

+

Verwalte die Raspberry Pis, die du steuern möchtest.

+
+
+ + +
+
+ + +
+ +
+ +
+ +
+ + +
+
+ Registrierte Hosts + +
Keine Hosts vorhanden.
+ +
+ +
+
> +
+
+
+
+
+ + +
+
+ +
+ +
+ + +
+
+
+
+
:
+
·
+
+ Updates: – + OS: – + Nie geprüft +
+
Update Debug: –
+
Upgrade Debug: –
+
+ +
+
+
+ +
+ +
+
+
+ + + + + diff --git a/temp/nexus-module-import/modules/pi_control/pages/index.php b/temp/nexus-module-import/modules/pi_control/pages/index.php new file mode 100644 index 00000000..a6e20871 --- /dev/null +++ b/temp/nexus-module-import/modules/pi_control/pages/index.php @@ -0,0 +1,41 @@ + module_fn('pi_control', 'table', $name); + +$hostCount = (int)$pdo->query('SELECT COUNT(*) FROM ' . $table('hosts'))->fetchColumn(); +$cmdCount = (int)$pdo->query('SELECT COUNT(*) FROM ' . $table('commands'))->fetchColumn(); +$runCount = (int)$pdo->query('SELECT COUNT(*) FROM ' . $table('runs'))->fetchColumn(); +?> + 'Raspberry Pi Steuerung', +]) ?> +
+
+
+
+

Raspberry Pi Steuerung

+

SSH Hosts verwalten, Befehle definieren und Aktionen ausführen.

+
+
+
+ +
+
+ Hosts +
Registriert:
+ +
+
+ Befehle +
Presets:
+ +
+
+ Konsole +
Runs:
+ +
+
+
+ diff --git a/temp/nexus-module-import/modules/pi_control/pages/terminal_info.php b/temp/nexus-module-import/modules/pi_control/pages/terminal_info.php new file mode 100644 index 00000000..7f8741f5 --- /dev/null +++ b/temp/nexus-module-import/modules/pi_control/pages/terminal_info.php @@ -0,0 +1,83 @@ + module_fn('pi_control', 'table', $name); + +header('Content-Type: application/json; charset=utf-8'); + +$settings = modules()->settings('pi_control'); +$sharedSecret = trim((string)($settings['terminal_shared_secret'] ?? '')); +if ($sharedSecret !== '') { + $provided = trim((string)($_SERVER['HTTP_X_TERMINAL_SECRET'] ?? '')); + if (!hash_equals($sharedSecret, $provided)) { + http_response_code(401); + echo json_encode(['ok' => false, 'error' => 'unauthorized']); + exit; + } +} + +$token = ''; +if (!empty($_GET['token'])) { + $token = trim((string)$_GET['token']); +} elseif (!empty($_SERVER['HTTP_X_TERMINAL_TOKEN'])) { + $token = trim((string)$_SERVER['HTTP_X_TERMINAL_TOKEN']); +} + +if ($token === '') { + http_response_code(400); + echo json_encode(['ok' => false, 'error' => 'missing_token']); + exit; +} + +$driver = (string)$pdo->getAttribute(PDO::ATTR_DRIVER_NAME); +$nowSql = $driver === 'pgsql' ? 'NOW()' : "DATETIME('now')"; + +$sessionStmt = $pdo->prepare( + 'SELECT * FROM ' . $table('sessions') . ' WHERE token = :token AND expires_at > ' . $nowSql . ' LIMIT 1' +); +$sessionStmt->execute(['token' => $token]); +$session = $sessionStmt->fetch(PDO::FETCH_ASSOC); + +if (!$session) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'invalid_or_expired']); + exit; +} + +$hostStmt = $pdo->prepare('SELECT * FROM ' . $table('hosts') . ' WHERE id = :id LIMIT 1'); +$hostStmt->execute(['id' => (int)$session['host_id']]); +$host = $hostStmt->fetch(PDO::FETCH_ASSOC); + +if (!$host) { + http_response_code(404); + echo json_encode(['ok' => false, 'error' => 'host_not_found']); + exit; +} + +$pdo->prepare('UPDATE ' . $table('sessions') . ' SET last_used_at = ' . $nowSql . ' WHERE id = :id') + ->execute(['id' => (int)$session['id']]); + +$commandText = (string)($session['command_text'] ?? ''); +if ($commandText !== '') { + $pdo->prepare('UPDATE ' . $table('sessions') . ' SET command_text = NULL WHERE id = :id') + ->execute(['id' => (int)$session['id']]); +} + +echo json_encode([ + 'ok' => true, + 'host' => [ + 'name' => (string)($host['name'] ?? ''), + 'host' => (string)($host['host'] ?? ''), + 'port' => (int)($host['port'] ?? 22), + 'username' => (string)($host['username'] ?? ''), + 'auth_type' => (string)($host['auth_type'] ?? 'key'), + 'key_path' => (string)($host['key_path'] ?? ''), + 'password' => (string)($host['password'] ?? ''), + ], + 'command' => $commandText, + 'strict_hostkey' => !empty($settings['terminal_strict_hostkey']), + 'tmux_session' => (string)($settings['terminal_tmux_session'] ?? ''), +]); +exit; diff --git a/temp/nexus-module-import/modules/pihole/assets/pihole.css b/temp/nexus-module-import/modules/pihole/assets/pihole.css new file mode 100644 index 00000000..6e00a93b --- /dev/null +++ b/temp/nexus-module-import/modules/pihole/assets/pihole.css @@ -0,0 +1,351 @@ +.pihole-page .card { background: var(--panel); } + +.pihole-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 14px; +} + +.pihole-stat { + padding: 16px; + background: var(--panel-2); +} + +.pihole-stat-value { + font-size: 1.6rem; + font-weight: 700; + margin-top: 6px; +} + +.pihole-stat-sub { + margin-top: 4px; + color: var(--muted); +} + +.pihole-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.pihole-actions { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.pihole-inline { + display: inline-flex; + gap: 8px; + align-items: center; +} + +.pihole-inline input { + width: 110px; +} + +.pihole-instance-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; + margin-top: 12px; +} + +.pihole-instance { + padding: 16px; + background: var(--panel-2); +} + +.pihole-instance-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.pihole-instance-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; + margin-top: 12px; +} + +.pihole-instance-value { + font-weight: 700; + margin-top: 4px; +} + +.pihole-status { + padding: 6px 10px; + border-radius: 999px; + font-size: 0.85rem; + font-weight: 600; + background: rgba(0, 179, 164, 0.15); + color: #0a6b63; +} + +.pihole-status.is-disabled { + background: rgba(255, 90, 61, 0.15); + color: #a83a28; +} + +.pihole-status.is-partial { + background: rgba(255, 166, 0, 0.18); + color: #8a5a00; +} + +.pihole-split { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 14px; +} + +.pihole-list { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.pihole-list-row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.7); +} + +.pihole-list-row strong { + font-weight: 600; +} + +.pihole-update { + margin-top: 10px; + font-size: 0.95rem; + color: var(--muted); +} + +.pihole-error { + margin-top: 8px; + font-size: 0.9rem; + color: #a83a28; +} + +.pihole-blocked { + display: grid; + gap: 8px; + margin-top: 12px; +} + +.pihole-blocked-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-radius: 12px; + background: rgba(255, 90, 61, 0.08); +} + +.pihole-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + align-items: end; + margin-top: 12px; +} + +.pihole-form label { + display: grid; + gap: 6px; +} + +.pihole-page.is-busy { + position: relative; +} + +.pihole-busy-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(10, 14, 24, 0.28); + backdrop-filter: blur(2px); + z-index: 80; +} + +.pihole-busy-overlay[hidden] { + display: none; +} + +.pihole-busy-card { + min-width: min(320px, 92vw); + display: grid; + gap: 8px; + padding: 18px 20px; + border-radius: 16px; + border: 1px solid var(--line); + background: var(--panel); + box-shadow: var(--shadow); +} + +.pihole-busy-card span { + color: var(--muted); +} + +.pihole-console-modal { + width: min(720px, 96vw); +} + +.pihole-console-body { + margin-top: 16px; + display: grid; + gap: 10px; + max-height: 48vh; + overflow: auto; + padding: 4px; +} + +.pihole-console-line { + display: grid; + gap: 4px; + padding: 12px 14px; + border-radius: 14px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.6); +} + +.pihole-console-line span { + font-size: 0.8rem; + color: var(--muted); +} + +.pihole-console-line strong { + font-weight: 600; +} + +.pihole-console-line.is-success { + border-color: rgba(0, 179, 164, 0.22); + background: rgba(0, 179, 164, 0.08); +} + +.pihole-console-line.is-error { + border-color: rgba(255, 90, 61, 0.24); + background: rgba(255, 90, 61, 0.08); +} + +.pihole-instance-card { + padding: 16px; + background: var(--panel-2); + display: grid; + gap: 12px; +} + +.pihole-card-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.pihole-test-result { + margin-top: 6px; + font-size: 0.9rem; + color: var(--muted); +} +.pihole-test-result.is-ok { color: #0a6b63; } +.pihole-test-result.is-auth { color: #a83a28; } +.pihole-test-result.is-unreachable { color: #a83a28; } +.pihole-test-result.is-invalid { color: #8a5a00; } +.pihole-test-result.is-error { color: #a83a28; } + +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.form-field { + display: grid; + gap: 6px; +} + +.pihole-instance-form { + margin-top: 16px; + display: grid; + gap: 18px; +} + +.pihole-instance-form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; +} + +.pihole-form-field-wide { + grid-column: 1 / -1; +} + +.pihole-checkbox-field { + display: inline-flex; + align-items: center; + gap: 10px; + min-height: 44px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.5); + font-weight: 600; + color: var(--text); +} + +.pihole-checkbox-field input { + width: 18px; + height: 18px; + margin: 0; +} + +.pihole-modal-card { + width: min(760px, 96vw); + padding: 20px; +} + +.pihole-modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; + padding-top: 4px; +} + +.icon-button { + border: 1px solid var(--line); + background: var(--panel); + border-radius: 10px; + width: 36px; + height: 36px; + cursor: pointer; + font-size: 1.1rem; +} + +@media (max-width: 680px) { + .pihole-actions { + flex-direction: column; + align-items: stretch; + } + + .pihole-inline { + width: 100%; + } + + .pihole-inline input { + width: 100%; + } + + .pihole-instance-form-grid { + grid-template-columns: 1fr; + } +} diff --git a/temp/nexus-module-import/modules/pihole/assets/pihole.js b/temp/nexus-module-import/modules/pihole/assets/pihole.js new file mode 100644 index 00000000..48616fed --- /dev/null +++ b/temp/nexus-module-import/modules/pihole/assets/pihole.js @@ -0,0 +1,507 @@ +(() => { + const page = document.querySelector('[data-pihole-page]'); + if (!page) return; + + const pageType = page.dataset.piholePage || 'dashboard'; + const configuredRefreshSeconds = Number(page.dataset.refreshSeconds || 0); + const defaultRefreshSeconds = pageType === 'dashboard' ? 1 : 5; + const refreshSeconds = Number.isFinite(configuredRefreshSeconds) && configuredRefreshSeconds >= 0 + ? configuredRefreshSeconds + : defaultRefreshSeconds; + + const fmt = new Intl.NumberFormat('de-DE'); + const fmtDate = (ts) => { + if (!ts) return '–'; + const d = new Date(ts * 1000); + return d.toLocaleString('de-DE'); + }; + + let refreshTimer = null; + let loadInFlight = false; + let actionInFlight = false; + let actionConsoleApi = null; + let actionConsoleBody = null; + let actionConsoleClose = null; + + const apiCall = async (action, payload = {}) => { + const res = await fetch(`/module/pihole/api?action=${encodeURIComponent(action)}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + } + ); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || `HTTP ${res.status}`); + } + if (data && data.results && typeof data.results === 'object') { + const failures = Object.values(data.results).filter((row) => row && row.ok === false); + if (failures.length) { + const first = failures[0]; + throw new Error(first.error || 'action_failed'); + } + } + return data; + }; + + const setText = (el, value) => { + if (!el) return; + el.textContent = value; + }; + + const ensureActionConsole = () => { + if (actionConsoleApi && actionConsoleBody && actionConsoleClose) { + return; + } + + const root = document.createElement('div'); + root.className = 'modal'; + root.dataset.piholeConsoleModal = '1'; + root.setAttribute('aria-hidden', 'true'); + root.innerHTML = ` + + `; + document.body.appendChild(root); + + actionConsoleBody = root.querySelector('[data-pihole-console-body]'); + actionConsoleClose = root.querySelector('[data-pihole-console-close]'); + const clearBtn = root.querySelector('[data-pihole-console-clear]'); + + actionConsoleApi = window.NexusModal && typeof window.NexusModal.create === 'function' + ? window.NexusModal.create(root, { initialFocus: '[data-pihole-console-close]' }) + : { + open() { + root.classList.add('is-open'); + root.setAttribute('aria-hidden', 'false'); + }, + close() { + root.classList.remove('is-open'); + root.setAttribute('aria-hidden', 'true'); + }, + }; + + if (actionConsoleClose) { + actionConsoleClose.addEventListener('click', () => { + if (!actionInFlight) { + actionConsoleApi.close(); + } + }); + } + + if (clearBtn) { + clearBtn.addEventListener('click', () => { + if (actionConsoleBody) { + actionConsoleBody.innerHTML = ''; + } + }); + } + }; + + const appendActionLog = (message, tone = 'info') => { + ensureActionConsole(); + if (!actionConsoleBody) { + return; + } + const row = document.createElement('div'); + row.className = `pihole-console-line is-${tone}`; + row.innerHTML = `${new Date().toLocaleTimeString('de-DE')}${message}`; + actionConsoleBody.appendChild(row); + actionConsoleBody.scrollTop = actionConsoleBody.scrollHeight; + }; + + const setActionLock = (locked, message = 'Bitte warten ...') => { + actionInFlight = locked; + page.classList.toggle('is-busy', locked); + ensureActionConsole(); + if (actionConsoleClose) { + actionConsoleClose.disabled = locked; + } + + page.querySelectorAll('button, input, select, textarea').forEach((el) => { + const formControl = el; + if (locked) { + formControl.dataset.piholeWasDisabled = formControl.disabled ? 'true' : 'false'; + formControl.disabled = true; + return; + } + formControl.disabled = formControl.dataset.piholeWasDisabled === 'true'; + delete formControl.dataset.piholeWasDisabled; + }); + }; + + const statusLabel = (status) => { + if (status === 'enabled') return 'Aktiv'; + if (status === 'disabled') return 'Deaktiviert'; + if (status === 'partial') return 'Teilweise'; + return 'Unbekannt'; + }; + + const setStatusBadge = (el, status) => { + if (!el) return; + el.textContent = statusLabel(status); + el.classList.remove('is-disabled', 'is-partial'); + if (status === 'disabled') el.classList.add('is-disabled'); + if (status === 'partial') el.classList.add('is-partial'); + }; + + const mapToList = (map, limit = 10) => { + if (!map || typeof map !== 'object') return []; + const entries = Object.entries(map) + .filter(([, value]) => typeof value === 'number' || typeof value === 'string') + .map(([key, value]) => [key, Number(value)]) + .sort((a, b) => b[1] - a[1]); + return entries.slice(0, limit); + }; + + const renderList = (container, map, emptyText) => { + if (!container) return; + const rows = mapToList(map, 10); + container.innerHTML = ''; + if (!rows.length) { + const div = document.createElement('div'); + div.className = 'muted'; + div.textContent = emptyText || 'Keine Daten'; + container.appendChild(div); + return; + } + rows.forEach(([label, value]) => { + const row = document.createElement('div'); + row.className = 'pihole-list-row'; + row.innerHTML = `${label}${fmt.format(value)}`; + container.appendChild(row); + }); + }; + + const renderBlocked = (container, list) => { + if (!container) return; + container.innerHTML = ''; + if (!Array.isArray(list) || !list.length) { + const div = document.createElement('div'); + div.className = 'muted'; + div.textContent = 'Keine Daten'; + container.appendChild(div); + return; + } + list.slice(0, 20).forEach((entry) => { + const domain = entry.domain || entry; + const instance = entry.instance || ''; + const row = document.createElement('div'); + row.className = 'pihole-blocked-row'; + row.innerHTML = `${domain}${instance}`; + container.appendChild(row); + }); + }; + + const renderDashboardData = (data) => { + const summary = data.aggregate?.summary || {}; + setText(document.querySelector('[data-summary-dns]'), fmt.format(Number(summary.dns_queries_today || 0))); + setText(document.querySelector('[data-summary-blocked]'), fmt.format(Number(summary.ads_blocked_today || 0))); + setText(document.querySelector('[data-summary-percent]'), `${Number(summary.ads_percentage_today || 0).toFixed(2)}%`); + setText(document.querySelector('[data-summary-clients]'), fmt.format(Number(summary.unique_clients || 0))); + setStatusBadge(document.querySelector('[data-summary-status]'), summary.status || 'unknown'); + setText(document.querySelector('[data-summary-last-refresh]'), `Letztes Update: ${fmtDate(data.ts)}`); + + renderInstances(data.instances || {}); + renderList(document.querySelector('[data-query-types]'), data.aggregate?.query_types, 'Keine Daten'); + renderList(document.querySelector('[data-forward-destinations]'), data.aggregate?.forward_destinations, 'Keine Daten'); + renderList(document.querySelector('[data-top-ads]'), data.aggregate?.top_ads, 'Keine Daten'); + renderList(document.querySelector('[data-top-queries]'), data.aggregate?.top_queries, 'Keine Daten'); + renderList(document.querySelector('[data-top-clients]'), data.aggregate?.query_sources, 'Keine Daten'); + renderBlocked(document.querySelector('[data-recent-blocked]'), data.aggregate?.recent_blocked); + }; + + const dashboardFingerprint = (data) => { + const aggregate = data?.aggregate?.summary || {}; + const instanceSummary = Object.values(data?.instances || {}).map((entry) => ({ + id: entry?.meta?.id || '', + blocked_domains: Number(entry?.summary?.blocked_domains || 0), + ads_blocked_today: Number(entry?.summary?.ads_blocked_today || 0), + unique_domains: Number(entry?.summary?.unique_domains || 0), + })); + + return JSON.stringify({ + blocked_domains: Number(aggregate.blocked_domains || 0), + ads_blocked_today: Number(aggregate.ads_blocked_today || 0), + unique_domains: Number(aggregate.unique_domains || 0), + instances: instanceSummary, + }); + }; + + const delay = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms)); + + const monitorGravityProgress = async (baselineData, instanceLabel) => { + const baselineFingerprint = dashboardFingerprint(baselineData || {}); + const maxPolls = 24; + const pollDelayMs = 5000; + + appendActionLog(`Pi-hole meldet keinen Live-Fortschritt. Pruefe jetzt zyklisch auf erkennbare Aenderungen fuer ${instanceLabel}.`, 'info'); + + for (let attempt = 1; attempt <= maxPolls; attempt += 1) { + await delay(pollDelayMs); + appendActionLog(`Pruefung ${attempt}/${maxPolls}: aktuelle Statusdaten werden geladen ...`, 'info'); + + try { + const data = await apiCall('dashboard'); + if (!data.ok) { + throw new Error(data.error || 'API error'); + } + + renderDashboardData(data); + const nextFingerprint = dashboardFingerprint(data); + if (nextFingerprint !== baselineFingerprint) { + appendActionLog(`Erkennbare Aenderung gefunden. Listen-Update fuer ${instanceLabel} scheint abgeschlossen zu sein.`, 'success'); + return true; + } + + if (attempt % 3 === 0) { + appendActionLog('Noch keine erkennbare Aenderung in den Pi-hole Statusdaten.', 'info'); + } + } catch (err) { + appendActionLog(`Statuspruefung fehlgeschlagen: ${err.message}`, 'error'); + } + } + + appendActionLog(`Innerhalb des Beobachtungsfensters wurde keine erkennbare Aenderung gefunden. Das Update fuer ${instanceLabel} kann trotzdem weiterlaufen oder bereits ohne sichtbare Statistikaenderung beendet worden sein.`, 'error'); + return false; + }; + + const renderInstances = (instances) => { + const holder = document.querySelector('[data-instance-cards]'); + const tpl = document.querySelector('#pihole-instance-template'); + if (!holder || !tpl) return; + holder.innerHTML = ''; + + Object.values(instances).forEach((entry) => { + const node = tpl.content.cloneNode(true); + const root = node.querySelector('[data-instance]'); + if (!root) return; + root.dataset.instance = entry.meta.id; + + const summary = entry.summary || {}; + setText(root.querySelector('[data-instance-name]'), entry.meta.name || entry.meta.id); + setText(root.querySelector('[data-instance-url]'), entry.meta.url || ''); + setText(root.querySelector('[data-instance-dns]'), fmt.format(Number(summary.dns_queries_today || 0))); + setText(root.querySelector('[data-instance-ads]'), fmt.format(Number(summary.ads_blocked_today || 0))); + const percent = summary.ads_percentage_today || summary.ads_percentage || 0; + setText(root.querySelector('[data-instance-percent]'), `${Number(percent).toFixed(2)}%`); + setStatusBadge(root.querySelector('[data-instance-status]'), summary.status || 'unknown'); + + const actions = root.querySelectorAll('[data-instance]'); + actions.forEach((btn) => { + if (btn.dataset.instance === '') { + btn.dataset.instance = entry.meta.id; + } + }); + const customInput = root.querySelector('[data-custom-minutes]'); + if (customInput) { + customInput.dataset.customMinutes = entry.meta.id; + } + + const updateEl = root.querySelector('[data-instance-update]'); + if (entry.updates && entry.updates.available) { + updateEl.textContent = 'Updates verfuegbar'; + } else { + updateEl.textContent = 'Keine Updates erkannt'; + } + + const errorEl = root.querySelector('[data-instance-errors]'); + if (errorEl) { + if (Array.isArray(entry.errors) && entry.errors.length) { + const lines = entry.errors.map((err) => { + const code = err.http_code ? `HTTP ${err.http_code}` : 'HTTP ?'; + const scope = err.scope || 'request'; + const msg = err.error || 'error'; + const hint = err.hint ? `, Hint: ${typeof err.hint === 'string' ? err.hint : JSON.stringify(err.hint)}` : ''; + return `${scope}: ${msg} (${code}${hint})`; + }); + errorEl.textContent = `API Fehler: ${lines.join(' | ')}`; + } else { + errorEl.textContent = ''; + } + } + + holder.appendChild(node); + }); + }; + + const bindActionButtons = () => { + document.addEventListener('click', async (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + + const action = btn.dataset.action; + const instance = btn.dataset.instance || 'all'; + const minutes = btn.dataset.minutes; + const scope = btn.closest('.pihole-instance') || document; + const customInput = scope.querySelector(`[data-custom-minutes="${instance}"]`); + let payload = { instance }; + let baselineData = null; + + if (action === 'disable') { + payload.minutes = Number(minutes || 0); + } + if (action === 'disable-custom') { + payload.minutes = Number(customInput?.value || 0); + } + + try { + const actionLabel = action === 'enable' + ? 'Pi-hole wird aktiviert ...' + : action === 'disable' || action === 'disable-custom' + ? 'Pi-hole wird deaktiviert ...' + : action === 'gravity' + ? 'Listen werden aktualisiert ...' + : 'Aktion wird ausgefuehrt ...'; + ensureActionConsole(); + if (actionConsoleBody) { + actionConsoleBody.innerHTML = ''; + } + actionConsoleApi.open(); + appendActionLog(actionLabel, 'info'); + setActionLock(true, actionLabel); + + baselineData = action === 'gravity' ? await apiCall('dashboard').catch(() => null) : null; + + if (action === 'enable') { + appendActionLog(`Aktiviere ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info'); + await apiCall('enable', payload); + } else if (action === 'disable' || action === 'disable-custom') { + if (!payload.minutes || payload.minutes <= 0) { + appendActionLog('Fehler: Bitte Minuten angeben.', 'error'); + return; + } + appendActionLog(`Deaktiviere ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`} fuer ${payload.minutes} Minuten.`, 'info'); + await apiCall('disable', payload); + } else if (action === 'gravity') { + appendActionLog(`Starte Listen-Update fuer ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info'); + await apiCall('gravity', payload); + const status = document.querySelector('[data-list-update-status]'); + if (status) status.textContent = 'Listen-Update gestartet.'; + } else if (action === 'update') { + appendActionLog(`Starte Pi-hole-Update fuer ${instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`}.`, 'info'); + await apiCall('update', payload); + } + appendActionLog('Aktion abgeschlossen. Dashboard wird aktualisiert.', 'success'); + if (action === 'gravity') { + await monitorGravityProgress(baselineData, instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`); + } else { + await loadDashboard(); + appendActionLog('Anzeige erfolgreich aktualisiert.', 'success'); + } + } catch (err) { + appendActionLog(`Aktion fehlgeschlagen: ${err.message}`, 'error'); + if (action === 'gravity' && /timed out/i.test(err.message)) { + appendActionLog('Der Request ist in Nexus abgelaufen. Pi-hole kann intern trotzdem weiterarbeiten. Starte weiterfuehrende Statuspruefung.', 'info'); + await monitorGravityProgress(baselineData, instance === 'all' ? 'alle Instanzen' : `Instanz ${instance}`); + } + } finally { + setActionLock(false); + } + }); + }; + + const bindForms = () => { + const domainForm = document.querySelector('[data-domain-form]'); + if (domainForm) { + domainForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(domainForm); + const type = String(formData.get('type') || 'block'); + const domain = String(formData.get('domain') || '').trim(); + const status = document.querySelector('[data-domain-status]'); + if (!domain) return; + try { + await apiCall('domain_add', { type, domain, instance: 'primary' }); + if (status) status.textContent = `Domain hinzugefuegt: ${domain}`; + domainForm.reset(); + } catch (err) { + if (status) status.textContent = `Fehler: ${err.message}`; + } + }); + } + + const adlistForm = document.querySelector('[data-adlist-form]'); + if (adlistForm) { + adlistForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(adlistForm); + const url = String(formData.get('url') || '').trim(); + const status = document.querySelector('[data-adlist-status]'); + if (!url) return; + try { + await apiCall('adlist_add', { url, instance: 'primary' }); + if (status) status.textContent = `Adlist hinzugefuegt: ${url}`; + adlistForm.reset(); + } catch (err) { + if (status) status.textContent = `Fehler: ${err.message}`; + } + }); + } + }; + + const loadDashboard = async () => { + if (loadInFlight || actionInFlight) return; + loadInFlight = true; + try { + const data = await apiCall('dashboard'); + if (!data.ok) throw new Error(data.error || 'API error'); + renderDashboardData(data); + const existing = page.querySelector('[data-pihole-load-error]'); + if (existing) existing.remove(); + } catch (err) { + let message = page.querySelector('[data-pihole-load-error]'); + if (!message) { + message = document.createElement('div'); + message.className = 'card'; + message.style.marginTop = '1rem'; + message.dataset.piholeLoadError = '1'; + page.appendChild(message); + } + message.textContent = `Fehler beim Laden der Pi-hole Daten: ${err.message}`; + } finally { + loadInFlight = false; + } + }; + + const stopAutoRefresh = () => { + if (refreshTimer !== null) { + window.clearInterval(refreshTimer); + refreshTimer = null; + } + }; + + const startAutoRefresh = () => { + stopAutoRefresh(); + if (refreshSeconds <= 0 || document.visibilityState !== 'visible') { + return; + } + refreshTimer = window.setInterval(loadDashboard, refreshSeconds * 1000); + }; + + bindActionButtons(); + bindForms(); + loadDashboard(); + startAutoRefresh(); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + loadDashboard(); + startAutoRefresh(); + return; + } + stopAutoRefresh(); + }); + window.addEventListener('beforeunload', stopAutoRefresh); +})(); diff --git a/temp/nexus-module-import/modules/pihole/assets/pihole_instances.js b/temp/nexus-module-import/modules/pihole/assets/pihole_instances.js new file mode 100644 index 00000000..42066873 --- /dev/null +++ b/temp/nexus-module-import/modules/pihole/assets/pihole_instances.js @@ -0,0 +1,141 @@ +(() => { + const modal = document.querySelector('[data-instance-modal]'); + const form = document.querySelector('[data-instance-form]'); + + const apiCall = async (action, payload = {}) => { + const res = await fetch(`/module/pihole/api?action=${encodeURIComponent(action)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; + }; + + document.addEventListener('click', async (event) => { + const testBtn = event.target.closest('[data-instance-test]'); + if (!testBtn) return; + + const card = testBtn.closest('[data-instance-id]'); + if (!card) return; + + const instanceId = card.dataset.instanceId || ''; + const resultEl = card.querySelector('[data-instance-result]'); + if (resultEl) { + resultEl.classList.remove('is-ok', 'is-auth', 'is-unreachable', 'is-invalid', 'is-error'); + resultEl.textContent = 'Teste Verbindung...'; + } + + try { + const res = await apiCall('test', { instance: instanceId }); + if (resultEl) { + const statusClass = res.status ? `is-${res.status}` : 'is-ok'; + resultEl.classList.add(statusClass); + resultEl.textContent = res.message || 'Verbindung OK.'; + } + } catch (err) { + if (resultEl) { + const msg = err.message || 'Fehler'; + resultEl.classList.add('is-error'); + resultEl.textContent = `Test fehlgeschlagen: ${msg}`; + } + } + }); + + if (!modal || !form) return; + + const title = document.querySelector('[data-instance-modal-title]'); + const closeBtn = document.querySelector('[data-instance-close]'); + const newBtn = document.querySelector('[data-instance-new]'); + const cancelBtn = document.querySelector('[data-instance-cancel]'); + + const currentIdInput = form.querySelector('input[name="current_id"]'); + const idInput = form.querySelector('input[name="instance_id"]'); + const nameInput = form.querySelector('input[name="name"]'); + const urlInput = form.querySelector('input[name="url"]'); + const passwordInput = form.querySelector('input[name="password"]'); + const primaryInput = form.querySelector('input[name="is_primary"]'); + + const resetForm = () => { + if (currentIdInput) currentIdInput.value = ''; + if (idInput) idInput.value = ''; + if (nameInput) nameInput.value = ''; + if (urlInput) urlInput.value = ''; + if (passwordInput) passwordInput.value = ''; + if (primaryInput) primaryInput.checked = false; + }; + + const modalApi = window.NexusModal && typeof window.NexusModal.create === 'function' + ? window.NexusModal.create(modal, { initialFocus: 'input[name="instance_id"]' }) + : null; + + const openModal = () => { + if (modalApi) { + modalApi.open(); + return; + } + modal.classList.add('is-open'); + modal.setAttribute('aria-hidden', 'false'); + if (idInput) { + window.setTimeout(() => idInput.focus(), 20); + } + }; + + const closeModal = () => { + if (modalApi) { + modalApi.close(); + return; + } + modal.classList.remove('is-open'); + modal.setAttribute('aria-hidden', 'true'); + }; + + document.querySelectorAll('[data-instance-edit]').forEach((btn) => { + btn.addEventListener('click', () => { + const card = btn.closest('[data-instance-id]'); + if (!card) return; + if (currentIdInput) currentIdInput.value = card.dataset.instanceId || ''; + if (idInput) idInput.value = card.dataset.instanceId || ''; + if (nameInput) nameInput.value = card.dataset.name || ''; + if (urlInput) urlInput.value = card.dataset.url || ''; + if (passwordInput) passwordInput.value = ''; + if (primaryInput) primaryInput.checked = card.dataset.primary === '1'; + if (title) title.textContent = 'Instanz bearbeiten'; + openModal(); + }); + }); + + if (newBtn) { + newBtn.addEventListener('click', () => { + resetForm(); + if (title) title.textContent = 'Neue Instanz'; + openModal(); + }); + } + + if (closeBtn) { + closeBtn.addEventListener('click', () => closeModal()); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + resetForm(); + closeModal(); + }); + } + + if (!modalApi) { + modal.addEventListener('click', (event) => { + if (event.target === modal) { + closeModal(); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && modal.classList.contains('is-open')) { + closeModal(); + } + }); + } +})(); diff --git a/temp/nexus-module-import/modules/pihole/bootstrap.php b/temp/nexus-module-import/modules/pihole/bootstrap.php new file mode 100644 index 00000000..a30feee9 --- /dev/null +++ b/temp/nexus-module-import/modules/pihole/bootstrap.php @@ -0,0 +1,116 @@ +registerFunction($moduleName, 'settings', function () use ($moduleName): array { + return modules()->settings($moduleName); +}); + +$mm->registerFunction($moduleName, 'instances', function () use ($moduleName): array { + $settings = modules()->settings($moduleName); + $apiPath = trim((string)($settings['api_path'] ?? '/admin/api.php')); + if ($apiPath === '') { + $apiPath = '/admin/api.php'; + } + if ($apiPath[0] !== '/') { + $apiPath = '/' . $apiPath; + } + + $timeout = (int)($settings['api_timeout_sec'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + + $verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true; + + $normalizeSecret = static function (array $row): string { + $password = trim((string)($row['password'] ?? '')); + if ($password !== '') { + return $password; + } + + return trim((string)($row['token'] ?? '')); + }; + + $instances = []; + + $rawJson = trim((string)($settings['instances_json'] ?? '')); + if ($rawJson !== '') { + $decoded = json_decode($rawJson, true); + if (is_array($decoded)) { + foreach ($decoded as $row) { + if (!is_array($row)) { + continue; + } + $id = trim((string)($row['id'] ?? '')); + $url = trim((string)($row['url'] ?? '')); + if ($id === '' || $url === '') { + continue; + } + $instances[$id] = [ + 'id' => $id, + 'name' => trim((string)($row['name'] ?? '')) ?: $id, + 'url' => rtrim($url, '/'), + 'password' => $normalizeSecret($row), + 'api_path' => $apiPath, + 'timeout' => $timeout, + 'verify_tls' => $verifyTls, + 'is_primary' => !empty($row['is_primary']), + ]; + } + } + } + + if (empty($instances)) { + foreach (['primary', 'secondary'] as $key) { + $urlKey = $key . '_url'; + $tokenKey = $key . '_token'; + $nameKey = $key . '_name'; + $url = trim((string)($settings[$urlKey] ?? '')); + if ($url === '') { + continue; + } + $instances[$key] = [ + 'id' => $key, + 'name' => trim((string)($settings[$nameKey] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'), + 'url' => rtrim($url, '/'), + 'password' => trim((string)($settings[$tokenKey] ?? '')), + 'api_path' => $apiPath, + 'timeout' => $timeout, + 'verify_tls' => $verifyTls, + 'is_primary' => $key === 'primary', + ]; + } + } + + $primaryId = trim((string)($settings['primary_id'] ?? '')); + if ($primaryId !== '' && isset($instances[$primaryId])) { + foreach ($instances as $id => &$row) { + $row['is_primary'] = ($id === $primaryId); + } + unset($row); + } else { + $hasPrimary = false; + foreach ($instances as $row) { + if (!empty($row['is_primary'])) { + $hasPrimary = true; + break; + } + } + if (!$hasPrimary && $instances) { + $firstKey = array_key_first($instances); + if ($firstKey !== null) { + $instances[$firstKey]['is_primary'] = true; + } + } + } + + return $instances; +}); + +$mm->registerFunction($moduleName, 'lists_primary_only', function () use ($moduleName): bool { + $settings = modules()->settings($moduleName); + return !empty($settings['lists_primary_only']); +}); diff --git a/temp/nexus-module-import/modules/pihole/design.json b/temp/nexus-module-import/modules/pihole/design.json new file mode 100644 index 00000000..70934771 --- /dev/null +++ b/temp/nexus-module-import/modules/pihole/design.json @@ -0,0 +1,14 @@ +{ + "eyebrow": "Modul", + "title": "Pi-hole", + "description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.", + "actions": [ + { "label": "Setup", "href": "/modules/setup/pihole", "variant": "ghost" }, + { "label": "Instanzen", "href": "/module/pihole/instances", "variant": "secondary" } + ], + "tabs": [ + { "label": "Dashboard", "href": "/module/pihole" }, + { "label": "Listen", "href": "/module/pihole/lists" }, + { "label": "Queries", "href": "/module/pihole/queries" } + ] +} diff --git a/temp/nexus-module-import/modules/pihole/module.json b/temp/nexus-module-import/modules/pihole/module.json new file mode 100644 index 00000000..f125f8af --- /dev/null +++ b/temp/nexus-module-import/modules/pihole/module.json @@ -0,0 +1,44 @@ +{ + "title": "Pi-hole", + "version": "0.1.0", + "description": "Pi-hole Monitoring, Listen und Steuerung fuer mehrere Instanzen.", + "setup": { + "fields": [ + { + "name": "api_timeout_sec", + "label": "API Timeout Standard (Sekunden)", + "type": "number", + "help": "Timeout fuer normale Pi-hole API-Abfragen wie Dashboard und Queries. Standard: 8 Sekunden.", + "required": false + }, + { + "name": "action_timeout_sec", + "label": "API Timeout Aktionen (Sekunden)", + "type": "number", + "help": "Timeout fuer laengere Aktionen wie Listen- oder Pi-hole-Updates. Standard: 120 Sekunden.", + "required": false + }, + { + "name": "dashboard_refresh_sec", + "label": "Refresh Dashboard (Sekunden)", + "type": "number", + "help": "Automatische Aktualisierung fuer das Dashboard. Standard: 1 Sekunde. 0 deaktiviert den Auto-Refresh.", + "required": false + }, + { + "name": "lists_refresh_sec", + "label": "Refresh Listen (Sekunden)", + "type": "number", + "help": "Automatische Aktualisierung fuer die Listen-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.", + "required": false + }, + { + "name": "queries_refresh_sec", + "label": "Refresh Queries (Sekunden)", + "type": "number", + "help": "Automatische Aktualisierung fuer die Queries-Seite. Standard: 5 Sekunden. 0 deaktiviert den Auto-Refresh.", + "required": false + } + ] + } +} diff --git a/temp/nexus-module-import/modules/pihole/pages/api.php b/temp/nexus-module-import/modules/pihole/pages/api.php new file mode 100644 index 00000000..37b8292c --- /dev/null +++ b/temp/nexus-module-import/modules/pihole/pages/api.php @@ -0,0 +1,1154 @@ +settings('pihole'); + +$body = file_get_contents('php://input'); +$payload = []; +if ($body !== false && $body !== '') { + $decoded = json_decode($body, true); + if (is_array($decoded)) { + $payload = $decoded; + } +} + +$respond = function (array $data, int $status = 200): void { + http_response_code($status); + echo json_encode($data, JSON_UNESCAPED_UNICODE); + exit; +}; + +$debugPush = static function (string $label, array $payload = []): void { + module_debug_push('pihole', array_merge(['label' => $label], $payload)); +}; + +app()->session()->start(); + +$sessionFingerprint = static function (array $instance): string { + return sha1(json_encode([ + 'id' => (string)($instance['id'] ?? ''), + 'url' => (string)($instance['url'] ?? ''), + 'password' => (string)($instance['password'] ?? ''), + ], JSON_UNESCAPED_UNICODE)); +}; + +$getSessionBucket = static function (): array { + $bucket = $_SESSION['pihole_api_sessions'] ?? []; + return is_array($bucket) ? $bucket : []; +}; + +$readSessionCache = static function (array $instance) use ($getSessionBucket, $sessionFingerprint): ?array { + $bucket = $getSessionBucket(); + $key = (string)($instance['id'] ?? ''); + if ($key === '' || !isset($bucket[$key]) || !is_array($bucket[$key])) { + return null; + } + + $row = $bucket[$key]; + if (($row['fingerprint'] ?? '') !== $sessionFingerprint($instance)) { + return null; + } + + return $row; +}; + +$writeSessionCache = static function (array $instance, string $sid, int $validity = 300, ?string $csrf = null) use ($sessionFingerprint): void { + $key = (string)($instance['id'] ?? ''); + if ($key === '' || $sid === '') { + return; + } + + if (!isset($_SESSION['pihole_api_sessions']) || !is_array($_SESSION['pihole_api_sessions'])) { + $_SESSION['pihole_api_sessions'] = []; + } + + $_SESSION['pihole_api_sessions'][$key] = [ + 'fingerprint' => $sessionFingerprint($instance), + 'sid' => $sid, + 'csrf' => $csrf ?? '', + 'validity' => $validity > 0 ? $validity : 300, + 'expires_at' => time() + max(30, $validity - 15), + 'updated_at' => time(), + ]; +}; + +$touchSessionCache = static function (array $instance, ?int $validity = null) use ($readSessionCache, $writeSessionCache): void { + $cached = $readSessionCache($instance); + if (!$cached || empty($cached['sid'])) { + return; + } + + $writeSessionCache( + $instance, + (string)$cached['sid'], + $validity ?? (int)($cached['validity'] ?? 300), + (string)($cached['csrf'] ?? '') + ); +}; + +$clearSessionCache = static function (array $instance): void { + $key = (string)($instance['id'] ?? ''); + if ($key === '' || !isset($_SESSION['pihole_api_sessions']) || !is_array($_SESSION['pihole_api_sessions'])) { + return; + } + + unset($_SESSION['pihole_api_sessions'][$key]); +}; + +$normalizeApiPath = function (string $baseUrl, string $apiPath): string { + $base = rtrim($baseUrl, '/'); + $path = $apiPath; + if ($path === '') { + $path = '/admin/api.php'; + } + if ($path[0] !== '/') { + $path = '/' . $path; + } + return $base . $path; +}; + +$httpRequest = function (string $method, string $url, array $headers, ?string $body, bool $verify, int $timeout): array { + $raw = ''; + $httpCode = 0; + $error = ''; + + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => $timeout, + CURLOPT_SSL_VERIFYPEER => $verify, + CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $raw = (string)curl_exec($ch); + if ($raw === '' && curl_errno($ch)) { + $error = curl_error($ch); + } + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + } else { + $ctx = stream_context_create([ + 'http' => [ + 'method' => $method, + 'timeout' => $timeout, + 'header' => implode("\r\n", $headers), + 'content' => $body ?? '', + ], + 'ssl' => [ + 'verify_peer' => $verify, + 'verify_peer_name' => $verify, + ], + ]); + $raw = (string)@file_get_contents($url, false, $ctx); + $httpCode = 200; + if ($raw === '') { + $error = 'HTTP request failed'; + } + } + + if ($error !== '') { + return ['ok' => false, 'error' => $error, 'http_code' => $httpCode, 'url' => $url]; + } + + $data = json_decode($raw, true); + if (!is_array($data)) { + return ['ok' => false, 'error' => 'invalid_json', 'http_code' => $httpCode, 'raw' => $raw, 'url' => $url]; + } + + if ($httpCode >= 400) { + $apiError = is_array($data['error'] ?? null) ? $data['error'] : []; + return [ + 'ok' => false, + 'error' => (string)($apiError['message'] ?? $apiError['key'] ?? ('HTTP ' . $httpCode)), + 'error_key' => (string)($apiError['key'] ?? ''), + 'hint' => $apiError['hint'] ?? null, + 'http_code' => $httpCode, + 'data' => $data, + 'url' => $url, + ]; + } + + if (isset($data['error'])) { + $apiError = is_array($data['error']) ? $data['error'] : []; + return [ + 'ok' => false, + 'error' => (string)($apiError['message'] ?? $apiError['key'] ?? 'api_error'), + 'error_key' => (string)($apiError['key'] ?? ''), + 'hint' => $apiError['hint'] ?? null, + 'http_code' => $httpCode, + 'data' => $data, + 'url' => $url, + ]; + } + + return ['ok' => true, 'data' => $data, 'http_code' => $httpCode, 'url' => $url]; +}; + +$v5Request = function (array $instance, array $params, ?int $timeoutOverride = null) use ($normalizeApiPath, $httpRequest): array { + if (!empty($instance['password']) && !isset($params['auth'])) { + $params['auth'] = $instance['password']; + } + + $url = $normalizeApiPath((string)$instance['url'], (string)$instance['api_path']); + $qs = http_build_query($params, '', '&', PHP_QUERY_RFC3986); + $full = $qs !== '' ? $url . '?' . $qs : $url; + + $timeout = $timeoutOverride ?? (int)($instance['timeout'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + $verify = !empty($instance['verify_tls']); + + return $httpRequest('GET', $full, ['Accept: application/json'], null, $verify, $timeout); +}; + +$v6Logout = function (array $instance, string $sid) use ($httpRequest): void { + if ($sid === '') { + return; + } + + $base = rtrim((string)$instance['url'], '/'); + $url = $base . '/api/auth?sid=' . rawurlencode($sid); + $timeout = (int)($instance['timeout'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + $verify = !empty($instance['verify_tls']); + $httpRequest('DELETE', $url, ['Accept: application/json', 'X-FTL-SID: ' . $sid], null, $verify, $timeout); +}; + +$v6Auth = function (array $instance) use ($httpRequest, $readSessionCache, $writeSessionCache, $clearSessionCache, $v6Logout): array { + $cached = $readSessionCache($instance); + if ($cached && !empty($cached['sid']) && (int)($cached['expires_at'] ?? 0) > time()) { + return [ + 'ok' => true, + 'http_code' => 200, + 'url' => rtrim((string)$instance['url'], '/') . '/api/auth', + 'data' => ['session' => ['sid' => (string)$cached['sid'], 'validity' => (int)($cached['validity'] ?? 300)]], + 'sid' => (string)$cached['sid'], + 'cached' => true, + ]; + } + + $base = rtrim((string)$instance['url'], '/'); + $url = $base . '/api/auth'; + $timeout = (int)($instance['timeout'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + $verify = !empty($instance['verify_tls']); + $payload = ['password' => (string)($instance['password'] ?? '')]; + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + $res = $httpRequest('POST', $url, ['Accept: application/json', 'Content-Type: application/json'], $body, $verify, $timeout); + if (!$res['ok']) { + $clearSessionCache($instance); + return $res; + } + $data = (array)($res['data'] ?? []); + $session = (array)($data['session'] ?? []); + $sid = (string)($session['sid'] ?? ''); + $validity = (int)($session['validity'] ?? 300); + $csrf = (string)($session['csrf'] ?? ''); + if ($cached && !empty($cached['sid']) && $cached['sid'] !== $sid) { + $v6Logout($instance, (string)$cached['sid']); + } + if ($sid !== '') { + $writeSessionCache($instance, $sid, $validity, $csrf); + } + $res['sid'] = $sid; + $res['cached'] = false; + return $res; +}; + +$v6Request = function (array $instance, string $path, string $method, array $payload, string $sid, ?int $timeoutOverride = null) use ($httpRequest, $clearSessionCache, $touchSessionCache): array { + $base = rtrim((string)$instance['url'], '/'); + $path = ltrim($path, '/'); + $url = $base . '/api/' . $path; + $timeout = $timeoutOverride ?? (int)($instance['timeout'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + $verify = !empty($instance['verify_tls']); + $headers = ['Accept: application/json']; + if ($sid !== '') { + $headers[] = 'sid: ' . $sid; + $headers[] = 'X-FTL-SID: ' . $sid; + } + + $body = null; + if ($method !== 'GET') { + if ($sid !== '' && !isset($payload['sid'])) { + $payload['sid'] = $sid; + } + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + $headers[] = 'Content-Type: application/json'; + } + + $result = $httpRequest($method, $url, $headers, $body, $verify, $timeout); + $httpCode = (int)($result['http_code'] ?? 0); + if ($sid !== '') { + if (($result['ok'] ?? false) === true) { + $touchSessionCache($instance); + } elseif (in_array($httpCode, [401, 403], true)) { + $clearSessionCache($instance); + } + } + + return $result; +}; + +$v6RequestAny = function (array $instance, array $paths, string $method, array $payload, string $sid, ?int $timeoutOverride = null) use ($v6Request): array { + $last = ['ok' => false, 'error' => 'no_path', 'http_code' => 0, 'url' => '']; + foreach ($paths as $path) { + $result = $v6Request($instance, (string)$path, $method, $payload, $sid, $timeoutOverride); + if (($result['ok'] ?? false) === true) { + return $result; + } + $last = $result; + $httpCode = (int)($result['http_code'] ?? 0); + if (!in_array($httpCode, [0, 400, 404], true)) { + return $result; + } + } + return $last; +}; + +$detectApi = function (array $instance) use ($v6Auth, $v6RequestAny, $v5Request, $clearSessionCache): array { + $sid = ''; + $authRes = null; + if (!empty($instance['password'])) { + $authRes = $v6Auth($instance); + if (($authRes['ok'] ?? false) && !empty($authRes['sid'])) { + $sid = (string)$authRes['sid']; + + $probe = $v6RequestAny($instance, ['stats/summary', 'summary'], 'GET', [], $sid); + if (!($probe['ok'] ?? false)) { + $versionProbe = $v6RequestAny($instance, ['dns/blocking'], 'GET', [], $sid); + if (($versionProbe['ok'] ?? false) || in_array((int)($versionProbe['http_code'] ?? 0), [401, 403], true)) { + $probe = $versionProbe; + } + } + if (!(empty($authRes['cached'])) && in_array((int)($probe['http_code'] ?? 0), [401, 403], true)) { + $clearSessionCache($instance); + $authRes = $v6Auth($instance); + if (($authRes['ok'] ?? false) && !empty($authRes['sid'])) { + $sid = (string)$authRes['sid']; + $probe = $v6RequestAny($instance, ['stats/summary', 'summary'], 'GET', [], $sid); + if (!($probe['ok'] ?? false)) { + $versionProbe = $v6RequestAny($instance, ['dns/blocking'], 'GET', [], $sid); + if (($versionProbe['ok'] ?? false) || in_array((int)($versionProbe['http_code'] ?? 0), [401, 403], true)) { + $probe = $versionProbe; + } + } + } + } + return ['version' => 6, 'sid' => $sid, 'probe' => $probe, 'auth' => $authRes]; + } + + $httpCode = (int)($authRes['http_code'] ?? 0); + if (in_array($httpCode, [401, 403], true)) { + return ['version' => 6, 'sid' => '', 'probe' => $authRes, 'auth' => $authRes]; + } + } + + $probe = $v6RequestAny($instance, ['stats/summary', 'summary', 'dns/blocking'], 'GET', [], $sid); + if ($probe['ok'] || in_array((int)($probe['http_code'] ?? 0), [401, 403], true)) { + return ['version' => 6, 'sid' => $sid, 'probe' => $probe, 'auth' => $authRes]; + } + + $legacy = $v5Request($instance, ['summaryRaw' => 1]); + if ($legacy['ok']) { + return ['version' => 5, 'sid' => '', 'probe' => $legacy]; + } + + return ['version' => 0, 'sid' => '', 'probe' => $probe, 'legacy' => $legacy, 'auth' => $authRes]; +}; + +$debugResult = static function (string $label, string $instanceId, array $instance, array $result) use ($debugPush): void { + $payload = [ + 'instance_id' => $instanceId, + 'instance_name' => (string)($instance['name'] ?? $instanceId), + 'url' => (string)($instance['url'] ?? ''), + 'ok' => (bool)($result['ok'] ?? false), + 'http_code' => (int)($result['http_code'] ?? 0), + 'error' => (string)($result['error'] ?? ''), + 'error_key' => (string)($result['error_key'] ?? ''), + 'hint' => $result['hint'] ?? null, + 'request_url' => (string)($result['url'] ?? ''), + ]; + + if (isset($result['data']) && is_array($result['data'])) { + $payload['response'] = $result['data']; + } elseif (isset($result['raw'])) { + $payload['raw'] = (string)$result['raw']; + } + + $debugPush($label, $payload); +}; + +$resolvePrimaryId = function () use ($instances): ?string { + foreach ($instances as $id => $row) { + if (!empty($row['is_primary'])) { + return $id; + } + } + $first = array_key_first($instances); + return $first !== null ? (string)$first : null; +}; + +$resolveActionTimeout = static function () use ($moduleSettings): int { + $timeout = (int)($moduleSettings['action_timeout_sec'] ?? 120); + return $timeout > 0 ? $timeout : 120; +}; + +$pickInstances = function (string $target) use ($instances, $resolvePrimaryId): array { + if ($target === 'all') { + return $instances; + } + if ($target === 'primary') { + $primaryId = $resolvePrimaryId(); + if ($primaryId !== null && isset($instances[$primaryId])) { + return [$primaryId => $instances[$primaryId]]; + } + } + if (isset($instances[$target])) { + return [$target => $instances[$target]]; + } + return []; +}; + +$aggregateTopList = function (array $items, array &$bucket): void { + foreach ($items as $label => $count) { + if (!is_string($label)) { + continue; + } + $bucket[$label] = ($bucket[$label] ?? 0) + (int)$count; + } +}; + +$aggregateMap = function (array $map, array &$bucket): void { + foreach ($map as $label => $count) { + if (!is_string($label)) { + continue; + } + $bucket[$label] = ($bucket[$label] ?? 0) + (int)$count; + } +}; + +$extractQueryTypes = function (array $data): array { + if (isset($data['querytypes']) && is_array($data['querytypes'])) { + return $data['querytypes']; + } + return $data; +}; + +$extractForwardDestinations = function (array $data): array { + if (isset($data['forward_destinations']) && is_array($data['forward_destinations'])) { + return $data['forward_destinations']; + } + return $data; +}; + +$extractSources = function (array $data): array { + if (isset($data['query_sources']) && is_array($data['query_sources'])) { + return $data['query_sources']; + } + return $data; +}; + +$parseUpdates = function (?array $versions): array { + if (!$versions) { + return ['available' => false, 'details' => []]; + } + $details = []; + $available = false; + foreach (['core', 'web', 'FTL'] as $key) { + $current = $versions[$key . '_current'] ?? null; + $latest = $versions[$key . '_latest'] ?? null; + $updateFlag = $versions[$key . '_update'] ?? null; + $needs = false; + if (is_string($updateFlag)) { + $needs = $updateFlag === 'true' || $updateFlag === '1'; + } elseif (is_bool($updateFlag)) { + $needs = $updateFlag; + } + if ($current && $latest && $current !== $latest) { + $needs = true; + } + $details[$key] = [ + 'current' => $current, + 'latest' => $latest, + 'update' => $needs, + ]; + if ($needs) { + $available = true; + } + } + return ['available' => $available, 'details' => $details]; +}; + +if ($action === 'dashboard') { + if (empty($instances)) { + $respond(['ok' => false, 'error' => 'no_instances'], 400); + } + + $debugPush('dashboard.request', [ + 'action' => $action, + 'instance_count' => count($instances), + ]); + + $aggregate = [ + 'summary' => [ + 'dns_queries_today' => 0, + 'ads_blocked_today' => 0, + 'unique_clients' => 0, + 'unique_domains' => 0, + 'blocked_domains' => 0, + 'queries_forwarded' => 0, + 'queries_cached' => 0, + 'status' => 'unknown', + ], + 'top_ads' => [], + 'top_queries' => [], + 'query_types' => [], + 'query_sources' => [], + 'forward_destinations' => [], + 'recent_blocked' => [], + 'updates' => ['available' => false, 'details' => []], + ]; + + $instancePayloads = []; + $statuses = []; + + $makeError = function (string $scope, array $result): array { + return [ + 'scope' => $scope, + 'error' => $result['error'] ?? 'error', + 'error_key' => $result['error_key'] ?? '', + 'hint' => $result['hint'] ?? null, + 'http_code' => $result['http_code'] ?? 0, + 'url' => $result['url'] ?? '', + ]; + }; + + $now = time(); + $from = $now - 3600; + + foreach ($instances as $id => $instance) { + $errors = []; + $summaryData = null; + $topData = null; + $queryTypesData = null; + $querySourcesData = null; + $forwardDestData = null; + $recentData = null; + $updates = ['available' => false, 'details' => []]; + $versionsData = null; + + $apiInfo = $detectApi($instance); + $debugPush('api.detect.result', [ + 'instance_id' => $id, + 'instance_name' => (string)($instance['name'] ?? $id), + 'version' => (int)($apiInfo['version'] ?? 0), + 'sid_present' => !empty($apiInfo['sid']), + ]); + if ($apiInfo['version'] === 6) { + $sid = (string)($apiInfo['sid'] ?? ''); + $summary = $apiInfo['probe']; + $debugResult('dashboard.summary', $id, $instance, $summary); + if (!$summary['ok']) { + $errors[] = $makeError('summary', $summary); + } + + $blocking = $v6RequestAny($instance, ['dns/blocking'], 'GET', [], $sid); + $debugResult('dashboard.blocking', $id, $instance, $blocking); + if (!$blocking['ok']) { + $errors[] = $makeError('blocking', $blocking); + } + + $queries = $v6RequestAny($instance, [ + 'queries', + 'queries/all', + 'queries?from=' . $from . '&until=' . $now, + 'queries/all?from=' . $from . '&until=' . $now, + ], 'GET', [], $sid); + $debugResult('dashboard.queries', $id, $instance, $queries); + if (!$queries['ok']) { + $errors[] = $makeError('queries', $queries); + } + + $upstreams = $v6RequestAny($instance, ['stats/upstreams', 'upstreams'], 'GET', [], $sid); + $debugResult('dashboard.upstreams', $id, $instance, $upstreams); + if (!$upstreams['ok']) { + $errors[] = $makeError('upstreams', $upstreams); + } + + if ($summary['ok'] && is_array($summary['data'])) { + $sum = $summary['data']; + $queriesBlock = (array)($sum['queries'] ?? []); + $clientsBlock = (array)($sum['clients'] ?? []); + $status = 'unknown'; + if ($blocking['ok'] && is_array($blocking['data'])) { + $blockingState = $blocking['data']['blocking'] ?? null; + if ($blockingState === true || $blockingState === 'enabled') { + $status = 'enabled'; + } elseif ($blockingState === false || $blockingState === 'disabled') { + $status = 'disabled'; + } + } + + $summaryData = [ + 'dns_queries_today' => (int)($queriesBlock['total'] ?? 0), + 'ads_blocked_today' => (int)($queriesBlock['blocked'] ?? 0), + 'unique_clients' => (int)($clientsBlock['active'] ?? $clientsBlock['total'] ?? 0), + 'unique_domains' => (int)($queriesBlock['unique_domains'] ?? 0), + 'blocked_domains' => (int)( + $sum['domains_being_blocked'] + ?? $sum['gravity']['domains_being_blocked'] + ?? $sum['gravity']['domains'] + ?? $sum['gravity']['blocked'] + ?? 0 + ), + 'queries_forwarded' => (int)($queriesBlock['forwarded'] ?? 0), + 'queries_cached' => (int)($queriesBlock['cached'] ?? 0), + 'status' => $status, + ]; + if ($summaryData['dns_queries_today'] > 0) { + $summaryData['ads_percentage_today'] = round( + $summaryData['ads_blocked_today'] / $summaryData['dns_queries_today'] * 100, + 2 + ); + } else { + $summaryData['ads_percentage_today'] = 0; + } + $queryTypesData = (array)($queriesBlock['types'] ?? []); + } + + if ($queries['ok'] && is_array($queries['data'])) { + $queryList = []; + if (isset($queries['data']['queries']) && is_array($queries['data']['queries'])) { + $queryList = $queries['data']['queries']; + } elseif (isset($queries['data']['data']) && is_array($queries['data']['data'])) { + $queryList = $queries['data']['data']; + } elseif (array_is_list($queries['data'])) { + $queryList = $queries['data']; + } + $topAds = []; + $topQueries = []; + $sources = []; + $recent = []; + + foreach ($queryList as $entry) { + if (!is_array($entry)) { + continue; + } + $domainRaw = $entry['domain'] ?? ''; + $domain = ''; + if (is_array($domainRaw)) { + $domain = (string)($domainRaw['name'] ?? $domainRaw['domain'] ?? ''); + } else { + $domain = (string)$domainRaw; + } + + $clientRaw = $entry['client'] ?? ''; + $client = ''; + if (is_array($clientRaw)) { + $name = (string)($clientRaw['name'] ?? ''); + $ip = (string)($clientRaw['ip'] ?? ''); + $client = $name !== '' ? ($name . ' (' . $ip . ')') : ($ip !== '' ? $ip : 'unknown'); + } else { + $client = (string)$clientRaw; + } + + if ($client !== '') { + $sources[$client] = ($sources[$client] ?? 0) + 1; + } + if ($domain !== '') { + $topQueries[$domain] = ($topQueries[$domain] ?? 0) + 1; + } + + $statusVal = strtoupper((string)($entry['status'] ?? '')); + $isBlocked = str_contains($statusVal, 'GRAVITY') + || str_contains($statusVal, 'BLOCK') + || str_contains($statusVal, 'DENY') + || str_contains($statusVal, 'CNAME'); + if ($isBlocked && $domain !== '') { + $topAds[$domain] = ($topAds[$domain] ?? 0) + 1; + $recent[] = ['domain' => $domain, 'instance' => $instance['name']]; + } + } + + $topData = ['top_ads' => $topAds, 'top_queries' => $topQueries]; + $querySourcesData = $sources; + $recentData = $recent; + } + + if ($upstreams['ok'] && is_array($upstreams['data'])) { + $upList = []; + if (isset($upstreams['data']['upstreams']) && is_array($upstreams['data']['upstreams'])) { + $upList = $upstreams['data']['upstreams']; + } elseif (isset($upstreams['data']['data']) && is_array($upstreams['data']['data'])) { + $upList = $upstreams['data']['data']; + } elseif (array_is_list($upstreams['data'])) { + $upList = $upstreams['data']; + } + $forwardDestData = []; + foreach ($upList as $item) { + if (!is_array($item)) { + continue; + } + $label = (string)($item['name'] ?? $item['ip'] ?? ''); + if ($label === '' && isset($item['port'])) { + $label = 'upstream:' . (string)$item['port']; + } + if ($label === '') { + continue; + } + $forwardDestData[$label] = (int)($item['count'] ?? 0); + } + } + } elseif ($apiInfo['version'] === 5) { + $summary = $apiInfo['probe']; + $topItems = $v5Request($instance, ['topItems' => 50]); + $queryTypes = $v5Request($instance, ['getQueryTypes' => 1]); + $querySources = $v5Request($instance, ['getQuerySources' => 1]); + $forwardDest = $v5Request($instance, ['getForwardDestinations' => 1]); + $recentBlocked = $v5Request($instance, ['recentBlocked' => 30]); + $versions = $v5Request($instance, ['versions' => 1]); + $debugResult('dashboard.summary', $id, $instance, $summary); + $debugResult('dashboard.topItems', $id, $instance, $topItems); + $debugResult('dashboard.queryTypes', $id, $instance, $queryTypes); + $debugResult('dashboard.querySources', $id, $instance, $querySources); + $debugResult('dashboard.forwardDestinations', $id, $instance, $forwardDest); + $debugResult('dashboard.recentBlocked', $id, $instance, $recentBlocked); + $debugResult('dashboard.versions', $id, $instance, $versions); + + if (!$summary['ok']) { + $errors[] = $makeError('summary', $summary); + } + if (!$topItems['ok']) { + $errors[] = $makeError('topItems', $topItems); + } + if (!$queryTypes['ok']) { + $errors[] = $makeError('queryTypes', $queryTypes); + } + if (!$querySources['ok']) { + $errors[] = $makeError('querySources', $querySources); + } + if (!$forwardDest['ok']) { + $errors[] = $makeError('forwardDestinations', $forwardDest); + } + if (!$recentBlocked['ok']) { + $errors[] = $makeError('recentBlocked', $recentBlocked); + } + if (!$versions['ok']) { + $errors[] = $makeError('versions', $versions); + } + + $summaryData = $summary['ok'] ? $summary['data'] : null; + $topData = $topItems['ok'] ? $topItems['data'] : null; + $queryTypesData = $queryTypes['ok'] ? $extractQueryTypes($queryTypes['data']) : null; + $querySourcesData = $querySources['ok'] ? $extractSources($querySources['data']) : null; + $forwardDestData = $forwardDest['ok'] ? $extractForwardDestinations($forwardDest['data']) : null; + $recentData = $recentBlocked['ok'] ? $recentBlocked['data'] : null; + $versionsData = $versions['ok'] ? $versions['data'] : null; + $updates = $parseUpdates(is_array($versionsData) ? $versionsData : null); + } else { + $probe = $apiInfo['probe'] ?? ['error' => 'unknown']; + if (is_array($probe)) { + $debugResult('dashboard.probe', $id, $instance, $probe); + } + $errors[] = $makeError('probe', is_array($probe) ? $probe : ['error' => 'unknown']); + } + + if (is_array($summaryData)) { + $aggregate['summary']['dns_queries_today'] += (int)($summaryData['dns_queries_today'] ?? 0); + $aggregate['summary']['ads_blocked_today'] += (int)($summaryData['ads_blocked_today'] ?? 0); + $aggregate['summary']['unique_clients'] += (int)($summaryData['unique_clients'] ?? 0); + $aggregate['summary']['unique_domains'] += (int)($summaryData['unique_domains'] ?? 0); + $aggregate['summary']['blocked_domains'] += (int)($summaryData['blocked_domains'] ?? 0); + $aggregate['summary']['queries_forwarded'] += (int)($summaryData['queries_forwarded'] ?? 0); + $aggregate['summary']['queries_cached'] += (int)($summaryData['queries_cached'] ?? 0); + $status = (string)($summaryData['status'] ?? 'unknown'); + $statuses[] = $status; + } + + if (is_array($topData)) { + if (!empty($topData['top_ads']) && is_array($topData['top_ads'])) { + $aggregateTopList($topData['top_ads'], $aggregate['top_ads']); + } + if (!empty($topData['top_queries']) && is_array($topData['top_queries'])) { + $aggregateTopList($topData['top_queries'], $aggregate['top_queries']); + } + } + + if (is_array($queryTypesData)) { + $aggregateMap($queryTypesData, $aggregate['query_types']); + } + + if (is_array($querySourcesData)) { + $aggregateMap($querySourcesData, $aggregate['query_sources']); + } + + if (is_array($forwardDestData)) { + $aggregateMap($forwardDestData, $aggregate['forward_destinations']); + } + + if (is_array($recentData)) { + foreach ($recentData as $entry) { + if (is_string($entry)) { + $aggregate['recent_blocked'][] = ['domain' => $entry, 'instance' => $instance['name']]; + } elseif (is_array($entry) && isset($entry['domain'])) { + $aggregate['recent_blocked'][] = ['domain' => (string)$entry['domain'], 'instance' => $instance['name']]; + } + } + } + + if (!empty($updates['available'])) { + $aggregate['updates']['available'] = true; + } + $aggregate['updates']['details'][$id] = $updates; + + $instancePayloads[$id] = [ + 'meta' => [ + 'id' => $id, + 'name' => $instance['name'], + 'url' => $instance['url'], + 'is_primary' => !empty($instance['is_primary']), + ], + 'summary' => $summaryData, + 'top_items' => $topData, + 'query_types' => $queryTypesData, + 'query_sources' => $querySourcesData, + 'forward_destinations' => $forwardDestData, + 'recent_blocked' => $recentData, + 'versions' => $versionsData, + 'updates' => $updates, + 'errors' => $errors, + ]; + } + + if ($aggregate['summary']['dns_queries_today'] > 0) { + $aggregate['summary']['ads_percentage_today'] = round( + $aggregate['summary']['ads_blocked_today'] / $aggregate['summary']['dns_queries_today'] * 100, + 2 + ); + } else { + $aggregate['summary']['ads_percentage_today'] = 0; + } + + $status = 'unknown'; + if ($statuses) { + $allEnabled = count(array_filter($statuses, fn($s) => $s === 'enabled')) === count($statuses); + $allDisabled = count(array_filter($statuses, fn($s) => $s === 'disabled')) === count($statuses); + if ($allEnabled) { + $status = 'enabled'; + } elseif ($allDisabled) { + $status = 'disabled'; + } else { + $status = 'partial'; + } + } + $aggregate['summary']['status'] = $status; + + $debugPush('dashboard.response', [ + 'aggregate_summary' => $aggregate['summary'], + 'instance_ids' => array_keys($instancePayloads), + ]); + + $respond([ + 'ok' => true, + 'ts' => time(), + 'instances' => $instancePayloads, + 'aggregate' => $aggregate, + ]); +} + +if ($action === 'test') { + require_admin(); + $target = (string)($payload['instance'] ?? ''); + if ($target === '') { + $respond(['ok' => false, 'error' => 'missing_instance'], 400); + } + if (!isset($instances[$target])) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $instance = $instances[$target]; + $debugPush('test.request', [ + 'instance_id' => $target, + 'instance_name' => (string)($instance['name'] ?? $target), + 'url' => (string)($instance['url'] ?? ''), + ]); + $apiInfo = $detectApi($instance); + $debugPush('test.detect.result', [ + 'instance_id' => $target, + 'version' => (int)($apiInfo['version'] ?? 0), + 'sid_present' => !empty($apiInfo['sid']), + ]); + if (!empty($apiInfo['probe']) && is_array($apiInfo['probe'])) { + $debugResult('test.probe', $target, $instance, $apiInfo['probe']); + } + if ($apiInfo['version'] === 6) { + $result = $apiInfo['probe']; + if ($result['ok']) { + $respond([ + 'ok' => true, + 'status' => 'ok', + 'message' => 'Verbindung OK. API v6 antwortet.', + ]); + } + $httpCode = (int)($result['http_code'] ?? 0); + $error = (string)($result['error'] ?? 'error'); + $status = 'error'; + $message = 'Unbekannter Fehler.'; + + if ($httpCode === 0) { + $status = 'unreachable'; + $message = 'Host nicht erreichbar oder kein HTTP-Response.'; + } elseif ($httpCode === 401 || $httpCode === 403) { + $status = 'auth'; + $message = 'Passwort oder App-Passwort falsch oder nicht berechtigt.'; + } elseif ($error === 'invalid_json') { + $status = 'invalid'; + $message = 'API antwortet nicht mit JSON. URL pruefen.'; + } else { + $status = 'error'; + $message = 'API Fehler: ' . $error . ' (HTTP ' . $httpCode . ')'; + } + + $respond([ + 'ok' => false, + 'status' => $status, + 'message' => $message, + 'http_code' => $httpCode, + 'error' => $error, + 'url' => (string)($result['url'] ?? ''), + ]); + } + + $result = $v5Request($instance, ['summaryRaw' => 1]); + if ($result['ok']) { + $respond([ + 'ok' => true, + 'status' => 'ok', + 'message' => 'Verbindung OK. API v5 antwortet.', + ]); + } + + $httpCode = (int)($result['http_code'] ?? 0); + $error = (string)($result['error'] ?? 'error'); + $status = 'error'; + $message = 'Unbekannter Fehler.'; + + if ($httpCode === 0) { + $status = 'unreachable'; + $message = 'Host nicht erreichbar oder kein HTTP-Response.'; + } elseif ($httpCode === 401 || $httpCode === 403) { + $status = 'auth'; + $message = 'Passwort oder Legacy-API-Token falsch oder nicht berechtigt.'; + } elseif ($error === 'invalid_json') { + $status = 'invalid'; + $message = 'API antwortet nicht mit JSON. URL oder API-Pfad pruefen.'; + } else { + $status = 'error'; + $message = 'API Fehler: ' . $error . ' (HTTP ' . $httpCode . ')'; + } + + $respond([ + 'ok' => false, + 'status' => $status, + 'message' => $message, + 'http_code' => $httpCode, + 'error' => $error, + 'url' => (string)($result['url'] ?? ''), + ]); +} + +if ($action === 'disable') { + require_admin(); + $debugPush('disable.request', ['payload' => $payload]); + $minutes = (int)($payload['minutes'] ?? 0); + $target = (string)($payload['instance'] ?? 'all'); + if ($minutes <= 0) { + $respond(['ok' => false, 'error' => 'invalid_minutes'], 400); + } + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $results = []; + foreach ($targets as $id => $instance) { + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $sid = (string)($apiInfo['sid'] ?? ''); + $result = $v6Request($instance, 'dns/blocking', 'POST', [ + 'blocking' => false, + 'timer' => $minutes * 60, + ], $sid); + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, ['disable' => $minutes * 60]); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } + $results[$id] = $result; + $debugResult('disable.result', $id, $instance, $result); + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'enable') { + require_admin(); + $debugPush('enable.request', ['payload' => $payload]); + $target = (string)($payload['instance'] ?? 'all'); + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $results = []; + foreach ($targets as $id => $instance) { + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $sid = (string)($apiInfo['sid'] ?? ''); + $result = $v6Request($instance, 'dns/blocking', 'POST', [ + 'blocking' => true, + ], $sid); + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, ['enable' => 1]); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } + $results[$id] = $result; + $debugResult('enable.result', $id, $instance, $result); + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'gravity') { + require_admin(); + $debugPush('gravity.request', ['payload' => $payload]); + $actionTimeout = $resolveActionTimeout(); + $target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary'); + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + $results = []; + foreach ($targets as $id => $instance) { + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $sid = (string)($apiInfo['sid'] ?? ''); + $result = $v6Request($instance, 'action/gravity', 'POST', [], $sid, $actionTimeout); + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, ['updateGravity' => 1], $actionTimeout); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } + $results[$id] = $result; + $debugResult('gravity.result', $id, $instance, $result); + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'domain_add') { + require_admin(); + $debugPush('domain_add.request', ['payload' => $payload]); + $domain = trim((string)($payload['domain'] ?? '')); + $type = (string)($payload['type'] ?? 'block'); + if ($domain === '') { + $respond(['ok' => false, 'error' => 'missing_domain'], 400); + } + $target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary'); + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $params = $type === 'allow' + ? ['list' => 'white', 'add' => $domain] + : ['list' => 'black', 'add' => $domain]; + + $results = []; + foreach ($targets as $id => $instance) { + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $result = ['ok' => false, 'error' => 'not_supported_v6', 'http_code' => 400, 'url' => '']; + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, $params); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } + $results[$id] = $result; + $debugResult('domain_add.result', $id, $instance, $result); + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'adlist_add') { + require_admin(); + $debugPush('adlist_add.request', ['payload' => $payload]); + $url = trim((string)($payload['url'] ?? '')); + if ($url === '') { + $respond(['ok' => false, 'error' => 'missing_url'], 400); + } + $target = $listsPrimaryOnly ? 'primary' : (string)($payload['instance'] ?? 'primary'); + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $results = []; + foreach ($targets as $id => $instance) { + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $result = ['ok' => false, 'error' => 'not_supported_v6', 'http_code' => 400, 'url' => '']; + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, ['list' => 'adlist', 'add' => $url]); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } + $results[$id] = $result; + $debugResult('adlist_add.result', $id, $instance, $result); + } + $respond(['ok' => true, 'results' => $results]); +} + +if ($action === 'update') { + require_admin(); + $debugPush('update.request', ['payload' => $payload]); + $actionTimeout = $resolveActionTimeout(); + $target = (string)($payload['instance'] ?? 'primary'); + $targets = $pickInstances($target); + if (!$targets) { + $respond(['ok' => false, 'error' => 'invalid_instance'], 400); + } + + $results = []; + foreach ($targets as $id => $instance) { + $apiInfo = $detectApi($instance); + if ($apiInfo['version'] === 6) { + $result = ['ok' => false, 'error' => 'not_supported_v6', 'http_code' => 400, 'url' => '']; + } elseif ($apiInfo['version'] === 5) { + $result = $v5Request($instance, ['update' => 1], $actionTimeout); + } else { + $result = ['ok' => false, 'error' => 'unknown_api', 'http_code' => 0, 'url' => '']; + } + $results[$id] = $result; + $debugResult('update.result', $id, $instance, $result); + } + $respond(['ok' => true, 'results' => $results]); +} + +$respond(['ok' => false, 'error' => 'unknown_action'], 400); diff --git a/temp/nexus-module-import/modules/pihole/pages/asset.php b/temp/nexus-module-import/modules/pihole/pages/asset.php new file mode 100644 index 00000000..5fbd20e5 --- /dev/null +++ b/temp/nexus-module-import/modules/pihole/pages/asset.php @@ -0,0 +1,31 @@ + $base . '/pihole.css', + 'pihole.js' => $base . '/pihole.js', + 'pihole_instances.js' => $base . '/pihole_instances.js', +]; + +if (!isset($map[$file])) { + http_response_code(404); + exit('Not found'); +} + +$path = $map[$file]; +if (!$base || !is_file($path) || !str_starts_with($path, $base)) { + http_response_code(404); + exit('Not found'); +} + +$ext = pathinfo($path, PATHINFO_EXTENSION); +if ($ext === 'css') { + header('Content-Type: text/css; charset=utf-8'); +} elseif ($ext === 'js') { + header('Content-Type: application/javascript; charset=utf-8'); +} else { + header('Content-Type: application/octet-stream'); +} + +readfile($path); +exit; diff --git a/temp/nexus-module-import/modules/pihole/pages/index.php b/temp/nexus-module-import/modules/pihole/pages/index.php new file mode 100644 index 00000000..8e6b1309 --- /dev/null +++ b/temp/nexus-module-import/modules/pihole/pages/index.php @@ -0,0 +1,143 @@ +assets(); +$assets->addStyle('/module/pihole/asset?file=pihole.css'); +$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true); + +$settings = modules()->settings('pihole'); +$instances = module_fn('pihole', 'instances'); +$hasConfig = !empty($instances); +$refreshSeconds = (int)($settings['dashboard_refresh_sec'] ?? 1); +if ($refreshSeconds < 0) { + $refreshSeconds = 1; +} +?> + 'Pi-hole Dashboard', + 'description' => 'Status, Blockings, Usage und Steuerung fuer beide Instanzen.', +]) ?> +
+ +
+
+ Hosts + Instanzen verwalten +
+ +
Keine Pi-hole Instanzen vorhanden. Bitte zuerst hinzufuegen.
+ + +
+ +
+
+ +
+
+ + Primaer + +
+ +
+ +
+ + + + +
+
+
DNS Queries (heute)
+
+
+
+
Ads geblockt
+
+
+
+
+
Unique Clients
+
+
+
+
Status
+
+
+
+ +
+
+ Blocker steuern (alle Instanzen) + Letztes Update: – +
+
+ + + + + + +
+ + +
+
+
+ +
+
+ Instanzen + Einzeln steuerbar & getrennte Updates +
+
+
+ +
+
+ Usage (Aggregiert) + Query-Typen und Weiterleitungen +
+
+
+
Query-Typen
+
+
+
+
Forward Destinations
+
+
+
+
+ +
+ + + diff --git a/temp/nexus-module-import/modules/pihole/pages/instances.php b/temp/nexus-module-import/modules/pihole/pages/instances.php new file mode 100644 index 00000000..4e37a835 --- /dev/null +++ b/temp/nexus-module-import/modules/pihole/pages/instances.php @@ -0,0 +1,416 @@ +assets(); +$assets->addStyle('/module/pihole/asset?file=pihole.css'); +$assets->addScript('/module/pihole/asset?file=pihole_instances.js', 'footer', true); + +require_admin(); + +$settings = modules()->settings($moduleName); +$notice = null; +$error = null; +$testResults = []; + +$httpRequest = static function (string $method, string $url, array $headers, ?string $body, bool $verify, int $timeout): array { + $raw = ''; + $httpCode = 0; + $requestError = ''; + + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => $timeout, + CURLOPT_SSL_VERIFYPEER => $verify, + CURLOPT_SSL_VERIFYHOST => $verify ? 2 : 0, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + ]); + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + $raw = (string)curl_exec($ch); + if ($raw === '' && curl_errno($ch)) { + $requestError = curl_error($ch); + } + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + } else { + $ctx = stream_context_create([ + 'http' => [ + 'method' => $method, + 'timeout' => $timeout, + 'header' => implode("\r\n", $headers), + 'content' => $body ?? '', + ], + 'ssl' => [ + 'verify_peer' => $verify, + 'verify_peer_name' => $verify, + ], + ]); + $raw = (string)@file_get_contents($url, false, $ctx); + if ($raw === '') { + $requestError = 'HTTP request failed'; + } else { + $httpCode = 200; + } + } + + if ($requestError !== '') { + return ['ok' => false, 'http_code' => $httpCode, 'error' => $requestError, 'raw' => $raw, 'url' => $url]; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return ['ok' => false, 'http_code' => $httpCode, 'error' => 'invalid_json', 'raw' => $raw, 'url' => $url]; + } + + return ['ok' => true, 'http_code' => $httpCode, 'data' => $decoded, 'url' => $url]; +}; + +$runConnectionTest = static function (array $instance, array $settings) use ($httpRequest): array { + $verifyTls = !isset($settings['verify_tls']) || $settings['verify_tls'] === '1' || $settings['verify_tls'] === 1 || $settings['verify_tls'] === true; + $timeout = (int)($settings['api_timeout_sec'] ?? 8); + if ($timeout <= 0) { + $timeout = 8; + } + + $url = rtrim((string)($instance['url'] ?? ''), '/'); + $password = trim((string)($instance['password'] ?? '')); + $v6AuthUrl = $url . '/api/auth'; + $v6Payload = json_encode(['password' => $password], JSON_UNESCAPED_UNICODE); + $v6Auth = $httpRequest('POST', $v6AuthUrl, ['Accept: application/json', 'Content-Type: application/json'], $v6Payload, $verifyTls, $timeout); + + if ($v6Auth['ok']) { + $session = (array)(($v6Auth['data']['session'] ?? []) ?: []); + $sid = trim((string)($session['sid'] ?? '')); + if ($sid !== '') { + $probe = $httpRequest('GET', $url . '/api/stats/summary', ['Accept: application/json', 'X-FTL-SID: ' . $sid], null, $verifyTls, $timeout); + if ($probe['ok']) { + return [ + 'ok' => true, + 'status' => 'ok', + 'message' => 'Verbindung OK. API v6 antwortet.', + 'version' => 6, + 'details' => ['auth' => $v6Auth, 'probe' => $probe], + ]; + } + + return [ + 'ok' => false, + 'status' => 'error', + 'message' => 'v6 Auth OK, aber Stats-Endpoint antwortet nicht sauber.', + 'version' => 6, + 'details' => ['auth' => $v6Auth, 'probe' => $probe], + ]; + } + } + + $legacyUrl = $url . '/admin/api.php?summaryRaw'; + if ($password !== '') { + $legacyUrl .= '&auth=' . rawurlencode($password); + } + $v5Probe = $httpRequest('GET', $legacyUrl, ['Accept: application/json'], null, $verifyTls, $timeout); + if ($v5Probe['ok']) { + return [ + 'ok' => true, + 'status' => 'ok', + 'message' => 'Verbindung OK. API v5 antwortet.', + 'version' => 5, + 'details' => ['auth' => $v6Auth, 'probe' => $v5Probe], + ]; + } + + $status = 'error'; + $message = 'Unbekannter Fehler.'; + $httpCode = (int)($v6Auth['http_code'] ?? $v5Probe['http_code'] ?? 0); + $requestError = (string)($v6Auth['error'] ?? $v5Probe['error'] ?? 'error'); + if ($httpCode === 0) { + $status = 'unreachable'; + $message = 'Host nicht erreichbar oder kein HTTP-Response.'; + } elseif ($httpCode === 401 || $httpCode === 403) { + $status = 'auth'; + $message = 'Passwort oder App-Passwort falsch oder nicht berechtigt.'; + } elseif ($requestError === 'invalid_json') { + $status = 'invalid'; + $message = 'API antwortet nicht mit JSON. URL pruefen.'; + } else { + $message = 'API Fehler: ' . $requestError . ' (HTTP ' . $httpCode . ')'; + } + + return [ + 'ok' => false, + 'status' => $status, + 'message' => $message, + 'details' => ['auth' => $v6Auth, 'probe' => $v5Probe], + ]; +}; + +$loadInstances = function (array $settings): array { + $normalizeSecret = static function (array $row): string { + $password = trim((string)($row['password'] ?? '')); + if ($password !== '') { + return $password; + } + + return trim((string)($row['token'] ?? '')); + }; + + $instances = []; + $rawJson = trim((string)($settings['instances_json'] ?? '')); + if ($rawJson !== '') { + $decoded = json_decode($rawJson, true); + if (is_array($decoded)) { + foreach ($decoded as $row) { + if (!is_array($row)) { + continue; + } + $id = trim((string)($row['id'] ?? '')); + $url = trim((string)($row['url'] ?? '')); + if ($id === '' || $url === '') { + continue; + } + $instances[$id] = [ + 'id' => $id, + 'name' => trim((string)($row['name'] ?? '')) ?: $id, + 'url' => $url, + 'password' => $normalizeSecret($row), + 'is_primary' => !empty($row['is_primary']), + ]; + } + } + } + + if (!$instances) { + foreach (['primary', 'secondary'] as $key) { + $url = trim((string)($settings[$key . '_url'] ?? '')); + if ($url === '') { + continue; + } + $instances[$key] = [ + 'id' => $key, + 'name' => trim((string)($settings[$key . '_name'] ?? '')) ?: ($key === 'primary' ? 'Primaer' : 'Sekundaer'), + 'url' => $url, + 'password' => trim((string)($settings[$key . '_token'] ?? '')), + 'is_primary' => $key === 'primary', + ]; + } + } + + return $instances; +}; + +$instances = $loadInstances($settings); + +$sanitizeId = function (string $id): string { + $id = preg_replace('/[^a-zA-Z0-9_-]/', '', $id); + return trim((string)$id); +}; + +$saveInstances = function (array $settings, array $instances): void { + $payload = $settings; + $payload['instances_json'] = json_encode(array_values($instances), JSON_UNESCAPED_UNICODE); + modules()->saveSettings('pihole', $payload); +}; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $deleteId = trim((string)($_POST['delete_id'] ?? '')); + $testId = trim((string)($_POST['test_id'] ?? '')); + $currentId = trim((string)($_POST['current_id'] ?? '')); + $instanceId = trim((string)($_POST['instance_id'] ?? '')); + $name = trim((string)($_POST['name'] ?? '')); + $url = trim((string)($_POST['url'] ?? '')); + $password = trim((string)($_POST['password'] ?? '')); + $isPrimary = isset($_POST['is_primary']); + + if ($testId !== '') { + if (isset($instances[$testId])) { + module_debug_push('pihole', [ + 'label' => 'connection.test.start', + 'instance_id' => $testId, + 'instance_name' => (string)($instances[$testId]['name'] ?? $testId), + 'url' => (string)($instances[$testId]['url'] ?? ''), + ]); + + $result = $runConnectionTest($instances[$testId], $settings); + $testResults[$testId] = $result; + + module_debug_push('pihole', [ + 'label' => 'connection.test.result', + 'instance_id' => $testId, + 'instance_name' => (string)($instances[$testId]['name'] ?? $testId), + 'result' => $result, + ]); + + $notice = $result['message'] ?? null; + } else { + $error = 'Test-Instanz nicht gefunden.'; + } + } elseif ($deleteId !== '') { + if (isset($instances[$deleteId])) { + unset($instances[$deleteId]); + $notice = 'Instanz geloescht.'; + } + } else { + $instanceId = $sanitizeId($instanceId); + if ($instanceId === '' || $url === '') { + $error = 'Bitte ID und URL angeben.'; + } elseif ($currentId === '' && isset($instances[$instanceId])) { + $error = 'Die Instanz-ID ist bereits vergeben. Bitte eine eindeutige ID verwenden.'; + } elseif ($currentId !== '' && $currentId !== $instanceId && isset($instances[$instanceId])) { + $error = 'Die neue Instanz-ID ist bereits vergeben. Bitte eine eindeutige ID verwenden.'; + } else { + $existingPassword = ''; + if ($currentId !== '' && isset($instances[$currentId])) { + $existingPassword = (string)($instances[$currentId]['password'] ?? ''); + } + $passwordToStore = $password !== '' ? $password : $existingPassword; + if ($currentId !== '' && $currentId !== $instanceId) { + unset($instances[$currentId]); + } + $instances[$instanceId] = [ + 'id' => $instanceId, + 'name' => $name !== '' ? $name : $instanceId, + 'url' => $url, + 'password' => $passwordToStore, + 'is_primary' => $isPrimary, + ]; + + if ($isPrimary) { + foreach ($instances as $id => &$row) { + $row['is_primary'] = ($id === $instanceId); + } + unset($row); + $settings['primary_id'] = $instanceId; + } + + $notice = $currentId !== '' ? 'Instanz aktualisiert.' : 'Instanz gespeichert.'; + } + } + + if (!$error) { + $saveInstances($settings, $instances); + $settings = modules()->settings($moduleName); + $instances = $loadInstances($settings); + } +} + +$primaryId = trim((string)($settings['primary_id'] ?? '')); +if ($primaryId === '') { + foreach ($instances as $id => $row) { + if (!empty($row['is_primary'])) { + $primaryId = $id; + break; + } + } +} +?> + 'Pi-hole Instanzen', + 'description' => 'Pi-hole Instanzen hinzufuegen, bearbeiten und loeschen.', +]) ?> +
+
+
+
+

Instanzen

+

Pi-hole Instanzen hinzufuegen, bearbeiten und loeschen.

+
+
+ +
+
+ + +
+ +
+ +
+ +
+ + +
+ +
Keine Instanzen vorhanden.
+ + +
+
+
+ +
ID:
+
URL:
+
+ + Primaer + +
+
+ +
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + + diff --git a/temp/nexus-module-import/modules/pihole/pages/lists.php b/temp/nexus-module-import/modules/pihole/pages/lists.php new file mode 100644 index 00000000..2fee01e5 --- /dev/null +++ b/temp/nexus-module-import/modules/pihole/pages/lists.php @@ -0,0 +1,97 @@ +assets(); +$assets->addStyle('/module/pihole/asset?file=pihole.css'); +$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true); + +$settings = modules()->settings('pihole'); +$instances = module_fn('pihole', 'instances'); +$hasConfig = !empty($instances); +$refreshSeconds = (int)($settings['lists_refresh_sec'] ?? 5); +if ($refreshSeconds < 0) { + $refreshSeconds = 5; +} +?> + 'Listen & Domains', + 'description' => 'Top-Domains, Listen-Updates fuer alle Instanzen und neue Eintraege auf der Primaer-Instanz.', +]) ?> +
+ +
+ Keine Instanzen konfiguriert +
Bitte zuerst eine Pi-hole Instanz hinzufuegen.
+ +
+ +
+
+ Listen-Updates + Gravity / Blocklisten fuer alle oder einzelne Instanzen neu laden +
+
+ + + + +
+
+
+ +
+
+
+ Top geblockte Domains (Aggregiert) + Letzte Statistiken +
+
+
+
+
+ Top erlaubte Domains (Aggregiert) + Letzte Statistiken +
+
+
+
+ +
+
+ Domainlisten erweitern + Eintraege werden auf der Primaer-Instanz gesetzt +
+
+ + + +
+
+
+ +
+
+ Adlist-URL hinzufuegen + Optional: unterstuetzt nur wenn die API den Endpunkt anbietet. +
+
+ + +
+
+
+ +
+ diff --git a/temp/nexus-module-import/modules/pihole/pages/queries.php b/temp/nexus-module-import/modules/pihole/pages/queries.php new file mode 100644 index 00000000..d59ea806 --- /dev/null +++ b/temp/nexus-module-import/modules/pihole/pages/queries.php @@ -0,0 +1,44 @@ +assets(); +$assets->addStyle('/module/pihole/asset?file=pihole.css'); +$assets->addScript('/module/pihole/asset?file=pihole.js', 'footer', true); + +$settings = modules()->settings('pihole'); +$instances = module_fn('pihole', 'instances'); +$hasConfig = !empty($instances); +$refreshSeconds = (int)($settings['queries_refresh_sec'] ?? 5); +if ($refreshSeconds < 0) { + $refreshSeconds = 5; +} +?> + 'Zugriffe & Blockings', + 'description' => 'Aktuelle Blockings, Top Clients und Status pro Instanz.', +]) ?> +
+ +
+ Keine Instanzen konfiguriert +
Bitte zuerst eine Pi-hole Instanz hinzufuegen.
+ +
+ +
+
+
+ Aktuelle Blockings + Letzte geblockte Domains +
+
+
+
+
+ Top Clients (Aggregiert) + Anfragen nach Client +
+
+
+
+ +
+