Nexus upgrade design and refresh
This commit is contained in:
6
modules/mining-checker/api/index.php
Normal file
6
modules/mining-checker/api/index.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||||
|
|
||||||
|
(new Modules\MiningChecker\Api\Router(dirname(__DIR__)))->handle($_GET['path'] ?? '');
|
||||||
0
modules/mining-checker/assets/css/.gitkeep
Normal file
0
modules/mining-checker/assets/css/.gitkeep
Normal file
961
modules/mining-checker/assets/css/app.css
Normal file
961
modules/mining-checker/assets/css/app.css
Normal file
@@ -0,0 +1,961 @@
|
|||||||
|
#mining-checker-app {
|
||||||
|
--mc-bg: #09111f;
|
||||||
|
--mc-surface: rgba(8, 15, 29, 0.74);
|
||||||
|
--mc-surface-strong: rgba(15, 23, 42, 0.92);
|
||||||
|
--mc-line: rgba(148, 163, 184, 0.18);
|
||||||
|
--mc-line-strong: rgba(255, 255, 255, 0.12);
|
||||||
|
--mc-text: #e5eef8;
|
||||||
|
--mc-text-muted: #a7b5c8;
|
||||||
|
--mc-accent: #3dd9c4;
|
||||||
|
--mc-accent-strong: #7dd3fc;
|
||||||
|
--mc-danger: #fb7185;
|
||||||
|
--mc-success: #34d399;
|
||||||
|
--mc-warning: #fbbf24;
|
||||||
|
min-height: 70vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(13, 148, 136, 0.16), transparent 26%),
|
||||||
|
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 22%),
|
||||||
|
linear-gradient(180deg, #04111d 0%, #0f172a 42%, #111827 100%);
|
||||||
|
color: var(--mc-text);
|
||||||
|
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="day"] #mining-checker-app {
|
||||||
|
--mc-bg: #f7fbfb;
|
||||||
|
--mc-surface: rgba(255, 255, 255, 0.84);
|
||||||
|
--mc-surface-strong: rgba(255, 255, 255, 0.96);
|
||||||
|
--mc-line: rgba(16, 33, 43, 0.13);
|
||||||
|
--mc-line-strong: color-mix(in srgb, var(--brand-accent, #ed1671) 32%, transparent);
|
||||||
|
--mc-text: #10212b;
|
||||||
|
--mc-text-muted: #66737b;
|
||||||
|
--mc-accent: var(--brand-accent, #ed1671);
|
||||||
|
--mc-accent-strong: var(--brand-accent-2, #06a9c8);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, color-mix(in srgb, var(--brand-accent-2, #06a9c8) 16%, transparent), transparent 26%),
|
||||||
|
radial-gradient(circle at top right, color-mix(in srgb, var(--brand-accent, #ed1671) 16%, transparent), transparent 22%),
|
||||||
|
linear-gradient(180deg, #f7fbfb 0%, #eef7f5 48%, #fff8e9 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="night"] #mining-checker-app {
|
||||||
|
--mc-accent: var(--brand-accent, #ed1671);
|
||||||
|
--mc-accent-strong: var(--brand-accent-2, #06a9c8);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, color-mix(in srgb, var(--brand-accent-2, #06a9c8) 18%, transparent), transparent 26%),
|
||||||
|
radial-gradient(circle at top right, color-mix(in srgb, var(--brand-accent, #ed1671) 20%, transparent), transparent 22%),
|
||||||
|
linear-gradient(180deg, #050b12 0%, #0c1721 48%, #111827 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-stat-value {
|
||||||
|
color: var(--mc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-badge,
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-badge--info,
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-button--ghost {
|
||||||
|
color: var(--mc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-button--tab {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: var(--mc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-button--tab-active,
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-button--secondary {
|
||||||
|
background: var(--mc-text);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-input,
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-select,
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-textarea,
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-file,
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-display-field {
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-debug-entry,
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-debug-text-console {
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="day"] #mining-checker-app .mc-suggestion strong {
|
||||||
|
color: var(--mc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app,
|
||||||
|
#mining-checker-app * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-grid-bg {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||||||
|
background-size: 24px 24px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-grid-bg[data-module-theme="custom"] {
|
||||||
|
--mc-bg: #09111f;
|
||||||
|
--mc-surface: rgba(8, 15, 29, 0.76);
|
||||||
|
--mc-surface-strong: rgba(15, 23, 42, 0.94);
|
||||||
|
--mc-line: rgba(148, 163, 184, 0.18);
|
||||||
|
--mc-line-strong: rgba(255, 255, 255, 0.12);
|
||||||
|
--mc-text: #e5eef8;
|
||||||
|
--mc-text-muted: #a7b5c8;
|
||||||
|
--mc-accent: #3dd9c4;
|
||||||
|
--mc-accent-strong: #7dd3fc;
|
||||||
|
color: var(--mc-text);
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||||
|
radial-gradient(circle at top left, rgba(13, 148, 136, 0.16), transparent 26%),
|
||||||
|
radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 22%),
|
||||||
|
linear-gradient(180deg, #04111d 0%, #0f172a 42%, #111827 100%);
|
||||||
|
background-size: 24px 24px, 24px 24px, auto, auto, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="logo"] {
|
||||||
|
--mc-accent: #ed1671;
|
||||||
|
--mc-accent-strong: #06a9c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="pink"] {
|
||||||
|
--mc-accent: #ed1671;
|
||||||
|
--mc-accent-strong: #f6aa21;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="cyan"] {
|
||||||
|
--mc-accent: #06a9c8;
|
||||||
|
--mc-accent-strong: #8bc53f;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="orange"] {
|
||||||
|
--mc-accent: #f6aa21;
|
||||||
|
--mc-accent-strong: #ed1671;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="green"] {
|
||||||
|
--mc-accent: #8bc53f;
|
||||||
|
--mc-accent-strong: #06a9c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-shell {
|
||||||
|
width: min(1360px, calc(100% - 24px));
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero,
|
||||||
|
#mining-checker-app .mc-panel,
|
||||||
|
#mining-checker-app .mc-stat-card,
|
||||||
|
#mining-checker-app .mc-dashboard-card,
|
||||||
|
#mining-checker-app .mc-target-card,
|
||||||
|
#mining-checker-app .mc-alert,
|
||||||
|
#mining-checker-app .mc-empty,
|
||||||
|
#mining-checker-app .mc-table-shell,
|
||||||
|
#mining-checker-app .mc-display-field {
|
||||||
|
border: 1px solid var(--mc-line);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: var(--mc-surface);
|
||||||
|
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero,
|
||||||
|
#mining-checker-app .mc-panel,
|
||||||
|
#mining-checker-app .mc-dashboard-card,
|
||||||
|
#mining-checker-app .mc-alert,
|
||||||
|
#mining-checker-app .mc-empty,
|
||||||
|
#mining-checker-app .mc-table-shell {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero-top,
|
||||||
|
#mining-checker-app .mc-inline-row,
|
||||||
|
#mining-checker-app .mc-flex-split,
|
||||||
|
#mining-checker-app .mc-section-head {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero-top,
|
||||||
|
#mining-checker-app .mc-flex-split,
|
||||||
|
#mining-checker-app .mc-section-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero-copy,
|
||||||
|
#mining-checker-app .mc-hero-controls,
|
||||||
|
#mining-checker-app .mc-panel-body,
|
||||||
|
#mining-checker-app .mc-form,
|
||||||
|
#mining-checker-app .mc-field,
|
||||||
|
#mining-checker-app .mc-filter-grid,
|
||||||
|
#mining-checker-app .mc-chart,
|
||||||
|
#mining-checker-app .mc-target-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-filter-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero-copy {
|
||||||
|
max-width: 860px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.4rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-text,
|
||||||
|
#mining-checker-app p,
|
||||||
|
#mining-checker-app td,
|
||||||
|
#mining-checker-app th,
|
||||||
|
#mining-checker-app label,
|
||||||
|
#mining-checker-app summary,
|
||||||
|
#mining-checker-app pre {
|
||||||
|
color: var(--mc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app h1,
|
||||||
|
#mining-checker-app h2,
|
||||||
|
#mining-checker-app h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-stats-grid,
|
||||||
|
#mining-checker-app .mc-target-grid,
|
||||||
|
#mining-checker-app .mc-overview-grid,
|
||||||
|
#mining-checker-app .mc-two-col,
|
||||||
|
#mining-checker-app .mc-main-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-stats-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-overview-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-target-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-two-col {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-main-grid {
|
||||||
|
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-stat-card,
|
||||||
|
#mining-checker-app .mc-target-card {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-kicker {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--mc-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
border: 1px solid var(--mc-line-strong);
|
||||||
|
background: color-mix(in srgb, var(--mc-accent) 16%, transparent);
|
||||||
|
color: var(--mc-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-badge--warn {
|
||||||
|
background: rgba(251, 191, 36, 0.14);
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-badge--info {
|
||||||
|
background: color-mix(in srgb, var(--mc-accent) 16%, transparent);
|
||||||
|
color: var(--mc-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-badge--danger {
|
||||||
|
background: rgba(251, 113, 133, 0.14);
|
||||||
|
color: #fecdd3;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-badge--success {
|
||||||
|
background: rgba(52, 211, 153, 0.14);
|
||||||
|
color: #a7f3d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button,
|
||||||
|
#mining-checker-app button,
|
||||||
|
#mining-checker-app input,
|
||||||
|
#mining-checker-app select,
|
||||||
|
#mining-checker-app textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button--primary {
|
||||||
|
background: linear-gradient(135deg, var(--mc-accent), var(--mc-accent-strong));
|
||||||
|
color: #05121f;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button--secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
color: #09111f;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button--danger {
|
||||||
|
background: linear-gradient(135deg, rgba(251, 113, 133, 0.92), rgba(239, 68, 68, 0.92));
|
||||||
|
color: #fff7f7;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button--ghost {
|
||||||
|
background: color-mix(in srgb, var(--mc-accent) 14%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--mc-accent) 34%, transparent);
|
||||||
|
color: var(--mc-accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-home-link {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-debug-tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-debug-console {
|
||||||
|
border-color: rgba(125, 211, 252, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-debug-view-switch {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-debug-log {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
max-height: 520px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-debug-text-console {
|
||||||
|
max-height: 520px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-debug-entry {
|
||||||
|
border: 1px solid var(--mc-line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(2, 6, 23, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button--tab {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--mc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button--tab-active {
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
color: #09111f;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-field {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-field-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--mc-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-input,
|
||||||
|
#mining-checker-app .mc-select,
|
||||||
|
#mining-checker-app .mc-textarea,
|
||||||
|
#mining-checker-app .mc-file {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--mc-line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 13px 15px;
|
||||||
|
color: var(--mc-text);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-select option {
|
||||||
|
color: #09111f;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-input::placeholder,
|
||||||
|
#mining-checker-app .mc-textarea::placeholder {
|
||||||
|
color: #6d7c90;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-input:focus,
|
||||||
|
#mining-checker-app .mc-select:focus,
|
||||||
|
#mining-checker-app .mc-textarea:focus,
|
||||||
|
#mining-checker-app .mc-file:focus {
|
||||||
|
border-color: rgba(125, 211, 252, 0.5);
|
||||||
|
box-shadow: 0 0 0 3px rgba(125, 211, 252, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid var(--mc-line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-token-list,
|
||||||
|
#mining-checker-app .mc-suggestion-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-token-list--inline {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-currency-selection-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-inline-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-inline-fields > .mc-field {
|
||||||
|
flex: 1 1 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-currency-search {
|
||||||
|
flex: 0 1 360px;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-token,
|
||||||
|
#mining-checker-app .mc-suggestion {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid var(--mc-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--mc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-token {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-token:hover,
|
||||||
|
#mining-checker-app .mc-suggestion:hover {
|
||||||
|
border-color: rgba(125, 211, 252, 0.4);
|
||||||
|
background: rgba(61, 217, 196, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-token-close {
|
||||||
|
color: var(--mc-accent-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-suggestion {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
#mining-checker-app .mc-currency-selection-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-currency-search {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-suggestion strong {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-alert--error {
|
||||||
|
border-color: rgba(251, 113, 133, 0.28);
|
||||||
|
background: rgba(127, 29, 29, 0.35);
|
||||||
|
color: #ffe4e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-alert--warning {
|
||||||
|
border-color: rgba(245, 158, 11, 0.28);
|
||||||
|
background: rgba(120, 53, 15, 0.34);
|
||||||
|
color: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-alert--success {
|
||||||
|
border-color: rgba(52, 211, 153, 0.28);
|
||||||
|
background: rgba(6, 78, 59, 0.34);
|
||||||
|
color: #d1fae5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-table-shell {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-table th,
|
||||||
|
#mining-checker-app .mc-table td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-table thead {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-empty {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app details {
|
||||||
|
border: 1px solid var(--mc-line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-mini-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-mini-card,
|
||||||
|
#mining-checker-app .mc-display-field {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-code-block {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--mc-line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-chart svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 80;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(2, 6, 23, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-modal {
|
||||||
|
width: min(720px, 100%);
|
||||||
|
max-height: min(80vh, 900px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid var(--mc-line);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(9, 17, 31, 0.96);
|
||||||
|
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.34);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-chart path,
|
||||||
|
#mining-checker-app .mc-chart polyline,
|
||||||
|
#mining-checker-app .mc-chart line,
|
||||||
|
#mining-checker-app .mc-chart rect {
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
#mining-checker-app .mc-hero-top,
|
||||||
|
#mining-checker-app .mc-inline-row,
|
||||||
|
#mining-checker-app .mc-flex-split,
|
||||||
|
#mining-checker-app .mc-section-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-main-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-shell {
|
||||||
|
width: min(100% - 10px, 1360px);
|
||||||
|
padding: 8px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-stack {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero,
|
||||||
|
#mining-checker-app .mc-panel,
|
||||||
|
#mining-checker-app .mc-dashboard-card,
|
||||||
|
#mining-checker-app .mc-alert,
|
||||||
|
#mining-checker-app .mc-empty,
|
||||||
|
#mining-checker-app .mc-table-shell {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-title {
|
||||||
|
font-size: clamp(1.45rem, 8vw, 2.15rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero-copy {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero-copy p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero-controls {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-home-link {
|
||||||
|
justify-self: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-tabs {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px -4px 0;
|
||||||
|
padding: 0 4px 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button--tab {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-table th,
|
||||||
|
#mining-checker-app .mc-table td {
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-modal {
|
||||||
|
max-height: min(92vh, 900px);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
#mining-checker-app,
|
||||||
|
#mining-checker-app * {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app {
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-grid-bg {
|
||||||
|
overflow-x: hidden;
|
||||||
|
background-size: 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-shell {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-stack {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero,
|
||||||
|
#mining-checker-app .mc-panel,
|
||||||
|
#mining-checker-app .mc-dashboard-card,
|
||||||
|
#mining-checker-app .mc-alert,
|
||||||
|
#mining-checker-app .mc-empty {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero {
|
||||||
|
border-radius: 0 0 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-panel,
|
||||||
|
#mining-checker-app .mc-dashboard-card,
|
||||||
|
#mining-checker-app .mc-alert,
|
||||||
|
#mining-checker-app .mc-empty,
|
||||||
|
#mining-checker-app .mc-table-shell {
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-title,
|
||||||
|
#mining-checker-app .mc-hero-copy p {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-hero-top {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-kicker {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-tabs {
|
||||||
|
margin: 10px -6px 0;
|
||||||
|
padding: 0 6px 6px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-button:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-stats-grid,
|
||||||
|
#mining-checker-app .mc-target-grid,
|
||||||
|
#mining-checker-app .mc-overview-grid,
|
||||||
|
#mining-checker-app .mc-two-col,
|
||||||
|
#mining-checker-app .mc-main-grid,
|
||||||
|
#mining-checker-app .mc-filter-grid,
|
||||||
|
#mining-checker-app .mc-mini-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-stat-card,
|
||||||
|
#mining-checker-app .mc-target-card,
|
||||||
|
#mining-checker-app .mc-mini-card,
|
||||||
|
#mining-checker-app .mc-display-field {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-stat-value {
|
||||||
|
font-size: clamp(1.45rem, 8vw, 1.85rem);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app h3 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-text,
|
||||||
|
#mining-checker-app p,
|
||||||
|
#mining-checker-app td,
|
||||||
|
#mining-checker-app th,
|
||||||
|
#mining-checker-app label,
|
||||||
|
#mining-checker-app summary,
|
||||||
|
#mining-checker-app pre {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-input,
|
||||||
|
#mining-checker-app .mc-select,
|
||||||
|
#mining-checker-app .mc-textarea,
|
||||||
|
#mining-checker-app .mc-file {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-inline-fields,
|
||||||
|
#mining-checker-app .mc-currency-selection-row,
|
||||||
|
#mining-checker-app .mc-debug-tools,
|
||||||
|
#mining-checker-app .mc-debug-view-switch {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-inline-fields > .mc-field {
|
||||||
|
flex-basis: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-token,
|
||||||
|
#mining-checker-app .mc-suggestion,
|
||||||
|
#mining-checker-app .mc-badge {
|
||||||
|
padding: 7px 10px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-table-shell {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-table {
|
||||||
|
max-width: none;
|
||||||
|
min-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-table th,
|
||||||
|
#mining-checker-app .mc-table td {
|
||||||
|
padding: 9px 10px;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-chart svg {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-modal-backdrop {
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mining-checker-app .mc-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(100vh - 16px);
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
0
modules/mining-checker/assets/js/.gitkeep
Normal file
0
modules/mining-checker/assets/js/.gitkeep
Normal file
2983
modules/mining-checker/assets/js/app.js
Normal file
2983
modules/mining-checker/assets/js/app.js
Normal file
File diff suppressed because it is too large
Load Diff
16
modules/mining-checker/bootstrap.php
Normal file
16
modules/mining-checker/bootstrap.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
});
|
||||||
24
modules/mining-checker/config/example.config.php
Normal file
24
modules/mining-checker/config/example.config.php
Normal 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',
|
||||||
|
];
|
||||||
46
modules/mining-checker/config/module.php
Normal file
46
modules/mining-checker/config/module.php
Normal 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',
|
||||||
|
],
|
||||||
|
];
|
||||||
125
modules/mining-checker/docs/README.md
Normal file
125
modules/mining-checker/docs/README.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# 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`
|
||||||
|
- `POST /api/mining-checker/v1/projects/{projectKey}/fx-refresh`
|
||||||
|
- `POST /api/mining-checker/v1/projects/{projectKey}/currencies-refresh`
|
||||||
|
- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history`
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
Der Endpunkt `POST /api/mining-checker/v1/projects/{projectKey}/fx-refresh` holt aktuelle Fiat-Wechselkurse von `currencyapi.net` und speichert sie in `miningcheck_fx_rates`.
|
||||||
|
|
||||||
|
Empfohlene Umgebungsvariablen:
|
||||||
|
|
||||||
|
- `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=...`
|
||||||
|
- `MINING_CHECKER_FX_TIMEOUT=10`
|
||||||
|
- `MINING_CHECKER_FX_CACHE_TTL=21600`
|
||||||
|
- `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 `GET /api/v2/rates?base=...&output=json&key=...` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die API liefert mehr Waehrungen als benoetigt, der Mining-Checker filtert lokal auf die angeforderten Zielwaehrungen und speichert die Kurse danach normalisiert in `miningcheck_fx_fetches` und `miningcheck_fx_rates`.
|
||||||
|
|
||||||
|
Pro Abruf entsteht genau ein Datensatz in `miningcheck_fx_fetches` mit Basiswaehrung, Provider und Stichtag. Alle Einzelkurse dieses Abrufs liegen darunter in `miningcheck_fx_rates` und teilen sich dieselbe `fetch_id`. Dadurch lassen sich Kurse innerhalb desselben Abrufs konsistent gegeneinander umrechnen.
|
||||||
|
|
||||||
|
Wenn fuer eine benoetigte Umrechnung noch kein passender FIAT-Fetch gespeichert ist, faellt der Mining-Checker auf vorhandene Kurs-Snapshots aus den Mining-Messpunkten (`measurement_rates`) zurueck.
|
||||||
|
|
||||||
|
Mit `POST /api/mining-checker/v1/projects/{projectKey}/currencies-refresh` kann die Waehrungstabelle einmalig oder bei Bedarf aus `GET /api/v2/currencies?output=json&key=...` synchronisiert werden. Dabei werden Code, Name, Symbol und Sortierung in `miningcheck_currencies` gespeichert.
|
||||||
|
|
||||||
|
Die im Tab `Waehrungen` ausgewaehlten Favoriten werden in `miningcheck_settings.preferred_currencies` gespeichert. Dadurch ist die Auswahl geraeteuebergreifend verfuegbar. Fuer bestehende Installationen ist dafuer einmal ein Schema-Upgrade noetig.
|
||||||
10
modules/mining-checker/module.json
Normal file
10
modules/mining-checker/module.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "Mining-Checker",
|
||||||
|
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
|
||||||
|
"enabled_by_default": true,
|
||||||
|
"auth": {
|
||||||
|
"required": true,
|
||||||
|
"users": [],
|
||||||
|
"groups": []
|
||||||
|
}
|
||||||
|
}
|
||||||
32
modules/mining-checker/pages/index.php
Normal file
32
modules/mining-checker/pages/index.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__) . '/bootstrap.php';
|
||||||
|
|
||||||
|
$moduleConfig = require dirname(__DIR__) . '/config/module.php';
|
||||||
|
$defaultProjectKey = (string) ($moduleConfig['default_project_key'] ?? 'doge-main');
|
||||||
|
$fxConfig = (array) ($moduleConfig['fx'] ?? []);
|
||||||
|
$fxProvider = (string) ($fxConfig['provider'] ?? 'currencyapi');
|
||||||
|
$fxBaseUrl = rtrim((string) ($fxConfig['url'] ?? 'https://currencyapi.net'), '/');
|
||||||
|
$fxCurrenciesUrl = rtrim((string) ($fxConfig['currencies_url'] ?? $fxBaseUrl), '/');
|
||||||
|
$fxApiKey = (string) ($fxConfig['api_key'] ?? '');
|
||||||
|
$fxApiKeyMasked = $fxApiKey === ''
|
||||||
|
? ''
|
||||||
|
: (strlen($fxApiKey) <= 10 ? $fxApiKey : substr($fxApiKey, 0, 6) . '...' . substr($fxApiKey, -4));
|
||||||
|
$moduleCss = file_get_contents(dirname(__DIR__) . '/assets/css/app.css') ?: '';
|
||||||
|
$moduleJs = file_get_contents(dirname(__DIR__) . '/assets/js/app.js') ?: '';
|
||||||
|
$moduleJs = str_replace('</script>', '<\/script>', $moduleJs);
|
||||||
|
?>
|
||||||
|
<div class="module-host-card mining-checker-host">
|
||||||
|
<div id="mining-checker-app"
|
||||||
|
data-default-project-key="<?= e($defaultProjectKey) ?>"
|
||||||
|
data-api-base="/api/mining-checker/v1"
|
||||||
|
data-fx-provider="<?= e($fxProvider) ?>"
|
||||||
|
data-fx-url="<?= e($fxBaseUrl) ?>"
|
||||||
|
data-fx-currencies-url="<?= e($fxCurrenciesUrl) ?>"
|
||||||
|
data-fx-api-key-mask="<?= e($fxApiKeyMasked) ?>"></div>
|
||||||
|
</div>
|
||||||
|
<style><?= $moduleCss ?></style>
|
||||||
|
<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><?= $moduleJs ?></script>
|
||||||
0
modules/mining-checker/partials/.gitkeep
Normal file
0
modules/mining-checker/partials/.gitkeep
Normal file
123
modules/mining-checker/sql/migrations/001_init.sql
Normal file
123
modules/mining-checker/sql/migrations/001_init.sql
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
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_currencies (
|
||||||
|
code VARCHAR(10) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
symbol VARCHAR(8) NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
is_crypto TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS miningcheck_currency_aliases (
|
||||||
|
alias_code VARCHAR(10) NOT NULL PRIMARY KEY,
|
||||||
|
currency_code VARCHAR(10) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
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 fk_mining_settings_daily_cost_currency_currency FOREIGN KEY (daily_cost_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_settings_report_currency_currency FOREIGN KEY (report_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_settings_crypto_currency_currency FOREIGN KEY (crypto_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_cost_plans_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 fk_mining_measurements_price_currency_currency FOREIGN KEY (price_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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)
|
||||||
|
);
|
||||||
91
modules/mining-checker/sql/migrations/002_seed_doge_main.sql
Normal file
91
modules/mining-checker/sql/migrations/002_seed_doge_main.sql
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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_currencies (code, name, symbol, is_active, sort_order)
|
||||||
|
VALUES
|
||||||
|
('EUR', 'Euro', 'EUR', 1, 10),
|
||||||
|
('USD', 'US-Dollar', 'USD', 1, 20),
|
||||||
|
('DOGE', 'Dogecoin', 'DOGE', 1, 100),
|
||||||
|
('BTC', 'Bitcoin', 'BTC', 1, 110),
|
||||||
|
('ETH', 'Ethereum', 'ETH', 1, 120),
|
||||||
|
('LTC', 'Litecoin', 'LTC', 1, 130),
|
||||||
|
('USDT', 'Tether', 'USDT', 1, 140),
|
||||||
|
('USDC', 'USD Coin', 'USDC', 1, 150)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
symbol = VALUES(symbol),
|
||||||
|
is_active = VALUES(is_active),
|
||||||
|
sort_order = VALUES(sort_order);
|
||||||
|
|
||||||
|
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);
|
||||||
34
modules/mining-checker/sql/migrations/003_timezone_utc.sql
Normal file
34
modules/mining-checker/sql/migrations/003_timezone_utc.sql
Normal 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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
226
modules/mining-checker/sql/schema.mysql.sql
Normal file
226
modules/mining-checker/sql/schema.mysql.sql
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
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_currencies (
|
||||||
|
code VARCHAR(10) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
symbol VARCHAR(8) NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
is_crypto TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS miningcheck_currency_aliases (
|
||||||
|
alias_code VARCHAR(10) NOT NULL PRIMARY KEY,
|
||||||
|
currency_code VARCHAR(10) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
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 fk_mining_settings_daily_cost_currency_currency FOREIGN KEY (daily_cost_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_settings_report_currency_currency FOREIGN KEY (report_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_settings_crypto_currency_currency FOREIGN KEY (crypto_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_cost_plans_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 fk_mining_measurements_price_currency_currency FOREIGN KEY (price_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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_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 fk_mining_measurement_rates_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_measurement_rates_quote_currency_currency FOREIGN KEY (quote_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_payouts_payout_currency_currency FOREIGN KEY (payout_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_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_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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_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,
|
||||||
|
CONSTRAINT fk_mining_miner_offers_base_price_currency_currency FOREIGN KEY (base_price_currency) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_purchased_miners_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_purchased_miners_reference_price_currency_currency FOREIGN KEY (reference_price_currency) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_fx_fetches_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_fx_rates_currency_code_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
244
modules/mining-checker/sql/schema.pgsql.sql
Normal file
244
modules/mining-checker/sql/schema.pgsql.sql
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
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_currencies (
|
||||||
|
code VARCHAR(10) PRIMARY KEY,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
symbol VARCHAR(8),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
is_crypto BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS miningcheck_currency_aliases (
|
||||||
|
alias_code VARCHAR(10) PRIMARY KEY,
|
||||||
|
currency_code VARCHAR(10) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
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 fk_mining_settings_daily_cost_currency_currency FOREIGN KEY (daily_cost_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_settings_report_currency_currency FOREIGN KEY (report_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_settings_crypto_currency_currency FOREIGN KEY (crypto_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_cost_plans_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
price_per_coin NUMERIC(20,8),
|
||||||
|
price_currency VARCHAR(10),
|
||||||
|
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 fk_mining_measurements_price_currency_currency FOREIGN KEY (price_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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 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 fk_mining_measurement_rates_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_measurement_rates_quote_currency_currency FOREIGN KEY (quote_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_payouts_payout_currency_currency FOREIGN KEY (payout_currency) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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_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_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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_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,
|
||||||
|
CONSTRAINT fk_mining_miner_offers_base_price_currency_currency FOREIGN KEY (base_price_currency) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_purchased_miners_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_purchased_miners_reference_price_currency_currency FOREIGN KEY (reference_price_currency) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_fx_fetches_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_fx_rates_currency_code_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
226
modules/mining-checker/sql/schema.sql
Normal file
226
modules/mining-checker/sql/schema.sql
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
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_currencies (
|
||||||
|
code VARCHAR(10) NOT NULL PRIMARY KEY,
|
||||||
|
name VARCHAR(64) NOT NULL,
|
||||||
|
symbol VARCHAR(8) NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
is_crypto TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS miningcheck_currency_aliases (
|
||||||
|
alias_code VARCHAR(10) NOT NULL PRIMARY KEY,
|
||||||
|
currency_code VARCHAR(10) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
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 fk_mining_settings_daily_cost_currency_currency FOREIGN KEY (daily_cost_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_settings_report_currency_currency FOREIGN KEY (report_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_settings_crypto_currency_currency FOREIGN KEY (crypto_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_cost_plans_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 fk_mining_measurements_price_currency_currency FOREIGN KEY (price_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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_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 fk_mining_measurement_rates_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_measurement_rates_quote_currency_currency FOREIGN KEY (quote_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_payouts_payout_currency_currency FOREIGN KEY (payout_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_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_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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_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,
|
||||||
|
CONSTRAINT fk_mining_miner_offers_base_price_currency_currency FOREIGN KEY (base_price_currency) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_purchased_miners_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
CONSTRAINT fk_mining_purchased_miners_reference_price_currency_currency FOREIGN KEY (reference_price_currency) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_fx_fetches_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code),
|
||||||
|
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,
|
||||||
|
CONSTRAINT fk_mining_fx_rates_currency_code_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code)
|
||||||
|
);
|
||||||
91
modules/mining-checker/sql/seed.sql
Normal file
91
modules/mining-checker/sql/seed.sql
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
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_currencies (code, name, symbol, is_active, sort_order)
|
||||||
|
VALUES
|
||||||
|
('EUR', 'Euro', 'EUR', 1, 10),
|
||||||
|
('USD', 'US-Dollar', 'USD', 1, 20),
|
||||||
|
('DOGE', 'Dogecoin', 'DOGE', 1, 100),
|
||||||
|
('BTC', 'Bitcoin', 'BTC', 1, 110),
|
||||||
|
('ETH', 'Ethereum', 'ETH', 1, 120),
|
||||||
|
('LTC', 'Litecoin', 'LTC', 1, 130),
|
||||||
|
('USDT', 'Tether', 'USDT', 1, 140),
|
||||||
|
('USDC', 'USD Coin', 'USDC', 1, 150)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
symbol = VALUES(symbol),
|
||||||
|
is_active = VALUES(is_active),
|
||||||
|
sort_order = VALUES(sort_order);
|
||||||
|
|
||||||
|
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);
|
||||||
1747
modules/mining-checker/src/Api/Router.php
Normal file
1747
modules/mining-checker/src/Api/Router.php
Normal file
File diff suppressed because it is too large
Load Diff
937
modules/mining-checker/src/Domain/AnalyticsService.php
Normal file
937
modules/mining-checker/src/Domain/AnalyticsService.php
Normal file
@@ -0,0 +1,937 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Modules\MiningChecker\Domain;
|
||||||
|
|
||||||
|
use Modules\MiningChecker\Support\ApiException;
|
||||||
|
|
||||||
|
final class AnalyticsService
|
||||||
|
{
|
||||||
|
private ?FxService $fx;
|
||||||
|
|
||||||
|
public function __construct(?FxService $fx = null)
|
||||||
|
{
|
||||||
|
$this->fx = $fx;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enrichMeasurements(array $measurements, array $settings): array
|
||||||
|
{
|
||||||
|
$baselineCoins = (float) ($settings['baseline_coins_total'] ?? 0.0);
|
||||||
|
$baselineAt = (string) ($settings['baseline_measured_at'] ?? '');
|
||||||
|
$costPlans = is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [];
|
||||||
|
$payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : [];
|
||||||
|
$measurementRates = is_array($settings['measurement_rates'] ?? null) ? $settings['measurement_rates'] : [];
|
||||||
|
|
||||||
|
$baselineTs = $this->utcTimestamp($baselineAt);
|
||||||
|
$previous = null;
|
||||||
|
$previousIntervalRate = null;
|
||||||
|
$result = [];
|
||||||
|
$payoutIndex = 0;
|
||||||
|
$cumulativePayouts = 0.0;
|
||||||
|
$latestPriceByCurrency = [];
|
||||||
|
|
||||||
|
foreach ($measurements as $row) {
|
||||||
|
$measuredTs = $this->utcTimestamp((string) $row['measured_at']);
|
||||||
|
while (isset($payouts[$payoutIndex])) {
|
||||||
|
$payoutTs = $this->utcTimestamp((string) ($payouts[$payoutIndex]['payout_at'] ?? ''));
|
||||||
|
if ($payoutTs <= 0 || $payoutTs > $measuredTs) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cumulativePayouts += (float) ($payouts[$payoutIndex]['coins_amount'] ?? 0);
|
||||||
|
$payoutIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$visibleCoinsTotal = (float) $row['coins_total'];
|
||||||
|
$effectiveCoinsTotal = $visibleCoinsTotal + $cumulativePayouts;
|
||||||
|
$growth = $effectiveCoinsTotal - $baselineCoins;
|
||||||
|
$hoursSinceBaseline = $baselineTs > 0 && $measuredTs > $baselineTs ? ($measuredTs - $baselineTs) / 3600 : 0.0;
|
||||||
|
$perHourSinceBaseline = $hoursSinceBaseline > 0 ? $growth / $hoursSinceBaseline : null;
|
||||||
|
$perDaySinceBaseline = $perHourSinceBaseline !== null ? $perHourSinceBaseline * 24 : null;
|
||||||
|
|
||||||
|
$intervalHours = null;
|
||||||
|
$intervalGrowth = null;
|
||||||
|
$perHourInterval = null;
|
||||||
|
$perDayInterval = null;
|
||||||
|
|
||||||
|
if (is_array($previous)) {
|
||||||
|
$intervalHours = max(0.0, ($measuredTs - ($this->utcTimestamp((string) $previous['measured_at']) ?: $measuredTs)) / 3600);
|
||||||
|
$intervalGrowth = $effectiveCoinsTotal - (float) ($previous['coins_total_effective'] ?? $previous['coins_total']);
|
||||||
|
$perHourInterval = $intervalHours > 0 ? $intervalGrowth / $intervalHours : null;
|
||||||
|
$perDayInterval = $perHourInterval !== null ? $perHourInterval * 24 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trendLabel = 'stabil';
|
||||||
|
if ($perHourInterval !== null && $previousIntervalRate !== null) {
|
||||||
|
$delta = $perHourInterval - $previousIntervalRate;
|
||||||
|
$threshold = max(abs($previousIntervalRate) * 0.05, 0.01);
|
||||||
|
if ($delta > $threshold) {
|
||||||
|
$trendLabel = 'steigend';
|
||||||
|
} elseif ($delta < -$threshold) {
|
||||||
|
$trendLabel = 'fallend';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawPrice = isset($row['price_per_coin']) && $row['price_per_coin'] !== null ? (float) $row['price_per_coin'] : null;
|
||||||
|
$rawPriceCurrency = $row['price_currency'] ?: null;
|
||||||
|
|
||||||
|
if ($rawPrice !== null && $rawPriceCurrency !== null) {
|
||||||
|
$latestPriceByCurrency[(string) $rawPriceCurrency] = $rawPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
$measurementDerivedPrices = $this->measurementDerivedPrices($measurementRates, (int) ($row['id'] ?? 0));
|
||||||
|
foreach ($measurementDerivedPrices as $derivedCurrency => $derivedPrice) {
|
||||||
|
$latestPriceByCurrency[$derivedCurrency] = $derivedPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceCurrency = $rawPriceCurrency !== null
|
||||||
|
? (string) $rawPriceCurrency
|
||||||
|
: $this->preferredPriceCurrency($latestPriceByCurrency, $measurementDerivedPrices);
|
||||||
|
$price = $rawPrice;
|
||||||
|
if ($price === null && $priceCurrency !== null && isset($measurementDerivedPrices[$priceCurrency])) {
|
||||||
|
$price = (float) $measurementDerivedPrices[$priceCurrency];
|
||||||
|
}
|
||||||
|
if ($price === null && $priceCurrency !== null && isset($latestPriceByCurrency[$priceCurrency])) {
|
||||||
|
$price = (float) $latestPriceByCurrency[$priceCurrency];
|
||||||
|
}
|
||||||
|
if ($price === null) {
|
||||||
|
foreach (['USD', 'EUR'] as $fallbackCurrency) {
|
||||||
|
$fxPrice = $this->convertAmount(1.0, 'DOGE', $fallbackCurrency);
|
||||||
|
if ($fxPrice !== null && $fxPrice > 0) {
|
||||||
|
$latestPriceByCurrency[$fallbackCurrency] = $fxPrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$priceCurrency = $priceCurrency ?? $this->preferredPriceCurrency($latestPriceByCurrency, $measurementDerivedPrices);
|
||||||
|
if ($priceCurrency !== null && isset($latestPriceByCurrency[$priceCurrency])) {
|
||||||
|
$price = (float) $latestPriceByCurrency[$priceCurrency];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effectiveDailyCost = $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency);
|
||||||
|
$currentValue = $price !== null ? $visibleCoinsTotal * $price : null;
|
||||||
|
$currentValueEffective = $price !== null ? $effectiveCoinsTotal * $price : null;
|
||||||
|
$theoreticalDailyRevenue = ($price !== null && $perDayInterval !== null) ? $perDayInterval * $price : null;
|
||||||
|
$theoreticalDailyProfit = (
|
||||||
|
$theoreticalDailyRevenue !== null &&
|
||||||
|
$effectiveDailyCost !== null
|
||||||
|
) ? $theoreticalDailyRevenue - $effectiveDailyCost : null;
|
||||||
|
$breakEvenPricePerCoin = (
|
||||||
|
$effectiveDailyCost !== null &&
|
||||||
|
$perDayInterval !== null &&
|
||||||
|
$perDayInterval > 0
|
||||||
|
) ? $effectiveDailyCost / $perDayInterval : null;
|
||||||
|
$profitMarginPercent = (
|
||||||
|
$theoreticalDailyRevenue !== null &&
|
||||||
|
$theoreticalDailyRevenue > 0 &&
|
||||||
|
$theoreticalDailyProfit !== null
|
||||||
|
) ? ($theoreticalDailyProfit / $theoreticalDailyRevenue) * 100 : null;
|
||||||
|
|
||||||
|
$normalizedFlags = $row['ocr_flags'];
|
||||||
|
if (is_string($normalizedFlags) && $normalizedFlags !== '') {
|
||||||
|
$decoded = json_decode($normalizedFlags, true);
|
||||||
|
$normalizedFlags = is_array($decoded) ? $decoded : [$normalizedFlags];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result[] = array_merge($row, [
|
||||||
|
'coins_total_visible' => $this->roundOrNull($visibleCoinsTotal, 6),
|
||||||
|
'coins_total_effective' => $this->roundOrNull($effectiveCoinsTotal, 6),
|
||||||
|
'payouts_cumulative' => $this->roundOrNull($cumulativePayouts, 6),
|
||||||
|
'growth_since_baseline' => $this->roundOrNull($growth, 6),
|
||||||
|
'hours_since_baseline' => $this->roundOrNull($hoursSinceBaseline, 4),
|
||||||
|
'doge_per_hour_since_baseline' => $this->roundOrNull($perHourSinceBaseline, 6),
|
||||||
|
'doge_per_day_since_baseline' => $this->roundOrNull($perDaySinceBaseline, 6),
|
||||||
|
'interval_hours' => $this->roundOrNull($intervalHours, 4),
|
||||||
|
'interval_growth' => $this->roundOrNull($intervalGrowth, 6),
|
||||||
|
'doge_per_hour_interval' => $this->roundOrNull($perHourInterval, 6),
|
||||||
|
'doge_per_day_interval' => $this->roundOrNull($perDayInterval, 6),
|
||||||
|
'trend_label' => $trendLabel,
|
||||||
|
'effective_price_per_coin' => $this->roundOrNull($price, 8),
|
||||||
|
'effective_price_currency' => $priceCurrency,
|
||||||
|
'price_is_fallback' => $rawPrice === null && $price !== null,
|
||||||
|
'current_value' => $this->roundOrNull($currentValue, 8),
|
||||||
|
'current_value_effective' => $this->roundOrNull($currentValueEffective, 8),
|
||||||
|
'effective_daily_cost' => $this->roundOrNull($effectiveDailyCost, 8),
|
||||||
|
'theoretical_daily_revenue' => $this->roundOrNull($theoreticalDailyRevenue, 8),
|
||||||
|
'theoretical_daily_profit' => $this->roundOrNull($theoreticalDailyProfit, 8),
|
||||||
|
'break_even_price_per_coin' => $this->roundOrNull($breakEvenPricePerCoin, 8),
|
||||||
|
'profit_margin_percent' => $this->roundOrNull($profitMarginPercent, 4),
|
||||||
|
'measured_date' => substr((string) $row['measured_at'], 0, 10),
|
||||||
|
'ocr_flags' => is_array($normalizedFlags) ? $normalizedFlags : [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($perHourInterval !== null) {
|
||||||
|
$previousIntervalRate = $perHourInterval;
|
||||||
|
}
|
||||||
|
$previous = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildSummary(array $measurements, array $settings, array $targets): array
|
||||||
|
{
|
||||||
|
if ($measurements === []) {
|
||||||
|
return [
|
||||||
|
'latest_measurement' => null,
|
||||||
|
'baseline' => $settings,
|
||||||
|
'targets' => [],
|
||||||
|
'payouts' => [],
|
||||||
|
'miner_offers' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$latest = $measurements[array_key_last($measurements)];
|
||||||
|
$latestPriceByCurrency = [];
|
||||||
|
foreach ($measurements as $measurement) {
|
||||||
|
if ($measurement['price_per_coin'] !== null && $measurement['price_currency'] !== null) {
|
||||||
|
$latestPriceByCurrency[(string) $measurement['price_currency']] = (float) $measurement['price_per_coin'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : [];
|
||||||
|
$purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : [];
|
||||||
|
$minerOffers = is_array($settings['miner_offers'] ?? null) ? $settings['miner_offers'] : [];
|
||||||
|
$currentHashrateMh = $this->totalHashrateMh(array_merge(
|
||||||
|
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
||||||
|
$purchasedMiners
|
||||||
|
));
|
||||||
|
$offerSummary = [];
|
||||||
|
foreach ($minerOffers as $offer) {
|
||||||
|
$offerSummary[] = $this->evaluateMinerOffer($offer, $latest, $latestPriceByCurrency, $currentHashrateMh, $settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetSummary = [];
|
||||||
|
foreach ($targets as $target) {
|
||||||
|
$currency = (string) $target['currency'];
|
||||||
|
$targetAmount = is_numeric($target['target_amount_fiat'] ?? null) ? (float) $target['target_amount_fiat'] : null;
|
||||||
|
$linkedOffer = null;
|
||||||
|
if (is_numeric($target['miner_offer_id'] ?? null)) {
|
||||||
|
foreach ($offerSummary as $offer) {
|
||||||
|
if ((int) ($offer['id'] ?? 0) === (int) $target['miner_offer_id']) {
|
||||||
|
$linkedOffer = $offer;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($linkedOffer)) {
|
||||||
|
$currency = (string) ($linkedOffer['reference_price_currency'] ?? $linkedOffer['effective_price_currency'] ?? $currency);
|
||||||
|
$targetAmount = is_numeric($linkedOffer['reference_price_amount'] ?? null)
|
||||||
|
? (float) $linkedOffer['reference_price_amount']
|
||||||
|
: (is_numeric($linkedOffer['effective_price_amount'] ?? null) ? (float) $linkedOffer['effective_price_amount'] : $targetAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
$price = $latestPriceByCurrency[$currency] ?? $this->convertLatestPrice($latestPriceByCurrency, $currency);
|
||||||
|
$requiredDoge = ($price && $targetAmount !== null) ? $targetAmount / $price : null;
|
||||||
|
$remainingDoge = $requiredDoge !== null ? $requiredDoge - (float) ($latest['coins_total_effective'] ?? $latest['coins_total']) : null;
|
||||||
|
$remainingDays = (
|
||||||
|
$remainingDoge !== null &&
|
||||||
|
$latest['doge_per_day_interval'] !== null &&
|
||||||
|
(float) $latest['doge_per_day_interval'] > 0
|
||||||
|
) ? $remainingDoge / (float) $latest['doge_per_day_interval'] : null;
|
||||||
|
$targetEtaAt = null;
|
||||||
|
if ($remainingDays !== null) {
|
||||||
|
if ($remainingDays <= 0) {
|
||||||
|
$targetEtaAt = (string) ($latest['measured_at'] ?? '');
|
||||||
|
} elseif (!empty($latest['measured_at'])) {
|
||||||
|
try {
|
||||||
|
$targetEtaAt = $this->formatUtcTimestamp(
|
||||||
|
$this->utcTimestamp((string) $latest['measured_at']) + (int) round($remainingDays * 86400)
|
||||||
|
);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$targetEtaAt = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetSummary[] = array_merge($target, [
|
||||||
|
'effective_target_amount_fiat' => $this->roundOrNull($targetAmount, 2),
|
||||||
|
'effective_currency' => $currency,
|
||||||
|
'linked_offer_id' => $linkedOffer['id'] ?? ($target['miner_offer_id'] ?? null),
|
||||||
|
'linked_offer_label' => $linkedOffer['label'] ?? null,
|
||||||
|
'latest_price_for_currency' => $price,
|
||||||
|
'required_doge' => $this->roundOrNull($requiredDoge, 6),
|
||||||
|
'remaining_doge' => $this->roundOrNull($remainingDoge, 6),
|
||||||
|
'remaining_days' => $this->roundOrNull($remainingDays, 4),
|
||||||
|
'target_eta_at' => $targetEtaAt,
|
||||||
|
'status' => $remainingDoge !== null && $remainingDoge <= 0 ? 'reached' : 'open',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '');
|
||||||
|
$investedCapital = $latestCurrency !== ''
|
||||||
|
? $this->totalInvestmentBasis(
|
||||||
|
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
||||||
|
$purchasedMiners,
|
||||||
|
$this->utcTimestamp((string) ($latest['measured_at'] ?? '')),
|
||||||
|
$latestCurrency
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
$currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null;
|
||||||
|
$breakEvenRemainingAmount = $investedCapital;
|
||||||
|
$breakEvenDaysOverall = (
|
||||||
|
$investedCapital !== null &&
|
||||||
|
$currentDailyRevenue !== null &&
|
||||||
|
$currentDailyRevenue > 0
|
||||||
|
) ? ($investedCapital / $currentDailyRevenue) : null;
|
||||||
|
$latestSummary = array_merge($latest, [
|
||||||
|
'invested_capital' => $this->roundOrNull($investedCapital, 8),
|
||||||
|
'break_even_remaining_amount' => $this->roundOrNull($breakEvenRemainingAmount, 8),
|
||||||
|
'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4),
|
||||||
|
]);
|
||||||
|
$currentProjection = $this->projectPerformance(
|
||||||
|
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
||||||
|
$purchasedMiners,
|
||||||
|
$latestSummary,
|
||||||
|
730
|
||||||
|
);
|
||||||
|
$latestSummary = array_merge($latestSummary, [
|
||||||
|
'projection_days' => $currentProjection['days'],
|
||||||
|
'projection_two_year_revenue' => $this->roundOrNull($currentProjection['revenue'], 8),
|
||||||
|
'projection_two_year_cost' => $this->roundOrNull($currentProjection['cost'], 8),
|
||||||
|
'projection_two_year_profit' => $this->roundOrNull($currentProjection['profit'], 8),
|
||||||
|
]);
|
||||||
|
$offerSummary = array_map(
|
||||||
|
fn (array $offer): array => $this->enrichOfferScenario(
|
||||||
|
$offer,
|
||||||
|
$latestSummary,
|
||||||
|
$currentHashrateMh,
|
||||||
|
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
|
||||||
|
$purchasedMiners
|
||||||
|
),
|
||||||
|
$offerSummary
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'latest_measurement' => $latestSummary,
|
||||||
|
'baseline' => $settings,
|
||||||
|
'targets' => $targetSummary,
|
||||||
|
'payouts' => [
|
||||||
|
'total_count' => count($payouts),
|
||||||
|
'total_coins' => $this->roundOrNull(array_sum(array_map(static fn (array $payout): float => (float) ($payout['coins_amount'] ?? 0), $payouts)), 6),
|
||||||
|
'current_visible_coins' => $this->roundOrNull((float) ($latest['coins_total_visible'] ?? $latest['coins_total']), 6),
|
||||||
|
'current_effective_coins' => $this->roundOrNull((float) ($latest['coins_total_effective'] ?? $latest['coins_total']), 6),
|
||||||
|
],
|
||||||
|
'current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4),
|
||||||
|
'miner_offers' => $offerSummary,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dashboardData(array $measurements, string $xField, string $yField, string $aggregation, array $filters = []): array
|
||||||
|
{
|
||||||
|
$allowedX = ['measured_at', 'measured_date', 'source', 'price_currency', 'trend_label'];
|
||||||
|
$allowedY = [
|
||||||
|
'coins_total',
|
||||||
|
'price_per_coin',
|
||||||
|
'growth_since_baseline',
|
||||||
|
'doge_per_hour_since_baseline',
|
||||||
|
'doge_per_day_since_baseline',
|
||||||
|
'doge_per_hour_interval',
|
||||||
|
'doge_per_day_interval',
|
||||||
|
'current_value',
|
||||||
|
'theoretical_daily_revenue',
|
||||||
|
'theoretical_daily_profit',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!in_array($xField, $allowedX, true) || !in_array($yField, $allowedY, true)) {
|
||||||
|
throw new ApiException('Dashboard-Felder sind nicht erlaubt.', 422, ['x_field' => $xField, 'y_field' => $yField]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$filtered = array_values(array_filter($measurements, static function (array $row) use ($filters): bool {
|
||||||
|
if (!empty($filters['source']) && $row['source'] !== $filters['source']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['currency']) && $row['price_currency'] !== $filters['currency']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['date_from']) && (string) $row['measured_at'] < (string) $filters['date_from']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($filters['date_to']) && (string) $row['measured_at'] > (string) $filters['date_to']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
|
||||||
|
if ($aggregation === 'none') {
|
||||||
|
return array_map(static fn (array $row): array => [
|
||||||
|
'x' => $row[$xField] ?? null,
|
||||||
|
'y' => $row[$yField] ?? null,
|
||||||
|
'row' => $row,
|
||||||
|
], $filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups = [];
|
||||||
|
foreach ($filtered as $row) {
|
||||||
|
$key = (string) ($row[$xField] ?? 'unknown');
|
||||||
|
$groups[$key][] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [];
|
||||||
|
foreach ($groups as $key => $rows) {
|
||||||
|
$values = array_values(array_filter(array_map(static fn (array $row) => $row[$yField] ?? null, $rows), static fn ($value): bool => $value !== null));
|
||||||
|
$aggregated = match ($aggregation) {
|
||||||
|
'avg' => $values === [] ? null : array_sum($values) / count($values),
|
||||||
|
'sum' => $values === [] ? null : array_sum($values),
|
||||||
|
'min' => $values === [] ? null : min($values),
|
||||||
|
'max' => $values === [] ? null : max($values),
|
||||||
|
'count' => count($rows),
|
||||||
|
'latest' => $rows[array_key_last($rows)][$yField] ?? null,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
$result[] = [
|
||||||
|
'x' => $key,
|
||||||
|
'y' => $this->roundOrNull(is_numeric($aggregated) ? (float) $aggregated : null, 6),
|
||||||
|
'points' => count($rows),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function roundOrNull(?float $value, int $precision): ?float
|
||||||
|
{
|
||||||
|
return $value === null ? null : round($value, $precision);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function effectiveDailyCost(array $costPlans, int $measurementTs, ?string $currency): ?float
|
||||||
|
{
|
||||||
|
if ($currency === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dailyTotal = 0.0;
|
||||||
|
$matched = false;
|
||||||
|
foreach ($costPlans as $plan) {
|
||||||
|
if (empty($plan['is_active'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startTs = $this->utcTimestamp((string) ($plan['starts_at'] ?? ''));
|
||||||
|
$runtimeMonths = (int) ($plan['runtime_months'] ?? 0);
|
||||||
|
if ($startTs <= 0 || $runtimeMonths <= 0 || $measurementTs < $startTs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runtimeDays = $runtimeMonths * 30.4375;
|
||||||
|
$endTs = (int) round($startTs + ($runtimeDays * 86400));
|
||||||
|
$isCovered = !empty($plan['auto_renew']) || $measurementTs <= $endTs;
|
||||||
|
if (!$isCovered) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$planDailyCost = (float) $plan['total_cost_amount'] / $runtimeDays;
|
||||||
|
$convertedDailyCost = $this->convertAmount(
|
||||||
|
$planDailyCost,
|
||||||
|
(string) ($plan['currency'] ?? ''),
|
||||||
|
$currency
|
||||||
|
);
|
||||||
|
if ($convertedDailyCost === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$matched = true;
|
||||||
|
$dailyTotal += $convertedDailyCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matched ? $dailyTotal : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function convertLatestPrice(array $latestPriceByCurrency, string $targetCurrency): ?float
|
||||||
|
{
|
||||||
|
foreach ($latestPriceByCurrency as $sourceCurrency => $price) {
|
||||||
|
$converted = $this->convertAmount((float) $price, (string) $sourceCurrency, $targetCurrency);
|
||||||
|
if ($converted !== null) {
|
||||||
|
return $converted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function preferredPriceCurrency(array $latestPriceByCurrency, array $measurementDerivedPrices = []): ?string
|
||||||
|
{
|
||||||
|
foreach (['USD', 'EUR', 'DOGE'] as $preferredCurrency) {
|
||||||
|
if (array_key_exists($preferredCurrency, $latestPriceByCurrency)) {
|
||||||
|
return $preferredCurrency;
|
||||||
|
}
|
||||||
|
if (array_key_exists($preferredCurrency, $measurementDerivedPrices)) {
|
||||||
|
return $preferredCurrency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$first = array_key_first($latestPriceByCurrency);
|
||||||
|
if (is_string($first)) {
|
||||||
|
return $first;
|
||||||
|
}
|
||||||
|
|
||||||
|
$derivedFirst = array_key_first($measurementDerivedPrices);
|
||||||
|
return is_string($derivedFirst) ? $derivedFirst : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function measurementDerivedPrices(array $measurementRates, int $measurementId): array
|
||||||
|
{
|
||||||
|
if ($measurementId <= 0 || $measurementRates === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$prices = [];
|
||||||
|
foreach ($measurementRates as $row) {
|
||||||
|
if ((int) ($row['measurement_id'] ?? 0) !== $measurementId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseCurrency = strtoupper(trim((string) ($row['base_currency'] ?? '')));
|
||||||
|
$quoteCurrency = strtoupper(trim((string) ($row['target_currency'] ?? $row['quote_currency'] ?? '')));
|
||||||
|
$rate = is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null;
|
||||||
|
|
||||||
|
if ($baseCurrency !== 'DOGE' || $quoteCurrency === '' || $rate === null || $rate <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prices[$quoteCurrency] = $rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $prices;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function convertAmount(?float $amount, ?string $fromCurrency, ?string $toCurrency): ?float
|
||||||
|
{
|
||||||
|
if ($amount === null || $fromCurrency === null || $toCurrency === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = strtoupper(trim($fromCurrency));
|
||||||
|
$to = strtoupper(trim($toCurrency));
|
||||||
|
if ($from === '' || $to === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($from === $to) {
|
||||||
|
return $amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->fx === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->fx->convert($amount, $from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function totalHashrateMh(array $entries): float
|
||||||
|
{
|
||||||
|
$total = 0.0;
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if (array_key_exists('is_active', $entry) && empty($entry['is_active'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$this->entryIsCovered($entry, null)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total += $this->normalizeHashrateMh($entry['mining_speed_value'] ?? null, $entry['mining_speed_unit'] ?? null);
|
||||||
|
$total += $this->normalizeHashrateMh($entry['bonus_speed_value'] ?? null, $entry['bonus_speed_unit'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function totalPurchasedMinerCost(array $purchasedMiners, string $targetCurrency, ?int $measurementTs = null): ?float
|
||||||
|
{
|
||||||
|
$target = strtoupper(trim($targetCurrency));
|
||||||
|
if ($target === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = 0.0;
|
||||||
|
$matched = false;
|
||||||
|
foreach ($purchasedMiners as $miner) {
|
||||||
|
if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$this->entryIsCovered($miner, $measurementTs > 0 ? $measurementTs : null, 'purchased_at')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_amount'] : null;
|
||||||
|
$currency = strtoupper(trim((string) ($miner['currency'] ?? '')));
|
||||||
|
if ($amount === null || $amount <= 0 || $currency === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$converted = $this->convertAmount($amount, $currency, $target);
|
||||||
|
if ($converted === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$matched = true;
|
||||||
|
$total += $converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matched ? $total : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency): ?float
|
||||||
|
{
|
||||||
|
$target = strtoupper(trim($targetCurrency));
|
||||||
|
if ($target === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = 0.0;
|
||||||
|
$matched = false;
|
||||||
|
|
||||||
|
$purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs);
|
||||||
|
if ($purchasedTotal !== null) {
|
||||||
|
$matched = true;
|
||||||
|
$total += $purchasedTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($costPlans as $plan) {
|
||||||
|
if (empty($plan['is_active'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startTs = $this->utcTimestamp((string) ($plan['starts_at'] ?? ''));
|
||||||
|
$runtimeMonths = (int) ($plan['runtime_months'] ?? 0);
|
||||||
|
if ($startTs <= 0 || $runtimeMonths <= 0 || ($measurementTs > 0 && $measurementTs < $startTs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runtimeDays = $runtimeMonths * 30.4375;
|
||||||
|
$endTs = (int) round($startTs + ($runtimeDays * 86400));
|
||||||
|
$isCovered = !empty($plan['auto_renew']) || $measurementTs <= 0 || $measurementTs <= $endTs;
|
||||||
|
if (!$isCovered) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$amount = is_numeric($plan['total_cost_amount'] ?? null) ? (float) $plan['total_cost_amount'] : null;
|
||||||
|
$currency = strtoupper(trim((string) ($plan['currency'] ?? '')));
|
||||||
|
if ($amount === null || $amount <= 0 || $currency === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$converted = $this->convertAmount($amount, $currency, $target);
|
||||||
|
if ($converted === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$matched = true;
|
||||||
|
$total += $converted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matched ? $total : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function entryIsCovered(array $entry, ?int $measurementTs = null, string $startField = 'starts_at'): bool
|
||||||
|
{
|
||||||
|
$runtimeMonths = (int) ($entry['runtime_months'] ?? 0);
|
||||||
|
if ($runtimeMonths <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startTs = $this->utcTimestamp((string) ($entry[$startField] ?? ''));
|
||||||
|
if ($startTs <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$checkTs = $measurementTs ?? time();
|
||||||
|
if ($checkTs < $startTs) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runtimeDays = $runtimeMonths * 30.4375;
|
||||||
|
$endTs = (int) round($startTs + ($runtimeDays * 86400));
|
||||||
|
return !empty($entry['auto_renew']) || $checkTs <= $endTs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeHashrateMh(mixed $value, mixed $unit): float
|
||||||
|
{
|
||||||
|
if (!is_numeric($value) || !is_string($unit) || trim($unit) === '') {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$numeric = (float) $value;
|
||||||
|
return match (trim($unit)) {
|
||||||
|
'MH/s' => $numeric,
|
||||||
|
'kH/s' => $numeric / 1000,
|
||||||
|
default => 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evaluateMinerOffer(array $offer, array $latest, array $latestPriceByCurrency, float $currentHashrateMh, array $settings): array
|
||||||
|
{
|
||||||
|
$offerHashrateMh = $this->normalizeHashrateMh($offer['mining_speed_value'] ?? null, $offer['mining_speed_unit'] ?? null)
|
||||||
|
+ $this->normalizeHashrateMh($offer['bonus_speed_value'] ?? null, $offer['bonus_speed_unit'] ?? null);
|
||||||
|
|
||||||
|
$paymentType = (string) ($offer['payment_type'] ?? '');
|
||||||
|
if ($paymentType === '') {
|
||||||
|
$paymentType = !empty($offer['price_currency']) && in_array(strtoupper((string) $offer['price_currency']), ['ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP'], true)
|
||||||
|
? 'crypto'
|
||||||
|
: 'fiat';
|
||||||
|
}
|
||||||
|
$basePriceAmount = is_numeric($offer['base_price_amount'] ?? null)
|
||||||
|
? (float) $offer['base_price_amount']
|
||||||
|
: (is_numeric($offer['reference_price_amount'] ?? null)
|
||||||
|
? (float) $offer['reference_price_amount']
|
||||||
|
: (is_numeric($offer['usd_reference_amount'] ?? null)
|
||||||
|
? (float) $offer['usd_reference_amount']
|
||||||
|
: (is_numeric($offer['price_amount'] ?? null) ? (float) $offer['price_amount'] : null)));
|
||||||
|
$basePriceCurrency = (string) ($offer['base_price_currency'] ?? $offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : ($offer['price_currency'] ?? '')));
|
||||||
|
$effectivePriceCurrency = $paymentType === 'crypto'
|
||||||
|
? (string) ($settings['crypto_currency'] ?? 'DOGE')
|
||||||
|
: $basePriceCurrency;
|
||||||
|
$effectivePriceAmount = $basePriceAmount;
|
||||||
|
if ($basePriceAmount !== null && $basePriceAmount > 0 && $basePriceCurrency !== '' && $effectivePriceCurrency !== '' && strtoupper($basePriceCurrency) !== strtoupper($effectivePriceCurrency)) {
|
||||||
|
$convertedReference = $this->convertAmount($basePriceAmount, $basePriceCurrency, $effectivePriceCurrency);
|
||||||
|
if ($convertedReference !== null && $convertedReference > 0) {
|
||||||
|
$effectivePriceAmount = $convertedReference;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$referencePriceAmount = $basePriceAmount;
|
||||||
|
$referencePriceCurrency = $basePriceCurrency !== '' ? $basePriceCurrency : null;
|
||||||
|
|
||||||
|
$expectedDogePerDay = null;
|
||||||
|
if ($currentHashrateMh > 0 && $offerHashrateMh > 0 && is_numeric($latest['doge_per_day_interval'] ?? null)) {
|
||||||
|
$expectedDogePerDay = ((float) $latest['doge_per_day_interval'] / $currentHashrateMh) * $offerHashrateMh;
|
||||||
|
}
|
||||||
|
|
||||||
|
$offerCurrencyPrice = $effectivePriceCurrency !== '' ? ($latestPriceByCurrency[$effectivePriceCurrency] ?? $this->convertLatestPrice($latestPriceByCurrency, $effectivePriceCurrency)) : null;
|
||||||
|
$expectedDailyRevenue = ($expectedDogePerDay !== null && $offerCurrencyPrice !== null)
|
||||||
|
? $expectedDogePerDay * $offerCurrencyPrice
|
||||||
|
: null;
|
||||||
|
$breakEvenDays = ($effectivePriceAmount !== null && $expectedDailyRevenue !== null && $expectedDailyRevenue > 0)
|
||||||
|
? $effectivePriceAmount / $expectedDailyRevenue
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$recommendation = 'keine Basis';
|
||||||
|
if ($breakEvenDays !== null) {
|
||||||
|
if ($breakEvenDays <= 180) {
|
||||||
|
$recommendation = 'lohnt eher';
|
||||||
|
} elseif ($breakEvenDays <= 365) {
|
||||||
|
$recommendation = 'abwaegen';
|
||||||
|
} else {
|
||||||
|
$recommendation = 'eher warten';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_merge($offer, [
|
||||||
|
'payment_type' => $paymentType,
|
||||||
|
'base_price_amount' => $this->roundOrNull($basePriceAmount, 8),
|
||||||
|
'base_price_currency' => $basePriceCurrency !== '' ? $basePriceCurrency : null,
|
||||||
|
'offer_hashrate_mh' => $this->roundOrNull($offerHashrateMh, 4),
|
||||||
|
'effective_price_amount' => $this->roundOrNull($effectivePriceAmount, 8),
|
||||||
|
'effective_price_currency' => $effectivePriceCurrency,
|
||||||
|
'reference_price_amount' => $this->roundOrNull($referencePriceAmount, 8),
|
||||||
|
'reference_price_currency' => $referencePriceCurrency !== '' ? $referencePriceCurrency : null,
|
||||||
|
'expected_doge_per_day' => $this->roundOrNull($expectedDogePerDay, 6),
|
||||||
|
'expected_daily_revenue' => $this->roundOrNull($expectedDailyRevenue, 8),
|
||||||
|
'break_even_days' => $this->roundOrNull($breakEvenDays, 2),
|
||||||
|
'recommendation' => $recommendation,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function enrichOfferScenario(array $offer, array $latest, float $currentHashrateMh, array $costPlans, array $purchasedMiners): array
|
||||||
|
{
|
||||||
|
$latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '');
|
||||||
|
$currentDogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null;
|
||||||
|
$currentDailyProfit = is_numeric($latest['theoretical_daily_profit'] ?? null) ? (float) $latest['theoretical_daily_profit'] : null;
|
||||||
|
$currentDailyCost = is_numeric($latest['effective_daily_cost'] ?? null) ? (float) $latest['effective_daily_cost'] : null;
|
||||||
|
$investedCapital = is_numeric($latest['invested_capital'] ?? null) ? (float) $latest['invested_capital'] : null;
|
||||||
|
$effectivePricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null;
|
||||||
|
$expectedDogePerDay = is_numeric($offer['expected_doge_per_day'] ?? null) ? (float) $offer['expected_doge_per_day'] : null;
|
||||||
|
$offerPriceAmount = is_numeric($offer['effective_price_amount'] ?? null) ? (float) $offer['effective_price_amount'] : null;
|
||||||
|
$offerPriceCurrency = (string) ($offer['effective_price_currency'] ?? '');
|
||||||
|
|
||||||
|
$scenarioOfferCost = (
|
||||||
|
$offerPriceAmount !== null &&
|
||||||
|
$offerPriceCurrency !== '' &&
|
||||||
|
$latestCurrency !== ''
|
||||||
|
) ? $this->convertAmount($offerPriceAmount, $offerPriceCurrency, $latestCurrency) : null;
|
||||||
|
$scenarioDogePerDay = (
|
||||||
|
$currentDogePerDay !== null &&
|
||||||
|
$expectedDogePerDay !== null
|
||||||
|
) ? ($currentDogePerDay + $expectedDogePerDay) : null;
|
||||||
|
$scenarioDailyRevenue = (
|
||||||
|
$scenarioDogePerDay !== null &&
|
||||||
|
$effectivePricePerCoin !== null
|
||||||
|
) ? ($scenarioDogePerDay * $effectivePricePerCoin) : null;
|
||||||
|
$scenarioDailyProfit = (
|
||||||
|
$scenarioDailyRevenue !== null &&
|
||||||
|
$currentDailyCost !== null
|
||||||
|
) ? ($scenarioDailyRevenue - $currentDailyCost) : null;
|
||||||
|
$scenarioInvestedCapital = (
|
||||||
|
$investedCapital !== null &&
|
||||||
|
$scenarioOfferCost !== null
|
||||||
|
) ? ($investedCapital + $scenarioOfferCost) : null;
|
||||||
|
$scenarioRemainingAmount = $scenarioInvestedCapital;
|
||||||
|
$scenarioBreakEvenDays = (
|
||||||
|
$scenarioInvestedCapital !== null &&
|
||||||
|
$scenarioDailyRevenue !== null &&
|
||||||
|
$scenarioDailyRevenue > 0
|
||||||
|
) ? ($scenarioInvestedCapital / $scenarioDailyRevenue) : null;
|
||||||
|
$scenarioDate = null;
|
||||||
|
$measuredTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? ''));
|
||||||
|
if ($measuredTs > 0 && $scenarioBreakEvenDays !== null) {
|
||||||
|
$scenarioDate = $this->formatUtcTimestamp((int) round($measuredTs + ($scenarioBreakEvenDays * 86400)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$scenarioPurchasedMiners = $purchasedMiners;
|
||||||
|
if ($scenarioOfferCost !== null && $latestCurrency !== '') {
|
||||||
|
$scenarioPurchasedMiners[] = [
|
||||||
|
'purchased_at' => $latest['measured_at'] ?? $this->currentUtcDateTime(),
|
||||||
|
'runtime_months' => $offer['runtime_months'] ?? null,
|
||||||
|
'mining_speed_value' => $offer['mining_speed_value'] ?? null,
|
||||||
|
'mining_speed_unit' => $offer['mining_speed_unit'] ?? null,
|
||||||
|
'bonus_speed_value' => $offer['bonus_speed_value'] ?? null,
|
||||||
|
'bonus_speed_unit' => $offer['bonus_speed_unit'] ?? null,
|
||||||
|
'total_cost_amount' => $scenarioOfferCost,
|
||||||
|
'currency' => $latestCurrency,
|
||||||
|
'auto_renew' => !empty($offer['auto_renew']) ? 1 : 0,
|
||||||
|
'is_active' => 1,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentProjection = $this->projectPerformance($costPlans, $purchasedMiners, $latest, 730);
|
||||||
|
$scenarioProjection = $this->projectPerformance($costPlans, $scenarioPurchasedMiners, $latest, 730);
|
||||||
|
|
||||||
|
return array_merge($offer, [
|
||||||
|
'scenario_currency' => $latestCurrency !== '' ? $latestCurrency : null,
|
||||||
|
'scenario_current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4),
|
||||||
|
'scenario_hashrate_mh' => $this->roundOrNull($currentHashrateMh + (float) ($offer['offer_hashrate_mh'] ?? 0), 4),
|
||||||
|
'scenario_current_doge_per_day' => $this->roundOrNull($currentDogePerDay, 6),
|
||||||
|
'scenario_doge_per_day' => $this->roundOrNull($scenarioDogePerDay, 6),
|
||||||
|
'scenario_current_daily_profit' => $this->roundOrNull($currentDailyProfit, 8),
|
||||||
|
'scenario_daily_profit' => $this->roundOrNull($scenarioDailyProfit, 8),
|
||||||
|
'scenario_daily_profit_delta' => (
|
||||||
|
$scenarioDailyProfit !== null &&
|
||||||
|
$currentDailyProfit !== null
|
||||||
|
) ? $this->roundOrNull($scenarioDailyProfit - $currentDailyProfit, 8) : null,
|
||||||
|
'scenario_current_invested_capital' => $this->roundOrNull($investedCapital, 8),
|
||||||
|
'scenario_invested_capital' => $this->roundOrNull($scenarioInvestedCapital, 8),
|
||||||
|
'scenario_offer_cost' => $this->roundOrNull($scenarioOfferCost, 8),
|
||||||
|
'scenario_break_even_remaining_amount' => $this->roundOrNull($scenarioRemainingAmount, 8),
|
||||||
|
'scenario_break_even_days' => $this->roundOrNull($scenarioBreakEvenDays, 4),
|
||||||
|
'scenario_break_even_date' => $scenarioDate,
|
||||||
|
'scenario_projection_days' => $scenarioProjection['days'],
|
||||||
|
'scenario_current_two_year_profit' => $this->roundOrNull($currentProjection['profit'], 8),
|
||||||
|
'scenario_two_year_profit' => $this->roundOrNull($scenarioProjection['profit'], 8),
|
||||||
|
'scenario_two_year_profit_delta' => (
|
||||||
|
$scenarioProjection['profit'] !== null &&
|
||||||
|
$currentProjection['profit'] !== null
|
||||||
|
) ? $this->roundOrNull($scenarioProjection['profit'] - $currentProjection['profit'], 8) : null,
|
||||||
|
'scenario_two_year_revenue' => $this->roundOrNull($scenarioProjection['revenue'], 8),
|
||||||
|
'scenario_two_year_cost' => $this->roundOrNull($scenarioProjection['cost'], 8),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function projectPerformance(array $costPlans, array $purchasedMiners, array $latest, int $days): array
|
||||||
|
{
|
||||||
|
$currency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '')));
|
||||||
|
$pricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null;
|
||||||
|
$dogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null;
|
||||||
|
$currentHashrateMh = $this->totalHashrateMh(array_merge($costPlans, $purchasedMiners));
|
||||||
|
$baseTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? ''));
|
||||||
|
if ($baseTs <= 0) {
|
||||||
|
$baseTs = $this->utcTimestamp($this->currentUtcDateTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($currency === '' || $pricePerCoin === null || $dogePerDay === null || $currentHashrateMh <= 0 || $days <= 0) {
|
||||||
|
return ['days' => $days, 'revenue' => null, 'cost' => null, 'profit' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$dogePerDayPerMh = $dogePerDay / $currentHashrateMh;
|
||||||
|
$revenue = 0.0;
|
||||||
|
$cost = 0.0;
|
||||||
|
|
||||||
|
for ($day = 0; $day < $days; $day++) {
|
||||||
|
$checkTs = $baseTs + ($day * 86400);
|
||||||
|
$activeHashrate = 0.0;
|
||||||
|
|
||||||
|
foreach ($costPlans as $plan) {
|
||||||
|
if (empty($plan['is_active']) || !$this->entryIsCovered($plan, $checkTs)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeHashrate += $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null);
|
||||||
|
$activeHashrate += $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null);
|
||||||
|
|
||||||
|
$runtimeMonths = (int) ($plan['runtime_months'] ?? 0);
|
||||||
|
if ($runtimeMonths > 0 && is_numeric($plan['total_cost_amount'] ?? null)) {
|
||||||
|
$runtimeDays = $runtimeMonths * 30.4375;
|
||||||
|
$dailyCost = $this->convertAmount((float) $plan['total_cost_amount'] / $runtimeDays, (string) ($plan['currency'] ?? ''), $currency);
|
||||||
|
if ($dailyCost !== null) {
|
||||||
|
$cost += $dailyCost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($purchasedMiners as $miner) {
|
||||||
|
if ((array_key_exists('is_active', $miner) && empty($miner['is_active'])) || !$this->entryIsCovered($miner, $checkTs, 'purchased_at')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeHashrate += $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null);
|
||||||
|
$activeHashrate += $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null);
|
||||||
|
|
||||||
|
$runtimeMonths = (int) ($miner['runtime_months'] ?? 0);
|
||||||
|
if ($runtimeMonths > 0 && is_numeric($miner['total_cost_amount'] ?? null)) {
|
||||||
|
$runtimeDays = $runtimeMonths * 30.4375;
|
||||||
|
$dailyCost = $this->convertAmount((float) $miner['total_cost_amount'] / $runtimeDays, (string) ($miner['currency'] ?? ''), $currency);
|
||||||
|
if ($dailyCost !== null) {
|
||||||
|
$cost += $dailyCost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$revenue += $activeHashrate * $dogePerDayPerMh * $pricePerCoin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'days' => $days,
|
||||||
|
'revenue' => $revenue,
|
||||||
|
'cost' => $cost,
|
||||||
|
'profit' => $revenue - $cost,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function utcTimestamp(?string $value): int
|
||||||
|
{
|
||||||
|
$normalized = trim((string) $value);
|
||||||
|
if ($normalized === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$utc = new \DateTimeZone('UTC');
|
||||||
|
$formats = ['Y-m-d H:i:s', 'Y-m-d H:i', \DateTimeInterface::ATOM];
|
||||||
|
foreach ($formats as $format) {
|
||||||
|
$date = \DateTimeImmutable::createFromFormat($format, $normalized, $utc);
|
||||||
|
if ($date instanceof \DateTimeImmutable) {
|
||||||
|
return $date->getTimestamp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (new \DateTimeImmutable($normalized, $utc))->getTimestamp();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatUtcTimestamp(int $timestamp): string
|
||||||
|
{
|
||||||
|
return (new \DateTimeImmutable('@' . $timestamp))
|
||||||
|
->setTimezone(new \DateTimeZone('UTC'))
|
||||||
|
->format('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentUtcDateTime(): string
|
||||||
|
{
|
||||||
|
return $this->formatUtcTimestamp(time());
|
||||||
|
}
|
||||||
|
}
|
||||||
759
modules/mining-checker/src/Domain/FxService.php
Normal file
759
modules/mining-checker/src/Domain/FxService.php
Normal file
@@ -0,0 +1,759 @@
|
|||||||
|
<?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 ?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
|
||||||
|
{
|
||||||
|
if ($amount === null || $from === null || $to === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rate = $this->rate($from, $to);
|
||||||
|
return $rate === null ? null : $amount * $rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rate(?string $from, ?string $to): ?float
|
||||||
|
{
|
||||||
|
$base = strtoupper(trim((string) $from));
|
||||||
|
$target = strtoupper(trim((string) $to));
|
||||||
|
|
||||||
|
if ($base === '' || $target === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($base === $target) {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = $base . ':' . $target;
|
||||||
|
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 refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
|
||||||
|
{
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
$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) ? strtotime((string) ($latestFetch['fetched_at'] ?? '')) : false;
|
||||||
|
$ageSeconds = $latestFetchedAt ? (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
|
||||||
|
{
|
||||||
|
$normalizedBase = strtoupper(trim($base));
|
||||||
|
return $this->fetchLatestProbe($normalizedBase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshCurrencyCatalog(): array
|
||||||
|
{
|
||||||
|
if ($this->repository === null) {
|
||||||
|
return [
|
||||||
|
'synced_count' => 0,
|
||||||
|
'currencies' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$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++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->repository->saveCurrencies($synced);
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
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
|
||||||
|
{
|
||||||
|
if ($this->repository === null) {
|
||||||
|
return ['EUR', 'USD'];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$currencies = $this->repository->listActiveFiatCurrencies();
|
||||||
|
return array_map(static fn (array $currency): string => (string) $currency['code'], $currencies);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
400
modules/mining-checker/src/Domain/OcrService.php
Normal file
400
modules/mining-checker/src/Domain/OcrService.php
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
<?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')));
|
||||||
|
$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): array
|
||||||
|
{
|
||||||
|
$flags = [];
|
||||||
|
$suggestedTime = null;
|
||||||
|
$coinsTotal = null;
|
||||||
|
$price = null;
|
||||||
|
$currency = null;
|
||||||
|
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$coinsCandidates = array_values(array_filter($decimalCandidates, static fn (array $item): bool => $item['value'] > 10 && $item['precision'] >= 4));
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$matchedFields = 0;
|
||||||
|
foreach ([$coinsTotal, $price, $currency] as $field) {
|
||||||
|
if ($field !== null) {
|
||||||
|
$matchedFields++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$confidence = max(0.05, min(0.99, ($matchedFields / 3) - (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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
79
modules/mining-checker/src/Domain/SeedData.php
Normal file
79
modules/mining-checker/src/Domain/SeedData.php
Normal 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],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
66
modules/mining-checker/src/Domain/SeedImporter.php
Normal file
66
modules/mining-checker/src/Domain/SeedImporter.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?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());
|
||||||
|
foreach (SeedData::currencies() as $currency) {
|
||||||
|
$this->repository->saveCurrency($currency);
|
||||||
|
}
|
||||||
|
$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,
|
||||||
|
'currencies_synced' => count(SeedData::currencies()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
if (!$config->useProjectDatabase()) {
|
||||||
|
throw new ApiException('Mining-Checker erwartet aktuell die Projekt-Datenbank. Eigene Modul-Datenbanken sind hier noch nicht implementiert.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dbConfig = app()->config()->dbConfig;
|
||||||
|
if ($dbConfig === []) {
|
||||||
|
throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 in config/db_settings_basic.php den Driver auf mysql oder pgsql.',
|
||||||
|
500,
|
||||||
|
['driver' => $dbConfig['driver'] ?? 'unknown']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
|
||||||
|
return AppDatabase::connectFromConfig($dbConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppDatabase::createFromArray($dbConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
1400
modules/mining-checker/src/Infrastructure/MiningRepository.php
Normal file
1400
modules/mining-checker/src/Infrastructure/MiningRepository.php
Normal file
File diff suppressed because it is too large
Load Diff
66
modules/mining-checker/src/Infrastructure/ModuleConfig.php
Normal file
66
modules/mining-checker/src/Infrastructure/ModuleConfig.php
Normal 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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
1265
modules/mining-checker/src/Infrastructure/SchemaManager.php
Normal file
1265
modules/mining-checker/src/Infrastructure/SchemaManager.php
Normal file
File diff suppressed because it is too large
Load Diff
29
modules/mining-checker/src/Support/ApiException.php
Normal file
29
modules/mining-checker/src/Support/ApiException.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
modules/mining-checker/src/Support/DebugState.php
Normal file
35
modules/mining-checker/src/Support/DebugState.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
modules/mining-checker/src/Support/DebugTrace.php
Normal file
60
modules/mining-checker/src/Support/DebugTrace.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
27
modules/mining-checker/src/Support/Http.php
Normal file
27
modules/mining-checker/src/Support/Http.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
0
modules/mining-checker/storage/uploads/.gitkeep
Normal file
0
modules/mining-checker/storage/uploads/.gitkeep
Normal file
@@ -50,11 +50,12 @@ $user = [
|
|||||||
'sub' => (string)($claims['sub'] ?? ''),
|
'sub' => (string)($claims['sub'] ?? ''),
|
||||||
'email' => (string)($claims['email'] ?? ''),
|
'email' => (string)($claims['email'] ?? ''),
|
||||||
'name' => (string)($claims['name'] ?? ($claims['preferred_username'] ?? '')),
|
'name' => (string)($claims['name'] ?? ($claims['preferred_username'] ?? '')),
|
||||||
|
'username' => (string)($claims['preferred_username'] ?? $claims['email'] ?? $claims['sub'] ?? ''),
|
||||||
'groups' => $groups,
|
'groups' => $groups,
|
||||||
'id_token' => $idToken,
|
'id_token' => $idToken,
|
||||||
];
|
];
|
||||||
|
|
||||||
$_SESSION['auth_user'] = $user;
|
app()->auth()->storeUser($claims, $groups, $idToken);
|
||||||
|
|
||||||
if (defined('APP_AUTH_DEBUG') && APP_AUTH_DEBUG) {
|
if (defined('APP_AUTH_DEBUG') && APP_AUTH_DEBUG) {
|
||||||
$log = [
|
$log = [
|
||||||
@@ -77,4 +78,6 @@ if (defined('APP_AUTH_DEBUG') && APP_AUTH_DEBUG) {
|
|||||||
@file_put_contents(__DIR__ . '/../../../debug/oidc_login.log', json_encode($log) . PHP_EOL, FILE_APPEND);
|
@file_put_contents(__DIR__ . '/../../../debug/oidc_login.log', json_encode($log) . PHP_EOL, FILE_APPEND);
|
||||||
}
|
}
|
||||||
|
|
||||||
redirect('/');
|
$returnTo = (string)($_SESSION['oidc_return_to'] ?? '/');
|
||||||
|
unset($_SESSION['oidc_return_to']);
|
||||||
|
redirect($returnTo !== '' && str_starts_with($returnTo, '/') && !str_starts_with($returnTo, '//') ? $returnTo : '/');
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ if (!empty($_SESSION['auth_user']['id_token'])) {
|
|||||||
$idToken = (string)$_SESSION['auth_user']['id_token'];
|
$idToken = (string)$_SESSION['auth_user']['id_token'];
|
||||||
}
|
}
|
||||||
|
|
||||||
unset($_SESSION['auth_user']);
|
unset($_SESSION['auth_user'], $_SESSION['auth_id_token'], $_SESSION['auth_expires_at']);
|
||||||
|
|
||||||
if ($config->authEnabled) {
|
if ($config->authEnabled) {
|
||||||
$client = new OidcClient($config);
|
$client = new OidcClient($config);
|
||||||
|
|||||||
@@ -1,34 +1,76 @@
|
|||||||
<?php
|
<?php
|
||||||
$modules = modules()->all();
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$auth = app()->auth();
|
||||||
|
$authUser = $auth->user();
|
||||||
|
$modules = array_values(array_filter(
|
||||||
|
$auth->filterModules(modules()->all()),
|
||||||
|
static fn (array $module): bool => !empty($module['enabled'])
|
||||||
|
));
|
||||||
?>
|
?>
|
||||||
<div class="card">
|
<section class="home-hero" data-reveal>
|
||||||
<div class="pill">Core</div>
|
<a class="brand-mark" href="/" aria-label="Nexus">
|
||||||
<h1 style="margin-top:.75rem;">Nexus Basis-System</h1>
|
<img src="/assets/images/kusche-logo.png" alt="Kusche Logo">
|
||||||
<p class="muted">Aktive Module verwalten und neue Module initialisieren.</p>
|
</a>
|
||||||
|
<div class="brand-copy">
|
||||||
<div style="margin-top:1rem;">
|
<span class="eyebrow">Nexus</span>
|
||||||
<a class="nav-link" href="/modules">Module verwalten</a>
|
<h1><?= e(defined('APP_DOMAIN_PRIMARY') ? (string)APP_DOMAIN_PRIMARY : 'Nexus') ?></h1>
|
||||||
|
<p>Kompakter Einstieg fuer die verfuegbaren Module.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="theme-switcher" aria-label="Farbschema">
|
||||||
|
<?php if ($auth->isEnabled()): ?>
|
||||||
|
<a class="auth-pill" href="<?= $authUser === null ? '/auth/login' : '/auth/logout' ?>">
|
||||||
|
<?= $authUser === null ? 'Login' : 'Logout ' . e((string)($authUser['username'] ?? $authUser['name'] ?? '')) ?>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<label>
|
||||||
|
<span>Modus</span>
|
||||||
|
<select data-theme-mode>
|
||||||
|
<option value="day">Day</option>
|
||||||
|
<option value="night">Night</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Farbe</span>
|
||||||
|
<select data-theme-accent>
|
||||||
|
<option value="logo">Logo</option>
|
||||||
|
<option value="pink">Pink</option>
|
||||||
|
<option value="cyan">Cyan</option>
|
||||||
|
<option value="orange">Orange</option>
|
||||||
|
<option value="green">Gruen</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div style="margin-top:1.5rem;" class="grid">
|
<section class="module-list-section" data-reveal>
|
||||||
<?php foreach ($modules as $module): ?>
|
<div class="section-head">
|
||||||
<div class="card" style="background:var(--panel-2);">
|
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px;">
|
|
||||||
<div>
|
<div>
|
||||||
<strong><?= e($module['title']) ?></strong>
|
<h2 class="section-title">Verfuegbare Module</h2>
|
||||||
<div class="muted" style="font-size:.85rem;"><?= e($module['description'] ?? '') ?></div>
|
<p>Module mit Login-Pflicht erscheinen erst nach passender Anmeldung.</p>
|
||||||
</div>
|
</div>
|
||||||
<?php if (!empty($module['enabled'])): ?>
|
<?php if ($authUser !== null): ?>
|
||||||
<span class="pill" style="border-color:var(--accent-2); color:var(--accent-2);">aktiv</span>
|
<a class="nav-link" href="/modules">Module verwalten</a>
|
||||||
<?php else: ?>
|
|
||||||
<span class="pill">inaktiv</span>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:.75rem; display:flex; gap:10px; flex-wrap:wrap;">
|
|
||||||
<a class="nav-link" href="/module/<?= e($module['name']) ?>">Öffnen</a>
|
<?php if ($modules === []): ?>
|
||||||
<a class="nav-link" href="/modules/setup/<?= e($module['name']) ?>">Setup</a>
|
<div class="empty-state" data-reveal>
|
||||||
</div>
|
Keine Module fuer den aktuellen Zugriff sichtbar.
|
||||||
</div>
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="module-list">
|
||||||
|
<?php foreach ($modules as $module): ?>
|
||||||
|
<a class="module-row" href="<?= e((string)($module['entry'] ?? ('/module/' . $module['name']))) ?>">
|
||||||
|
<span class="module-row__icon"><?= e(strtoupper(substr((string)($module['title'] ?? $module['name']), 0, 1))) ?></span>
|
||||||
|
<span class="module-row__content">
|
||||||
|
<span class="module-kicker"><?= e((string)($module['name'] ?? '')) ?></span>
|
||||||
|
<strong class="module-title"><?= e((string)($module['title'] ?? $module['name'] ?? 'Modul')) ?></strong>
|
||||||
|
<span class="module-desc"><?= e((string)($module['description'] ?? '')) ?></span>
|
||||||
|
</span>
|
||||||
|
<span class="module-row__action">Oeffnen</span>
|
||||||
|
</a>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|||||||
@@ -69,9 +69,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
modules()->saveSettings($moduleName, $payload);
|
modules()->saveSettings($moduleName, $payload);
|
||||||
|
modules()->saveAuth($moduleName, [
|
||||||
|
'required' => isset($_POST['auth_required']),
|
||||||
|
'users' => (string)($_POST['auth_users'] ?? ''),
|
||||||
|
'groups' => (string)($_POST['auth_groups'] ?? ''),
|
||||||
|
]);
|
||||||
$notice = 'Setup gespeichert.';
|
$notice = 'Setup gespeichert.';
|
||||||
$current = array_replace_recursive($current, $payload);
|
$current = array_replace_recursive($current, $payload);
|
||||||
|
$module = modules()->get($moduleName) ?: $module;
|
||||||
}
|
}
|
||||||
|
$authConfig = is_array($module['auth'] ?? null) ? $module['auth'] : ['required' => false, 'users' => [], 'groups' => []];
|
||||||
?>
|
?>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="pill">Setup</div>
|
<div class="pill">Setup</div>
|
||||||
@@ -122,6 +129,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
</label>
|
</label>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<div class="card" style="padding:14px; background:var(--panel-2); display:grid; gap:12px;">
|
||||||
|
<strong>Modulzugriff</strong>
|
||||||
|
<label class="muted" style="display:flex; align-items:center; gap:10px;">
|
||||||
|
<input type="checkbox" name="auth_required" value="1" <?= !empty($authConfig['required']) ? 'checked' : '' ?>>
|
||||||
|
<span>Login fuer dieses Modul erforderlich</span>
|
||||||
|
</label>
|
||||||
|
<label class="muted" style="display:grid; gap:6px;">
|
||||||
|
<span>Erlaubte Benutzer</span>
|
||||||
|
<textarea name="auth_users" rows="3" placeholder="Keycloak-Sub, Benutzername oder E-Mail, je Zeile oder Komma"><?= e(implode("\n", is_array($authConfig['users'] ?? null) ? $authConfig['users'] : [])) ?></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="muted" style="display:grid; gap:6px;">
|
||||||
|
<span>Erlaubte Gruppen</span>
|
||||||
|
<textarea name="auth_groups" rows="3" placeholder="/admin oder mining-users, je Zeile oder Komma"><?= e(implode("\n", is_array($authConfig['groups'] ?? null) ? $authConfig['groups'] : [])) ?></textarea>
|
||||||
|
</label>
|
||||||
|
<small class="muted">Wenn Login aktiv ist und Benutzer/Gruppen leer bleiben, darf jeder eingeloggte Benutzer das Modul oeffnen.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display:flex; gap:10px;">
|
<div style="display:flex; gap:10px;">
|
||||||
<button class="cta-button" type="submit">Speichern</button>
|
<button class="cta-button" type="submit">Speichern</button>
|
||||||
<a class="nav-link" href="/modules">Zurück</a>
|
<a class="nav-link" href="/modules">Zurück</a>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ $sidebarDefault = ($moduleSidebar['default'] ?? 'collapsed') === 'open' ? 'open'
|
|||||||
$sidebarItems = $moduleSidebar['items'] ?? [];
|
$sidebarItems = $moduleSidebar['items'] ?? [];
|
||||||
?>
|
?>
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="de" data-theme="<?= e($theme) ?>" data-accent="logo">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@@ -27,7 +27,7 @@ $sidebarItems = $moduleSidebar['items'] ?? [];
|
|||||||
<?php asset_styles(); ?>
|
<?php asset_styles(); ?>
|
||||||
<?php asset_scripts('header'); ?>
|
<?php asset_scripts('header'); ?>
|
||||||
</head>
|
</head>
|
||||||
<body data-theme="<?= e($theme) ?>">
|
<body>
|
||||||
<div class="bg-orb orb-a"></div>
|
<div class="bg-orb orb-a"></div>
|
||||||
<div class="bg-orb orb-b"></div>
|
<div class="bg-orb orb-b"></div>
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ $sidebarItems = $moduleSidebar['items'] ?? [];
|
|||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button class="nav-link dropdown-toggle" type="button">Module ▾</button>
|
<button class="nav-link dropdown-toggle" type="button">Module ▾</button>
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<?php foreach (modules()->all() as $m): ?>
|
<?php foreach (app()->auth()->filterModules(modules()->all()) as $m): ?>
|
||||||
<?php if (!empty($m['enabled'])): ?>
|
<?php if (!empty($m['enabled'])): ?>
|
||||||
<a class="dropdown-item" href="/module/<?= e($m['name']) ?>"><?= e($m['title']) ?></a>
|
<a class="dropdown-item" href="/module/<?= e($m['name']) ?>"><?= e($m['title']) ?></a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -496,3 +496,386 @@ body {
|
|||||||
.site-footer { margin: 0 12px 12px; }
|
.site-footer { margin: 0 12px 12px; }
|
||||||
.header-nav { flex-wrap: wrap; justify-content: flex-end; }
|
.header-nav { flex-wrap: wrap; justify-content: flex-end; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--surface: rgba(255, 255, 255, 0.9);
|
||||||
|
--surface-strong: #ffffff;
|
||||||
|
--accent-pink: #ed1671;
|
||||||
|
--accent-cyan: #06a9c8;
|
||||||
|
--accent-orange: #f6aa21;
|
||||||
|
--accent-green: #8bc53f;
|
||||||
|
--brand-accent: var(--accent-pink);
|
||||||
|
--brand-accent-2: var(--accent-cyan);
|
||||||
|
--brand-accent-3: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-accent="pink"] {
|
||||||
|
--brand-accent: var(--accent-pink);
|
||||||
|
--brand-accent-2: var(--accent-orange);
|
||||||
|
--brand-accent-3: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-accent="cyan"] {
|
||||||
|
--brand-accent: var(--accent-cyan);
|
||||||
|
--brand-accent-2: var(--accent-green);
|
||||||
|
--brand-accent-3: var(--accent-pink);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-accent="orange"] {
|
||||||
|
--brand-accent: var(--accent-orange);
|
||||||
|
--brand-accent-2: var(--accent-pink);
|
||||||
|
--brand-accent-3: var(--accent-cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-accent="green"] {
|
||||||
|
--brand-accent: var(--accent-green);
|
||||||
|
--brand-accent-2: var(--accent-cyan);
|
||||||
|
--brand-accent-3: var(--accent-orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="day"] {
|
||||||
|
--bg: #f7fbfb;
|
||||||
|
--panel: rgba(255, 255, 255, 0.92);
|
||||||
|
--panel-2: #f1fbf7;
|
||||||
|
--surface: rgba(255, 255, 255, 0.9);
|
||||||
|
--surface-strong: #ffffff;
|
||||||
|
--text: #10212b;
|
||||||
|
--muted: #66737b;
|
||||||
|
--accent: var(--brand-accent);
|
||||||
|
--accent-2: var(--brand-accent-2);
|
||||||
|
--line: rgba(16, 33, 43, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="night"] {
|
||||||
|
--bg: #07121a;
|
||||||
|
--panel: rgba(8, 18, 28, 0.9);
|
||||||
|
--panel-2: rgba(18, 33, 48, 0.92);
|
||||||
|
--surface: rgba(8, 18, 28, 0.88);
|
||||||
|
--surface-strong: #101d2a;
|
||||||
|
--text: #eff8fb;
|
||||||
|
--muted: #a6b8c2;
|
||||||
|
--accent: var(--brand-accent);
|
||||||
|
--accent-2: var(--brand-accent-2);
|
||||||
|
--line: rgba(255, 255, 255, 0.12);
|
||||||
|
--shadow: 0 22px 60px rgba(0, 0, 0, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
min-height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, color-mix(in srgb, var(--brand-accent-2) 20%, transparent), transparent 26%),
|
||||||
|
radial-gradient(circle at top right, color-mix(in srgb, var(--brand-accent) 18%, transparent), transparent 24%),
|
||||||
|
linear-gradient(135deg, #f7fbfb 0%, #eef7f5 52%, #fff4df 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="night"] {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, color-mix(in srgb, var(--brand-accent-2) 28%, transparent), transparent 28%),
|
||||||
|
radial-gradient(circle at top right, color-mix(in srgb, var(--brand-accent) 24%, transparent), transparent 24%),
|
||||||
|
linear-gradient(135deg, #050b12 0%, #0c1721 52%, #111827 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 12% 20%, color-mix(in srgb, var(--accent-green) 16%, transparent), transparent 24%),
|
||||||
|
radial-gradient(circle at 90% 6%, color-mix(in srgb, var(--accent-orange) 16%, transparent), transparent 20%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-logo {
|
||||||
|
height: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
width: min(80vw, 1680px);
|
||||||
|
margin: 0 auto;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content > .card,
|
||||||
|
.home-hero,
|
||||||
|
.module-row,
|
||||||
|
.empty-state,
|
||||||
|
.module-host-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 12px 30px rgba(1, 22, 32, 0.08);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 76px;
|
||||||
|
height: 76px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(16, 33, 43, 0.08), 0 12px 30px rgba(6, 169, 200, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark img {
|
||||||
|
display: block;
|
||||||
|
width: 62px;
|
||||||
|
height: 62px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow,
|
||||||
|
.module-kicker {
|
||||||
|
color: var(--brand-accent);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--brand-accent) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero h1,
|
||||||
|
.section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero h1 {
|
||||||
|
font-size: clamp(1.5rem, 4vw, 2.35rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero p,
|
||||||
|
.section-head p,
|
||||||
|
.module-desc {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-row:hover,
|
||||||
|
.module-row:focus-visible {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: color-mix(in srgb, var(--brand-accent) 36%, transparent);
|
||||||
|
background: var(--surface-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-row__icon {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, var(--brand-accent-2), var(--brand-accent)), var(--brand-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-row__content {
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-row__action,
|
||||||
|
.auth-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, var(--brand-accent), var(--brand-accent-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-row__action::after {
|
||||||
|
content: "->";
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switcher {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switcher label {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switcher select,
|
||||||
|
.card select {
|
||||||
|
background: var(--surface-strong);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switcher select {
|
||||||
|
min-width: 118px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 30px 8px 11px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 28px;
|
||||||
|
border-radius: 18px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-host-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(18px);
|
||||||
|
animation: rise 480ms ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.site-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-body,
|
||||||
|
.module-subnav {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
width: min(100% - 20px, 1680px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content:has(#mining-checker-app) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-host-card:has(#mining-checker-app) {
|
||||||
|
border-left: 0;
|
||||||
|
border-right: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-hero {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-switcher {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark img {
|
||||||
|
width: 49px;
|
||||||
|
height: 49px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-row {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-row__action {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: start;
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
align-items: start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
BIN
public/assets/images/kusche-logo.png
Executable file
BIN
public/assets/images/kusche-logo.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -28,6 +28,55 @@
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
document.documentElement.classList.add('js');
|
||||||
|
|
||||||
|
function readThemePreference(key, fallback) {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) || fallback;
|
||||||
|
} catch (error) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeMode = readThemePreference('nexus.theme', document.documentElement.dataset.theme || 'day');
|
||||||
|
const themeAccent = readThemePreference('nexus.accent', document.documentElement.dataset.accent || 'logo');
|
||||||
|
|
||||||
|
function applyTheme(mode, accent) {
|
||||||
|
const normalizedMode = ['day', 'night'].includes(mode) ? mode : 'day';
|
||||||
|
const normalizedAccent = ['logo', 'pink', 'cyan', 'orange', 'green'].includes(accent) ? accent : 'logo';
|
||||||
|
document.documentElement.dataset.theme = normalizedMode;
|
||||||
|
document.documentElement.dataset.accent = normalizedAccent;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('nexus.theme', normalizedMode);
|
||||||
|
localStorage.setItem('nexus.accent', normalizedAccent);
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore blocked storage; the current page still receives the theme.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme(themeMode, themeAccent);
|
||||||
|
|
||||||
|
const themeModeSelect = document.querySelector('[data-theme-mode]');
|
||||||
|
const themeAccentSelect = document.querySelector('[data-theme-accent]');
|
||||||
|
|
||||||
|
if (themeModeSelect) {
|
||||||
|
themeModeSelect.value = document.documentElement.dataset.theme;
|
||||||
|
themeModeSelect.addEventListener('change', () => {
|
||||||
|
applyTheme(themeModeSelect.value, document.documentElement.dataset.accent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (themeAccentSelect) {
|
||||||
|
themeAccentSelect.value = document.documentElement.dataset.accent;
|
||||||
|
themeAccentSelect.addEventListener('change', () => {
|
||||||
|
applyTheme(document.documentElement.dataset.theme, themeAccentSelect.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of document.querySelectorAll('[data-reveal]')) {
|
||||||
|
element.classList.add('reveal');
|
||||||
|
}
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
const openBtn = document.querySelector('[data-debug-open]');
|
const openBtn = document.querySelector('[data-debug-open]');
|
||||||
const modal = document.getElementById('debug-modal');
|
const modal = document.getElementById('debug-modal');
|
||||||
|
|||||||
154
public/index.php
154
public/index.php
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Modules\MiningChecker\Support\ApiException as MiningApiException;
|
||||||
|
use Modules\MiningChecker\Support\DebugState as MiningDebugState;
|
||||||
|
|
||||||
// boot application (config, autoload, services)
|
// boot application (config, autoload, services)
|
||||||
require_once __DIR__ . '/../config/fileload.php';
|
require_once __DIR__ . '/../config/fileload.php';
|
||||||
|
|
||||||
@@ -8,6 +11,8 @@ require_once __DIR__ . '/../config/fileload.php';
|
|||||||
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||||
$uriPath = preg_replace('~/{2,}~', '/', $uriPath);
|
$uriPath = preg_replace('~/{2,}~', '/', $uriPath);
|
||||||
$uriPath = trim($uriPath, '/');
|
$uriPath = trim($uriPath, '/');
|
||||||
|
$projectRoot = dirname(__DIR__);
|
||||||
|
$auth = app()->auth();
|
||||||
$isRetoolPath = ($uriPath === 'retool' || str_starts_with($uriPath, 'retool/'));
|
$isRetoolPath = ($uriPath === 'retool' || str_starts_with($uriPath, 'retool/'));
|
||||||
if (defined('APP_BASIC_AUTH') && APP_BASIC_AUTH && !$isRetoolPath) {
|
if (defined('APP_BASIC_AUTH') && APP_BASIC_AUTH && !$isRetoolPath) {
|
||||||
$authUser = getenv('STAGING_AUTH_USER') ?: 'staging';
|
$authUser = getenv('STAGING_AUTH_USER') ?: 'staging';
|
||||||
@@ -27,9 +32,15 @@ $publicPaths = [
|
|||||||
'auth/login',
|
'auth/login',
|
||||||
'auth/callback',
|
'auth/callback',
|
||||||
'auth/logout',
|
'auth/logout',
|
||||||
|
'auth/keycloak/login',
|
||||||
|
'auth/keycloak/callback',
|
||||||
|
'auth/keycloak/logout',
|
||||||
|
'auth/me',
|
||||||
'module/pi_control/terminal_info',
|
'module/pi_control/terminal_info',
|
||||||
];
|
];
|
||||||
if (defined('APP_AUTH_ENABLED') && APP_AUTH_ENABLED && !in_array($uriPath, $publicPaths, true)) {
|
$requiresGlobalAuth = in_array($uriPath, ['settings', 'users', 'modules', 'modules/install', 'debug'], true)
|
||||||
|
|| str_starts_with($uriPath, 'modules/setup/');
|
||||||
|
if (defined('APP_AUTH_ENABLED') && APP_AUTH_ENABLED && $requiresGlobalAuth && !in_array($uriPath, $publicPaths, true)) {
|
||||||
$user = auth_user();
|
$user = auth_user();
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
header('Location: /auth/login', true, 302);
|
header('Location: /auth/login', true, 302);
|
||||||
@@ -43,6 +54,139 @@ if (str_contains($uriPath, '..')) {
|
|||||||
exit('Bad request');
|
exit('Bad request');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($uriPath === 'auth/keycloak/login') {
|
||||||
|
$returnTo = (string)($_GET['return_to'] ?? '/');
|
||||||
|
$auth->login($returnTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($uriPath === 'auth/keycloak/callback') {
|
||||||
|
$uriPath = 'auth/callback';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($uriPath === 'auth/keycloak/logout') {
|
||||||
|
$uriPath = 'auth/logout';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($uriPath === 'auth/me') {
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode([
|
||||||
|
'authenticated' => $auth->isAuthenticated(),
|
||||||
|
'user' => $auth->user(),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('~^api/module-auth/([a-zA-Z0-9_-]+)$~', $uriPath, $moduleAuthMatches)) {
|
||||||
|
$moduleName = $moduleAuthMatches[1];
|
||||||
|
$moduleMeta = app()->modules()->get($moduleName);
|
||||||
|
if ($moduleMeta === null) {
|
||||||
|
http_response_code(404);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => 'module_not_found'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!$auth->isAuthenticated()) {
|
||||||
|
http_response_code(401);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => 'auth_required'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if (!$auth->canAccessModule($moduleMeta)) {
|
||||||
|
http_response_code(403);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode(['error' => 'forbidden'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||||
|
echo json_encode(['data' => ($moduleMeta['auth'] ?? ['required' => false, 'users' => [], 'groups' => []])], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'PUT') {
|
||||||
|
$input = json_decode((string)file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($input)) {
|
||||||
|
$input = [];
|
||||||
|
}
|
||||||
|
echo json_encode(['data' => app()->modules()->saveAuth($moduleName, $input)], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'method_not_allowed'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('~^api/mining-checker(?:/(.*))?$~', $uriPath, $apiMatches)) {
|
||||||
|
$moduleMeta = app()->modules()->get('mining-checker') ?? ['auth' => ['required' => false]];
|
||||||
|
if (!$auth->canAccessModule($moduleMeta)) {
|
||||||
|
http_response_code($auth->isAuthenticated() ? 403 : 401);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode([
|
||||||
|
'error' => $auth->isAuthenticated() ? 'forbidden' : 'auth_required',
|
||||||
|
'login_url' => '/auth/login',
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once $projectRoot . '/modules/mining-checker/bootstrap.php';
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new Modules\MiningChecker\Api\Router($projectRoot . '/modules/mining-checker'))->handle($apiMatches[1] ?? '');
|
||||||
|
} catch (MiningApiException $exception) {
|
||||||
|
$debugTrace = MiningDebugState::export();
|
||||||
|
http_response_code($exception->statusCode());
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode([
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
'context' => $exception->context(),
|
||||||
|
'debug' => $debugTrace !== [] ? $debugTrace : null,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$debugTrace = MiningDebugState::export();
|
||||||
|
http_response_code(500);
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
echo json_encode([
|
||||||
|
'error' => 'Unerwarteter Mining-Checker Fehler.',
|
||||||
|
'context' => ['message' => $exception->getMessage()],
|
||||||
|
'debug' => $debugTrace !== [] ? $debugTrace : null,
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('~^module-assets/([a-zA-Z0-9_-]+)/(.*)$~', $uriPath, $assetMatches)) {
|
||||||
|
$module = $assetMatches[1];
|
||||||
|
$relativeAssetPath = trim($assetMatches[2], '/');
|
||||||
|
if ($relativeAssetPath === '' || str_contains($relativeAssetPath, '..')) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit('Bad request');
|
||||||
|
}
|
||||||
|
|
||||||
|
$assetFile = $projectRoot . '/modules/' . $module . '/assets/' . $relativeAssetPath;
|
||||||
|
if (!is_file($assetFile)) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit('Asset not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = strtolower(pathinfo($assetFile, PATHINFO_EXTENSION));
|
||||||
|
$contentType = match ($extension) {
|
||||||
|
'css' => 'text/css; charset=utf-8',
|
||||||
|
'js' => 'application/javascript; charset=utf-8',
|
||||||
|
'json' => 'application/json; charset=utf-8',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'jpg', 'jpeg' => 'image/jpeg',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
'svg' => 'image/svg+xml',
|
||||||
|
default => 'application/octet-stream',
|
||||||
|
};
|
||||||
|
|
||||||
|
header('Content-Type: ' . $contentType);
|
||||||
|
readfile($assetFile);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
// Basispfad fuer Landingpages
|
// Basispfad fuer Landingpages
|
||||||
$pagesBase = realpath(__DIR__ . '/../partials/landingpages') ?: (__DIR__ . '/../partials/landingpages');
|
$pagesBase = realpath(__DIR__ . '/../partials/landingpages') ?: (__DIR__ . '/../partials/landingpages');
|
||||||
$page404 = $pagesBase . '/errorpages/404.php';
|
$page404 = $pagesBase . '/errorpages/404.php';
|
||||||
@@ -68,7 +212,15 @@ if (str_starts_with($uriPath, 'modules/install')) {
|
|||||||
} elseif (preg_match('~^module/([a-zA-Z0-9_-]+)(?:/(.+))?$~', $uriPath, $m)) {
|
} elseif (preg_match('~^module/([a-zA-Z0-9_-]+)(?:/(.+))?$~', $uriPath, $m)) {
|
||||||
$module = $m[1];
|
$module = $m[1];
|
||||||
$page = isset($m[2]) && $m[2] !== '' ? trim($m[2], '/') : 'index';
|
$page = isset($m[2]) && $m[2] !== '' ? trim($m[2], '/') : 'index';
|
||||||
|
$moduleMeta = app()->modules()->get($module);
|
||||||
|
if ($moduleMeta !== null) {
|
||||||
|
$auth->requireModuleAccess($moduleMeta);
|
||||||
|
}
|
||||||
$modulePage = app()->modules()->resolvePage($module, $page);
|
$modulePage = app()->modules()->resolvePage($module, $page);
|
||||||
|
$moduleBootstrap = $projectRoot . '/modules/' . $module . '/bootstrap.php';
|
||||||
|
if (is_file($moduleBootstrap)) {
|
||||||
|
require_once $moduleBootstrap;
|
||||||
|
}
|
||||||
if ($modulePage) {
|
if ($modulePage) {
|
||||||
$target = $modulePage;
|
$target = $modulePage;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ final class App
|
|||||||
private ?\PDO $pdo;
|
private ?\PDO $pdo;
|
||||||
private ?\PDO $basePdo;
|
private ?\PDO $basePdo;
|
||||||
private ModuleManager $modules;
|
private ModuleManager $modules;
|
||||||
|
private AuthService $auth;
|
||||||
|
|
||||||
private function __construct(private Config $config)
|
private function __construct(private Config $config)
|
||||||
{
|
{
|
||||||
@@ -41,6 +42,7 @@ final class App
|
|||||||
}
|
}
|
||||||
$this->modules = new ModuleManager($this->basePdo, $modulesPath);
|
$this->modules = new ModuleManager($this->basePdo, $modulesPath);
|
||||||
$this->modules->bootEnabled();
|
$this->modules->bootEnabled();
|
||||||
|
$this->auth = new AuthService($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function init(Config $config): self
|
public static function init(Config $config): self
|
||||||
@@ -68,4 +70,5 @@ final class App
|
|||||||
public function pdo(): ?\PDO { return $this->pdo; }
|
public function pdo(): ?\PDO { return $this->pdo; }
|
||||||
public function basePdo(): ?\PDO { return $this->basePdo; }
|
public function basePdo(): ?\PDO { return $this->basePdo; }
|
||||||
public function modules(): ModuleManager { return $this->modules; }
|
public function modules(): ModuleManager { return $this->modules; }
|
||||||
|
public function auth(): AuthService { return $this->auth; }
|
||||||
}
|
}
|
||||||
|
|||||||
172
src/App/AuthService.php
Normal file
172
src/App/AuthService.php
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class AuthService
|
||||||
|
{
|
||||||
|
private const SESSION_TTL = 604800;
|
||||||
|
|
||||||
|
public function __construct(private App $app) {}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->app->config()->authEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(): void
|
||||||
|
{
|
||||||
|
$this->app->session()->start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): ?array
|
||||||
|
{
|
||||||
|
$this->start();
|
||||||
|
$user = $_SESSION['auth_user'] ?? null;
|
||||||
|
if (!is_array($user)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = (int) ($_SESSION['auth_expires_at'] ?? 0);
|
||||||
|
if ($expiresAt > 0 && $expiresAt < time()) {
|
||||||
|
$this->clearLocalSession();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['auth_expires_at'] = time() + self::SESSION_TTL;
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAuthenticated(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(string $returnTo = '/'): void
|
||||||
|
{
|
||||||
|
if (!$this->isEnabled()) {
|
||||||
|
redirect($returnTo !== '' ? $returnTo : '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->start();
|
||||||
|
$_SESSION['oidc_return_to'] = $this->safeReturnTo($returnTo);
|
||||||
|
redirect('/auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function callback(): void
|
||||||
|
{
|
||||||
|
$query = (string) ($_SERVER['QUERY_STRING'] ?? '');
|
||||||
|
redirect('/auth/callback' . ($query !== '' ? '?' . $query : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
redirect('/auth/logout');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeUser(array $claims, array $groups, string $idToken): void
|
||||||
|
{
|
||||||
|
$username = (string) ($claims['preferred_username'] ?? $claims['email'] ?? $claims['sub'] ?? '');
|
||||||
|
$_SESSION['auth_user'] = [
|
||||||
|
'sub' => (string) ($claims['sub'] ?? ''),
|
||||||
|
'username' => $username,
|
||||||
|
'email' => (string) ($claims['email'] ?? ''),
|
||||||
|
'name' => (string) ($claims['name'] ?? $username),
|
||||||
|
'groups' => $groups,
|
||||||
|
'id_token' => $idToken,
|
||||||
|
];
|
||||||
|
$_SESSION['auth_id_token'] = $idToken;
|
||||||
|
$_SESSION['auth_expires_at'] = time() + self::SESSION_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canAccessModule(array $module): bool
|
||||||
|
{
|
||||||
|
$auth = is_array($module['auth'] ?? null) ? $module['auth'] : [];
|
||||||
|
$required = (bool) ($auth['required'] ?? false);
|
||||||
|
if (!$required || !$this->isEnabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->user();
|
||||||
|
if ($user === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedUsers = $this->normalizeList($auth['users'] ?? []);
|
||||||
|
$allowedGroups = $this->normalizeList($auth['groups'] ?? []);
|
||||||
|
if ($allowedUsers === [] && $allowedGroups === []) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = strtolower((string) ($user['username'] ?? ''));
|
||||||
|
$email = strtolower((string) ($user['email'] ?? ''));
|
||||||
|
$sub = strtolower((string) ($user['sub'] ?? ''));
|
||||||
|
foreach ($allowedUsers as $allowedUser) {
|
||||||
|
if ($allowedUser === $username || ($email !== '' && $allowedUser === $email) || ($sub !== '' && $allowedUser === $sub)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$userGroups = $this->normalizeList($user['groups'] ?? []);
|
||||||
|
return array_intersect($allowedGroups, $userGroups) !== [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requireModuleAccess(array $module): void
|
||||||
|
{
|
||||||
|
if ($this->canAccessModule($module)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->isAuthenticated()) {
|
||||||
|
$this->login($this->currentPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(403);
|
||||||
|
exit('Forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function filterModules(array $modules): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($modules, fn (array $module): bool => $this->canAccessModule($module)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeList(mixed $values): array
|
||||||
|
{
|
||||||
|
if (is_string($values)) {
|
||||||
|
$values = preg_split('/[,\\n]+/', $values) ?: [];
|
||||||
|
}
|
||||||
|
if (!is_array($values)) {
|
||||||
|
$values = [$values];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
$item = strtolower(trim((string) $value));
|
||||||
|
if ($item !== '') {
|
||||||
|
$normalized[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clearLocalSession(): void
|
||||||
|
{
|
||||||
|
unset($_SESSION['auth_user'], $_SESSION['auth_id_token'], $_SESSION['auth_expires_at']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function currentPath(): string
|
||||||
|
{
|
||||||
|
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
|
||||||
|
return $this->safeReturnTo($uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeReturnTo(string $returnTo): string
|
||||||
|
{
|
||||||
|
$returnTo = trim($returnTo);
|
||||||
|
if ($returnTo === '' || !str_starts_with($returnTo, '/') || str_starts_with($returnTo, '//')) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
return $returnTo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ class Config
|
|||||||
public string $oidcAdminGroup;
|
public string $oidcAdminGroup;
|
||||||
public string $oidcUserGroup;
|
public string $oidcUserGroup;
|
||||||
public string $modulesPath;
|
public string $modulesPath;
|
||||||
|
public array $dbConfig;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public array $db,
|
public array $db,
|
||||||
@@ -40,17 +41,18 @@ class Config
|
|||||||
$this->keaDbVersion = defined('APP_KEA_DB_VERSION') ? (string)APP_KEA_DB_VERSION : '';
|
$this->keaDbVersion = defined('APP_KEA_DB_VERSION') ? (string)APP_KEA_DB_VERSION : '';
|
||||||
$this->baseDb = $baseDb;
|
$this->baseDb = $baseDb;
|
||||||
$this->baseDbEnabled = $baseDbEnabled;
|
$this->baseDbEnabled = $baseDbEnabled;
|
||||||
$this->authEnabled = defined('APP_AUTH_ENABLED') ? (bool)APP_AUTH_ENABLED : false;
|
$this->dbConfig = $baseDbEnabled && !empty($baseDb) ? $baseDb : $db;
|
||||||
$this->oidcIssuer = defined('APP_OIDC_ISSUER') ? (string)APP_OIDC_ISSUER : '';
|
$this->authEnabled = defined('APP_AUTH_ENABLED') ? (bool)APP_AUTH_ENABLED : (defined('KEYCLOAK_ENABLED') ? (bool)KEYCLOAK_ENABLED : false);
|
||||||
$this->oidcClientId = defined('APP_OIDC_CLIENT_ID') ? (string)APP_OIDC_CLIENT_ID : '';
|
$this->oidcIssuer = defined('APP_OIDC_ISSUER') ? (string)APP_OIDC_ISSUER : (defined('KEYCLOAK_ISSUER') ? (string)KEYCLOAK_ISSUER : '');
|
||||||
$this->oidcClientSecret = defined('APP_OIDC_CLIENT_SECRET') ? (string)APP_OIDC_CLIENT_SECRET : '';
|
$this->oidcClientId = defined('APP_OIDC_CLIENT_ID') ? (string)APP_OIDC_CLIENT_ID : (defined('KEYCLOAK_CLIENT_ID') ? (string)KEYCLOAK_CLIENT_ID : '');
|
||||||
$this->oidcRedirectUri = defined('APP_OIDC_REDIRECT_URI') ? (string)APP_OIDC_REDIRECT_URI : '';
|
$this->oidcClientSecret = defined('APP_OIDC_CLIENT_SECRET') ? (string)APP_OIDC_CLIENT_SECRET : (defined('KEYCLOAK_CLIENT_SECRET') ? (string)KEYCLOAK_CLIENT_SECRET : '');
|
||||||
$this->oidcAuthEndpoint = defined('APP_OIDC_AUTH_ENDPOINT') ? (string)APP_OIDC_AUTH_ENDPOINT : '';
|
$this->oidcRedirectUri = defined('APP_OIDC_REDIRECT_URI') ? (string)APP_OIDC_REDIRECT_URI : (defined('KEYCLOAK_REDIRECT_URI') ? (string)KEYCLOAK_REDIRECT_URI : '');
|
||||||
$this->oidcTokenEndpoint = defined('APP_OIDC_TOKEN_ENDPOINT') ? (string)APP_OIDC_TOKEN_ENDPOINT : '';
|
$this->oidcAuthEndpoint = defined('APP_OIDC_AUTH_ENDPOINT') ? (string)APP_OIDC_AUTH_ENDPOINT : (defined('KEYCLOAK_AUTH_ENDPOINT') ? (string)KEYCLOAK_AUTH_ENDPOINT : '');
|
||||||
$this->oidcUserinfoEndpoint = defined('APP_OIDC_USERINFO_ENDPOINT') ? (string)APP_OIDC_USERINFO_ENDPOINT : '';
|
$this->oidcTokenEndpoint = defined('APP_OIDC_TOKEN_ENDPOINT') ? (string)APP_OIDC_TOKEN_ENDPOINT : (defined('KEYCLOAK_TOKEN_ENDPOINT') ? (string)KEYCLOAK_TOKEN_ENDPOINT : '');
|
||||||
$this->oidcLogoutEndpoint = defined('APP_OIDC_LOGOUT_ENDPOINT') ? (string)APP_OIDC_LOGOUT_ENDPOINT : '';
|
$this->oidcUserinfoEndpoint = defined('APP_OIDC_USERINFO_ENDPOINT') ? (string)APP_OIDC_USERINFO_ENDPOINT : (defined('KEYCLOAK_USERINFO_ENDPOINT') ? (string)KEYCLOAK_USERINFO_ENDPOINT : '');
|
||||||
$this->oidcPostLogoutRedirectUri = defined('APP_OIDC_POST_LOGOUT_REDIRECT_URI') ? (string)APP_OIDC_POST_LOGOUT_REDIRECT_URI : '';
|
$this->oidcLogoutEndpoint = defined('APP_OIDC_LOGOUT_ENDPOINT') ? (string)APP_OIDC_LOGOUT_ENDPOINT : (defined('KEYCLOAK_LOGOUT_ENDPOINT') ? (string)KEYCLOAK_LOGOUT_ENDPOINT : '');
|
||||||
$this->oidcGroupClaim = defined('APP_OIDC_GROUP_CLAIM') ? (string)APP_OIDC_GROUP_CLAIM : 'groups';
|
$this->oidcPostLogoutRedirectUri = defined('APP_OIDC_POST_LOGOUT_REDIRECT_URI') ? (string)APP_OIDC_POST_LOGOUT_REDIRECT_URI : (defined('KEYCLOAK_POST_LOGOUT_REDIRECT_URI') ? (string)KEYCLOAK_POST_LOGOUT_REDIRECT_URI : '');
|
||||||
|
$this->oidcGroupClaim = defined('APP_OIDC_GROUP_CLAIM') ? (string)APP_OIDC_GROUP_CLAIM : (defined('KEYCLOAK_GROUP_CLAIM') ? (string)KEYCLOAK_GROUP_CLAIM : 'groups');
|
||||||
$this->oidcAdminGroup = defined('APP_OIDC_ADMIN_GROUP') ? (string)APP_OIDC_ADMIN_GROUP : 'admin';
|
$this->oidcAdminGroup = defined('APP_OIDC_ADMIN_GROUP') ? (string)APP_OIDC_ADMIN_GROUP : 'admin';
|
||||||
$this->oidcUserGroup = defined('APP_OIDC_USER_GROUP') ? (string)APP_OIDC_USER_GROUP : 'user';
|
$this->oidcUserGroup = defined('APP_OIDC_USER_GROUP') ? (string)APP_OIDC_USER_GROUP : 'user';
|
||||||
$this->modulesPath = defined('APP_MODULES_PATH') ? (string)APP_MODULES_PATH : '';
|
$this->modulesPath = defined('APP_MODULES_PATH') ? (string)APP_MODULES_PATH : '';
|
||||||
|
|||||||
@@ -245,7 +245,8 @@ final class ModuleManager
|
|||||||
|
|
||||||
$module = [
|
$module = [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'title' => $data['title'] ?? $name,
|
'slug' => $name,
|
||||||
|
'title' => $data['title'] ?? $data['name'] ?? $name,
|
||||||
'version' => $data['version'] ?? '',
|
'version' => $data['version'] ?? '',
|
||||||
'description' => $data['description'] ?? '',
|
'description' => $data['description'] ?? '',
|
||||||
'setup' => $data['setup'] ?? [],
|
'setup' => $data['setup'] ?? [],
|
||||||
@@ -253,6 +254,9 @@ final class ModuleManager
|
|||||||
'sidebar' => $data['sidebar'] ?? [],
|
'sidebar' => $data['sidebar'] ?? [],
|
||||||
'db_defaults' => $data['db_defaults'] ?? [],
|
'db_defaults' => $data['db_defaults'] ?? [],
|
||||||
'path' => $dir,
|
'path' => $dir,
|
||||||
|
'entry' => '/module/' . rawurlencode($name),
|
||||||
|
'auth' => is_array($data['auth'] ?? null) ? $data['auth'] : ['required' => false, 'users' => [], 'groups' => []],
|
||||||
|
'enabled_by_default' => (bool)($data['enabled_by_default'] ?? false),
|
||||||
'enabled' => false,
|
'enabled' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -268,7 +272,7 @@ final class ModuleManager
|
|||||||
private function loadEnabledState(string $name, array $module): bool
|
private function loadEnabledState(string $name, array $module): bool
|
||||||
{
|
{
|
||||||
if (!$this->basePdo) {
|
if (!$this->basePdo) {
|
||||||
return false;
|
return (bool)($module['enabled_by_default'] ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
$stmt = $this->basePdo->prepare(
|
$stmt = $this->basePdo->prepare(
|
||||||
@@ -287,8 +291,64 @@ final class ModuleManager
|
|||||||
$stmt->bindValue(':name', $name, \PDO::PARAM_STR);
|
$stmt->bindValue(':name', $name, \PDO::PARAM_STR);
|
||||||
$stmt->bindValue(':title', (string)$module['title'], \PDO::PARAM_STR);
|
$stmt->bindValue(':title', (string)$module['title'], \PDO::PARAM_STR);
|
||||||
$stmt->bindValue(':version', (string)$module['version'], \PDO::PARAM_STR);
|
$stmt->bindValue(':version', (string)$module['version'], \PDO::PARAM_STR);
|
||||||
$stmt->bindValue(':enabled', false, \PDO::PARAM_BOOL);
|
$enabledByDefault = (bool)($module['enabled_by_default'] ?? false);
|
||||||
|
$stmt->bindValue(':enabled', $enabledByDefault, \PDO::PARAM_BOOL);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
return false;
|
return $enabledByDefault;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveAuth(string $name, array $auth): array
|
||||||
|
{
|
||||||
|
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $name)) {
|
||||||
|
throw new \RuntimeException('Invalid module name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$module = $this->get($name);
|
||||||
|
if (!$module) {
|
||||||
|
throw new \RuntimeException('Module not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = $module['path'] . '/module.json';
|
||||||
|
$raw = is_file($manifest) ? file_get_contents($manifest) : '';
|
||||||
|
$data = $raw ? json_decode($raw, true) : [];
|
||||||
|
if (!is_array($data)) {
|
||||||
|
$data = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['auth'] = [
|
||||||
|
'required' => (bool) ($auth['required'] ?? false),
|
||||||
|
'users' => $this->normalizeList($auth['users'] ?? []),
|
||||||
|
'groups' => $this->normalizeList($auth['groups'] ?? []),
|
||||||
|
];
|
||||||
|
|
||||||
|
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||||
|
if (!is_string($json)) {
|
||||||
|
throw new \RuntimeException('Could not encode module metadata.');
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($manifest, $json . PHP_EOL, LOCK_EX);
|
||||||
|
$this->scanModules();
|
||||||
|
|
||||||
|
return $data['auth'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeList(mixed $values): array
|
||||||
|
{
|
||||||
|
if (is_string($values)) {
|
||||||
|
$values = preg_split('/[,\\n]+/', $values) ?: [];
|
||||||
|
}
|
||||||
|
if (!is_array($values)) {
|
||||||
|
$values = [$values];
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
$item = trim((string) $value);
|
||||||
|
if ($item !== '') {
|
||||||
|
$normalized[] = $item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($normalized));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ final class OidcClient
|
|||||||
$params = [
|
$params = [
|
||||||
'client_id' => $this->config->oidcClientId,
|
'client_id' => $this->config->oidcClientId,
|
||||||
'response_type' => 'code',
|
'response_type' => 'code',
|
||||||
'scope' => 'openid profile email',
|
'scope' => 'openid profile email groups',
|
||||||
'redirect_uri' => $this->config->oidcRedirectUri,
|
'redirect_uri' => $this->config->oidcRedirectUri,
|
||||||
'state' => $state,
|
'state' => $state,
|
||||||
'nonce' => $nonce,
|
'nonce' => $nonce,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace App;
|
|||||||
|
|
||||||
final class SessionManager
|
final class SessionManager
|
||||||
{
|
{
|
||||||
|
private const SESSION_TTL = 604800;
|
||||||
|
|
||||||
private string $sessionCookieName;
|
private string $sessionCookieName;
|
||||||
private string $clientCookieName;
|
private string $clientCookieName;
|
||||||
|
|
||||||
@@ -30,15 +32,16 @@ final class SessionManager
|
|||||||
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
|
|| (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https');
|
||||||
|
|
||||||
session_set_cookie_params([
|
session_set_cookie_params([
|
||||||
'lifetime' => 0,
|
'lifetime' => self::SESSION_TTL,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'domain' => $this->config->cookieDomain(),
|
'domain' => (string)($this->config->cookieDomain() ?? ''),
|
||||||
'secure' => $secure,
|
'secure' => $secure,
|
||||||
'httponly' => true,
|
'httponly' => true,
|
||||||
'samesite' => 'Lax',
|
'samesite' => 'Lax',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
session_start();
|
session_start();
|
||||||
|
$this->extendSessionCookie($secure);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function ensureClientId(int $lifetimeSeconds = 31536000): string
|
public function ensureClientId(int $lifetimeSeconds = 31536000): string
|
||||||
@@ -57,7 +60,7 @@ final class SessionManager
|
|||||||
setcookie($this->clientCookieName, $id, [
|
setcookie($this->clientCookieName, $id, [
|
||||||
'expires' => time() + $lifetimeSeconds,
|
'expires' => time() + $lifetimeSeconds,
|
||||||
'path' => '/',
|
'path' => '/',
|
||||||
'domain' => $this->config->cookieDomain(),
|
'domain' => (string)($this->config->cookieDomain() ?? ''),
|
||||||
'secure' => $secure,
|
'secure' => $secure,
|
||||||
'httponly' => false, // accessible to JS if needed
|
'httponly' => false, // accessible to JS if needed
|
||||||
'samesite' => 'Lax',
|
'samesite' => 'Lax',
|
||||||
@@ -68,4 +71,22 @@ final class SessionManager
|
|||||||
|
|
||||||
return $id;
|
return $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function extendSessionCookie(bool $secure): void
|
||||||
|
{
|
||||||
|
$name = session_name();
|
||||||
|
$value = session_id();
|
||||||
|
if ($name === '' || $value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setcookie($name, $value, [
|
||||||
|
'expires' => time() + self::SESSION_TTL,
|
||||||
|
'path' => '/',
|
||||||
|
'domain' => (string)($this->config->cookieDomain() ?? ''),
|
||||||
|
'secure' => $secure,
|
||||||
|
'httponly' => true,
|
||||||
|
'samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,11 @@ function user_theme(): string
|
|||||||
$stmt->execute(['id' => $clientId]);
|
$stmt->execute(['id' => $clientId]);
|
||||||
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
|
||||||
$theme = is_array($row) ? (string)($row['theme'] ?? '') : '';
|
$theme = is_array($row) ? (string)($row['theme'] ?? '') : '';
|
||||||
return $theme !== '' ? $theme : 'light';
|
return match ($theme) {
|
||||||
|
'dark', 'night' => 'night',
|
||||||
|
'light', 'day', '' => 'day',
|
||||||
|
default => $theme,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function set_user_theme(string $theme): void
|
function set_user_theme(string $theme): void
|
||||||
@@ -64,14 +68,12 @@ function current_module_name(): ?string
|
|||||||
|
|
||||||
function auth_enabled(): bool
|
function auth_enabled(): bool
|
||||||
{
|
{
|
||||||
return app()->config()->authEnabled;
|
return app()->auth()->isEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
function auth_user(): ?array
|
function auth_user(): ?array
|
||||||
{
|
{
|
||||||
$session = app()->session();
|
return app()->auth()->user();
|
||||||
$session->start();
|
|
||||||
return $_SESSION['auth_user'] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function auth_display_name(): string
|
function auth_display_name(): string
|
||||||
|
|||||||
Reference in New Issue
Block a user