mining checker
All checks were successful
Deploy / deploy-staging (push) Successful in 27s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-21 00:14:19 +02:00
parent 2cdd14c400
commit b81de785ac
63 changed files with 11549 additions and 1338 deletions

View File

@@ -56,6 +56,18 @@ return [
'supports_tray' => false, 'supports_tray' => false,
'module_name' => 'desktop', 'module_name' => 'desktop',
'summary' => 'Persoenliche Einstellungen fuer Desktop-Typ, Benutzerdaten, Apps und Widgets in einem gemeinsamen Setup-Bereich.', 'summary' => 'Persoenliche Einstellungen fuer Desktop-Typ, Benutzerdaten, Apps und Widgets in einem gemeinsamen Setup-Bereich.',
'native_module' => [
'initializer' => 'initUserSelfManagementApp',
'options' => [],
'assets' => [
'styles' => [
['href' => '/assets/apps/user-self-management/app.css'],
],
'scripts' => [
['src' => '/assets/apps/user-self-management/app.js'],
],
],
],
], ],
[ [
'app_id' => 'admin-apps', 'app_id' => 'admin-apps',
@@ -98,30 +110,4 @@ return [
'module_name' => 'auth', 'module_name' => 'auth',
'summary' => 'Uebergabepunkt fuer das spaetere Keycloak-Theme im Desktop-Look.', 'summary' => 'Uebergabepunkt fuer das spaetere Keycloak-Theme im Desktop-Look.',
], ],
[
'app_id' => 'mining-checker',
'title' => 'Mining-Checker',
'icon' => 'MC',
'entry_route' => '/apps/mining-checker',
'window_mode' => 'window',
'content_mode' => 'native-module',
'default_width' => 1180,
'default_height' => 760,
'supports_widget' => false,
'supports_tray' => true,
'module_name' => 'finance',
'summary' => 'Vollstaendiger Rechner fuer Mining-Profitabilitaet, Stromkosten, ROI und Snapshot-Historie.',
'module_options' => [
'default_project_key' => 'doge-main',
'active_view' => 'overview',
'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'],
],
],
],
]; ];

View File

@@ -36,6 +36,7 @@ Das Startmenue ist in drei Bereiche gegliedert:
- Desktop-Icons koennen direkt geoeffnet werden. - Desktop-Icons koennen direkt geoeffnet werden.
- Im Startmenue lassen sich Programme ueber den `Auswahlbereich` starten. - Im Startmenue lassen sich Programme ueber den `Auswahlbereich` starten.
- Fenster koennen verschoben, minimiert, maximiert und geschlossen werden. - Fenster koennen verschoben, minimiert, maximiert und geschlossen werden.
- Programme koennen sowohl globale System-Apps als auch klassische Module sein.
## Einstellungen oeffnen ## Einstellungen oeffnen
@@ -48,6 +49,16 @@ Dort koennen derzeit insbesondere verwaltet werden:
- sichtbare Apps - sichtbare Apps
- Inhalte des `Infobereichs` - Inhalte des `Infobereichs`
## Module
Klassische Module liegen unter `modules/<modul>/` und koennen als normale `App` im Desktop erscheinen.
Aktuell gilt:
- der `Mining-Checker` ist das erste echte Modul in dieser Form
- Modul-Businesslogik bleibt im Modul und wird nicht in den Desktop-Core verschoben
- gemeinsame Desktop-Mechaniken wie Fenster, Asset-Einbindung und Zugriffsschutz werden global bereitgestellt
## Benutzerdaten ## Benutzerdaten
Aktuell gelten folgende Regeln: Aktuell gelten folgende Regeln:

View File

@@ -80,6 +80,8 @@ Stand dieser Datei:
- Startmenue mit `User Setting Bereich`, `Funktion-Bereich` und `Auswahlbereich` - Startmenue mit `User Setting Bereich`, `Funktion-Bereich` und `Auswahlbereich`
- rechter `Infobereich` mit benutzerbezogener Sichtbarkeit - rechter `Infobereich` mit benutzerbezogener Sichtbarkeit
- `User Self Management` als Setup-App - `User Self Management` als Setup-App
- erster echter Modulpfad fuer klassische Module mit Modul-Discovery aus `modules/<modul>/`
- `Mining-Checker` als erstes angebundenes klassisches Modul
- Benutzereinstellungen fuer Desktop-Skin, App-Auswahl, Infobereich und Profildaten in einer eigenen Datenbanktabelle mit `_user_data`-Suffix - Benutzereinstellungen fuer Desktop-Skin, App-Auswahl, Infobereich und Profildaten in einer eigenen Datenbanktabelle mit `_user_data`-Suffix
- vorhandene JSON-Dateien dienen nur noch als Fallback oder Uebergang fuer lokale Entwicklung und Altbestaende - vorhandene JSON-Dateien dienen nur noch als Fallback oder Uebergang fuer lokale Entwicklung und Altbestaende
- LDAP-Synchronisierung fuer Standardfelder wie Name, E-Mail, Telefon, Titel und Ort - LDAP-Synchronisierung fuer Standardfelder wie Name, E-Mail, Telefon, Titel und Ort
@@ -105,6 +107,14 @@ Aus [modules/README.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/m
- klassische Module bleiben unter `modules/<modul>/` - klassische Module bleiben unter `modules/<modul>/`
- Businesslogik wird nicht in den Desktop-Core verschoben - Businesslogik wird nicht in den Desktop-Core verschoben
- ein Desktop-Modul kann eigene `desktop.php`, `pages/`, `api/` und `assets/` Strukturen mitbringen
- Modul-Assets muessen nicht in den globalen Desktop-Asset-Baum kopiert werden
### Aktueller Modulstand
- `Mining-Checker` ist das erste Modul, das als echte Desktop-App aus `modules/mining-checker/` angebunden wird
- die Desktop-Shell kann Moduldefinitionen zentral erkennen und als `App` bereitstellen
- wiederverwendbare Modul-Helfer liegen im globalen Kern, die Fachlogik bleibt im Modul
### Skins ### Skins
@@ -125,6 +135,7 @@ Fuer einen spaeteren Hilfebereich sollen Inhalte aus dieser Datei in Themenblcke
- `Fenster verwenden` - `Fenster verwenden`
- `Infobereich konfigurieren` - `Infobereich konfigurieren`
- `Apps und Desktop Type verwalten` - `Apps und Desktop Type verwalten`
- `Module verstehen und starten`
- `Benutzerdaten und Synchronisierung` - `Benutzerdaten und Synchronisierung`
Die Inhalte sollen spaeter moeglichst nicht neu erfunden, sondern aus den zentral gepflegten Dateien abgeleitet werden. Die Inhalte sollen spaeter moeglichst nicht neu erfunden, sondern aus den zentral gepflegten Dateien abgeleitet werden.

View File

@@ -23,6 +23,7 @@ Zentraler Dokumentationsindex fuer das Projekt.
- `public/` Web-Root mit Desktop-Shell auf `/` - `public/` Web-Root mit Desktop-Shell auf `/`
- `src/Desktop/` zentrale Desktop-Mechaniken - `src/Desktop/` zentrale Desktop-Mechaniken
- `src/ModulesCore/` gemeinsame Helfer fuer Modul-Discovery, Modul-HTTP und wiederverwendbare Modul-Anbindung
- `partials/desktop/` Shell-Template - `partials/desktop/` Shell-Template
- `modules/` Zielort fuer klassische Module - `modules/` Zielort fuer klassische Module
- `temp/nexus-module-import/` Rohbasis fuer importierte Nexus-Module - `temp/nexus-module-import/` Rohbasis fuer importierte Nexus-Module
@@ -43,3 +44,8 @@ Es gilt:
- wichtige Inhalte aus Unterordner-`README.md`-Dateien werden zentral in `docs/CONTENT.md` gespiegelt - wichtige Inhalte aus Unterordner-`README.md`-Dateien werden zentral in `docs/CONTENT.md` gespiegelt
- Nutzungswissen wird in `docs/ANLEITUNG.md` gepflegt - Nutzungswissen wird in `docs/ANLEITUNG.md` gepflegt
- Entwicklungsregeln werden in `docs/WEITERENTWICKLUNG.md` gepflegt - Entwicklungsregeln werden in `docs/WEITERENTWICKLUNG.md` gepflegt
Aktuell wichtig:
- der Mining-Checker ist das erste als echtes Desktop-Modul angebundene Modul unter `modules/mining-checker/`
- Modul-Assets koennen ueber einen gemeinsamen Auslieferungsweg aus dem Modul selbst geladen werden

View File

@@ -1,6 +1,6 @@
# Umsetzungsstatus Desktop UI # Umsetzungsstatus Desktop UI
Stand: 2026-06-07 Stand: 2026-06-21
Diese Datei ist die laufende Arbeitsgrundlage fuer die Umsetzung der Anweisungen aus `docs/Umsetzungsanweisung/`. Diese Datei ist die laufende Arbeitsgrundlage fuer die Umsetzung der Anweisungen aus `docs/Umsetzungsanweisung/`.
@@ -23,8 +23,10 @@ Aktuell ist das Projekt auf einem V1-Scaffold-Stand:
- Skin-System `Windows` / `Apple` / `Linux` ist vorbereitet - Skin-System `Windows` / `Apple` / `Linux` ist vorbereitet
- einfacher Fenster-Manager ist vorhanden - einfacher Fenster-Manager ist vorhanden
- App-Registry, Widget-Registry und Import-Basis sind angelegt - App-Registry, Widget-Registry und Import-Basis sind angelegt
- erster echter Modulmechanismus fuer klassische Module ist vorhanden
- `Mining-Checker` ist als erstes klassisches Modul angebunden
- Keycloak ist nur konzeptionell vorbereitet, nicht integriert - Keycloak ist nur konzeptionell vorbereitet, nicht integriert
- Admin-Bereiche, persistente User-Desktops und echte Modul-Anbindung sind noch offen - Admin-Bereiche und persistente User-Desktops sind noch offen
## Verbindliche Regeln ## Verbindliche Regeln
@@ -49,7 +51,7 @@ Erfuellt:
Offen: Offen:
- globale Verwaltungsseiten nur als Platzhalter vorhanden - globale Verwaltungsseiten nur als Platzhalter vorhanden
- klassische Module sind noch nicht schrittweise an die Shell angebunden - weitere klassische Module sind noch nicht schrittweise an die Shell angebunden
- persoenliche Dashboards sind noch nicht funktional - persoenliche Dashboards sind noch nicht funktional
Nachweise: Nachweise:
@@ -98,7 +100,7 @@ Erfuellt:
Offen: Offen:
- `modules/<modul>/` im neuen Projekt enthalten noch keine wirklich angebundenen Module - weitere `modules/<modul>/` sind noch nicht angebunden
- `partials/landingpages/` und `partials/structure/` sind nur minimal vorbereitet - `partials/landingpages/` und `partials/structure/` sind nur minimal vorbereitet
- `tools/` und `debug/` sind noch nicht mit Funktion befuellt - `tools/` und `debug/` sind noch nicht mit Funktion befuellt
- Skin-Ordnerkonzept mit `base`-Fallback sowie pro Skin getrennten Layout-/Asset-Dateien fachlich fertigziehen und abnehmen - Skin-Ordnerkonzept mit `base`-Fallback sowie pro Skin getrennten Layout-/Asset-Dateien fachlich fertigziehen und abnehmen
@@ -119,18 +121,24 @@ Erfuellt:
- Rohkopie der Altmodule liegt in `temp/nexus-module-import/modules/` - Rohkopie der Altmodule liegt in `temp/nexus-module-import/modules/`
- relevante Doku aus der Altbasis wurde in den Import-Ordner uebernommen - relevante Doku aus der Altbasis wurde in den Import-Ordner uebernommen
- Produktivcode referenziert den Import-Ordner nicht zur Laufzeit - Produktivcode referenziert den Import-Ordner nicht zur Laufzeit
- `Mining-Checker` wurde als erstes Modul aus der Projektstruktur `modules/mining-checker/` an die Shell angebunden
- Modul-Assets werden aus dem Modul selbst geladen statt ueber eine alte Sonderkopie
- gemeinsame Modul-Helfer fuer Discovery und Zugriffsschutz sind im globalen Kern vorbereitet
Offen: Offen:
- keine Modulpruefung je Modul durchgefuehrt - keine Modulpruefung je Modul durchgefuehrt
- keine App-Registry-Eintraege fuer echte importierte Module vorhanden - weitere App-Registry-Eintraege fuer echte importierte Module fehlen noch
- keine Modulansichten in die Fensterlogik integriert - Fenstertauglichkeit weiterer Module ist nicht geprueft
- kein Skin-Test je Modul erfolgt - kein Skin-Test je Modul erfolgt
Nachweise: Nachweise:
- [temp/nexus-module-import](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/temp/nexus-module-import:1) - [temp/nexus-module-import](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/temp/nexus-module-import:1)
- [config/apps.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/config/apps.php:1) - [config/apps.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/config/apps.php:1)
- [modules/mining-checker](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/modules/mining-checker:1)
- [src/ModulesCore/ModuleRegistry.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/ModulesCore/ModuleRegistry.php:1)
- [public/module-assets/index.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/module-assets/index.php:1)
### 5. `04_UMSETZUNGSPLAN_V1.md` ### 5. `04_UMSETZUNGSPLAN_V1.md`
@@ -168,7 +176,7 @@ Phase 4 Inhaltssystem:
- `ERLEDIGT`: App Registry als Grundstruktur - `ERLEDIGT`: App Registry als Grundstruktur
- `TEILWEISE`: Widget Registry als Grundstruktur, noch nicht abgenommen - `TEILWEISE`: Widget Registry als Grundstruktur, noch nicht abgenommen
- `OFFEN`: Seitenmodule - `TEILWEISE`: Seitenmodule, erster echter Modulpfad vorhanden
- `OFFEN`: persoenliche Linklisten - `OFFEN`: persoenliche Linklisten
- `TEILWEISE`: oeffentliches Home-Dashboard nur als Placeholder - `TEILWEISE`: oeffentliches Home-Dashboard nur als Placeholder
- `TEILWEISE`: persoenliche Workspaces nur als Placeholder - `TEILWEISE`: persoenliche Workspaces nur als Placeholder
@@ -185,8 +193,8 @@ Phase 5 Admin-Bereiche:
Phase 6 Modul-Anbindung: Phase 6 Modul-Anbindung:
- `TEILWEISE`: importierte Nexus-Module liegen vor - `TEILWEISE`: importierte Nexus-Module liegen vor
- `OFFEN`: erste Module als Apps in der Shell - `TEILWEISE`: erstes Modul als App in der Shell
- `OFFEN`: Fenstertauglichkeit je Modul pruefen - `OFFEN`: Fenstertauglichkeit weiterer Module pruefen
- `OFFEN`: langfristige UX-Anpassung je Modul - `OFFEN`: langfristige UX-Anpassung je Modul
V1-Minimalziel: V1-Minimalziel:
@@ -244,17 +252,23 @@ Vorhanden:
- JSON-Payload fuer Frontend per `api/desktop.php` - JSON-Payload fuer Frontend per `api/desktop.php`
- PHP-Autoload-Basis - PHP-Autoload-Basis
- App-Registry - App-Registry
- Modul-Registry fuer klassische Module
- Widget-Registry - Widget-Registry
- Skin-Resolver - Skin-Resolver
- Default-Workspace- und Widget-State - Default-Workspace- und Widget-State
- einfacher Window-Manager - einfacher Window-Manager
- Window-Controls fuer minimieren, maximieren und schliessen - Window-Controls fuer minimieren, maximieren und schliessen
- CSS/JS fuer Shell, Fenster, Taskbar, Startmenue, Widgets und Uhr - CSS/JS fuer Shell, Fenster, Taskbar, Startmenue, Widgets und Uhr
- gemeinsamer Modul-Asset-Endpoint
- `Mining-Checker` als erstes echtes Modul unter `modules/`
Hauptdateien: Hauptdateien:
- [public/index.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/index.php:1) - [public/index.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/index.php:1)
- [api/desktop.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/api/desktop.php:1) - [api/desktop.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/api/desktop.php:1)
- [src/App/App.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/App/App.php:1) - [src/App/App.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/App/App.php:1)
- [src/ModulesCore/ModuleRegistry.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/ModulesCore/ModuleRegistry.php:1)
- [src/ModulesCore/ModuleHttp.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/ModulesCore/ModuleHttp.php:1)
- [src/Desktop/DesktopState.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/Desktop/DesktopState.php:1) - [src/Desktop/DesktopState.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/src/Desktop/DesktopState.php:1)
- [public/assets/desktop/desktop.js](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/assets/desktop/desktop.js:1) - [public/assets/desktop/desktop.js](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/assets/desktop/desktop.js:1)
- [public/module-assets/index.php](/home/lars/Schreibtisch/Projekte/desktop.kusche.berlin/public/module-assets/index.php:1)

View File

@@ -48,6 +48,7 @@ Verbindlich ist:
- `modules/<modul>/` bleibt Ort fuer klassische Module - `modules/<modul>/` bleibt Ort fuer klassische Module
- globale Desktop-Mechaniken liegen im gemeinsamen Kern - globale Desktop-Mechaniken liegen im gemeinsamen Kern
- globale Modul-Helfer duerfen im gemeinsamen Kern liegen, Fachlogik aber nicht
- Skins definieren Darstellung und Interaktionsdetails, nicht die Fachlogik - Skins definieren Darstellung und Interaktionsdetails, nicht die Fachlogik
- Hilfe- und Inhaltsdateien sollen spaeter maschinenlesbar oder zumindest klar strukturierbar in einen Hilfebereich ueberfuehrt werden koennen - Hilfe- und Inhaltsdateien sollen spaeter maschinenlesbar oder zumindest klar strukturierbar in einen Hilfebereich ueberfuehrt werden koennen

View File

@@ -4,6 +4,17 @@ Klassische Module bleiben in diesem Projekt unter `modules/<modul>/`.
Die neue Desktop-Shell ist nur die UI-Schicht. Modul-Businesslogik wird nicht in den Desktop-Core verschoben. Die neue Desktop-Shell ist nur die UI-Schicht. Modul-Businesslogik wird nicht in den Desktop-Core verschoben.
## Aktuelle Modulstruktur
Ein Desktop-Modul kann zusaetzlich diese Projektdateien besitzen:
- `desktop.php` fuer die Desktop-App-Metadaten und Asset-Definitionen
- `pages/` fuer Standalone-Seiten oder iframe/native Einstiegspunkte
- `api/` fuer modulinterne HTTP-Endpunkte
- `assets/` fuer modulnahe CSS- und JS-Dateien
Module-Assets werden in diesem Projekt nicht direkt aus `public/assets/apps/...` dupliziert, sondern ueber den generischen Endpoint `/module-assets/index.php` aus dem jeweiligen Modul ausgeliefert.
## Pflegehinweis ## Pflegehinweis
Diese Datei muss gepflegt bleiben, wenn sich Modulstruktur oder Modulregeln aendern. Diese Datei muss gepflegt bleiben, wenn sich Modulstruktur oder Modulregeln aendern.

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
$prefix = '/api/mining-checker/index.php/';
$relativePath = trim((string) ($_GET['path'] ?? ''), '/');
if ($relativePath === '') {
$relativePath = str_starts_with($requestPath, $prefix)
? substr($requestPath, strlen($prefix))
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
}
(new Modules\MiningChecker\Api\Router(dirname(__DIR__)))->handle($relativePath);

View File

@@ -1,33 +1,32 @@
(function () { (function () {
function boot(root) { const root = document.getElementById('mining-checker-app');
if (!root || !window.React || !window.ReactDOM) { if (!root || !window.React || !window.ReactDOM) {
return false; return;
} }
const h = React.createElement; const h = React.createElement;
const { useEffect, useMemo, useRef, useState } = React; const { useEffect, useMemo, useRef, useState } = React;
const apiBase = root.dataset.apiBase || '/api/mining-checker/v1'; const apiBase = root.dataset.apiBase || '/api/mining-checker/v1';
const initialProjectKey = root.dataset.defaultProjectKey || 'doge-main'; const initialProjectKey = root.dataset.defaultProjectKey || 'doge-main';
const initialActiveTab = String(root.dataset.activeView || 'overview').trim() || 'overview'; const initialActiveTab = String(root.dataset.activeView || 'overview').trim() || 'overview';
const configuredSections = (() => { const configuredSections = (() => {
try { try {
const parsed = JSON.parse(root.dataset.sectionsJson || '[]'); const parsed = JSON.parse(root.dataset.sectionsJson || '[]');
if (!Array.isArray(parsed)) { 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 []; return [];
} }
})(); return parsed
const initialDebugMode = root.dataset.moduleDebugEnabled === '1' .map((section) => {
|| (document.body && document.body.dataset.moduleDebugEnabled === '1'); 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) { function getCookie(name) {
const pattern = `; ${document.cookie}`; const pattern = `; ${document.cookie}`;
const parts = pattern.split(`; ${name}=`); const parts = pattern.split(`; ${name}=`);
@@ -260,8 +259,8 @@
const requestOptions = options && typeof options === 'object' ? { ...options } : {}; const requestOptions = options && typeof options === 'object' ? { ...options } : {};
const debugEnabled = !!debugBus.enabled; const debugEnabled = !!debugBus.enabled;
const timeoutMs = typeof requestOptions.timeoutMs === 'number' const timeoutMs = typeof requestOptions.timeoutMs === 'number'
? (debugEnabled ? Math.max(requestOptions.timeoutMs, 20000) : requestOptions.timeoutMs) ? (debugEnabled ? Math.max(requestOptions.timeoutMs, 30000) : requestOptions.timeoutMs)
: (debugEnabled ? 20000 : 8000); : (debugEnabled ? 30000 : 20000);
delete requestOptions.timeoutMs; delete requestOptions.timeoutMs;
const controller = new AbortController(); const controller = new AbortController();
@@ -1204,7 +1203,8 @@
} }
} }
async function loadBootstrap(key) { async function loadBootstrap(key, options) {
const suppressError = !!(options && options.suppressError);
const cacheKey = `${key}:${activeTab || 'overview'}`; const cacheKey = `${key}:${activeTab || 'overview'}`;
const cachedPayload = bootstrapCacheRef.current.get(cacheKey) || null; const cachedPayload = bootstrapCacheRef.current.get(cacheKey) || null;
@@ -1220,18 +1220,20 @@
loadGuardTriggered = true; loadGuardTriggered = true;
setLoading(false); setLoading(false);
setPayload((previous) => previous || cachedPayload || normalizeBootstrap(null, key)); setPayload((previous) => previous || cachedPayload || normalizeBootstrap(null, key));
setError((previous) => previous || 'Bootstrap-Request haengt oder braucht zu lange.'); if (!suppressError) {
}, 12000); setError((previous) => previous || 'Bootstrap-Request haengt oder braucht zu lange.');
}
}, 30000);
try { try {
if (schemaStatus.missing_count > 0 || schemaStatus.pending_upgrade_count > 0) { if (schemaStatus.missing_count > 0 || schemaStatus.pending_upgrade_count > 0) {
setPayload(normalizeBootstrap(null, key)); setPayload(normalizeBootstrap(null, key));
setError('Mining-Checker Schema ist noch nicht initialisiert. Bitte im Tab Settings die Datenbank initialisieren.'); setError('Mining-Checker Schema ist noch nicht initialisiert. Bitte im Tab Settings die Datenbank initialisieren.');
return; return false;
} }
const params = new URLSearchParams({ view: activeTab || 'overview' }); const params = new URLSearchParams({ view: activeTab || 'overview' });
const data = await request(`${apiBase}/projects/${encodeURIComponent(key)}/bootstrap?${params.toString()}`, { timeoutMs: 10000 }); const data = await request(`${apiBase}/projects/${encodeURIComponent(key)}/bootstrap?${params.toString()}`, { timeoutMs: 25000 });
const normalized = normalizeBootstrap(data, key); const normalized = normalizeBootstrap(data, key);
bootstrapCacheRef.current.set(cacheKey, normalized); bootstrapCacheRef.current.set(cacheKey, normalized);
setPayload(normalized); setPayload(normalized);
@@ -1252,9 +1254,13 @@
...previous, ...previous,
currency: normalized.settings.currencies?.[0]?.code || previous.currency || 'EUR', currency: normalized.settings.currencies?.[0]?.code || previous.currency || 'EUR',
})); }));
return true;
} catch (err) { } catch (err) {
setError(err.message); if (!suppressError) {
setError(err.message);
}
setPayload(normalizeBootstrap(null, key)); setPayload(normalizeBootstrap(null, key));
return false;
} finally { } finally {
window.clearTimeout(loadGuard); window.clearTimeout(loadGuard);
if (!loadGuardTriggered) { if (!loadGuardTriggered) {
@@ -1263,6 +1269,62 @@
} }
} }
async function reloadBootstrapAfterMutation(successMessage) {
const refreshed = await loadBootstrap(projectKey, { suppressError: true });
if (!refreshed) {
setError('Speichern war erfolgreich, aber die Ansicht konnte nicht automatisch aktualisiert werden.');
if (successMessage) {
setMessage(successMessage);
}
}
return refreshed;
}
function invalidateProjectBootstrapCache(key) {
const prefix = `${key}:`;
Array.from(bootstrapCacheRef.current.keys()).forEach((cacheKey) => {
if (String(cacheKey).startsWith(prefix)) {
bootstrapCacheRef.current.delete(cacheKey);
}
});
}
function applySavedPayout(savedPayout) {
if (!savedPayout || typeof savedPayout !== 'object') {
return;
}
setPayload((previous) => {
const current = previous || normalizeBootstrap(null, projectKey);
const previousPayouts = Array.isArray(current.settings?.payouts) ? current.settings.payouts : [];
const savedId = String(savedPayout.id ?? '');
const payouts = previousPayouts
.filter((row) => savedId === '' || String(row?.id ?? '') !== savedId)
.concat(savedPayout)
.sort((left, right) => String(left?.payout_at || '').localeCompare(String(right?.payout_at || '')));
const totalCoins = payouts.reduce((sum, row) => {
const amount = Number(row?.coins_amount);
return Number.isFinite(amount) ? sum + amount : sum;
}, 0);
return {
...current,
settings: {
...(current.settings || {}),
payouts,
},
summary: {
...(current.summary || {}),
payouts: {
...(current.summary?.payouts || {}),
total_count: payouts.length,
total_coins: totalCoins,
},
},
};
});
}
useEffect(() => { useEffect(() => {
loadBootstrap(projectKey); loadBootstrap(projectKey);
}, [projectKey, activeTab]); }, [projectKey, activeTab]);
@@ -1416,7 +1478,7 @@
source: 'manual', source: 'manual',
}); });
setOcrPreview(null); setOcrPreview(null);
await loadBootstrap(projectKey); await reloadBootstrapAfterMutation(fromPreview ? 'OCR-Vorschlag bestaetigt und gespeichert.' : 'Messpunkt gespeichert.');
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -1445,7 +1507,7 @@
}); });
setMessage('Wallet-Snapshot gespeichert.'); setMessage('Wallet-Snapshot gespeichert.');
setOcrPreview(null); setOcrPreview(null);
await loadBootstrap(projectKey); await reloadBootstrapAfterMutation('Wallet-Snapshot gespeichert.');
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -1470,7 +1532,7 @@
method: 'DELETE', method: 'DELETE',
}); });
setMessage('Messpunkt geloescht.'); setMessage('Messpunkt geloescht.');
await loadBootstrap(projectKey); await reloadBootstrapAfterMutation('Messpunkt geloescht.');
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -1501,7 +1563,7 @@
if (!result.error_count) { if (!result.error_count) {
setImportForm((previous) => ({ ...previous, rows_text: '' })); setImportForm((previous) => ({ ...previous, rows_text: '' }));
} }
await loadBootstrap(projectKey); await reloadBootstrapAfterMutation(`Import abgeschlossen: ${summary}.`);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -1562,7 +1624,7 @@
}), }),
}); });
setMessage('Dashboard gespeichert.'); setMessage('Dashboard gespeichert.');
await loadBootstrap(projectKey); await reloadBootstrapAfterMutation('Dashboard gespeichert.');
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -1591,7 +1653,7 @@
}), }),
}); });
setMessage('Settings gespeichert.'); setMessage('Settings gespeichert.');
await loadBootstrap(projectKey); await reloadBootstrapAfterMutation('Settings gespeichert.');
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -1648,7 +1710,7 @@
setMessage('Ziel gespeichert.'); setMessage('Ziel gespeichert.');
setTargetForm({ label: '', target_amount_fiat: '', currency: currencies[0]?.code || 'EUR', miner_offer_id: '', is_active: true, sort_order: 0 }); setTargetForm({ label: '', target_amount_fiat: '', currency: currencies[0]?.code || 'EUR', miner_offer_id: '', is_active: true, sort_order: 0 });
setTargetModalOpen(false); setTargetModalOpen(false);
await loadBootstrap(projectKey); await reloadBootstrapAfterMutation('Ziel gespeichert.');
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -1669,7 +1731,7 @@
method: 'DELETE', method: 'DELETE',
}); });
setMessage('Ziel geloescht.'); setMessage('Ziel geloescht.');
await loadBootstrap(projectKey); await reloadBootstrapAfterMutation('Ziel geloescht.');
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -1691,7 +1753,7 @@
body: JSON.stringify({ auto_renew: !row.auto_renew }), body: JSON.stringify({ auto_renew: !row.auto_renew }),
}); });
setMessage(`Automatische Verlängerung ${row.auto_renew ? 'deaktiviert' : 'aktiviert'}.`); setMessage(`Automatische Verlängerung ${row.auto_renew ? 'deaktiviert' : 'aktiviert'}.`);
await loadBootstrap(projectKey); await reloadBootstrapAfterMutation(`Automatische Verlaengerung ${row.auto_renew ? 'deaktiviert' : 'aktiviert'}.`);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -1726,7 +1788,7 @@
is_active: true, is_active: true,
}); });
setCostPlanModalOpen(false); setCostPlanModalOpen(false);
await loadBootstrap(projectKey); await reloadBootstrapAfterMutation('Miner gespeichert.');
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -1738,16 +1800,19 @@
event.preventDefault(); event.preventDefault();
setSaving(true); setSaving(true);
setError(''); setError('');
setMessage('');
try { try {
await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/payouts`, { const savedPayout = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/payouts`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payoutForm), body: JSON.stringify(payoutForm),
timeoutMs: 8000,
}); });
applySavedPayout(savedPayout);
invalidateProjectBootstrapCache(projectKey);
setMessage('Auszahlung gespeichert.'); setMessage('Auszahlung gespeichert.');
setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: currentSettings.crypto_currency || 'DOGE', note: '' }); setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: currentSettings.crypto_currency || 'DOGE', note: '' });
setPayoutModalOpen(false); setPayoutModalOpen(false);
await loadBootstrap(projectKey);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -2203,6 +2268,16 @@
value: latest ? fmtNumber(latest.doge_per_day_interval, 4) : 'n/a', 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}` : ''), sub: payload?.summary?.current_hashrate_mh ? `Hashrate ${fmtNumber(payload.summary.current_hashrate_mh, 4)} MH/s` : (latest ? `Trend ${latest.trend_label}` : ''),
}), }),
h(StatCard, {
key: 'perday-since-payout',
label: `${currentCoinCurrency} pro Tag seit letzter Auszahlung`,
value: latest && latest.doge_per_day_since_last_payout !== null && latest.doge_per_day_since_last_payout !== undefined
? fmtNumber(latest.doge_per_day_since_last_payout, 4)
: 'n/a',
sub: latest && latest.last_payout_at
? `Seit ${fmtDate(latest.last_payout_at)} · ${fmtNumber(latest.coins_since_last_payout, 6)} ${currentCoinCurrency}`
: 'Noch keine Auszahlung vor dem letzten Upload',
}),
h(StatCard, { h(StatCard, {
key: 'value', key: 'value',
label: 'Aktueller Gegenwert', label: 'Aktueller Gegenwert',
@@ -2273,7 +2348,7 @@
panel('Mining-History', 'Die letzten 10 Mining-Uploads inkl. Performance-Werten und OCR-Metadaten.', h('div', { className: 'mc-table-shell' }, [ 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('table', { key: 'table', className: 'mc-table' }, [
h('thead', { key: 'thead' }, h('tr', null, [ h('thead', { key: 'thead' }, h('tr', null, [
'Zeit', 'Coins', 'Kurs', 'Quelle', perDayLabel, 'Trend', 'Notiz', 'Aktion' 'Zeit', 'Coins', 'Kurs', 'Quelle', perDayLabel, 'Seit Auszahlung/Tag', 'Trend', 'Notiz', 'Aktion'
].map((label) => h('th', { key: label }, label)))), ].map((label) => h('th', { key: label }, label)))),
h('tbody', { key: 'tbody' }, h('tbody', { key: 'tbody' },
measurements.slice(-10).reverse().map((row) => h('tr', { key: row.id }, [ measurements.slice(-10).reverse().map((row) => h('tr', { key: row.id }, [
@@ -2282,6 +2357,9 @@
h('td', { key: 'price' }, row.price_per_coin ? `${fmtNumber(row.price_per_coin, 6)} ${row.price_currency}` : 'n/a'), 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: 'source' }, row.source),
h('td', { key: 'rate' }, fmtNumber(row.doge_per_day_interval, 4)), h('td', { key: 'rate' }, fmtNumber(row.doge_per_day_interval, 4)),
h('td', { key: 'rate-payout' }, row.doge_per_day_since_last_payout !== null && row.doge_per_day_since_last_payout !== undefined
? `${fmtNumber(row.doge_per_day_since_last_payout, 4)}${row.last_payout_at ? ` seit ${fmtDate(row.last_payout_at)}` : ''}`
: 'n/a'),
h('td', { key: 'trend' }, row.trend_label), h('td', { key: 'trend' }, row.trend_label),
h('td', { key: 'note' }, row.note || row.ocr_flags.join(', ') || '—'), h('td', { key: 'note' }, row.note || row.ocr_flags.join(', ') || '—'),
h('td', { key: 'action' }, h('button', { h('td', { key: 'action' }, h('button', {
@@ -3185,47 +3263,5 @@
} }
} }
ReactDOM.createRoot(root).render(h(App)); ReactDOM.createRoot(root).render(h(App));
return true;
}
window.initMiningCheckerApp = function initMiningCheckerApp(root, options) {
if (!(root instanceof HTMLElement)) {
return false;
}
const config = options && typeof options === 'object' ? options : {};
if (config.apiBase) {
root.dataset.apiBase = String(config.apiBase);
}
if (config.defaultProjectKey) {
root.dataset.defaultProjectKey = String(config.defaultProjectKey);
}
if (config.activeView) {
root.dataset.activeView = String(config.activeView);
}
if (config.sections) {
root.dataset.sectionsJson = JSON.stringify(config.sections);
}
if (config.moduleDebugEnabled !== undefined) {
root.dataset.moduleDebugEnabled = config.moduleDebugEnabled ? '1' : '0';
}
if (root.dataset.miningCheckerMounted === '1') {
return true;
}
const mounted = boot(root);
if (mounted) {
root.dataset.miningCheckerMounted = '1';
}
return mounted;
};
const root = document.getElementById('mining-checker-app');
if (root) {
window.initMiningCheckerApp(root);
}
})(); })();

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
spl_autoload_register(static function (string $class): void {
$prefix = 'Modules\\MiningChecker\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relativeClass = substr($class, strlen($prefix));
$file = __DIR__ . '/src/' . str_replace('\\', '/', $relativeClass) . '.php';
if (is_file($file)) {
require_once $file;
}
});

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'MINING_CHECKER_DEFAULT_PROJECT_KEY' => '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',
];

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
return [
'default_project_key' => 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',
],
];

View File

@@ -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" }
]
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
$manifest = [];
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
if ($manifestRaw !== false) {
$decoded = json_decode($manifestRaw, true);
$manifest = is_array($decoded) ? $decoded : [];
}
$design = [];
$designRaw = is_file(__DIR__ . '/design.json') ? file_get_contents(__DIR__ . '/design.json') : false;
if ($designRaw !== false) {
$decoded = json_decode($designRaw, true);
$design = is_array($decoded) ? $decoded : [];
}
$sections = array_values(array_filter(
is_array($design['sections'] ?? null) ? $design['sections'] : [],
static fn (mixed $section): bool => is_array($section)
));
return [
'app_id' => 'mining-checker',
'title' => (string) ($manifest['title'] ?? 'Mining-Checker'),
'icon' => 'MC',
'entry_route' => '/apps/mining-checker',
'window_mode' => 'window',
'content_mode' => 'native-module',
'default_width' => 1180,
'default_height' => 760,
'supports_widget' => false,
'supports_tray' => true,
'module_name' => 'finance',
'summary' => (string) ($manifest['description'] ?? 'Vollstaendiger Rechner fuer Mining-Profitabilitaet, Stromkosten, ROI und Snapshot-Historie.'),
'native_module' => [
'initializer' => 'initMiningCheckerApp',
'options' => [
'apiBase' => '/api/mining-checker/index.php/v1',
'defaultProjectKey' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main',
'activeView' => 'overview',
'sections' => $sections,
'moduleDebugEnabled' => false,
],
'assets' => [
'styles' => [
['href' => '/module-assets/index.php?module=mining-checker&path=assets/css/app.css'],
],
'scripts' => [
['src' => 'https://unpkg.com/react@18/umd/react.production.min.js', 'crossorigin' => true],
['src' => 'https://unpkg.com/react-dom@18/umd/react-dom.production.min.js', 'crossorigin' => true],
['src' => '/module-assets/index.php?module=mining-checker&path=assets/js/app.js'],
],
],
],
];

View File

@@ -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.

View File

@@ -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": []
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
require_once dirname(__DIR__) . '/bootstrap.php';
use ModulesCore\ModuleHttp;
session_start();
$projectRoot = dirname(__DIR__, 3);
ModuleHttp::requireDesktopAccess($projectRoot);
$design = [];
$designRaw = is_file(dirname(__DIR__) . '/design.json') ? file_get_contents(dirname(__DIR__) . '/design.json') : false;
if ($designRaw !== false) {
$decoded = json_decode($designRaw, true);
$design = is_array($decoded) ? $decoded : [];
}
$moduleConfig = require dirname(__DIR__) . '/config/module.php';
$defaultProjectKey = (string) ($moduleConfig['default_project_key'] ?? 'doge-main');
$sections = is_array($design['sections'] ?? null) ? $design['sections'] : [];
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
$sectionKeys = array_values(array_filter(array_map(
static fn (mixed $section): string => is_array($section) ? trim((string) ($section['key'] ?? '')) : '',
$sections
)));
if ($sectionKeys === []) {
$sectionKeys = ['overview'];
}
if (!in_array($activeView, $sectionKeys, true)) {
$activeView = $sectionKeys[0];
}
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mining-Checker</title>
<style>
:root {
color-scheme: light;
--surface: rgba(255, 255, 255, 0.92);
--line: rgba(148, 163, 184, 0.24);
--text: #0f172a;
--muted: #475569;
--brand-accent: #14b8a6;
--brand-accent-3: #0f766e;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(45, 212, 191, 0.12), transparent 24%),
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
color: var(--text);
}
.mining-module-shell {
min-height: 100vh;
padding: 18px;
}
</style>
<link rel="stylesheet" href="/module-assets/index.php?module=mining-checker&amp;path=assets/css/app.css">
</head>
<body data-module-debug-enabled="0">
<div class="mining-module-shell">
<div
id="mining-checker-app"
data-default-project-key="<?= htmlspecialchars($defaultProjectKey, ENT_QUOTES) ?>"
data-api-base="/api/mining-checker/index.php/v1"
data-active-view="<?= htmlspecialchars($activeView, ENT_QUOTES) ?>"
data-sections-json="<?= htmlspecialchars(is_string($sectionsJson) ? $sectionsJson : '[]', ENT_QUOTES) ?>"
></div>
</div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="/module-assets/index.php?module=mining-checker&amp;path=assets/js/app.js"></script>
</body>
</html>

View File

View File

@@ -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)
);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
);

View File

@@ -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);

View File

@@ -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
);

View File

@@ -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);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,950 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\MiningRepository;
use Modules\MiningChecker\Support\DebugTrace;
final class FxService
{
private ?MiningRepository $repository;
private string $provider;
private string $apiBaseUrl;
private string $currenciesApiBaseUrl;
private string $apiKey;
private int $timeout;
private int $cacheTtl;
private bool $autoFetchOnMiss;
private array $memoryCache = [];
private array $snapshotCache = [];
private ?DebugTrace $debug;
public function __construct(
?MiningRepository $repository = null,
string $apiBaseUrl = 'https://currencyapi.net',
string $currenciesApiBaseUrl = 'https://currencyapi.net',
int $timeout = 10,
int $cacheTtl = 21600,
bool $autoFetchOnMiss = false,
string $provider = 'currencyapi',
string $apiKey = '',
?DebugTrace $debug = null
)
{
$this->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);
}
}

View File

@@ -0,0 +1,665 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\ModuleConfig;
use Modules\MiningChecker\Support\ApiException;
final class OcrService
{
private ModuleConfig $config;
public function __construct(ModuleConfig $config)
{
$this->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,
$totalValueCurrency === 'USD' ? $totalValueAmount : null
);
$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<float|int> $candidates
*/
private function pickWalletUnitPrice(float $balance, array $candidates, ?float $walletTotal = null): ?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) {
$candidate = $candidates[0];
if ($walletTotal !== null && $walletTotal > 0 && ($balance * $candidate) > ($walletTotal * 1.5)) {
return round($candidate / $balance, 8);
}
return $candidate;
}
$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);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
final class SeedData
{
public static function projectKey(): string
{
return 'doge-main';
}
public static function projectName(): string
{
return 'DOGE Mining Main';
}
public static function settings(): array
{
return [
'baseline_measured_at' => '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],
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\MiningRepository;
final class SeedImporter
{
private MiningRepository $repository;
public function __construct(MiningRepository $repository)
{
$this->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,
];
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Infrastructure;
use App\Database as AppDatabase;
use Modules\MiningChecker\Support\ApiException;
use PDO;
final class ConnectionFactory
{
public static function make(ModuleConfig $config): PDO
{
$moduleSettings = modules()->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']
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Infrastructure;
final class ModuleConfig
{
private array $config;
public function __construct(array $config)
{
$this->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'));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace MiningChecker; namespace Modules\MiningChecker\Legacy;
use RuntimeException; use RuntimeException;

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Legacy;
use ModulesCore\ModuleHttp;
final class UserScope
{
public static function current(): string
{
return ModuleHttp::currentUserScope();
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
use RuntimeException;
final class ApiException extends RuntimeException
{
private int $statusCode;
private array $context;
public function __construct(string $message, int $statusCode = 400, array $context = [])
{
parent::__construct($message);
$this->statusCode = $statusCode;
$this->context = $context;
}
public function statusCode(): int
{
return $this->statusCode;
}
public function context(): array
{
return $this->context;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class DebugState
{
private static array $trace = [];
private static ?string $latestFilePath = null;
public static function replace(array $trace): void
{
self::$trace = $trace;
}
public static function export(): array
{
return self::$trace;
}
public static function clear(): void
{
self::$trace = [];
}
public static function setLatestFilePath(?string $filePath): void
{
self::$latestFilePath = $filePath;
}
public static function latestFilePath(): ?string
{
return self::$latestFilePath;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class DebugTrace
{
private bool $enabled;
private array $entries = [];
private ?string $filePath;
public function __construct(bool $enabled = false, ?string $filePath = null)
{
$this->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));
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class Http
{
public static function json(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;
}
public static function input(): array
{
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '[]', true);
return is_array($data) ? $data : [];
}
return $_POST;
}
}

View File

@@ -20,15 +20,27 @@ $assetVersion = static function (string $publicPath): string {
return $mtime === false ? $publicPath : $publicPath . '?v=' . $mtime; return $mtime === false ? $publicPath : $publicPath . '?v=' . $mtime;
}; };
$hasMiningChecker = false; $nativeModuleStyles = [];
$hasUserSelfManagement = false; $nativeModuleScripts = [];
foreach ($desktopPayload['apps'] as $desktopApp) { foreach ($desktopPayload['apps'] as $desktopApp) {
if (($desktopApp['app_id'] ?? null) === 'mining-checker') { $assets = is_array($desktopApp['native_module']['assets'] ?? null) ? $desktopApp['native_module']['assets'] : [];
$hasMiningChecker = true;
foreach ((array) ($assets['styles'] ?? []) as $style) {
if (!is_array($style) || trim((string) ($style['href'] ?? '')) === '') {
continue;
}
$href = (string) $style['href'];
$nativeModuleStyles[$href] = $style;
} }
if (($desktopApp['app_id'] ?? null) === 'user-self-management') { foreach ((array) ($assets['scripts'] ?? []) as $script) {
$hasUserSelfManagement = true; if (!is_array($script) || trim((string) ($script['src'] ?? '')) === '') {
continue;
}
$src = (string) $script['src'];
$nativeModuleScripts[$src] = $script;
} }
} }
@@ -167,15 +179,14 @@ $renderStartIcon = static function (array $profile): string {
<?php if (!empty($skinProfile['js_path']) && $skinProfile['js_path'] !== $baseSkinProfile['js_path']): ?> <?php if (!empty($skinProfile['js_path']) && $skinProfile['js_path'] !== $baseSkinProfile['js_path']): ?>
<script src="<?= htmlspecialchars($assetVersion((string) $skinProfile['js_path']), ENT_QUOTES) ?>"></script> <script src="<?= htmlspecialchars($assetVersion((string) $skinProfile['js_path']), ENT_QUOTES) ?>"></script>
<?php endif; ?> <?php endif; ?>
<?php if ($hasMiningChecker): ?> <?php foreach ($nativeModuleStyles as $style): ?>
<link rel="stylesheet" href="<?= htmlspecialchars($assetVersion('/assets/apps/mining-checker/app.css'), ENT_QUOTES) ?>"> <link rel="stylesheet" href="<?= htmlspecialchars((string) $style['href'], ENT_QUOTES) ?>">
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script> <?php endforeach; ?>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> <?php foreach ($nativeModuleScripts as $script): ?>
<script src="<?= htmlspecialchars($assetVersion('/assets/apps/mining-checker/app.js'), ENT_QUOTES) ?>"></script> <script
<?php endif; ?> <?php if (!empty($script['crossorigin'])): ?>crossorigin<?php endif; ?>
<?php if ($hasUserSelfManagement): ?> src="<?= htmlspecialchars((string) $script['src'], ENT_QUOTES) ?>"
<link rel="stylesheet" href="<?= htmlspecialchars($assetVersion('/assets/apps/user-self-management/app.css'), ENT_QUOTES) ?>"> ></script>
<script src="<?= htmlspecialchars($assetVersion('/assets/apps/user-self-management/app.js'), ENT_QUOTES) ?>"></script> <?php endforeach; ?>
<?php endif; ?>
</body> </body>
</html> </html>

View File

@@ -4,830 +4,11 @@ declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php'; require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
use App\ConfigLoader; use ModulesCore\ModuleHttp;
use App\AccountGate;
use App\KeycloakAuth;
use MiningChecker\LegacyModuleStore;
use MiningChecker\MiningCheckerUserScope;
session_start(); session_start();
$projectRoot = dirname(__DIR__, 3); $projectRoot = dirname(__DIR__, 3);
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak')); ModuleHttp::requireDesktopAccess($projectRoot, true);
$accountGate = new AccountGate(ConfigLoader::load($projectRoot, 'registration'));
header('Content-Type: application/json; charset=utf-8'); require $projectRoot . '/modules/mining-checker/api/index.php';
if ($auth->isAuthenticated()) {
$currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : [];
$accountCheck = $accountGate->checkUsername((string) ($currentUser['username'] ?? ''));
if (!($accountCheck['allowed'] ?? false)) {
$auth->logout();
respond(['error' => (string) ($accountCheck['message'] ?? 'Dieses Konto ist noch nicht freigeschaltet.')], 403);
}
}
if (!$auth->shouldShowDesktop()) {
respond(['error' => 'Nicht autorisiert.'], 401);
}
$store = new LegacyModuleStore($projectRoot, MiningCheckerUserScope::current());
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
$path = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
$prefix = '/api/mining-checker/index.php/';
$relativePath = str_starts_with($path, $prefix)
? substr($path, strlen($prefix))
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
$relativePath = trim($relativePath, '/');
if ($relativePath === '') {
respond(['data' => ['ok' => true, 'module' => 'mining-checker']], 200);
}
if ($relativePath === 'v1/health') {
respond(['data' => ['ok' => true, 'module' => 'mining-checker']], 200);
}
if ($relativePath === 'v1/debug/latest') {
respond(['data' => ['entries' => [], 'file' => null]], 200);
}
if (!preg_match('~^v1/projects/([a-zA-Z0-9_-]+)(?:/(.*))?$~', $relativePath, $matches)) {
respond(['error' => 'Unbekannter API-Pfad.'], 404);
}
$projectKey = $matches[1];
$resource = trim((string) ($matches[2] ?? ''), '/');
try {
$project = $store->loadProject($projectKey);
$payload = inputPayload();
if ($resource === 'schema-status' && $method === 'GET') {
respond(['data' => schemaStatus()], 200);
}
if ($resource === 'initialize' && $method === 'POST') {
respond(['data' => initializeSchema($payload)], 201);
}
if ($resource === 'upgrade' && $method === 'POST') {
respond(['data' => upgradeSchema()], 201);
}
if ($resource === 'rebuild-preserve-core' && $method === 'POST') {
respond(['data' => ['message' => 'JSON-Datenbasis neu aufgebaut.', 'restored' => ['projects' => 1]]], 201);
}
if ($resource === 'legacy-fx-migrate' && $method === 'POST') {
respond(['data' => [
'message' => 'Keine Legacy-FX-Daten vorhanden.',
'legacy_fetches_found' => 0,
'fx_fetches_imported' => 0,
'fx_fetches_reused' => 0,
'measurements_updated' => 0,
'measurements_unresolved' => 0,
]], 201);
}
if ($resource === 'sql-import' && $method === 'POST') {
$file = is_array($_FILES['sql_file'] ?? null) ? $_FILES['sql_file'] : null;
respond(['data' => [
'message' => 'SQL-Datei fuer den JSON-Modus nicht ausgefuehrt, aber Upload erkannt.',
'statement_count' => 0,
'file' => (string) ($file['name'] ?? 'upload.sql'),
]], 201);
}
if ($resource === 'connection-test' && $method === 'GET') {
respond(['data' => [
'driver' => 'json-store',
'database' => 'data/mining-checker/users',
'project_key' => $projectKey,
]], 200);
}
if ($resource === 'fx-history' && $method === 'GET') {
respond(['data' => array_values($project['fx_history'] ?? [])], 200);
}
if ($resource === 'bootstrap' && $method === 'GET') {
$view = trim((string) ($_GET['view'] ?? 'overview'));
respond(['data' => bootstrapPayload($projectKey, $project, $view)], 200);
}
if ($resource === 'measurements' && $method === 'GET') {
respond(['data' => array_values($project['measurements'] ?? [])], 200);
}
if ($resource === 'measurements' && $method === 'POST') {
$measurement = createMeasurement($store, $projectKey, $project, $payload);
respond(['data' => $measurement], 201);
}
if (preg_match('~^measurements/(\d+)$~', $resource, $idMatch) && $method === 'DELETE') {
$project['measurements'] = array_values(array_filter(
(array) ($project['measurements'] ?? []),
static fn (array $row): bool => (int) ($row['id'] ?? 0) !== (int) $idMatch[1]
));
$store->saveProject($projectKey, $project);
respond(['data' => ['deleted' => true]], 200);
}
if ($resource === 'measurements-import' && $method === 'POST') {
$result = importMeasurements($store, $projectKey, $project, $payload);
respond(['data' => $result], 201);
}
if ($resource === 'wallet-snapshots' && $method === 'GET') {
respond(['data' => array_values($project['wallet_snapshots'] ?? [])], 200);
}
if ($resource === 'wallet-snapshots' && $method === 'POST') {
$snapshot = createWalletSnapshot($store, $projectKey, $project, $payload);
respond(['data' => $snapshot], 201);
}
if ($resource === 'ocr-preview' && $method === 'POST') {
respond(['data' => ocrPreview($payload)], 201);
}
if ($resource === 'settings' && $method === 'GET') {
respond(['data' => buildSettingsPayload($project)], 200);
}
if ($resource === 'settings' && $method === 'PUT') {
$project['settings'] = array_replace((array) $project['settings'], normalizeSettingsPayload($payload));
$store->saveProject($projectKey, $project);
respond(['data' => buildSettingsPayload($project)], 200);
}
if ($resource === 'targets' && $method === 'GET') {
respond(['data' => array_values($project['targets'] ?? [])], 200);
}
if ($resource === 'targets' && $method === 'POST') {
$target = normalizeTarget($payload);
$target['id'] = $store->nextId($projectKey, 'targets');
$project['targets'][] = $target;
$store->saveProject($projectKey, $project);
respond(['data' => $target], 201);
}
if (preg_match('~^targets/(\d+)$~', $resource, $idMatch) && $method === 'PATCH') {
foreach ($project['targets'] as &$target) {
if ((int) ($target['id'] ?? 0) === (int) $idMatch[1]) {
$target = array_replace($target, normalizeTarget($payload, false));
break;
}
}
unset($target);
$store->saveProject($projectKey, $project);
respond(['data' => findById((array) $project['targets'], (int) $idMatch[1])], 200);
}
if (preg_match('~^targets/(\d+)$~', $resource, $idMatch) && $method === 'DELETE') {
$project['targets'] = array_values(array_filter(
(array) ($project['targets'] ?? []),
static fn (array $row): bool => (int) ($row['id'] ?? 0) !== (int) $idMatch[1]
));
$store->saveProject($projectKey, $project);
respond(['data' => ['deleted' => true]], 200);
}
if ($resource === 'dashboards' && $method === 'GET') {
respond(['data' => array_values($project['dashboards'] ?? [])], 200);
}
if ($resource === 'dashboards' && $method === 'POST') {
$dashboard = [
'id' => $store->nextId($projectKey, 'dashboards'),
'name' => trim((string) ($payload['name'] ?? 'Dashboard')),
'chart_type' => trim((string) ($payload['chart_type'] ?? 'line')),
'x_field' => trim((string) ($payload['x_field'] ?? 'measured_at')),
'y_field' => trim((string) ($payload['y_field'] ?? 'coins_total')),
'aggregation' => trim((string) ($payload['aggregation'] ?? 'none')),
'filters' => is_array($payload['filters'] ?? null) ? $payload['filters'] : ['source' => '', 'currency' => ''],
];
$project['dashboards'][] = $dashboard;
$store->saveProject($projectKey, $project);
respond(['data' => $dashboard], 201);
}
if ($resource === 'dashboard-data' && $method === 'GET') {
respond(['data' => dashboardData($project, $_GET)], 200);
}
if ($resource === 'cost-plans' && $method === 'GET') {
respond(['data' => array_values($project['cost_plans'] ?? [])], 200);
}
if ($resource === 'cost-plans' && $method === 'POST') {
$plan = normalizeCostPlan($payload);
$plan['id'] = $store->nextId($projectKey, 'cost_plans');
$project['cost_plans'][] = $plan;
$store->saveProject($projectKey, $project);
respond(['data' => $plan], 201);
}
if ($resource === 'payouts' && $method === 'GET') {
respond(['data' => array_values($project['payouts'] ?? [])], 200);
}
if ($resource === 'payouts' && $method === 'POST') {
$payout = [
'id' => $store->nextId($projectKey, 'payouts'),
'payout_at' => normalizeDateTime((string) ($payload['payout_at'] ?? gmdate('Y-m-d H:i:s'))),
'coins_amount' => (float) ($payload['coins_amount'] ?? 0),
'payout_currency' => strtoupper(trim((string) ($payload['payout_currency'] ?? 'DOGE'))),
'note' => trim((string) ($payload['note'] ?? '')),
];
$project['payouts'][] = $payout;
$store->saveProject($projectKey, $project);
respond(['data' => $payout], 201);
}
if ($resource === 'miner-offers' && $method === 'GET') {
respond(['data' => array_values($project['miner_offers'] ?? [])], 200);
}
if ($resource === 'miner-offers' && $method === 'POST') {
$offer = normalizeMinerOffer($payload);
$offer['id'] = $store->nextId($projectKey, 'miner_offers');
$project['miner_offers'][] = $offer;
$store->saveProject($projectKey, $project);
respond(['data' => $offer], 201);
}
if (preg_match('~^miner-offers/(\d+)/purchase$~', $resource, $idMatch) && $method === 'POST') {
$offer = findById((array) $project['miner_offers'], (int) $idMatch[1]);
if ($offer === null) {
respond(['error' => 'Miner-Angebot nicht gefunden.'], 404);
}
$miner = normalizePurchasedMiner($payload, $offer);
$miner['id'] = $store->nextId($projectKey, 'purchased_miners');
$project['purchased_miners'][] = $miner;
$store->saveProject($projectKey, $project);
respond(['data' => $miner], 201);
}
if ($resource === 'purchased-miners' && $method === 'GET') {
respond(['data' => array_values($project['purchased_miners'] ?? [])], 200);
}
if (preg_match('~^purchased-miners/(\d+)$~', $resource, $idMatch) && $method === 'PATCH') {
foreach ($project['purchased_miners'] as &$miner) {
if ((int) ($miner['id'] ?? 0) === (int) $idMatch[1]) {
$miner['auto_renew'] = (bool) ($payload['auto_renew'] ?? $miner['auto_renew'] ?? false);
break;
}
}
unset($miner);
$store->saveProject($projectKey, $project);
respond(['data' => findById((array) $project['purchased_miners'], (int) $idMatch[1])], 200);
}
respond(['error' => 'Ressource oder Methode nicht implementiert.'], 404);
} catch (Throwable $throwable) {
respond(['error' => $throwable->getMessage()], 500);
}
function respond(array $payload, int $status): never
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
/**
* @return array<string, mixed>
*/
function inputPayload(): array
{
if (stripos((string) ($_SERVER['CONTENT_TYPE'] ?? ''), 'application/json') !== false) {
$raw = file_get_contents('php://input');
$decoded = json_decode($raw !== false ? $raw : '', true);
return is_array($decoded) ? $decoded : [];
}
return $_POST;
}
/**
* @param array<string, mixed> $project
* @return array<string, mixed>
*/
function bootstrapPayload(string $projectKey, array $project, string $view): array
{
$measurements = array_values($project['measurements'] ?? []);
usort($measurements, static fn (array $a, array $b): int => strcmp((string) ($a['measured_at'] ?? ''), (string) ($b['measured_at'] ?? '')));
$walletSnapshots = array_values($project['wallet_snapshots'] ?? []);
usort($walletSnapshots, static fn (array $a, array $b): int => strcmp((string) ($b['measured_at'] ?? ''), (string) ($a['measured_at'] ?? '')));
$settings = buildSettingsPayload($project);
$dashboards = array_values($project['dashboards'] ?? []);
$targets = array_values($project['targets'] ?? []);
$payouts = array_values($project['payouts'] ?? []);
$minerOffers = evaluateMinerOffers($project);
$purchasedMiners = array_values($project['purchased_miners'] ?? []);
$latestMeasurement = $measurements === [] ? null : $measurements[array_key_last($measurements)];
$latestWallet = $walletSnapshots[0] ?? null;
$walletBalances = is_array($latestWallet['balances_json'] ?? null) ? $latestWallet['balances_json'] : [];
$payoutCoins = array_reduce($payouts, static fn (float $sum, array $row): float => $sum + (float) ($row['coins_amount'] ?? 0), 0.0);
$latestCoins = (float) ($latestMeasurement['coins_total'] ?? 0);
$currentHashrateMh = currentHashrateMh($project);
return [
'project' => [
'project_key' => $projectKey,
'project_name' => $project['project']['project_name'] ?? strtoupper($projectKey),
],
'settings' => $settings,
'measurements' => $measurements,
'targets' => $targets,
'dashboards' => $dashboards,
'wallet_snapshots' => $walletSnapshots,
'fx_snapshots' => [],
'bootstrap_meta' => [
'view' => $view,
'overview_window_days' => 15,
],
'summary' => [
'latest_measurement' => $latestMeasurement,
'baseline' => $settings,
'targets' => $targets,
'current_hashrate_mh' => $currentHashrateMh,
'miner_offers' => $minerOffers,
'payouts' => [
'total_count' => count($payouts),
'total_coins' => $payoutCoins,
'current_visible_coins' => $latestCoins,
'current_effective_coins' => $latestCoins + $payoutCoins,
'wallet_balances' => $walletBalances,
'wallet_balance_current_asset' => (float) ($walletBalances[strtoupper((string) ($settings['crypto_currency'] ?? 'DOGE'))] ?? 0),
'holdings_current_asset' => $latestCoins + (float) ($walletBalances[strtoupper((string) ($settings['crypto_currency'] ?? 'DOGE'))] ?? 0),
],
],
];
}
/**
* @param array<string, mixed> $project
* @return array<string, mixed>
*/
function buildSettingsPayload(array $project): array
{
$settings = array_replace([
'baseline_measured_at' => gmdate('Y-m-d H:i:s'),
'baseline_coins_total' => 0,
'daily_cost_amount' => 0,
'daily_cost_currency' => 'EUR',
'report_currency' => 'EUR',
'crypto_currency' => 'DOGE',
'display_timezone' => 'Europe/Berlin',
'fx_max_age_hours' => 3,
'module_theme_mode' => 'inherit',
'module_theme_accent' => 'teal',
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
], (array) ($project['settings'] ?? []));
$settings['cost_plans'] = array_values($project['cost_plans'] ?? []);
$settings['payouts'] = array_values($project['payouts'] ?? []);
$settings['miner_offers'] = array_values($project['miner_offers'] ?? []);
$settings['purchased_miners'] = array_values($project['purchased_miners'] ?? []);
$settings['measurement_rates'] = [];
$settings['currencies'] = defaultCurrencies();
return $settings;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
function normalizeSettingsPayload(array $payload): array
{
return [
'baseline_measured_at' => normalizeDateTime((string) ($payload['baseline_measured_at'] ?? gmdate('Y-m-d H:i:s'))),
'baseline_coins_total' => (float) ($payload['baseline_coins_total'] ?? 0),
'daily_cost_amount' => (float) ($payload['daily_cost_amount'] ?? 0),
'daily_cost_currency' => strtoupper(trim((string) ($payload['daily_cost_currency'] ?? 'EUR'))),
'report_currency' => strtoupper(trim((string) ($payload['report_currency'] ?? 'EUR'))),
'crypto_currency' => strtoupper(trim((string) ($payload['crypto_currency'] ?? 'DOGE'))),
'preferred_currencies' => array_values(array_filter(array_map(
static fn (mixed $value): string => strtoupper(trim((string) $value)),
is_array($payload['preferred_currencies'] ?? null) ? $payload['preferred_currencies'] : []
))),
];
}
function createMeasurement(LegacyModuleStore $store, string $projectKey, array &$project, array $payload): array
{
$measurement = [
'id' => $store->nextId($projectKey, 'measurements'),
'measured_at' => normalizeDateTime((string) ($payload['measured_at'] ?? gmdate('Y-m-d H:i:s'))),
'coins_total' => (float) ($payload['coins_total'] ?? 0),
'coin_currency' => strtoupper(trim((string) ($payload['coin_currency'] ?? $project['settings']['crypto_currency'] ?? 'DOGE'))),
'price_per_coin' => (float) ($payload['price_per_coin'] ?? 0),
'price_currency' => strtoupper(trim((string) ($payload['price_currency'] ?? $project['settings']['report_currency'] ?? 'EUR'))),
'note' => trim((string) ($payload['note'] ?? '')),
'source' => trim((string) ($payload['source'] ?? 'manual')),
'image_path' => trim((string) ($payload['image_path'] ?? '')),
'ocr_raw_text' => trim((string) ($payload['ocr_raw_text'] ?? '')),
'ocr_confidence' => (float) ($payload['ocr_confidence'] ?? 0),
'ocr_flags' => is_array($payload['ocr_flags'] ?? null) ? $payload['ocr_flags'] : [],
];
$project['measurements'][] = $measurement;
$store->saveProject($projectKey, $project);
return $measurement;
}
function createWalletSnapshot(LegacyModuleStore $store, string $projectKey, array &$project, array $payload): array
{
$balances = is_array($payload['balances_json'] ?? null) ? $payload['balances_json'] : [];
$snapshot = [
'id' => $store->nextId($projectKey, 'wallet_snapshots'),
'measured_at' => normalizeDateTime((string) ($payload['measured_at'] ?? gmdate('Y-m-d H:i:s'))),
'total_value_amount' => (float) ($payload['total_value_amount'] ?? 0),
'total_value_currency' => strtoupper(trim((string) ($payload['total_value_currency'] ?? 'EUR'))),
'wallet_balance' => (float) ($payload['wallet_balance'] ?? 0),
'wallet_currency' => strtoupper(trim((string) ($payload['wallet_currency'] ?? $project['settings']['crypto_currency'] ?? 'DOGE'))),
'balances_json' => $balances,
'note' => trim((string) ($payload['note'] ?? '')),
'source' => trim((string) ($payload['source'] ?? 'manual')),
'image_path' => trim((string) ($payload['image_path'] ?? '')),
'ocr_raw_text' => trim((string) ($payload['ocr_raw_text'] ?? '')),
'ocr_confidence' => (float) ($payload['ocr_confidence'] ?? 0),
'ocr_flags' => is_array($payload['ocr_flags'] ?? null) ? $payload['ocr_flags'] : [],
];
$project['wallet_snapshots'][] = $snapshot;
$store->saveProject($projectKey, $project);
return $snapshot;
}
/**
* @return array<string, mixed>
*/
function importMeasurements(LegacyModuleStore $store, string $projectKey, array &$project, array $payload): array
{
$rowsText = (string) ($payload['rows_text'] ?? '');
$defaultCurrency = strtoupper(trim((string) ($payload['default_currency'] ?? 'USD')));
$source = trim((string) ($payload['source'] ?? 'manual-import'));
$lines = preg_split('/\R+/', trim($rowsText)) ?: [];
$imported = 0;
$duplicates = 0;
$errors = 0;
foreach ($lines as $line) {
if (trim($line) === '') {
continue;
}
$parts = preg_split('/[;,|\t]+/', $line) ?: [];
if (count($parts) < 2) {
$errors++;
continue;
}
$measuredAt = normalizeDateTime(trim((string) $parts[0]));
$coinsTotal = (float) str_replace(',', '.', trim((string) $parts[1]));
$price = isset($parts[2]) ? (float) str_replace(',', '.', trim((string) $parts[2])) : 0;
$currency = isset($parts[3]) ? strtoupper(trim((string) $parts[3])) : $defaultCurrency;
$duplicate = array_filter((array) ($project['measurements'] ?? []), static function (array $row) use ($measuredAt, $coinsTotal): bool {
return (string) ($row['measured_at'] ?? '') === $measuredAt && (float) ($row['coins_total'] ?? 0) === $coinsTotal;
});
if ($duplicate !== []) {
$duplicates++;
continue;
}
createMeasurement($store, $projectKey, $project, [
'measured_at' => $measuredAt,
'coins_total' => $coinsTotal,
'price_per_coin' => $price,
'price_currency' => $currency,
'source' => $source,
'note' => 'Import',
]);
$imported++;
}
return [
'imported' => $imported,
'duplicates_ignored' => $duplicates,
'error_count' => $errors,
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
function ocrPreview(array $payload): array
{
$hintText = trim((string) ($payload['ocr_hint_text'] ?? $_POST['ocr_hint_text'] ?? ''));
$dateContext = trim((string) ($payload['date_context'] ?? $_POST['date_context'] ?? date('Y-m-d')));
preg_match('/(\d+(?:[.,]\d+)?)/', $hintText, $matches);
$coins = isset($matches[1]) ? (float) str_replace(',', '.', $matches[1]) : 0.0;
return [
'kind' => 'measurement',
'suggested' => [
'measured_at' => normalizeDateTime($dateContext . ' 12:00'),
'coins_total' => $coins,
'price_per_coin' => 0,
'price_currency' => 'USD',
'note' => $hintText,
'source' => 'image_ocr',
],
'suggested_wallet' => [
'measured_at' => normalizeDateTime($dateContext . ' 12:00'),
'total_value_amount' => 0,
'total_value_currency' => 'EUR',
'wallet_balance' => $coins,
'wallet_currency' => 'DOGE',
'balances_json' => ['DOGE' => $coins],
'note' => $hintText,
'source' => 'image_ocr',
],
'confidence' => $coins > 0 ? 0.42 : 0.0,
'flags' => ['ocr_provider_missing:json-preview'],
'image_path' => '',
'raw_text' => $hintText,
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
function normalizeTarget(array $payload, bool $isNew = true): array
{
$target = [
'label' => trim((string) ($payload['label'] ?? '')),
'target_amount_fiat' => (float) ($payload['target_amount_fiat'] ?? 0),
'currency' => strtoupper(trim((string) ($payload['currency'] ?? 'EUR'))),
'miner_offer_id' => trim((string) ($payload['miner_offer_id'] ?? '')),
'is_active' => (bool) ($payload['is_active'] ?? true),
'sort_order' => (int) ($payload['sort_order'] ?? 0),
];
if (!$isNew) {
return array_filter($target, static fn (mixed $value): bool => $value !== '' && $value !== null);
}
return $target;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
function normalizeCostPlan(array $payload): array
{
$speedValue = (float) ($payload['mining_speed_value'] ?? 0);
$speedUnit = trim((string) ($payload['mining_speed_unit'] ?? 'MH/s'));
$bonusPercent = (float) ($payload['bonus_percent'] ?? 0);
$bonusSpeedValue = $speedValue * ($bonusPercent / 100);
return [
'label' => trim((string) ($payload['label'] ?? 'Miner')),
'starts_at' => normalizeDateTime((string) ($payload['starts_at'] ?? gmdate('Y-m-d H:i:s'))),
'runtime_months' => (int) ($payload['runtime_months'] ?? 1),
'mining_speed_value' => $speedValue,
'mining_speed_unit' => $speedUnit,
'bonus_percent' => $bonusPercent,
'bonus_speed_value' => $bonusSpeedValue,
'bonus_speed_unit' => $speedUnit,
'auto_renew' => (bool) ($payload['auto_renew'] ?? true),
'base_price_amount' => (float) ($payload['base_price_amount'] ?? 0),
'payment_type' => trim((string) ($payload['payment_type'] ?? 'fiat')),
'total_cost_amount' => (float) ($payload['total_cost_amount'] ?? 0),
'currency' => strtoupper(trim((string) ($payload['currency'] ?? 'EUR'))),
'note' => trim((string) ($payload['note'] ?? '')),
'is_active' => (bool) ($payload['is_active'] ?? true),
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
function normalizeMinerOffer(array $payload): array
{
$basePrice = (float) ($payload['base_price_amount'] ?? 0);
$runtimeMonths = max(1, (int) ($payload['runtime_months'] ?? 1));
$paymentType = trim((string) ($payload['payment_type'] ?? 'fiat'));
$hashrateMh = $paymentType === 'crypto' ? 75 : 50;
return [
'label' => trim((string) ($payload['label'] ?? 'Angebot')),
'runtime_months' => $runtimeMonths,
'bonus_percent' => (float) ($payload['bonus_percent'] ?? 0),
'base_price_amount' => $basePrice,
'base_price_currency' => strtoupper(trim((string) ($payload['base_price_currency'] ?? 'USD'))),
'payment_type' => $paymentType,
'auto_renew' => (bool) ($payload['auto_renew'] ?? false),
'note' => trim((string) ($payload['note'] ?? '')),
'is_active' => (bool) ($payload['is_active'] ?? true),
'offer_hashrate_mh' => $hashrateMh,
];
}
/**
* @param array<string, mixed> $offer
* @return array<string, mixed>
*/
function normalizePurchasedMiner(array $payload, array $offer): array
{
$speedValue = (float) ($payload['mining_speed_value'] ?? $offer['offer_hashrate_mh'] ?? 0);
$speedUnit = trim((string) ($payload['mining_speed_unit'] ?? 'MH/s'));
$bonusPercent = (float) ($payload['bonus_percent'] ?? $offer['bonus_percent'] ?? 0);
return [
'label' => trim((string) ($payload['label'] ?? $offer['label'] ?? 'Gemieteter Miner')),
'purchased_at' => normalizeDateTime((string) ($payload['purchased_at'] ?? gmdate('Y-m-d H:i:s'))),
'runtime_months' => (int) ($offer['runtime_months'] ?? 1),
'mining_speed_value' => $speedValue,
'mining_speed_unit' => $speedUnit,
'bonus_percent' => $bonusPercent,
'bonus_speed_value' => $speedValue * ($bonusPercent / 100),
'bonus_speed_unit' => $speedUnit,
'total_cost_amount' => (float) ($payload['total_cost_amount'] ?? $offer['base_price_amount'] ?? 0),
'currency' => strtoupper(trim((string) ($payload['currency'] ?? $offer['base_price_currency'] ?? 'USD'))),
'reference_price_amount' => (float) ($payload['reference_price_amount'] ?? $offer['base_price_amount'] ?? 0),
'reference_price_currency' => strtoupper(trim((string) ($payload['reference_price_currency'] ?? $offer['base_price_currency'] ?? 'USD'))),
'auto_renew' => (bool) ($payload['auto_renew'] ?? $offer['auto_renew'] ?? false),
'note' => trim((string) ($payload['note'] ?? '')),
'is_active' => true,
'miner_offer_id' => (int) ($offer['id'] ?? 0),
];
}
/**
* @param array<string, mixed> $project
* @return array<int, array<string, mixed>>
*/
function evaluateMinerOffers(array $project): array
{
return array_values(array_map(static function (array $offer): array {
return array_replace($offer, [
'base_offer_id' => $offer['id'] ?? 0,
'effective_price_amount' => (float) ($offer['base_price_amount'] ?? 0),
'effective_price_currency' => strtoupper(trim((string) ($offer['base_price_currency'] ?? 'USD'))),
'offer_hashrate_mh' => (float) ($offer['offer_hashrate_mh'] ?? 50),
]);
}, (array) ($project['miner_offers'] ?? [])));
}
/**
* @param array<string, mixed> $project
* @return array<string, mixed>
*/
function dashboardData(array $project, array $query): array
{
$measurements = array_values($project['measurements'] ?? []);
usort($measurements, static fn (array $a, array $b): int => strcmp((string) ($a['measured_at'] ?? ''), (string) ($b['measured_at'] ?? '')));
$xField = trim((string) ($query['x_field'] ?? 'measured_at'));
$yField = trim((string) ($query['y_field'] ?? 'coins_total'));
$points = array_map(static function (array $row) use ($xField, $yField): array {
return [
'x' => $row[$xField] ?? null,
'y' => is_numeric($row[$yField] ?? null) ? (float) $row[$yField] : null,
];
}, $measurements);
return [
'points' => $points,
'series' => [
[
'label' => $yField,
'points' => $points,
],
],
];
}
function schemaStatus(): array
{
return [
'required_tables' => ['json_store'],
'present_tables' => ['json_store'],
'missing_tables' => [],
'pending_upgrades' => [],
'present_count' => 1,
'missing_count' => 0,
'pending_upgrade_count' => 0,
'all_present' => true,
];
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
function initializeSchema(array $payload): array
{
return [
'message' => !empty($payload['drop_existing']) ? 'JSON-Datenbestand wurde zurueckgesetzt.' : 'JSON-Datenbestand ist bereit.',
'after' => schemaStatus(),
'dropped_tables' => !empty($payload['drop_existing']) ? ['json_store'] : [],
];
}
/**
* @return array<string, mixed>
*/
function upgradeSchema(): array
{
return [
'message' => 'Keine Schema-Upgrades fuer JSON-Speicher erforderlich.',
'after' => schemaStatus(),
'upgraded' => [],
];
}
/**
* @return array<int, array<string, mixed>>
*/
function defaultCurrencies(): array
{
return [
['code' => 'EUR', 'name' => 'Euro', 'is_crypto' => false],
['code' => 'USD', 'name' => 'US-Dollar', 'is_crypto' => false],
['code' => 'DOGE', 'name' => 'Dogecoin', 'is_crypto' => true],
['code' => 'BTC', 'name' => 'Bitcoin', 'is_crypto' => true],
['code' => 'ETH', 'name' => 'Ethereum', 'is_crypto' => true],
['code' => 'LTC', 'name' => 'Litecoin', 'is_crypto' => true],
['code' => 'USDT', 'name' => 'Tether', 'is_crypto' => true],
['code' => 'USDC', 'name' => 'USD Coin', 'is_crypto' => true],
];
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array<string, mixed>|null
*/
function findById(array $rows, int $id): ?array
{
foreach ($rows as $row) {
if ((int) ($row['id'] ?? 0) === $id) {
return $row;
}
}
return null;
}
/**
* @param array<string, mixed> $project
*/
function currentHashrateMh(array $project): float
{
$costPlans = array_reduce((array) ($project['cost_plans'] ?? []), static function (float $sum, array $row): float {
return $sum + toMh((float) ($row['mining_speed_value'] ?? 0), (string) ($row['mining_speed_unit'] ?? 'MH/s')) + toMh((float) ($row['bonus_speed_value'] ?? 0), (string) ($row['bonus_speed_unit'] ?? 'MH/s'));
}, 0.0);
$purchased = array_reduce((array) ($project['purchased_miners'] ?? []), static function (float $sum, array $row): float {
return $sum + toMh((float) ($row['mining_speed_value'] ?? 0), (string) ($row['mining_speed_unit'] ?? 'MH/s')) + toMh((float) ($row['bonus_speed_value'] ?? 0), (string) ($row['bonus_speed_unit'] ?? 'MH/s'));
}, 0.0);
return $costPlans + $purchased;
}
function toMh(float $value, string $unit): float
{
return match (strtoupper(trim($unit))) {
'KH/S' => $value / 1000,
'MH/S' => $value,
'GH/S' => $value * 1000,
'TH/S' => $value * 1000000,
default => $value,
};
}
function normalizeDateTime(string $value): string
{
$trimmed = trim($value);
if ($trimmed === '') {
return gmdate('Y-m-d H:i:s');
}
$normalized = str_replace('T', ' ', $trimmed);
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $normalized) === 1) {
return $normalized . ' 00:00:00';
}
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $normalized) === 1) {
return $normalized . ':00';
}
return $normalized;
}

View File

@@ -3,40 +3,20 @@
declare(strict_types=1); declare(strict_types=1);
require_once dirname(__DIR__, 5) . '/src/App/bootstrap.php'; require_once dirname(__DIR__, 5) . '/src/App/bootstrap.php';
require_once dirname(__DIR__, 5) . '/modules/mining-checker/bootstrap.php';
use App\ConfigLoader; use Modules\MiningChecker\Legacy\LegacyModuleStore;
use App\AccountGate; use Modules\MiningChecker\Legacy\UserScope;
use App\KeycloakAuth; use ModulesCore\ModuleHttp;
use MiningChecker\LegacyModuleStore;
use MiningChecker\MiningCheckerUserScope;
session_start(); session_start();
$projectRoot = dirname(__DIR__, 5); $projectRoot = dirname(__DIR__, 5);
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak')); ModuleHttp::requireDesktopAccess($projectRoot, true);
$accountGate = new AccountGate(ConfigLoader::load($projectRoot, 'registration'));
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
if ($auth->isAuthenticated()) { $store = new LegacyModuleStore($projectRoot, UserScope::current());
$currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : [];
$accountCheck = $accountGate->checkUsername((string) ($currentUser['username'] ?? ''));
if (!($accountCheck['allowed'] ?? false)) {
$auth->logout();
http_response_code(403);
echo json_encode(['error' => (string) ($accountCheck['message'] ?? 'Dieses Konto ist noch nicht freigeschaltet.')], JSON_UNESCAPED_UNICODE);
exit;
}
}
if (!$auth->shouldShowDesktop()) {
http_response_code(401);
echo json_encode(['error' => 'Nicht autorisiert.'], JSON_UNESCAPED_UNICODE);
exit;
}
$store = new LegacyModuleStore($projectRoot, MiningCheckerUserScope::current());
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if ($method === 'GET') { if ($method === 'GET') {

View File

@@ -12,6 +12,7 @@ use Desktop\AppRegistry;
use Desktop\AppVisibility; use Desktop\AppVisibility;
use Desktop\SkinResolver; use Desktop\SkinResolver;
use Desktop\WidgetRegistry; use Desktop\WidgetRegistry;
use ModulesCore\ModuleRegistry;
session_start(); session_start();
@@ -27,7 +28,8 @@ if ($method === 'OPTIONS') {
$keycloakConfig = ConfigLoader::load($projectRoot, 'keycloak'); $keycloakConfig = ConfigLoader::load($projectRoot, 'keycloak');
$auth = new KeycloakAuth($keycloakConfig); $auth = new KeycloakAuth($keycloakConfig);
$registry = new AppRegistry($projectRoot . '/config/apps.php'); $moduleRegistry = new ModuleRegistry($projectRoot . '/modules');
$registry = new AppRegistry($projectRoot . '/config/apps.php', $moduleRegistry->desktopApps());
$widgetRegistry = new WidgetRegistry($projectRoot . '/config/widgets.php'); $widgetRegistry = new WidgetRegistry($projectRoot . '/config/widgets.php');
$service = new UserSelfManagementService($projectRoot); $service = new UserSelfManagementService($projectRoot);
$registrationConfig = ConfigLoader::load($projectRoot, 'registration'); $registrationConfig = ConfigLoader::load($projectRoot, 'registration');

View File

@@ -2,128 +2,4 @@
declare(strict_types=1); declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php'; require dirname(__DIR__, 3) . '/modules/mining-checker/pages/index.php';
use App\ConfigLoader;
use App\AccountGate;
use App\KeycloakAuth;
session_start();
$projectRoot = dirname(__DIR__, 3);
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
$accountGate = new AccountGate(ConfigLoader::load($projectRoot, 'registration'));
if ($auth->isAuthenticated()) {
$currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : [];
$accountCheck = $accountGate->checkUsername((string) ($currentUser['username'] ?? ''));
if (!($accountCheck['allowed'] ?? false)) {
$auth->logout();
$target = (string) ($accountCheck['state'] ?? '') === 'pending'
? '/auth/pending/?username=' . urlencode((string) ($currentUser['username'] ?? ''))
: '/auth/inactive/?message=' . urlencode((string) ($accountCheck['message'] ?? 'Dieses Konto ist noch nicht freigeschaltet.'));
header('Location: ' . $target, true, 302);
exit;
}
}
if (!$auth->shouldShowDesktop()) {
header('Location: /auth/keycloak', true, 302);
exit;
}
$assetVersion = static function (string $publicPath) use ($projectRoot): string {
$filesystemPath = $projectRoot . '/public' . $publicPath;
if (!is_file($filesystemPath)) {
return $publicPath;
}
$mtime = filemtime($filesystemPath);
return $mtime === false ? $publicPath : $publicPath . '?v=' . $mtime;
};
$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'],
];
$defaultProjectKey = getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main';
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
$sectionKeys = array_values(array_filter(array_map(
static fn (mixed $section): string => is_array($section) ? trim((string) ($section['key'] ?? '')) : '',
$sections
)));
if ($sectionKeys === []) {
$sectionKeys = ['overview'];
}
if (!in_array($activeView, $sectionKeys, true)) {
$activeView = $sectionKeys[0];
}
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mining-Checker</title>
<style>
:root {
color-scheme: light;
--surface: rgba(255, 255, 255, 0.92);
--line: rgba(148, 163, 184, 0.24);
--text: #0f172a;
--muted: #475569;
--brand-accent: #14b8a6;
--brand-accent-3: #0f766e;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(45, 212, 191, 0.12), transparent 24%),
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
color: var(--text);
}
.mining-module-shell {
min-height: 100vh;
padding: 18px;
}
</style>
<link rel="stylesheet" href="<?= htmlspecialchars($assetVersion('/assets/apps/mining-checker/app.css'), ENT_QUOTES) ?>">
</head>
<body data-module-debug-enabled="0">
<div class="mining-module-shell">
<div
id="mining-checker-app"
data-default-project-key="<?= htmlspecialchars($defaultProjectKey, ENT_QUOTES) ?>"
data-api-base="/api/mining-checker/index.php/v1"
data-active-view="<?= htmlspecialchars($activeView, ENT_QUOTES) ?>"
data-sections-json="<?= htmlspecialchars(is_string($sectionsJson) ? $sectionsJson : '[]', ENT_QUOTES) ?>"
></div>
</div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="<?= htmlspecialchars($assetVersion('/assets/apps/mining-checker/app.js'), ENT_QUOTES) ?>"></script>
</body>
</html>

View File

@@ -1035,29 +1035,20 @@ if (payloadNode) {
} }
const moduleHost = node.querySelector('.window-module-host'); const moduleHost = node.querySelector('.window-module-host');
if (moduleHost && definition.content_mode === 'native-module' && definition.app_id === 'mining-checker') { if (moduleHost && definition.content_mode === 'native-module') {
const moduleOptions = definition.module_options || {}; const nativeModule = definition.native_module || {};
if (typeof window.initMiningCheckerApp === 'function') { const initializer = nativeModule.initializer;
window.initMiningCheckerApp(moduleHost, { const baseOptions = nativeModule.options && typeof nativeModule.options === 'object' ? nativeModule.options : {};
apiBase: '/api/mining-checker/index.php/v1', const moduleOptions = {
defaultProjectKey: moduleOptions.default_project_key || 'doge-main', ...baseOptions,
activeView: moduleOptions.active_view || 'overview', apiBase: baseOptions.apiBase || settingsApi,
sections: Array.isArray(moduleOptions.sections) ? moduleOptions.sections : [], activeSkin,
moduleDebugEnabled: false, };
});
} else {
moduleHost.innerHTML = '<div class="window-module-error">Mining-Checker Assets konnten nicht initialisiert werden.</div>';
}
}
if (moduleHost && definition.content_mode === 'native-module' && definition.app_id === 'user-self-management') { if (typeof initializer === 'string' && typeof window[initializer] === 'function') {
if (typeof window.initUserSelfManagementApp === 'function') { window[initializer](moduleHost, moduleOptions);
window.initUserSelfManagementApp(moduleHost, {
apiBase: settingsApi,
activeSkin,
});
} else { } else {
moduleHost.innerHTML = '<div class="window-module-error">User Self Management konnte nicht initialisiert werden.</div>'; moduleHost.innerHTML = `<div class="window-module-error">${escapeHtml(definition.title || 'Modul')} konnte nicht initialisiert werden.</div>`;
} }
} }
@@ -1113,6 +1104,7 @@ if (payloadNode) {
entry_route: app.entry_route || '', entry_route: app.entry_route || '',
content_mode: app.content_mode || 'summary', content_mode: app.content_mode || 'summary',
module_options: app.module_options || null, module_options: app.module_options || null,
native_module: app.native_module || null,
}); });
}; };

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/src/App/bootstrap.php';
use ModulesCore\ModuleRegistry;
$projectRoot = dirname(__DIR__, 2);
$module = trim((string) ($_GET['module'] ?? ''));
$path = trim((string) ($_GET['path'] ?? ''));
if ($module === '' || $path === '' || str_contains($path, '..')) {
http_response_code(400);
exit('Invalid module asset request.');
}
$registry = new ModuleRegistry($projectRoot . '/modules');
$modulePath = $registry->modulePath($module);
if ($modulePath === null) {
http_response_code(404);
exit('Module not found.');
}
$assetPath = realpath($modulePath . '/' . ltrim($path, '/'));
$allowedRoot = realpath($modulePath);
if ($assetPath === false || $allowedRoot === false || !str_starts_with($assetPath, $allowedRoot . DIRECTORY_SEPARATOR) || !is_file($assetPath)) {
http_response_code(404);
exit('Asset not found.');
}
$extension = strtolower(pathinfo($assetPath, PATHINFO_EXTENSION));
$contentType = match ($extension) {
'css' => 'text/css; charset=utf-8',
'js' => 'application/javascript; charset=utf-8',
'json' => 'application/json; charset=utf-8',
'svg' => 'image/svg+xml',
'png' => 'image/png',
'jpg', 'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'webp' => 'image/webp',
default => 'application/octet-stream',
};
$mtime = filemtime($assetPath);
if ($mtime !== false) {
header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT');
}
header('Content-Type: ' . $contentType);
header('Cache-Control: public, max-age=300');
readfile($assetPath);

View File

@@ -11,6 +11,7 @@ use Desktop\DesktopState;
use Desktop\SkinResolver; use Desktop\SkinResolver;
use Desktop\WidgetRegistry; use Desktop\WidgetRegistry;
use Desktop\WindowManager; use Desktop\WindowManager;
use ModulesCore\ModuleRegistry;
final class App final class App
{ {
@@ -26,7 +27,8 @@ final class App
{ {
$keycloakConfig = ConfigLoader::load($this->projectRoot, 'keycloak'); $keycloakConfig = ConfigLoader::load($this->projectRoot, 'keycloak');
$auth = new KeycloakAuth($keycloakConfig); $auth = new KeycloakAuth($keycloakConfig);
$registry = new AppRegistry($this->projectRoot . '/config/apps.php'); $moduleRegistry = new ModuleRegistry($this->projectRoot . '/modules');
$registry = new AppRegistry($this->projectRoot . '/config/apps.php', $moduleRegistry->desktopApps());
$widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php'); $widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php');
$skins = SkinResolver::all(); $skins = SkinResolver::all();
$isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']); $isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']);

View File

@@ -6,7 +6,7 @@ spl_autoload_register(static function (string $class): void {
$prefixes = [ $prefixes = [
'App\\' => __DIR__ . '/', 'App\\' => __DIR__ . '/',
'Desktop\\' => dirname(__DIR__) . '/Desktop/', 'Desktop\\' => dirname(__DIR__) . '/Desktop/',
'MiningChecker\\' => dirname(__DIR__) . '/MiningChecker/', 'ModulesCore\\' => dirname(__DIR__) . '/ModulesCore/',
]; ];
foreach ($prefixes as $prefix => $baseDir) { foreach ($prefixes as $prefix => $baseDir) {

View File

@@ -9,11 +9,14 @@ final class AppRegistry
/** @var array<int, array<string, mixed>> */ /** @var array<int, array<string, mixed>> */
private array $apps; private array $apps;
public function __construct(string $configPath) /**
* @param array<int, array<string, mixed>> $additionalApps
*/
public function __construct(string $configPath, array $additionalApps = [])
{ {
/** @var array<int, array<string, mixed>> $apps */ /** @var array<int, array<string, mixed>> $apps */
$apps = require $configPath; $apps = require $configPath;
$this->apps = $apps; $this->apps = array_values(array_merge($apps, $additionalApps));
} }
/** /**

View File

@@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
namespace MiningChecker;
final class MiningCheckerCalculator
{
/**
* @param array<string, mixed> $settings
* @return array<string, float|null>
*/
public function calculate(array $settings): array
{
$hashrate = $this->toHashrateHps(
(float) ($settings['hashrate'] ?? 0),
(string) ($settings['hashrate_unit'] ?? 'MH/s')
);
$networkHashrate = $this->toHashrateHps(
(float) ($settings['network_hashrate'] ?? 0),
(string) ($settings['network_hashrate_unit'] ?? 'TH/s')
);
$powerWatts = max(0.0, (float) ($settings['power_watts'] ?? 0));
$electricityPrice = max(0.0, (float) ($settings['electricity_price'] ?? 0));
$poolFeePercent = max(0.0, min(100.0, (float) ($settings['pool_fee_percent'] ?? 0)));
$coinPrice = max(0.0, (float) ($settings['coin_price'] ?? 0));
$blockReward = max(0.0, (float) ($settings['block_reward'] ?? 0));
$blockTimeSeconds = max(1.0, (float) ($settings['block_time_seconds'] ?? 60));
$hardwareCost = max(0.0, (float) ($settings['hardware_cost'] ?? 0));
$share = $networkHashrate > 0 ? $hashrate / $networkHashrate : null;
$blocksPerDay = 86400 / $blockTimeSeconds;
$dailyCoinsGross = $share !== null ? $share * $blocksPerDay * $blockReward : null;
$dailyCoinsNet = $dailyCoinsGross !== null ? $dailyCoinsGross * (1 - ($poolFeePercent / 100)) : null;
$dailyRevenue = $dailyCoinsNet !== null ? $dailyCoinsNet * $coinPrice : null;
$dailyPowerCost = ($powerWatts / 1000) * 24 * $electricityPrice;
$dailyProfit = $dailyRevenue !== null ? $dailyRevenue - $dailyPowerCost : null;
$monthlyProfit = $dailyProfit !== null ? $dailyProfit * 30 : null;
$annualProfit = $dailyProfit !== null ? $dailyProfit * 365 : null;
$breakEvenDays = ($dailyProfit !== null && $dailyProfit > 0.0 && $hardwareCost > 0.0)
? $hardwareCost / $dailyProfit
: null;
$efficiency = $hashrate > 0 ? $powerWatts / ($hashrate / 1000000) : null;
return [
'hashrate_hps' => $hashrate > 0 ? $hashrate : null,
'network_hashrate_hps' => $networkHashrate > 0 ? $networkHashrate : null,
'share_ratio' => $share,
'blocks_per_day' => $blocksPerDay,
'daily_coins_gross' => $dailyCoinsGross,
'daily_coins_net' => $dailyCoinsNet,
'daily_revenue' => $dailyRevenue,
'daily_power_cost' => $dailyPowerCost,
'daily_profit' => $dailyProfit,
'monthly_profit' => $monthlyProfit,
'annual_profit' => $annualProfit,
'break_even_days' => $breakEvenDays,
'efficiency_w_per_mh' => $efficiency,
];
}
private function toHashrateHps(float $value, string $unit): float
{
if ($value <= 0) {
return 0.0;
}
$normalized = strtoupper(trim($unit));
$multipliers = [
'H/S' => 1,
'KH/S' => 1000,
'MH/S' => 1000000,
'GH/S' => 1000000000,
'TH/S' => 1000000000000,
'PH/S' => 1000000000000000,
'EH/S' => 1000000000000000000,
];
$multiplier = $multipliers[$normalized] ?? 1000000;
return $value * $multiplier;
}
}

View File

@@ -1,92 +0,0 @@
<?php
declare(strict_types=1);
namespace MiningChecker;
use RuntimeException;
final class MiningCheckerStore
{
public function __construct(
private readonly string $projectRoot,
private readonly string $userScope,
) {
}
/**
* @return array<string, mixed>
*/
public function load(): array
{
$path = $this->path();
if (!is_file($path)) {
return $this->defaultState();
}
$raw = file_get_contents($path);
if ($raw === false || trim($raw) === '') {
return $this->defaultState();
}
$data = json_decode($raw, true);
return is_array($data) ? array_replace_recursive($this->defaultState(), $data) : $this->defaultState();
}
/**
* @param array<string, mixed> $state
*/
public function save(array $state): void
{
$directory = dirname($this->path());
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('Mining-Checker-Verzeichnis konnte nicht erstellt werden.');
}
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Mining-Checker-Daten konnten nicht serialisiert werden.');
}
if (file_put_contents($this->path(), $json . PHP_EOL, LOCK_EX) === false) {
throw new RuntimeException('Mining-Checker-Daten konnten nicht gespeichert werden.');
}
}
private function path(): string
{
$safeScope = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $this->userScope) ?: 'guest';
return $this->projectRoot . '/data/mining-checker/' . $safeScope . '.json';
}
/**
* @return array<string, mixed>
*/
private function defaultState(): array
{
return [
'settings' => [
'project_name' => 'DOGE Mining',
'coin_symbol' => 'DOGE',
'algorithm' => 'Scrypt',
'currency' => 'EUR',
'hashrate' => 950,
'hashrate_unit' => 'MH/s',
'network_hashrate' => 920,
'network_hashrate_unit' => 'TH/s',
'block_reward' => 10000,
'block_time_seconds' => 60,
'coin_price' => 0.14,
'power_watts' => 3420,
'electricity_price' => 0.32,
'pool_fee_percent' => 1.5,
'hardware_cost' => 8400,
'notes' => '',
],
'history' => [],
];
}
}

View File

@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace MiningChecker;
final class MiningCheckerUserScope
{
public static function current(): string
{
$auth = $_SESSION['desktop_auth'] ?? null;
if (!is_array($auth)) {
return 'guest';
}
$user = is_array($auth['user'] ?? null) ? $auth['user'] : [];
return (string) ($user['sub'] ?? $user['username'] ?? 'guest');
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace ModulesCore;
use App\AccountGate;
use App\ConfigLoader;
use App\KeycloakAuth;
final class ModuleHttp
{
public static function requireDesktopAccess(string $projectRoot, bool $json = false): void
{
$auth = new KeycloakAuth(ConfigLoader::load($projectRoot, 'keycloak'));
$accountGate = new AccountGate(ConfigLoader::load($projectRoot, 'registration'));
if ($auth->isAuthenticated()) {
$currentUser = is_array($_SESSION['desktop_auth']['user'] ?? null) ? $_SESSION['desktop_auth']['user'] : [];
$accountCheck = $accountGate->checkUsername((string) ($currentUser['username'] ?? ''));
if (!($accountCheck['allowed'] ?? false)) {
$auth->logout();
$message = (string) ($accountCheck['message'] ?? 'Dieses Konto ist noch nicht freigeschaltet.');
if ($json) {
self::respondJson(['error' => $message], 403);
}
$target = (string) ($accountCheck['state'] ?? '') === 'pending'
? '/auth/pending/?username=' . urlencode((string) ($currentUser['username'] ?? ''))
: '/auth/inactive/?message=' . urlencode($message);
header('Location: ' . $target, true, 302);
exit;
}
}
if (!$auth->shouldShowDesktop()) {
if ($json) {
self::respondJson(['error' => 'Nicht autorisiert.'], 401);
}
header('Location: /auth/keycloak', true, 302);
exit;
}
}
public static function currentUserScope(): string
{
$auth = $_SESSION['desktop_auth'] ?? null;
if (!is_array($auth)) {
return 'guest';
}
$user = is_array($auth['user'] ?? null) ? $auth['user'] : [];
return (string) ($user['sub'] ?? $user['username'] ?? 'guest');
}
/**
* @param array<string, mixed> $payload
*/
private static function respondJson(array $payload, int $statusCode): never
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace ModulesCore;
final class ModuleRegistry
{
/** @var array<int, array<string, mixed>>|null */
private ?array $desktopApps = null;
public function __construct(
private readonly string $modulesPath,
) {
}
/**
* @return array<int, array<string, mixed>>
*/
public function desktopApps(): array
{
if ($this->desktopApps !== null) {
return $this->desktopApps;
}
$apps = [];
foreach ($this->moduleDirectories() as $moduleName => $modulePath) {
$desktopConfigPath = $modulePath . '/desktop.php';
if (!is_file($desktopConfigPath)) {
continue;
}
$desktopConfig = require $desktopConfigPath;
if (!is_array($desktopConfig)) {
continue;
}
$manifest = $this->readJson($modulePath . '/module.json');
$desktopConfig['module_id'] = $moduleName;
$desktopConfig['title'] ??= (string) ($manifest['title'] ?? ucfirst($moduleName));
$desktopConfig['summary'] ??= (string) ($manifest['description'] ?? '');
$desktopConfig['required_groups'] ??= array_values(array_map(
'strval',
(array) ($manifest['auth']['groups'] ?? [])
));
$desktopConfig['enabled_by_default'] ??= (bool) ($manifest['enabled_by_default'] ?? false);
$apps[] = $desktopConfig;
}
return $this->desktopApps = $apps;
}
public function modulePath(string $moduleName): ?string
{
$directories = $this->moduleDirectories();
return $directories[$moduleName] ?? null;
}
/**
* @return array<string, string>
*/
private function moduleDirectories(): array
{
$paths = glob(rtrim($this->modulesPath, '/') . '/*', GLOB_ONLYDIR);
if (!is_array($paths)) {
return [];
}
$directories = [];
foreach ($paths as $path) {
$moduleName = basename($path);
if ($moduleName === '' || $moduleName[0] === '.') {
continue;
}
$directories[$moduleName] = $path;
}
ksort($directories);
return $directories;
}
/**
* @return array<string, mixed>
*/
private function readJson(string $path): array
{
if (!is_file($path)) {
return [];
}
$raw = file_get_contents($path);
if ($raw === false || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
}