Compare commits

...

210 Commits

Author SHA1 Message Date
127a0e71e1 yXyX
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-20 00:54:00 +02:00
7323673158 adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-20 00:41:20 +02:00
813dd86811 ycyxc
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-05-20 00:38:05 +02:00
81d1e486e8 update
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-20 00:32:18 +02:00
3ed4fba58c nexus base
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-15 01:19:31 +02:00
52158ef041 asdasd 2026-05-15 00:38:56 +02:00
27026533ac xdvdsf
All checks were successful
Deploy / deploy-staging (push) Successful in 9s
Deploy / deploy-production (push) Has been skipped
2026-05-15 00:37:02 +02:00
9c17fc0ca9 yxcyxc
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-05-11 02:06:52 +02:00
f46de880f4 sadsad
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-11 02:03:32 +02:00
0f8f9567fe asdas
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-11 01:57:23 +02:00
ebbdb52f93 yxcyx
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-11 01:29:42 +02:00
418423c5a8 ysysd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-11 01:09:37 +02:00
9b2cf69d2c yxcyxc
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-05-11 00:55:59 +02:00
e92b5dea17 dsadsa
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-11 00:49:16 +02:00
95ae657626 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-11 00:38:44 +02:00
3379e7f465 ysads
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-05-11 00:34:07 +02:00
df2f217e22 sdfsd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-11 00:11:14 +02:00
faa4c237c8 adad
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-11 00:03:17 +02:00
7c33d60f14 adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-10 23:58:51 +02:00
9848c1709b dvdf
All checks were successful
Deploy / deploy-staging (push) Successful in 8s
Deploy / deploy-production (push) Has been skipped
2026-05-10 23:48:06 +02:00
8aa52ec639 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-09 01:52:09 +02:00
c6642c0ef5 adasd
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-05-09 01:47:22 +02:00
de99f21332 dsasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-09 01:34:41 +02:00
693086029d asdasd
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-05-09 01:15:05 +02:00
fc95898a9d Miner-Upgrade
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped
2026-05-09 00:58:48 +02:00
ee5a46254f ydassdsa
All checks were successful
Deploy / deploy-staging (push) Successful in 30s
Deploy / deploy-production (push) Has been skipped
2026-05-08 23:47:00 +02:00
bec6fb1e0a xyxc
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 6s
2026-05-06 00:38:16 +02:00
7eeed06a40 fdsfdf
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 6s
2026-05-06 00:23:19 +02:00
4d9d9f3480 yxcxyc
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 6s
2026-05-06 00:21:53 +02:00
ecf0e83c4e adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-06 00:08:02 +02:00
fbfcf50b67 addasd
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-05-06 00:05:42 +02:00
47757675c2 ydcfydf
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped
2026-05-06 00:00:37 +02:00
48b7583f19 yyxx
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-05 23:46:23 +02:00
e335a8d5bf xycyxc
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped
2026-05-05 23:20:29 +02:00
eb8b04a621 cyxc
All checks were successful
Deploy / deploy-staging (push) Successful in 8s
Deploy / deploy-production (push) Has been skipped
2026-05-05 20:36:41 +02:00
fd092ef615 yxcxyc
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-04 22:41:58 +02:00
3795fc1de5 sddsf
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-04 22:38:51 +02:00
748d2c2e59 yxcyxc
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-04 22:29:46 +02:00
002d450deb yxcyc
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped
2026-05-04 22:22:55 +02:00
a9fefa7779 asdasd
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-05-04 03:13:30 +02:00
979b15d4d8 sadasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-04 03:10:09 +02:00
c3ba24e939 dsadas
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-04 03:03:30 +02:00
c81e89dc3f adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-04 02:51:25 +02:00
9659ce27b4 dsadas
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-04 02:43:04 +02:00
3cd5d90f1a Boersendchcker update
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-04 02:31:42 +02:00
03166c575e adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-04 02:14:28 +02:00
3f1731fa25 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-04 02:12:10 +02:00
9acf70d7ce asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-04 02:07:58 +02:00
206bede930 asdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-04 01:57:49 +02:00
468736ac4d sdsfsf
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-05-04 01:55:41 +02:00
f161a4c622 adssad
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-04 01:43:30 +02:00
e121369461 adsad
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-04 01:43:10 +02:00
a82893d4ff adsad
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-04 01:40:03 +02:00
0385864f1a asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-04 01:26:08 +02:00
2c1a751e82 aasdsd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-04 01:23:43 +02:00
f5b30d6991 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-04 01:12:05 +02:00
33119f66f7 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-03 02:18:45 +02:00
94fc39dd50 ysdsd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-03 02:14:48 +02:00
3b9940d030 modul layout
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-03 02:02:32 +02:00
2d56289477 layout
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-03 01:50:10 +02:00
fa424957b6 asdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-03 01:02:54 +02:00
7f038f03e8 dsfd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-02 03:36:09 +02:00
6d59d94273 asfsfd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-02 03:24:16 +02:00
fe83f53749 asdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-02 03:23:07 +02:00
1eaaf0f877 adsd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-02 03:21:43 +02:00
46e7276b14 dsdf
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-02 03:19:14 +02:00
f5a93931be sddsdf
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-05-02 03:08:52 +02:00
ed0f7b6762 sdsfd
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 6s
2026-05-02 03:04:44 +02:00
77bc307781 asdsd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-02 02:59:46 +02:00
b9f248aae0 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-02 02:56:00 +02:00
aa30feba85 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-02 02:15:46 +02:00
a44a0f6f0b ddfsdf
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-02 02:09:44 +02:00
44815f9c95 adsads
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-02 02:06:52 +02:00
6cbf76918c sdfsf
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-02 02:03:42 +02:00
58d0c18e25 yssd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-02 01:50:10 +02:00
f920991015 dsfsdf
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-02 01:38:39 +02:00
7ad4b45d22 dfdsf
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-02 01:25:54 +02:00
c098413e23 adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped
2026-05-02 01:20:34 +02:00
2de8c95fdb fx update
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-01 04:00:24 +02:00
86eeef71a8 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-01 03:54:12 +02:00
d5e9d588cd sasdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-01 03:44:52 +02:00
aff9dd30e1 asdsd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-01 03:41:14 +02:00
c444ece852 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-01 03:37:41 +02:00
5a154f896b aasdsd
All checks were successful
Deploy / deploy-staging (push) Successful in 8s
Deploy / deploy-production (push) Has been skipped
2026-05-01 03:13:29 +02:00
29e2724cd8 asdsa
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-01 02:53:21 +02:00
6ce45f6d23 ddfsdfdf
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-01 02:49:22 +02:00
d8f532ce0e ycyc
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-01 02:24:54 +02:00
a9c64c5d68 ddsfds
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-01 02:22:43 +02:00
f7f99bd700 asdsd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-01 02:17:30 +02:00
eb864077bf asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-01 02:14:25 +02:00
8ec7270128 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-05-01 02:09:30 +02:00
dfbb66bf74 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-05-01 02:04:46 +02:00
f193f12c71 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 10s
Deploy / deploy-production (push) Has been skipped
2026-04-30 03:44:27 +02:00
e1ee3b17d4 adasd
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 6s
2026-04-30 03:42:25 +02:00
10dc9bb0a7 asdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-30 03:37:11 +02:00
e1b2f7e613 asdasd
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 6s
2026-04-30 03:12:30 +02:00
8879a4ae5c cron
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-30 03:00:21 +02:00
0c5c89acfa asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-30 02:39:50 +02:00
9c9ec477d0 csdsd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-29 03:03:37 +02:00
34fc8ead14 adsdas
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-29 03:02:04 +02:00
8bab6f9e26 ydasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-29 02:58:05 +02:00
1ba8b550f6 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-29 02:47:37 +02:00
235630ee1e ssad
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-29 02:43:40 +02:00
ef87dc6cd3 asdsad
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-04-29 02:38:09 +02:00
79872f3337 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-29 02:32:42 +02:00
4ead35047a ysdsd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-29 02:21:22 +02:00
a0f19ff6b4 asdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-29 02:08:26 +02:00
9e687455fa sdadas
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-29 01:57:49 +02:00
00c879d029 asdsd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-29 01:48:53 +02:00
488964df3a adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-29 01:44:14 +02:00
af1d94a3b6 adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-29 01:31:47 +02:00
b76edb49b0 dfdsf
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-29 01:27:49 +02:00
1da63807eb asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-29 01:19:32 +02:00
cea88963da adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-29 01:14:29 +02:00
f1b41e07cb adsasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-29 00:58:31 +02:00
74b1651eac fx-rates
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-29 00:53:38 +02:00
1dd74d4674 FX Modul
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped
2026-04-29 00:46:40 +02:00
8140f1e1b1 adsd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-27 02:41:19 +02:00
65f01b2706 sadasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-27 02:37:39 +02:00
6a97450ced asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-27 02:34:24 +02:00
f4fa8acb97 sdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-27 02:23:44 +02:00
0174fa9d27 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-27 02:16:54 +02:00
73dae688ab popup
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-27 02:07:34 +02:00
f64975b5f7 fsdfdsf
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-27 02:04:27 +02:00
6d9767a388 dsfdsf
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-04-27 02:01:35 +02:00
bd6242cd76 sdsds
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-27 01:58:04 +02:00
f94dd83b68 ysdsd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-27 01:50:16 +02:00
44945a31da asdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-27 01:38:54 +02:00
bff852291e sdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-27 01:30:00 +02:00
d04a2214dd sdfdsf
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-27 01:19:34 +02:00
805f5adebf sdfdsf
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-27 01:16:01 +02:00
42dd155904 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-27 01:10:43 +02:00
0858d33f38 adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-27 01:09:12 +02:00
bd20634ad2 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-27 00:57:05 +02:00
d758ed0f49 dadas
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 6s
2026-04-27 00:48:15 +02:00
329304f1aa dfdsf
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-27 00:44:49 +02:00
463b2cf5e1 pi hole
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-27 00:41:37 +02:00
e7a1878c72 cron
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-27 00:24:30 +02:00
7ce9173d57 rueckbau
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped
2026-04-27 00:11:03 +02:00
39ea8da129 sdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-25 00:21:58 +02:00
5bfb37f926 mining
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-25 00:08:59 +02:00
d6f09326f4 dsfdsf
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-24 23:54:04 +02:00
739e4d4c42 debug
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-24 23:12:19 +02:00
92c9bed5bb adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-24 02:32:28 +02:00
c73656d895 quota einbau
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-24 02:07:24 +02:00
9103218c35 umstellung
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-24 02:00:37 +02:00
c2c91032db dfsdf
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-24 00:06:34 +02:00
e5f32f8a6a cyxcyx
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-23 23:52:31 +02:00
f10f5a4cf3 dfdf
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped
2026-04-23 23:47:16 +02:00
2c79a2192d yydfds
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-23 23:45:01 +02:00
342e87d25e css
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-23 23:38:41 +02:00
9719bf6c9f dsfdf
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-23 23:36:11 +02:00
c14e673faf dsfdf
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-23 23:16:03 +02:00
8b1924989b yxyc
All checks were successful
Deploy / deploy-staging (push) Successful in 9s
Deploy / deploy-production (push) Has been skipped
2026-04-23 00:45:52 +02:00
0c90aa0b88 sfdsf
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-23 00:40:05 +02:00
39bddf39e2 sfsdf
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-23 00:21:47 +02:00
ac3ac0803b Boersenchecker UI2
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-23 00:03:06 +02:00
327f40adec Boersenchecker UI
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-23 00:01:20 +02:00
2e39acbf03 asdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-22 23:54:53 +02:00
e83d187a16 erwre
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-22 01:41:12 +02:00
91dc84d027 boerse
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-22 01:31:18 +02:00
a1bab34bd3 adfasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-22 01:04:43 +02:00
04a4c3f2c1 boersenchecker
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-22 00:40:13 +02:00
1d948f0508 asdad
All checks were successful
Deploy / deploy-staging (push) Successful in 31s
Deploy / deploy-production (push) Has been skipped
2026-04-22 00:03:19 +02:00
82ad817ad3 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-16 01:35:08 +02:00
9f2af4676d KEA update
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-16 01:19:07 +02:00
04ec9dc227 kea groups
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-15 02:54:47 +02:00
598348a4b6 kea
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-15 02:48:23 +02:00
7157c98dcb adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-15 02:41:09 +02:00
0b555e7dd4 adads
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-15 02:02:42 +02:00
08a8df87e2 adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped
2026-04-15 01:56:18 +02:00
5a3ebc607c asdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 11s
Deploy / deploy-production (push) Has been skipped
2026-04-15 01:27:52 +02:00
56b3493c9e adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-15 01:22:03 +02:00
11fb43b9ce setup
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-15 01:06:16 +02:00
5677a8d806 asdsad
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-15 00:56:09 +02:00
93c867040e update
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-14 22:48:35 +02:00
3e9fa3d4a1 adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-14 22:38:11 +02:00
da9a6841e4 adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped
2026-04-14 22:33:49 +02:00
677f9314f5 KEA Setup
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-13 02:13:43 +02:00
4d73eec687 update
All checks were successful
Deploy / deploy-staging (push) Successful in 9s
Deploy / deploy-production (push) Has been skipped
2026-04-13 01:49:13 +02:00
aea4e9fa5f upgrade domain
Some checks failed
Deploy / deploy-staging (push) Failing after 28s
Deploy / deploy-production (push) Has been skipped
2026-04-13 01:36:20 +02:00
dc7373fc08 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-11 03:33:07 +02:00
94f3295b6c import x
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 5s
2026-04-11 03:29:21 +02:00
067f962cb2 importer general
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-11 03:25:38 +02:00
37f5b7f5e6 asdasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-11 03:21:52 +02:00
7d0ad8eb29 importer 4
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-11 03:13:37 +02:00
e2e87016ad importer 3
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-11 03:11:45 +02:00
35a0b10d0c importer 2
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-11 03:09:09 +02:00
7400caa687 Importer
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-11 03:02:39 +02:00
394935a53b DADAS
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-11 02:51:14 +02:00
8786c079a8 asd
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 6s
2026-04-11 02:45:46 +02:00
bbd2e39f86 asdasd
All checks were successful
Deploy / deploy-production (push) Has been skipped
Deploy / deploy-staging (push) Successful in 6s
2026-04-11 02:40:45 +02:00
c15c90bf6d asdsd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-11 02:31:23 +02:00
dc4abe9563 upgrade
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-11 02:29:37 +02:00
ea1bdd75ad adasd
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-11 02:25:19 +02:00
9cf4322ffc layout
All checks were successful
Deploy / deploy-staging (push) Successful in 5s
Deploy / deploy-production (push) Has been skipped
2026-04-11 02:23:14 +02:00
f883655b3d workflwo
All checks were successful
Deploy / deploy-staging (push) Successful in 6s
Deploy / deploy-production (push) Has been skipped
2026-04-11 02:14:48 +02:00
494f1ec82e gitea worklfow
Some checks failed
Deploy / deploy-staging (push) Failing after 4s
Deploy / deploy-production (push) Has been skipped
2026-04-11 02:11:54 +02:00
566dde909f change settings 2026-04-11 01:58:10 +02:00
971b8a15fb settings 2026-04-11 01:46:40 +02:00
e83925ba64 Nexus upgrade design and refresh 2026-04-11 01:23:28 +02:00
9d5bb2d3cf asdasd 2026-03-19 02:01:57 +01:00
f55667ba83 asd 2026-03-19 02:01:54 +01:00
291ce9f0c7 nexus... 2026-03-19 00:13:30 +01:00
8de05d5552 pi hole setup 2026-03-09 02:24:29 +01:00
c144d9bcd2 pi hole settings 2026-03-09 02:08:40 +01:00
b999c2cf15 modulcreation 2026-03-09 02:01:31 +01:00
5687fbb21b pi hole update 2026-03-09 02:00:04 +01:00
4adb50dc57 Update pihole module 2026-03-09 01:44:57 +01:00
c77b4b3ea7 Modul pihole 2026-03-09 01:32:39 +01:00
157 changed files with 39662 additions and 1203 deletions

0
.codex Normal file
View File

246
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,246 @@
name: Deploy
on:
push:
branches:
- develop
- main
jobs:
deploy-staging:
if: gitea.ref == 'refs/heads/develop'
runs-on: private-server
env:
BASE_DIRS: "src public api partials tools data debug modules"
CONFIG_BASE_DIR: "config"
CONFIG_ENV_DIR: "config/staging"
DEPLOY_ENV: "staging"
LOCAL_ROOT: "/deploy/nexus"
RSYNC_OPTS: "--delete --inplace --no-whole-file --no-owner --no-group --no-perms --omit-dir-times --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r"
SITE_DOMAIN_DIR: "${{ vars.SITE_DOMAIN_DIR }}"
TARGET_PATH: "/deploy/nexus/staging/"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Debug workspace
run: |
echo "Workspace:" && pwd
ls -la
- name: Deploy staging
shell: bash
run: |
set -euo pipefail
if [ -z "${SITE_DOMAIN_DIR}" ]; then
echo "❌ SITE_DOMAIN_DIR ist leer."
echo " Bitte in Gitea als Repository Variable setzen."
exit 1
fi
echo "Deploy Domain: ${SITE_DOMAIN_DIR}"
echo "Target path: ${TARGET_PATH}"
echo "=== DEBUG: who/where ==="
hostname || true
id || true
echo "GITEA_REPOSITORY=${GITEA_REPOSITORY:-}"
echo "GITEA_REF=${GITEA_REF:-}"
echo "=== DEBUG: mount + path visibility ==="
ls -la /deploy || true
ls -la /deploy/webserver || true
df -h /deploy/webserver || true
mount | grep -E "/deploy/webserver" || true
echo "=== DEBUG: write test (pre-sync) ==="
mkdir -p "${TARGET_PATH}"
date > "${TARGET_PATH}/__ci_write_test.txt" || true
ls -la "${TARGET_PATH}" || true
cat "${TARGET_PATH}/__ci_write_test.txt" || true
echo "📁 Prüfe lokale Basisverzeichnisse..."
MISSING=0
for d in $BASE_DIRS; do
if [ ! -d "$d" ]; then
echo "❌ Verzeichnis '$d/' fehlt im Repo!"
MISSING=1
fi
done
if [ ! -d "$CONFIG_BASE_DIR" ]; then
echo "❌ Basis-Konfig-Verzeichnis '$CONFIG_BASE_DIR/' fehlt!"
MISSING=1
fi
if [ ! -d "$CONFIG_ENV_DIR" ]; then
echo "❌ Env-Konfiguration '$CONFIG_ENV_DIR/' fehlt!"
MISSING=1
fi
if [ "$MISSING" -ne 0 ]; then
echo "⛔ Abbruch wegen fehlender Verzeichnisse."
exit 1
fi
echo "🧱 Stelle sicher, dass Zielpfad existiert..."
mkdir -p "${TARGET_PATH}"
echo "🚀 Deploy ${DEPLOY_ENV} → ${TARGET_PATH}"
chmod -R u+rwX,go+rX "${TARGET_PATH}" || true
echo "RSYNC_OPTS: ${RSYNC_OPTS}"
for d in $BASE_DIRS; do
echo "🔁 Sync ${d}/ → ${TARGET_PATH}${d}/"
mkdir -p "${TARGET_PATH}${d}/"
rsync -a ${RSYNC_OPTS} --exclude=".gitkeep" "${d}/" "${TARGET_PATH}${d}/"
done
echo "🧩 Baue gemischtes Config-Verzeichnis (config/*.php + ${CONFIG_ENV_DIR})..."
rm -rf .ci_config_deploy
mkdir -p .ci_config_deploy
if [ -d "${CONFIG_BASE_DIR}" ]; then
for f in ${CONFIG_BASE_DIR}/*.php; do
if [ -f "$f" ]; then
echo " Basis-Config-Datei: $f"
cp "$f" .ci_config_deploy/
fi
done
fi
if [ -d "${CONFIG_ENV_DIR}" ]; then
echo " Env-Config aus ${CONFIG_ENV_DIR}/"
cp -R "${CONFIG_ENV_DIR}/." .ci_config_deploy/
fi
echo "🔁 Sync .ci_config_deploy/ → ${TARGET_PATH}${CONFIG_BASE_DIR}/"
mkdir -p "${TARGET_PATH}${CONFIG_BASE_DIR}/"
rsync -a ${RSYNC_OPTS} --exclude=".gitkeep" ".ci_config_deploy/" "${TARGET_PATH}${CONFIG_BASE_DIR}/"
echo "=== DEBUG: post-sync spot checks ==="
ls -la "${TARGET_PATH}" | head -n 200 || true
find "${TARGET_PATH}" -maxdepth 2 -type f | head -n 200 || true
echo "✅ Deploy ${DEPLOY_ENV} abgeschlossen."
deploy-production:
if: gitea.ref == 'refs/heads/main'
runs-on: private-server
env:
BASE_DIRS: "src public api partials tools data debug modules"
CONFIG_BASE_DIR: "config"
CONFIG_ENV_DIR: "config/prod"
DEPLOY_ENV: "production"
LOCAL_ROOT: "/deploy/nexus"
RSYNC_OPTS: "--delete --inplace --no-whole-file --no-owner --no-group --no-perms --omit-dir-times --chmod=Du=rwx,Dgo=rx,Fu=rw,Fgo=r"
SITE_DOMAIN_DIR: "${{ vars.SITE_DOMAIN_DIR }}"
TARGET_PATH: "/deploy/nexus/live/"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Debug workspace
run: |
echo "Workspace:" && pwd
ls -la
- name: Deploy production
shell: bash
run: |
set -euo pipefail
if [ -z "${SITE_DOMAIN_DIR}" ]; then
echo "❌ SITE_DOMAIN_DIR ist leer."
echo " Bitte in Gitea als Repository Variable setzen."
exit 1
fi
echo "Deploy Domain: ${SITE_DOMAIN_DIR}"
echo "Target path: ${TARGET_PATH}"
echo "=== DEBUG: who/where ==="
hostname || true
id || true
echo "GITEA_REPOSITORY=${GITEA_REPOSITORY:-}"
echo "GITEA_REF=${GITEA_REF:-}"
echo "=== DEBUG: mount + path visibility ==="
ls -la /deploy || true
ls -la /deploy/webserver || true
df -h /deploy/webserver || true
mount | grep -E "/deploy/webserver" || true
echo "=== DEBUG: write test (pre-sync) ==="
mkdir -p "${TARGET_PATH}"
date > "${TARGET_PATH}/__ci_write_test.txt" || true
ls -la "${TARGET_PATH}" || true
cat "${TARGET_PATH}/__ci_write_test.txt" || true
echo "📁 Prüfe lokale Basisverzeichnisse..."
MISSING=0
for d in $BASE_DIRS; do
if [ ! -d "$d" ]; then
echo "❌ Verzeichnis '$d/' fehlt im Repo!"
MISSING=1
fi
done
if [ ! -d "$CONFIG_BASE_DIR" ]; then
echo "❌ Basis-Konfig-Verzeichnis '$CONFIG_BASE_DIR/' fehlt!"
MISSING=1
fi
if [ ! -d "$CONFIG_ENV_DIR" ]; then
echo "❌ Env-Konfiguration '$CONFIG_ENV_DIR/' fehlt!"
MISSING=1
fi
if [ "$MISSING" -ne 0 ]; then
echo "⛔ Abbruch wegen fehlender Verzeichnisse."
exit 1
fi
echo "🧱 Stelle sicher, dass Zielpfad existiert..."
mkdir -p "${TARGET_PATH}"
echo "🚀 Deploy ${DEPLOY_ENV} → ${TARGET_PATH}"
chmod -R u+rwX,go+rX "${TARGET_PATH}" || true
echo "RSYNC_OPTS: ${RSYNC_OPTS}"
for d in $BASE_DIRS; do
echo "🔁 Sync ${d}/ → ${TARGET_PATH}${d}/"
mkdir -p "${TARGET_PATH}${d}/"
rsync -a ${RSYNC_OPTS} --exclude=".gitkeep" "${d}/" "${TARGET_PATH}${d}/"
done
echo "🧩 Baue gemischtes Config-Verzeichnis (config/*.php + ${CONFIG_ENV_DIR})..."
rm -rf .ci_config_deploy
mkdir -p .ci_config_deploy
if [ -d "${CONFIG_BASE_DIR}" ]; then
for f in ${CONFIG_BASE_DIR}/*.php; do
if [ -f "$f" ]; then
echo " Basis-Config-Datei: $f"
cp "$f" .ci_config_deploy/
fi
done
fi
if [ -d "${CONFIG_ENV_DIR}" ]; then
echo " Env-Config aus ${CONFIG_ENV_DIR}/"
cp -R "${CONFIG_ENV_DIR}/." .ci_config_deploy/
fi
echo "🔁 Sync .ci_config_deploy/ → ${TARGET_PATH}${CONFIG_BASE_DIR}/"
mkdir -p "${TARGET_PATH}${CONFIG_BASE_DIR}/"
rsync -a ${RSYNC_OPTS} --exclude=".gitkeep" ".ci_config_deploy/" "${TARGET_PATH}${CONFIG_BASE_DIR}/"
echo "=== DEBUG: post-sync spot checks ==="
ls -la "${TARGET_PATH}" | head -n 200 || true
find "${TARGET_PATH}" -maxdepth 2 -type f | head -n 200 || true
echo "✅ Deploy ${DEPLOY_ENV} abgeschlossen."

View File

@@ -0,0 +1,23 @@
Neues Projekt auf Basis dieser Vorlage erstellen.
Dabei sind die Dateien `GENERAL.md` und `BASE_FILES.md` zwingend vollstaendig zu beachten.
Ohne Beachtung dieser beiden Dateien darf keine Projekterstellung erfolgen.
Vor der Erstellung muessen die beiden Domains gesetzt werden:
- Live-Domain: <LIVE_DOMAIN>
- Staging-Domain: <STAGING_DOMAIN>
Wichtige Pflichtregel:
- Wenn eine der beiden Domains fehlt, muss die Erstellung blockiert werden.
- Wenn einer der Platzhalter `<LIVE_DOMAIN>` oder `<STAGING_DOMAIN>` noch unveraendert vorhanden ist, muss die Erstellung ebenfalls blockiert werden.
- Eine Ausfuehrung ist nur erlaubt, wenn beide Platzhalter durch echte Domainnamen ersetzt wurden.
Weitere Pflichtregeln:
- Alle bestehenden Dateien und Ordner sind vor der Neuerstellung zu loeschen.
- Die Datei `.gitlab-ci.yml` ist die einzige Ausnahme und muss erhalten bleiben.
- Falls `.gitlab-ci.yml` Domainreferenzen, URLs oder projektspezifische Angaben enthaelt, muessen diese auf das neue Projekt angepasst werden.
Danach ist das neue Projekt exakt nach den Vorgaben aus `GENERAL.md` und `BASE_FILES.md` zu erzeugen.

443
BASE_FILES.md Normal file
View File

@@ -0,0 +1,443 @@
# Basisdateien fuer neue Projekte
Dieses Dokument enthaelt die Basisdateien, die bei einem neuen Projekt direkt angelegt werden sollen. Der Schwerpunkt liegt auf den Config-Dateien, weil diese fuer den ersten lauffaehigen Stand zwingend benoetigt werden.
Dieses Dokument ist dafuer gedacht, zusammen mit `GENERAL.md` in ein neues GitLab-Projekt kopiert zu werden. Danach kann die Projektstruktur inklusive Basisdateien direkt erstellt werden.
## Wichtige Regel vor der Erstellung
Ein neues Projekt darf nur erstellt werden, wenn beide Domains bekannt sind:
- Live-Domain
- Staging-Domain
Wenn eine oder beide Angaben fehlen, muss die Erstellung gestoppt werden. Vor dem Anlegen der Dateien ist dann explizit nach beiden Domains zu fragen.
Ohne beide Domains duerfen insbesondere diese Dateien nicht final erzeugt werden:
- `config/prod/domaindata.php`
- `config/prod/settings.php`
- `config/staging/domaindata.php`
- `config/staging/settings.php`
Vor der Neuerstellung ist das Repository ausserdem auf den neuen Projektstand zurueckzusetzen:
- alle bestehenden Dateien und Ordner loeschen
- `.gitlab-ci.yml` ausdruecklich behalten
- `.gitlab-ci.yml` anschliessend auf alte Domainreferenzen, Umgebungs-URLs und projektspezifische Angaben pruefen
- gefundene Domainreferenzen in `.gitlab-ci.yml` auf die neue Live- und Staging-Domain anpassen
## Platzhalter fuer neue Projekte
In den folgenden Vorlagen werden diese Platzhalter verwendet:
- `<LIVE_DOMAIN>` fuer die Produktiv-Domain
- `<STAGING_DOMAIN>` fuer die Staging-Domain
- `<APP_PREFIX>` fuer den Cookie- und App-Prefix
- `<APP_NAME>` fuer einen allgemeinen Projektnamen oder OIDC-Client-Namen
## Datei: `config/fileload.php`
```php
<?php
declare(strict_types=1);
spl_autoload_register(function ($class) {
if (str_starts_with($class, 'App\\Repository\\')) {
$prefix = 'App\\Repository\\';
$baseDir = __DIR__ . '/../src/Repository/';
} elseif (str_starts_with($class, 'App\\')) {
$prefix = 'App\\';
$baseDir = __DIR__ . '/../src/App/';
} else {
return;
}
$len = strlen($prefix);
$relativeClass = substr($class, $len);
$file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
if (file_exists($file)) {
require $file;
}
});
require_once __DIR__ . '/../src/App/functions.php';
$domainFile = __DIR__ . '/domaindata.php';
$settingsFile = __DIR__ . '/settings.php';
$configFile = __DIR__ . '/db.php';
$baseConfigFile = __DIR__ . '/base_db.php';
$fallbackBaseConfigStaging = __DIR__ . '/staging/db_settings_basic.php';
$fallbackBaseConfigProd = __DIR__ . '/prod/db_settings_basic.php';
if (file_exists($domainFile)) {
require_once $domainFile;
}
if (file_exists($settingsFile)) {
require_once $settingsFile;
}
$dbConfig = [];
if (file_exists($configFile)) {
$dbConfig = require $configFile;
}
$baseDbConfig = [];
if (file_exists($baseConfigFile)) {
$baseDbConfig = require $baseConfigFile;
}
if (empty($baseDbConfig) && file_exists($fallbackBaseConfigStaging)) {
$baseDbConfig = require $fallbackBaseConfigStaging;
}
if (empty($baseDbConfig) && file_exists($fallbackBaseConfigProd)) {
$baseDbConfig = require $fallbackBaseConfigProd;
}
global $appConfig;
$dbEnabled = defined('APP_DB_ENABLED') ? APP_DB_ENABLED : true;
$baseDbEnabled = defined('APP_BASE_DB_ENABLED') ? APP_BASE_DB_ENABLED : false;
$appConfig = new \App\Config($dbConfig, $dbEnabled, $baseDbConfig, $baseDbEnabled);
\App\App::init($appConfig);
```
## Datei: `config/config.php`
```php
<?php
declare(strict_types=1);
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
foreach (['domaindata.php', 'settings.php'] as $cfgFile) {
$rootPath = __DIR__ . '/' . $cfgFile;
if (file_exists($rootPath)) {
require_once $rootPath;
} else {
throw new \RuntimeException("Missing required config file: $cfgFile (expected $rootPath)");
}
}
if (!defined('ASSET_VERSION')) {
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
}
if (!defined('APP_DOMAIN_PRIMARY')) {
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
}
if (!defined('APP_URL_PRIMARY')) {
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
}
if (!defined('APP_API_BASE')) {
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
}
if (!defined('APP_DB_ENABLED')) {
define('APP_DB_ENABLED', false);
}
```
## Datei: `config/base_db.php`
```php
<?php
declare(strict_types=1);
$path = __DIR__ . '/db_settings_basic.php';
if (!file_exists($path)) {
throw new RuntimeException('Missing base DB config: expected config/db_settings_basic.php');
}
return require $path;
```
## Datei: `config/staging/domaindata.php`
```php
<?php
declare(strict_types=1);
if (!defined('APP_DOMAIN_NAME')) {
define('APP_DOMAIN_NAME', '<STAGING_DOMAIN>');
}
if (!defined('APP_PREFIX')) {
define('APP_PREFIX', '<APP_PREFIX>');
}
```
## Datei: `config/prod/domaindata.php`
```php
<?php
declare(strict_types=1);
if (!defined('APP_DOMAIN_NAME')) {
define('APP_DOMAIN_NAME', '<LIVE_DOMAIN>');
}
if (!defined('APP_PREFIX')) {
define('APP_PREFIX', '<APP_PREFIX>');
}
```
## Datei: `config/staging/settings.php`
```php
<?php
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
define('APP_DB_ENABLED', false);
define('APP_DB_DEBUG', true);
define('APP_DB_AUTO_INIT', true);
define('APP_BASE_DB_ENABLED', true);
define('APP_AUTH_ENABLED', false);
define('APP_DEBUG_TOOL', true);
define('APP_AUTH_DEBUG', true);
/*
Optional fuer Projekte mit OIDC:
define('APP_OIDC_ISSUER', 'https://auth.example.tld/realms/<APP_NAME>');
define('APP_OIDC_AUTH_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/auth');
define('APP_OIDC_TOKEN_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/token');
define('APP_OIDC_USERINFO_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/userinfo');
define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/logout');
define('APP_OIDC_CLIENT_ID', '<APP_NAME>');
define('APP_OIDC_CLIENT_SECRET', 'CHANGE_ME');
define('APP_OIDC_REDIRECT_URI', 'https://<STAGING_DOMAIN>/auth/callback');
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://<STAGING_DOMAIN>/');
define('APP_OIDC_GROUP_CLAIM', 'groups');
define('APP_OIDC_ADMIN_GROUP', 'appadmin');
define('APP_OIDC_USER_GROUP', 'user');
*/
```
## Datei: `config/prod/settings.php`
```php
<?php
define('ASSET_VERSION', 'dev-' . date('Ymd-His'));
define('APP_DOMAIN_PRIMARY', APP_DOMAIN_NAME);
define('APP_URL_PRIMARY', 'https://' . APP_DOMAIN_PRIMARY);
define('APP_API_BASE', 'https://api.' . APP_DOMAIN_PRIMARY);
define('APP_DB_ENABLED', false);
define('APP_DB_DEBUG', false);
define('APP_DB_AUTO_INIT', true);
define('APP_BASE_DB_ENABLED', true);
define('APP_AUTH_ENABLED', false);
define('APP_DEBUG_TOOL', false);
define('APP_AUTH_DEBUG', false);
/*
Optional fuer Projekte mit OIDC:
define('APP_OIDC_ISSUER', 'https://auth.example.tld/realms/<APP_NAME>');
define('APP_OIDC_AUTH_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/auth');
define('APP_OIDC_TOKEN_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/token');
define('APP_OIDC_USERINFO_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/userinfo');
define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.example.tld/realms/<APP_NAME>/protocol/openid-connect/logout');
define('APP_OIDC_CLIENT_ID', '<APP_NAME>');
define('APP_OIDC_CLIENT_SECRET', 'CHANGE_ME');
define('APP_OIDC_REDIRECT_URI', 'https://<LIVE_DOMAIN>/auth/callback');
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://<LIVE_DOMAIN>/');
define('APP_OIDC_GROUP_CLAIM', 'groups');
define('APP_OIDC_ADMIN_GROUP', 'appadmin');
define('APP_OIDC_USER_GROUP', 'user');
*/
```
## Datei: `config/staging/db_settings_basic.php`
```php
<?php
declare(strict_types=1);
return [
'dsn' => 'sqlite:' . __DIR__ . '/../../data/app_staging.sqlite',
'user' => null,
'password' => null,
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
],
];
```
## Datei: `config/prod/db_settings_basic.php`
```php
<?php
declare(strict_types=1);
return [
'dsn' => 'sqlite:' . __DIR__ . '/../../data/app_prod.sqlite',
'user' => null,
'password' => null,
'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
],
];
```
## Datei: `public/index.php`
Minimaler Einstiegspunkt fuer den Webzugriff:
```php
<?php
declare(strict_types=1);
require_once __DIR__ . '/../config/fileload.php';
$uriPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$uriPath = preg_replace('~/{2,}~', '/', $uriPath);
$uriPath = trim($uriPath, '/');
if (str_contains($uriPath, '..')) {
http_response_code(400);
exit('Bad request');
}
$pagesBase = realpath(__DIR__ . '/../partials/landingpages') ?: (__DIR__ . '/../partials/landingpages');
$page404 = $pagesBase . '/404.php';
if (preg_match('~^module/([a-zA-Z0-9_-]+)(?:/(.+))?$~', $uriPath, $m)) {
$module = $m[1];
$page = isset($m[2]) && $m[2] !== '' ? trim($m[2], '/') : 'index';
$modulePage = app()->modules()->resolvePage($module, $page);
$target = $modulePage ?: $page404;
if (!$modulePage) {
http_response_code(404);
}
} elseif ($uriPath === '' || $uriPath === 'index' || $uriPath === 'index.php') {
$target = $pagesBase . '/index.php';
} else {
$base = $pagesBase . '/' . $uriPath;
if (is_dir($base) && is_file($base . '/index.php')) {
$target = $base . '/index.php';
} elseif (is_file($base . '.php')) {
$target = $base . '.php';
} else {
http_response_code(404);
$target = $page404;
}
}
ob_start();
require $target;
$content = ob_get_clean();
tpl('layout_start', 'structure');
echo $content;
tpl('layout_end', 'structure');
```
## Datei: `partials/landingpages/index.php`
```php
<div class="card">
<h1>Projektstart</h1>
<p>Die Grundstruktur wurde erfolgreich erstellt.</p>
</div>
```
## Datei: `partials/landingpages/404.php`
```php
<div class="card">
<h1>404</h1>
<p>Die angeforderte Seite wurde nicht gefunden.</p>
</div>
```
## Datei: `partials/structure/layout_start.php`
```php
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Projektbasis</title>
<link rel="stylesheet" href="/assets/css/app.css?v=<?= urlencode(app()->config()->assetVersion) ?>">
</head>
<body>
<main class="main-content">
```
## Datei: `partials/structure/layout_end.php`
```php
</main>
<script src="/assets/js/app.js?v=<?= urlencode(app()->config()->assetVersion) ?>" defer></script>
</body>
</html>
```
## Datei: `public/assets/css/app.css`
```css
body {
margin: 0;
font-family: sans-serif;
background: #f5f5f5;
color: #111;
}
.main-content {
max-width: 960px;
margin: 0 auto;
padding: 32px 16px;
}
.card {
background: #fff;
border: 1px solid #ddd;
border-radius: 12px;
padding: 24px;
}
```
## Datei: `public/assets/js/app.js`
```js
document.documentElement.classList.add('js');
```
## Datei: `public/.htaccess`
```apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
```
## Empfohlene Startreihenfolge in einem neuen Projekt
1. Neues GitLab-Projekt anlegen.
2. Repository lokal mit VS Code verknuepfen.
3. Alle bestehenden Dateien und Ordner entfernen, `.gitlab-ci.yml` jedoch behalten.
4. `GENERAL.md` und `BASE_FILES.md` in das neue Repository kopieren.
5. `.gitlab-ci.yml` auf alte Domain- und Projektreferenzen pruefen und anpassen.
6. Pflichtstruktur aus `GENERAL.md` anlegen.
7. Basisdateien aus `BASE_FILES.md` anlegen.
8. Live- und Staging-Domain einsetzen.
9. Danach erst die eigentliche Projekterstellung oder Modulentwicklung starten.
## Kurzregel
Ohne `GENERAL.md`, ohne `BASE_FILES.md` oder ohne beide Domains ist die saubere Erstellung eines neuen Projekts auf dieser Basis nicht vollstaendig und soll nicht ausgefuehrt werden.

375
GENERAL.md Normal file
View File

@@ -0,0 +1,375 @@
# Projektbasis fuer neue Projekte
Dieses Dokument kombiniert die Erklaerung des allgemeinen Projektaufbaus mit einer konkreten Startstruktur fuer neue Projekte. Ziel ist, dass neue Projekte dieselbe technische Basis verwenden und dieselben Regeln fuer Routing, Verzeichnisse, Module, Assets und Konfiguration einhalten.
## Grundprinzip
Das Projekt basiert auf einer schlanken PHP-Struktur mit:
- einem zentralen Web-Einstiegspunkt in `public/index.php`
- einem zentralen Bootstrap in `config/fileload.php`
- globalen Seiten unter `partials/landingpages/`
- globalem Layout unter `partials/structure/`
- optionalen Fachmodulen unter `modules/`
- globalen Assets unter `public/assets/`
- einer Deployment-gesteuerten Config-Aufteilung mit `config/staging/` und `config/prod/`
## Verbindliche Startstruktur fuer neue Projekte
Die folgende Struktur soll als Basis fuer neue Projekte verwendet werden. Sie beruecksichtigt ausdruecklich auch die Basisverzeichnisse aus `.gitlab-ci.yml`.
```text
/
|-- .gitlab-ci.yml
|-- GENERAL.md
|-- api/
| `-- .gitkeep
|-- config/
| |-- .gitkeep
| |-- base_db.php
| |-- config.php
| |-- fileload.php
| |-- prod/
| | |-- .gitkeep
| | |-- db_settings_basic.php
| | |-- domaindata.php
| | `-- settings.php
| `-- staging/
| |-- .gitkeep
| |-- db_settings_basic.php
| |-- domaindata.php
| `-- settings.php
|-- data/
| `-- .gitkeep
|-- debug/
| `-- .gitkeep
|-- modules/
| `-- .gitkeep
|-- partials/
| |-- landingpages/
| | |-- .gitkeep
| | `-- index.php
| `-- structure/
| |-- .gitkeep
| |-- layout_end.php
| `-- layout_start.php
|-- public/
| |-- .gitkeep
| |-- .htaccess
| |-- index.php
| `-- assets/
| |-- css/
| | |-- .gitkeep
| | `-- app.css
| |-- images/
| | `-- .gitkeep
| `-- js/
| |-- .gitkeep
| `-- app.js
|-- src/
| |-- .gitkeep
| |-- App/
| | |-- .gitkeep
| | |-- App.php
| | |-- Assets.php
| | |-- Config.php
| | |-- Database.php
| | |-- ModuleManager.php
| | `-- functions.php
| `-- Repository/
| `-- .gitkeep
`-- tools/
`-- .gitkeep
```
## Pflichtordner
Diese Ordner muessen als Basis immer vorhanden sein, weil sie entweder vom Projektaufbau oder vom Deployment vorausgesetzt werden:
- `api/`
- `config/`
- `data/`
- `debug/`
- `modules/`
- `partials/`
- `public/`
- `src/`
- `tools/`
Diese Liste entspricht der Deploy-Basis aus `.gitlab-ci.yml` plus den notwendigen Unterordnern fuer die Anwendung.
## Regel fuer leere Ordner
Wenn ein notwendiger Ordner anfangs noch keine echten Dateien enthaelt, muss er eine leere Datei mit dem Namen `.gitkeep` enthalten. Das gilt insbesondere fuer:
- `api/`
- `data/`
- `debug/`
- `modules/`
- `tools/`
- `public/assets/images/`
- `src/Repository/`
- weitere leere Unterordner, die im Template bereits vorgesehen sind
Damit bleiben die Verzeichnisse versionierbar und werden beim Kopieren und Deployment nicht versehentlich ausgelassen.
## Rollen der wichtigsten Verzeichnisse
### `public/`
`public/` ist der Webroot. Hier liegen:
- `index.php` als zentraler Router
- globale Assets unter `public/assets/`
- Webserver-Dateien wie `.htaccess`
### `src/`
`src/` enthaelt den PHP-Kern der Anwendung.
- `src/App/` fuer App-Klassen, Bootstrap-nahe Logik, Config, Assets, Datenbank, Request, Session und Modulverwaltung
- `src/Repository/` fuer Datenzugriffslogik
### `partials/`
`partials/` enthaelt globale Templates.
- `partials/landingpages/` fuer globale, direkt routbare Seiten
- `partials/structure/` fuer das globale Layout
### `modules/`
`modules/` enthaelt fachlich getrennte Erweiterungen. Jedes Modul bleibt in seinem eigenen Ordner und kapselt Seiten, Assets, Partials und optionale Bootstrap-Logik.
### `api/`
`api/` ist fuer API-Funktionen vorgesehen, falls das Projekt eigene API-Endpunkte anbieten soll. Wenn ein Projekt keine API bereitstellt, kann der Ordner leer bleiben, soll aber als Teil der Basisstruktur dennoch vorhanden sein.
### `config/`
`config/` enthaelt den Bootstrap der Konfiguration sowie die umgebungsspezifischen Quellverzeichnisse `staging` und `prod`.
### `tools/`
`tools/` ist fuer CLI-Skripte, Worker und Hilfsprogramme vorgesehen.
## Routing- und Renderstruktur
### `public/index.php`
`public/index.php` ist der einzige Web-Einstiegspunkt und uebernimmt:
- Laden von `config/fileload.php`
- Normalisierung des Request-Pfads
- Pruefung optionaler Authentifizierung oder Schutzregeln
- Routing auf globale Seiten unter `partials/landingpages/`
- Routing auf Modulseiten unter `modules/<modul>/pages/`
- 404-Behandlung
- Rendern des Inhalts innerhalb des globalen Layouts
### Globale Seiten
Globale Seiten liegen unter `partials/landingpages/`. Das Routing ist dateibasiert, also ohne externen Framework-Router.
Beispiele:
- `/` -> `partials/landingpages/index.php`
- `/users` -> `partials/landingpages/users/index.php`
### Layout
Die globale HTML-Struktur liegt unter `partials/structure/`.
- `layout_start.php` oeffnet das Seitenlayout
- `layout_end.php` schliesst es
### Module
Modulrouten folgen dem Schema:
```text
/module/<modulname>
/module/<modulname>/<seite>
```
Diese Routen verweisen auf Dateien unter:
```text
modules/<modulname>/pages/
```
## Modulstruktur fuer neue Projekte
Wenn ein neues Projekt Module verwendet, sollte jedes Modul nach demselben Muster aufgebaut sein:
```text
modules/<modulname>/
|-- assets/
| `-- .gitkeep
|-- pages/
| |-- .gitkeep
| `-- index.php
|-- partials/
| `-- .gitkeep
|-- bootstrap.php
`-- module.json
```
Regeln:
- modulspezifisches CSS und JavaScript bleibt unter `modules/<modulname>/assets/`
- globale Assets gehoeren nicht in Modulordner
- Modulcode gehoert nicht nach `public/assets/`
- Seiten eines Moduls liegen unter `pages/`
- wiederverwendbare Modul-Templates liegen unter `partials/`
## Asset-Struktur
### Globale Assets
Projektweite Dateien liegen unter:
- `public/assets/css/`
- `public/assets/js/`
- `public/assets/images/`
### Modul-Assets
Modulbezogene Dateien liegen unter:
- `modules/<modulname>/assets/`
Neue Projekte sollen diese Trennung konsequent beibehalten.
## Config-Aufteilung mit `staging` und `prod`
Dieser Teil ist fuer neue Projekte verpflichtend.
### Quellverzeichnisse im Repository
Im Repository liegen die umgebungsspezifischen Config-Quellen unter:
```text
config/staging/
config/prod/
```
Darin liegen typischerweise:
- `domaindata.php`
- `settings.php`
- `db_settings_basic.php`
### Laufzeitlogik
Die Anwendung selbst laedt zur Laufzeit nicht direkt aus `config/staging/` oder `config/prod/`, sondern aus dem Root von `config/`, also z. B.:
- `config/settings.php`
- `config/domaindata.php`
- `config/db_settings_basic.php`
`config/fileload.php` erwartet genau diese Root-Dateien.
### Deployment-Regel
Beim Deployment wird die passende Umgebung in das aktive `config/`-Root kopiert:
1. allgemeine Root-Dateien aus `config/` werden bereitgestellt
2. je nach Branch wird die Umgebungsvariante darueberkopiert
3. `develop` verwendet `config/staging/`
4. `main` verwendet `config/prod/`
5. im Zielsystem gelten die kopierten Dateien danach so, als waeren sie direkt normale Root-Configs
Neue Projekte muessen exakt dieses Prinzip uebernehmen.
## Domain-Pflicht fuer neue Projekterstellungen
Neue Projekte duerfen nur erstellt werden, wenn beide Domains vorliegen:
- Live-Domain
- Staging-Domain
Diese Domains muessen in den umgebungsspezifischen Config-Dateien verwendet und automatisch eingetragen werden, insbesondere in:
- `config/prod/domaindata.php`
- `config/prod/settings.php`
- `config/staging/domaindata.php`
- `config/staging/settings.php`
- `.gitlab-ci.yml`, falls dort Domainreferenzen oder Umgebungs-URLs hinterlegt sind
## Verbindliche Erstellungsregel
Wenn bei der Aufgabenstellung fuer ein neues Projekt keine Domains angegeben werden, muss vor der Projekterstellung explizit nach beiden Werten gefragt werden.
Die Erstellung ist in diesem Fall zu blockieren.
Sie darf erst fortgesetzt werden, wenn beide Angaben vorhanden sind:
1. Live-Domain
2. Staging-Domain
Fehlt mindestens eine der beiden Angaben, muss die Erstellung unterbunden werden mit einem klaren Hinweis, dass die Domains fehlen.
Zusaetzlich gilt:
- Platzhalterwerte fuer Domains duerfen nicht unveraendert uebernommen werden
- insbesondere Werte wie `<LIVE_DOMAIN>` und `<STAGING_DOMAIN>` muessen vor der Erstellung durch echte Domainnamen ersetzt werden
- solange einer dieser Platzhalter noch vorhanden ist, muss die Erstellung blockiert werden
- eine Ausfuehrung darf erst erfolgen, wenn beide echten Domainwerte gesetzt wurden
## Reset-Regel fuer neue Projekterstellungen
Vor der eigentlichen Erstellung eines neuen Projekts muessen alle bestehenden Dateien und Ordner im Repository entfernt werden, mit genau einer Ausnahme:
- `.gitlab-ci.yml`
Die bestehende `.gitlab-ci.yml` bleibt erhalten, weil sie die Deploy-Basis vorgibt. Sie muss jedoch im Zuge der neuen Projekterstellung geprueft und angepasst werden, wenn darin Domainreferenzen, Umgebungs-URLs oder projektspezifische Zielpfade enthalten sind.
Fuer neue Projekterstellungen gilt deshalb verbindlich:
1. vorhandene Projektdateien und Projektordner loeschen
2. `.gitlab-ci.yml` behalten
3. `.gitlab-ci.yml` auf alte Domain- oder Projektreferenzen pruefen
4. vorhandene Domainreferenzen in `.gitlab-ci.yml` auf die neue Live- und Staging-Domain anpassen
5. danach erst die neue Basisstruktur und die neuen Basisdateien erstellen
## Mindestinhalt der Config-Dateien
Die folgenden Dateien koennen als Basis aus diesem Projekttyp uebernommen werden und muessen pro neuem Projekt angepasst werden:
- `config/fileload.php`
- `config/config.php`
- `config/base_db.php`
- `config/prod/domaindata.php`
- `config/prod/settings.php`
- `config/staging/domaindata.php`
- `config/staging/settings.php`
Dabei gilt:
- die allgemeine Config-Logik kann uebernommen werden
- domainspezifische Werte muessen pro Projekt ersetzt werden
- staging und prod muessen immer unterschiedliche Zielwerte bekommen, sofern nicht ausdruecklich anders gefordert
## Basisdateien fuer neue Projekte
Neben der Ordnerstruktur werden fuer neue Projekte auch Basisdateien benoetigt, insbesondere im Config-Bereich. Die konkreten Inhalte sind in einer separaten Datei beschrieben:
- `BASE_FILES.md`
Diese Datei ist dafuer gedacht, nach dem Anlegen eines neuen GitLab-Projekts zusammen mit `GENERAL.md` in das neue Repository kopiert zu werden, damit die Erstellung der Grundstruktur und der Konfigurationsdateien direkt auf einer klaren Vorlage basiert.
## Kurzfassung fuer neue Projekte
Neue Projekte auf dieser Basis verwenden:
- einen zentralen Router in `public/index.php`
- einen zentralen Bootstrap in `config/fileload.php`
- globale Seiten in `partials/landingpages/`
- globale Layout-Dateien in `partials/structure/`
- optionale Module in `modules/`
- globale Assets in `public/assets/`
- modulspezifische Assets in `modules/<modulname>/assets/`
- eine Deploy-gesteuerte Config mit `config/staging/` und `config/prod/`
Leere Pflichtordner erhalten immer `.gitkeep`. Eine neue Projekterstellung ist nur zulaessig, wenn sowohl Live- als auch Staging-Domain vorab angegeben wurden.

View File

@@ -0,0 +1,12 @@
Bitte `PROJECT_CONTEXT.md` lesen und strikt einhalten.
Für Modul-Arbeiten zusätzlich immer `MODULE_DEVELOPMENT.md` lesen und beachten.
Wichtig: Modul-spezifischer Code/Assets ausschließlich unter /modules/<modul>/,
keine Änderungen an /public/assets/* für modul-spezifische Features.
Staging/Live: /config/<env> im Repo wird nach /app/<env>/config kopiert.
Modul-Assets müssen über /module/<modul>/asset geladen werden.
Setup-Regel: `Allgemein`, `Datenbank`, `Zugriffsrechte` und `Cron Einstellungen` kommen immer aus dem globalen Setup-System.
Nur `Custom Settings` darf modulspezifisch sein.
Für Zeitzonen und Debug nach Möglichkeit die globalen Helfer aus `src/App/functions.php` nutzen.
Global bereits im Setup vorhanden sind Navigation, Debug-Feld, DB-Logik, Zugriffsschutz, Cron-/Scheduler-Logik, Setup-Aktionen, Statusblöcke und Zeitzonen-Vererbung.
Ein neues Modul soll dafür nur noch `setup.fields`, optionale `scheduler_jobs` / `interval_tasks` und bei Bedarf `setup_actions` / `setup_status` liefern.
Zeitwerte intern immer in UTC speichern. Anzeige- und Cron-Zeitzonen sind nur für Darstellung bzw. Scheduling gedacht.

154
MODULE_DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,154 @@
Projektkontext: Modul-Entwicklung
1) Modul-Konzept
- Jedes klassische Modul lebt unter `/modules/<modulname>/`.
- Pflicht-Dateien sind in der Regel:
- `module.json`
- `bootstrap.php`
- `pages/*.php`
- `assets/*`
2) Modulspezifische Assets
- Modul-JS und Modul-CSS müssen im Modul-Ordner liegen.
- Laden über Modul-Assets, zum Beispiel:
- `$assets->addStyle('/module/pi_control/asset?file=pi_control.css');`
- `$assets->addScript('/module/pi_control/asset?file=hosts.js', 'footer', true);`
- Keine modulspezifischen Änderungen in `/public/assets/*`.
3) Scope-Regeln
- Modul-Aufgaben: nur `/modules/<modul>/` und gegebenenfalls `/tools/<modul>` ändern
- globale Layouts: `/partials/structure/` und `/public/assets/css/app.css`
- Konfigurationslogik nur bei echtem Globalbedarf in `/config/` oder `/src/`
4) UI-Regeln für Module
- Modulseiten sollen diesem Muster folgen:
- Seitenheader-Box
- Submenü-Box
- danach Bereichs-Boxen und/oder Karten-Boxen
- `Setup` gehört in Modulen grundsätzlich in die Submenü-Box
- die Optik von Submenü-Aktionen kommt ausschließlich aus dem globalen CSS
- Module dürfen dort keine eigenen Farb- oder Variantenlogiken einschleusen
5) Globales Setup-System
- Modul-Setup wird zentral über `partials/landingpages/modules/setup.php` gerendert.
- Die Bereiche
- `Allgemein`
- `Datenbank`
- `Zugriffsrechte`
- `Cron Einstellungen`
müssen für alle Module aus dieser gemeinsamen Setup-Logik kommen.
- Nur `Custom Settings` darf modulspezifischen Inhalt enthalten.
- Modul-spezifische Sonderlayouts für die Bereiche `Allgemein`, `Datenbank`, `Zugriffsrechte` oder `Cron Einstellungen` sind nicht erlaubt.
Was global im Setup bereits verfügbar ist:
- gemeinsame Setup-Navigation mit festen Unterseiten
- rechte Aktionsseite mit
- `Nexus Übersicht`
- `Zurück zum Modul`
- gemeinsames Speichern pro Bereich
- gemeinsames Rendering von
- Textfeldern
- Zahlenfeldern
- Checkboxen
- Selects
- Multiselects
- Textareas
- globale `Debug aktivieren`-Option pro Modul im Bereich `Allgemein`
- gemeinsame Datenbank-Logik mit
- `Eigene Modul-DB nutzen`
- Anzeige oder Verbergen der Custom-DB-Felder
- optional mehreren DB-Gruppen wie `db.*` und `metadata_db.*`
- `Verbindung testen`
- `Standardwerte laden`
- gemeinsame Auth- und Zugriffslogik mit
- `Login erforderlich`
- erlaubten Benutzern
- erlaubten Gruppen
- bekannten Keycloak-Benutzern und -Gruppen
- gemeinsame Cron- und Scheduler-Logik mit
- Anzeige von Intervall-Tasks
- Anzeige von Cron-Jobs
- Bearbeiten von Cron-Einträgen im Modal
- Cron-Test direkt aus dem Setup
- Mehrfacheinträgen bei `mode = multi`
- Modul-Zeitzonen-Override für Crons
- Vererbung globaler Cron-Zeitzonen-Defaults
- gemeinsame Darstellung von Setup-Aktionen und Statusblöcken
- globale Zeitzonen-Datalist aus `nexus_timezones`
6) Was ein Modul für das Setup liefern darf
- `module.json` mit `setup.fields`
- optional `setup.sections.database`
- optional `interval_tasks`
- optional `scheduler_jobs`
- optional `auth`
- optional Bootstrap-Funktionen:
- `setup_actions`
- `run_setup_action`
- `setup_status`
- `runtime_settings`
- `save_runtime_settings`
7) Was ein Modul nicht selbst bauen darf
- eigene Setup-Seitenstruktur für `Allgemein`, `Datenbank`, `Zugriffsrechte`, `Cron Einstellungen`
- eigene DB-Toggle-Logik für Standard und Custom
- eigene Cron-Editor-Grundlogik
- eigene Debug-UI-Grundlogik
- eigene globale Zeitzonen-Defaults
8) Setup-Navigation
- Setup-Routen laufen zentral über:
- `/modules/setup/<modul>/general`
- `/modules/setup/<modul>/database`
- `/modules/setup/<modul>/access`
- `/modules/setup/<modul>/cron`
- `/modules/setup/<modul>/custom`
- `Setup` gehört in der Modulansicht in die rechte Aktionsseite der Submenü-Box
9) Steuerung per `module.json`
- Ein Modul kann über `setup.sections.database: true|false` steuern, ob der Menüpunkt `Datenbank` angezeigt wird.
- Wenn `setup.sections.database` fehlt, kann die zentrale Setup-Logik den Punkt implizit aktivieren, sobald DB-Felder vorhanden sind.
- Modulfelder für `Allgemein`, `Datenbank`, `Cron` und `Custom Settings` werden zentral nach Feldnamen aufgeteilt:
- `debug_enabled` -> `Allgemein`
- `use_separate_db` und `db.*` oder `metadata_db.*` -> `Datenbank`
- `schedule_timezone` -> `Cron Einstellungen`
- alle übrigen Setup-Felder -> `Custom Settings`
Weitere anerkannte Setup-Bausteine:
- `interval_tasks`
- `scheduler_jobs`
- `auth`
- `db_defaults`
- `metadata_db_defaults`
10) Speicherregel
- Beim Speichern eines Setup-Bereichs dürfen nur die in diesem Bereich sichtbaren Felder aktualisiert werden.
- Felder aus anderen Bereichen dürfen nicht mit `null`, `0` oder leeren Strings überschrieben werden.
11) Datenbankbereich
- `Eigene Modul-DB nutzen` ist der zentrale Standard-Schalter für Module mit optionaler eigener DB.
- Wenn der Schalter deaktiviert ist, dürfen keine Custom-DB-Eingabefelder sichtbar sein.
- Datenbankaktionen und Tabellenstatus gehören in den Menüpunkt `Datenbank`, nicht in `Custom Settings`.
12) Globale PHP-Helfer für Module
- Neue Module sollen für zentrale Zeit- und Debug-Defaults nach Möglichkeit die globalen Funktionen aus `src/App/functions.php` nutzen:
- `nexus_settings()`
- `nexus_save_settings(array $settings)`
- `nexus_system_timezone_name()`
- `nexus_display_timezone_name()`
- `nexus_cron_timezone_name()`
- `module_debug_enabled(string $module)`
- `module_debug_push(string $module, array $entry)`
- `module_debug_clear(string $module)`
13) Regeln für neue Module
- Keine Zeitzone wie `Europe/Berlin` hart im Modul als Standard erzwingen, wenn dafür ein globaler Nexus-Default existiert.
- Für Anzeige- und Formatierungslogik nach Möglichkeit `nexus_display_timezone_name()` nutzen.
- Für Cron-Fallbacks nach Möglichkeit `nexus_cron_timezone_name()` nutzen.
- Neue Module dürfen keine lokalen Zeitzonen direkt in Datenbank-Zeitspalten persistieren.
14) Pi-Control-Besonderheiten
- Worker und Jobs unter `/tools/pi_control/`
- Check-Updates und Cron nutzen die gleichen SSH-Routinen
- Host-Karten, Befehle und Konsole sind UI im Modul
- Update- und Upgrade-Checks liefern Debug-Ausgaben, die als Tooltip oder Debugzeile angezeigt werden

119
NEXUS_SYSTEM.md Normal file
View File

@@ -0,0 +1,119 @@
Projektkontext: Nexus-System
1) Projekt-Zweck und Ziel
- Nexus ist ein zentrales, webbasiertes Admin-Interface zur Steuerung einer dynamischen IT-Infrastruktur.
- Module kapseln fachliche Funktionen, das Nexus-System stellt das globale Grundgerüst bereit.
- Das generelle Nexus-System wird unabhängig von den bestehenden Fachmodulen weiterentwickelt.
2) Umgebungen und Domains
- Live: `nexus.kusche.berlin`
- Staging: `staging.nexus.kusche.berlin`
Container- und Deploy-Layout:
- `/app/live/` -> Live-Code
- `/app/staging/` -> Staging-Code
- jeweils mit eigenem `config`-Unterordner:
- `/app/live/config/`
- `/app/staging/config/`
Repo-Layout zu Configs:
- `/config/live/` und `/config/staging/` liegen im Repo
- beim Deployment werden die Dateien daraus nach `/app/<env>/config` kopiert
- wichtig: im laufenden Container existiert `/app/config/` nicht, sondern nur `/app/live/config` und `/app/staging/config`
3) Verzeichnisstruktur
- `/public/` -> Web Root mit globalen Assets
- `/api/` -> Backend- und API-Endpunkte
- `/src/` -> PHP-Kernklassen und Utilities
- `/tools/` -> CLI, Worker und Jobs
- `/config/` -> Umgebungs-Configs
- `/modules/<modul>/` -> klassische Nexus-Module
- `/partials/structure/` -> Header, Footer, Menüs
- `/partials/landingpages/` -> komplette Seitenlayouts
- `/debug/` -> Custom Logs
4) Code-Änderungen nach Scope
- globale Layouts: `/partials/structure/` und `/public/assets/css/app.css`
- Konfigurationslogik: nur wenn nötig `/config/` und `/src/`
- Modul-Aufgaben: nur `/modules/<modul>/` und gegebenenfalls `/tools/<modul>`
5) UI-Naming und Seitenaufbau
- `Seitenheader-Box`: oberste globale Header-Box mit Seitentitel, Login und Farbschema
- `Submenü-Box`: Box direkt unter der Seitenheader-Box für modul- oder seitenbezogene Aktionen
- `Submenü-Aktionen`: rechtsbündige Zusatzbuttons innerhalb der Submenü-Box, z.B. `Setup` oder `Nexus Übersicht`
- `Bereichs-Box`: größere Inhaltsbox für einen zusammenhängenden Seitenbereich
- `Karten-Box`: kleinere Karte auf derselben Ebene wie Bereichs-Boxen, meist in Grids
Zentrale CSS-Klassen:
- `main-header-box`
- `submenu-box`
- `module-submenu-actions`
- `section-box`
- `card-box`
Globale Struktur:
- zuerst Seitenheader-Box
- danach Submenü-Box
- danach Bereichs-Boxen und/oder Karten-Boxen je nach Seite
Layout-Regeln:
- vertikale Abstände zwischen `main-header-box`, `submenu-box` und den ersten Folge-Boxen müssen aus der globalen Shell kommen
- maßgeblich sind `module-page-bg` und `module-page-stack` in `public/assets/css/app.css`
- Top-Level-Wrapper wie Grids, Kartencontainer oder Modul-Listen dürfen keinen zusätzlichen `margin-top` oder Sonder-Gap erzeugen, der den Abstand nach dem Submenü verändert
- bei Layout-Reviews ist explizit zu prüfen, ob `Main-Header -> Submenü -> erste Section/Card` optisch denselben Rhythmus hat wie auf Referenzseiten
- die Optik der Submenü-Aktionsbuttons kommt ausschließlich aus dem globalen CSS
Beispielstruktur:
- Börsenchecker: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Bereichs-Box
- FX-Rates: Seitenheader-Box, Submenü-Box, danach Bereichs-Boxen
- Mining-Checker: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Karten-Boxen, Bereichs-Box
- Modulverwaltung: Seitenheader-Box, Submenü-Box, danach Karten-Boxen
6) Nexus-Kerngerüst
- Der aktuelle Ausbauschritt betrifft das generelle Nexus-System, nicht die bestehenden Fachmodule.
- Bestehende Module sind funktional und strukturell zunächst ausgenommen und dürfen durch Arbeiten am Kerngerüst nicht beeinträchtigt werden.
- Neue Kernfunktionen müssen parallel zum bestehenden Modulsystem eingeführt werden.
- Zielbild ist ein Nexus-Grundsystem mit flexiblen Benutzer-Dashboards, Integrationen und datengetriebenen Seitenmodulen.
Produktprinzip:
- Nexus ist nicht nur Modul-Launcher, sondern ein persönliches und gruppenfähiges Dashboard-System.
- Benutzer sollen eigene Dashboards anlegen, anordnen und konfigurieren können.
- Inhalte auf Dashboards sollen aus drei Quellen kommen können:
- interne Nexus-Funktionen
- externe Integrationen
- einfache Seitenmodule ohne eigene Modul-Implementierung
Abgrenzung zu bestehenden Modulen:
- das Verzeichnis `/modules/<modul>/` bleibt das Zuhause klassischer Nexus-Module
- das Dashboard-System ist ein zusätzliches globales Kernsystem
- neue Kernfunktionen müssen ohne Umbau bestehender Module lauffähig sein
7) Globale Zeitzonen-Logik
- globale Nexus-Einstellungen liegen unter `/settings`
- dort werden zentral gepflegt:
- `Anzeige-Zeitzone`
- `Standard-Zeitzone für Crons`
Regeln:
- ohne Custom bei der Anzeige-Zeitzone wird die System-Zeitzone verwendet
- die aktive Anzeige-Zeitzone soll angezeigt werden
- die Standard-Zeitzone für Crons ist der globale Default für Modul-Crons
- Module dürfen diese Zeitzone im Setup übersteuern
- einzelne Cron-Einträge dürfen sie ebenfalls übersteuern
UTC-Speicherregel:
- Zeitwerte sollen projektweit intern immer in `UTC` gespeichert werden
- Anzeige-Zeitzonen dienen nur der Darstellung für Benutzer
- Cron-Zeitzonen dienen nur der lokalen Auswertung von Zeitplänen und Fälligkeiten
- beim Einlesen lokaler Eingaben muss vor dem Speichern nach `UTC` normalisiert werden
- beim Anzeigen gespeicherter Werte muss von `UTC` in die jeweils wirksame Anzeige-Zeitzone umgerechnet werden
8) Globales Debug-System
- das Debug-Popup ist eine globale Infrastruktur aus dem zentralen Layout
- die Aktivierung bleibt pro Modul über `debug_enabled` im Modul-Setup steuerbar
- das Debug-Symbol darf nur sichtbar sein, wenn für das aktuelle Modul Debug aktiv ist
- Module sollen keine eigene separate Debug-Oberfläche bauen, wenn der globale Debug-Stream genutzt werden kann
9) Sicherheits- und Netzwerk-Constraints
- Zugriff im Heimnetz `192.168.178.0/24` per Nginx begrenzt
- SSH-Hosts nur Heimnetz

View File

@@ -1,71 +1,41 @@
Projekt-Zusammenfassung: Nexus Control Panel
1) Projekt-Zweck & Ziel
Nexus ist ein zentrales, webbasiertes Admin-Interface zur Steuerung einer dynamischen IT-Infrastruktur.
Module kapseln fachliche Funktionen (z.B. KEA DHCP, Pi Control).
Diese Datei ist ab jetzt der zentrale Einstieg und verweist auf die drei maßgeblichen Dokumentationsbereiche.
2) Aktive Module (Kurzüberblick)
- KEA DHCP: Verwaltung von Hosts/Leases, Statische Reservierungen, Metadaten.
- Pi Control: Verwaltung von SSH-Hosts, Befehle/Preset, Konsole, Host-Status, Update/Upgrade-Checks.
1) Nexus-System
- Datei: [NEXUS_SYSTEM.md](NEXUS_SYSTEM.md)
- Inhalt:
- globales Grundgerüst von Nexus
- Umgebungen, Verzeichnisstruktur und globale Scope-Regeln
- UI-Naming mit Seitenheader-Box, Submenü-Box, Bereichs-Box und Karten-Box
- globale Zeitzonen- und Debug-Regeln
- Kerngerüst für das zukünftige Dashboard-System
3) Umgebungen & Domains
- Live: nexus.int.kusche.berlin
- Staging: staging.nexus.int.kusche.berlin
2) Modul-Entwicklung
- Datei: [MODULE_DEVELOPMENT.md](MODULE_DEVELOPMENT.md)
- Inhalt:
- Regeln für klassische Module unter `/modules/<modul>/`
- Setup-System und zulässige Modulbausteine
- globale Vorgaben für Modul-Layouts, Assets und Helper
- Abgrenzung zwischen Modul-Code und globalem Nexus-System
Container/Deploy-Layout:
- /app/live/ -> Live-Code
- /app/staging/ -> Staging-Code
- Jeweils mit eigenem /config-Unterordner:
- /app/live/config/
- /app/staging/config/
3) Widget-Einbindung und Integrationen
- Datei: [WIDGET_INTEGRATION.md](WIDGET_INTEGRATION.md)
- Inhalt:
- Benutzer-Dashboards
- Dashboard-Elemente und Widget-Typen
- Integrationen zu Fremdsystemen
- on-the-fly Seitenmodule
- Kollisionsschutz zu bestehenden Modulen
- empfohlene Umsetzungsphasen
Repo-Layout zu Configs:
- /config/live/ und /config/staging/ liegen im Repo.
- Beim Deployment werden die Dateien daraus nach /app/<env>/config kopiert.
- WICHTIG: Im laufenden Container existiert /app/config/ NICHT, sondern nur /app/live/config und /app/staging/config.
4) Wichtige Leitplanken
- Änderungen am generellen Nexus-System dürfen nicht in bestehende Module eingreifen, wenn das nicht ausdrücklich verlangt ist.
- Klassische Fachmodule und das neue Dashboard- oder Widget-System sind getrennte Ebenen.
- Modulspezifische Assets gehören weiterhin ausschließlich in den jeweiligen Modulordner.
- Globale Layout- und Designregeln bleiben zentral in `public/assets/css/app.css` und den globalen Partials.
4) Verzeichnisstruktur (Repo)
- /public/ -> Web Root (index.php, globale Assets)
- /api/ -> Backend/API-Endpunkte
- /src/ -> PHP-Kernklassen/Utilities
- /tools/ -> CLI/Worker/Jobs (z.B. tools/pi_control)
- /config/ -> Umgebungs-Configs (live/staging)
- /modules/<modul>/ -> Module (Pages, Assets, Bootstrap)
- /partials/structure/ -> Header/Footer/Menüs
- /partials/landingpages/ -> Komplette Seitenlayouts
- /debug/ -> Custom Logs
5) Modul-Konzept (WICHTIG)
- Jedes Modul lebt unter /modules/<modulname>/.
- Pflicht-Dateien i.d.R.:
- module.json
- bootstrap.php (Schema/Setup)
- pages/*.php (UI/Endpoints)
- assets/* (modulspezifisches CSS/JS)
Modulspezifische Assets:
- Modul-JS/CSS MUSS im Modul-Ordner liegen.
- Laden über Modul-Assets, z.B.:
- $assets->addStyle('/module/pi_control/asset?file=pi_control.css');
- $assets->addScript('/module/pi_control/asset?file=hosts.js', 'footer', true);
- KEINE modulspezifischen Änderungen in /public/assets/*.
6) Code-Änderungen nach Scope
- Modul-Aufgaben: nur /modules/<modul>/ + ggf. /tools/<modul> ändern.
- Globale Layouts: /partials/structure und /public/assets/css/app.css.
- Konfigurationslogik: nur wenn nötig /config/ und /src/.
7) Sicherheits-/Netzwerk-Constraints
- Zugriff im Heimnetz (192.168.178.0/24) per Nginx begrenzt.
- SSH-Hosts nur Heimnetz.
8) Pi Control Besonderheiten (konkret)
- Worker/Jobs unter /tools/pi_control/
- Check-Updates & Cron nutzen die gleichen SSH-Routinen.
- Host-Karten, Befehle und Konsole sind UI im Modul.
- Update/Upgrade-Checks liefern Debug-Ausgaben, die als Tooltip oder Debugzeile angezeigt werden.
9) Zusammenfassung (kurz)
Nexus ist modular, mit strikter Trennung zwischen globalem Layout und modulspezifischem Code.
Staging/Live haben eigene /app/<env>/config-Strukturen; /config/<env> im Repo wird beim Deployment kopiert.
Modul-Assets gehören ausschließlich in den Modul-Ordner und werden dort geladen.
5) Für neue Chats und Arbeitsaufträge
- Für globale Nexus-Themen zuerst `NEXUS_SYSTEM.md` lesen.
- Für Arbeiten an klassischen Modulen zuerst `MODULE_DEVELOPMENT.md` lesen.
- Für Dashboard-, Widget- oder Integrationsarbeiten zuerst `WIDGET_INTEGRATION.md` lesen.

View File

@@ -1,6 +1,64 @@
# Nexus
# Comment by Lars
## UI-Naming
Für die Oberfläche gilt projektweit dieses Naming:
- `Seitenheader-Box`: globaler Header mit Seitentitel, Login und Farbschema
- `Submenü-Box`: zusätzliche modul- oder seitenbezogene Aktionen direkt unter dem Seitenheader; `Setup` soll in Modulen immer vorhanden sein
- `Submenü-Aktionen`: rechtsbündige Zusatzbuttons innerhalb der Submenü-Box, z.B. `Setup` oder `Nexus Übersicht`
- `Bereichs-Box`: größere Inhaltsbox für einen zusammenhängenden Bereich; davon können beliebig viele untereinander folgen
- `Karten-Box`: kleinere Inhaltskarte auf derselben Ebene wie Bereichs-Boxen, typischerweise innerhalb eines Grids für Kennzahlen, Statistiken oder Modulübersichten
Zentrale CSS-Klassen für dieses Layout:
- `main-header-box`
- `submenu-box`
- `module-submenu-actions`
- `section-box`
- `card-box`
Beispiele:
- `Börsenchecker`: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Bereichs-Box
- `FX-Rates`: Seitenheader-Box, Submenü-Box, danach Bereichs-Boxen
- `Mining-Checker`: Seitenheader-Box, Submenü-Box, Bereichs-Box, Karten-Boxen, Karten-Boxen, Bereichs-Box
- `Modulverwaltung`: Seitenheader-Box, Submenü-Box, danach Karten-Boxen
Technisch:
- globale Shell und Header in `partials/structure/` und `public/assets/css/app.css`
- modulbezogene Inhalte und Assets ausschließlich unter `modules/<modul>/`
- vertikale Abstände zwischen `main-header-box`, `submenu-box` und den ersten Folge-Boxen müssen aus der globalen Shell kommen; Top-Level-Grids oder Wrapper dürfen dort keinen zusätzlichen `margin-top` oder Sonderabstand einführen
- bei Layout-Checks ist ausdrücklich zu prüfen, ob `Main-Header -> Submenü -> erste Section/Card` denselben Rhythmus hat wie auf Referenzseiten
- die Optik von Submenü-Aktionen kommt ausschließlich aus dem globalen CSS; Module sollen dort keine eigenen Farbvarianten definieren
## Nexus-Kerngerüst
Das generelle Nexus-System wird unabhängig von den bestehenden Fachmodulen weiterentwickelt. Aktuell gilt:
- bestehende Module unter `modules/<modul>/` bleiben zunächst unberührt
- neue Kernfunktionen müssen kollisionsfrei parallel zum Modulsystem aufgebaut werden
- Zielbild ist ein flexibles Dashboard-System im Stil moderner Startseitenlösungen mit:
- mehreren Dashboards pro Benutzer
- frei platzierbaren Dashboard-Elementen
- zentralen Integrationen zu Fremdsystemen
- datengetriebenen Seitenmodulen ohne eigenen Modulordner
Die maßgeblichen Begriffe und Regeln dafür stehen in:
- `PROJECT_CONTEXT.md` als Einstieg
- `NEXUS_SYSTEM.md`
- `MODULE_DEVELOPMENT.md`
- `WIDGET_INTEGRATION.md`
Wichtig:
- Integrationen sind globale Systembausteine, keine Module
- Seitenmodule sind on the fly konfigurierbare Inhalte ohne Code unter `modules/`
- spätere Dashboard-Anbindungen bestehender Module sollen nur über definierte Adapter oder Provider erfolgen
## Getting started

126
WIDGET_INTEGRATION.md Normal file
View File

@@ -0,0 +1,126 @@
Projektkontext: Widget-Einbindung und Integrationen
1) Zielbild
- Nexus soll ein flexibles Dashboard-System im Stil moderner Startseitenlösungen werden.
- Benutzer sollen eigene Dashboards anlegen, anordnen und konfigurieren können.
- Inhalte auf Dashboards sollen aus internen Nexus-Funktionen, externen Integrationen und Seitenmodulen kommen können.
2) Begriffe
- `Dashboard`
- eine benutzerspezifische oder freigegebene Übersichtsseite
- `Dashboard-Element`
- ein platzierbares Objekt innerhalb eines Dashboards
- `Widget-Typ`
- beschreibt, welche Art Element gerendert wird
- `Integration`
- zentrale Anbindung an ein externes System
- `Seitenmodul`
- datengetriebenes, on-the-fly angelegtes Modul ohne eigenen Code-Ordner unter `/modules/`
3) Anforderungen an Benutzer-Dashboards
- jeder Benutzer soll mehrere eigene Dashboards anlegen können
- jedes Dashboard braucht mindestens:
- Name
- Slug oder technische ID
- Eigentümer
- Sichtbarkeit
- Sortierung
- optional Standardstatus
- Dashboards sollen perspektivisch auch teilbar sein:
- privat
- gruppenbasiert
- optional global sichtbar
- ein Benutzer soll seine Dashboards selbst umsortieren, umbenennen, duplizieren und löschen können
- Dashboard-Konfigurationen müssen datenbankbasiert gespeichert werden, nicht in Moduldateien
4) Anforderungen an Dashboard-Layout und Editor
- Dashboards müssen vom Benutzer flexibel bearbeitet werden können.
- Jedes Dashboard-Element braucht mindestens:
- Spaltenbreite
- Höhe oder Zeilenspanne
- Position im Grid
- gerätespezifische Layoutdaten, sobald Mobile/Desktop getrennt unterstützt werden
- der Editor soll ein frei konfigurierbares Grid unterstützen
- Größen und Positionen müssen pro Element speicherbar sein
- ein Wechsel zwischen Anzeige-Modus und Bearbeiten-Modus ist vorzusehen
- das Layout-System dieses Editors ist globales Nexus-Kerngerüst und darf nicht in einzelnen Modulen dupliziert werden
5) Widget-Typen V1
- `link`
- einzelner Verweis zu einer internen oder externen Seite
- `iframe`
- eingebettete Webseite oder Tool-Oberfläche
- `bookmark_group`
- Sammlung mehrerer Links oder Schnellzugriffe
- `external_status`
- kompakte Zustandsanzeige aus einer Integration
- perspektivisch zusätzlich:
- Kennzahl
- Integrationsstatus
- Modulansicht im Kleinformat
6) Integrationen als Kernsystem
- Integrationen sind keine Module und keine Widgets, sondern eine eigene Systemschicht.
- Eine Integration stellt Verbindung, Zugangsdaten, Basis-URL und technische Einstellungen für ein Fremdsystem bereit.
- Widgets oder Seitenmodule greifen nicht direkt auf Rohkonfigurationen zu, sondern auf eine definierte Integration.
- Zugangsdaten müssen zentral und getrennt von Widget-Konfigurationen gespeichert werden.
- Eine Integration soll mehrfach anlegbar sein, zum Beispiel mehrere Home-Assistant- oder Pi-hole-Instanzen.
- Integrationen müssen benennbar und für Benutzer oder Gruppen freigebbar sein.
Beispiele für Integrationen:
- Home Assistant
- Pi-hole
- Proxmox
- Docker
- Arr-Tools
7) Seitenmodule on the fly
- Ein Seitenmodul ist ein vom Benutzer oder Administrator angelegtes Objekt ohne eigenen Programmcode.
- Seitenmodule sind Teil des Nexus-Kerngerüsts und nicht Teil des klassischen Modulordners.
- Ein Seitenmodul kann mindestens folgende Typen haben:
- `link`
- `iframe`
- `bookmark_group`
- `external_status`
- Seitenmodule sollen in Dashboards eingebunden werden können.
- Seitenmodule sollen optional auch in der allgemeinen Nexus-Übersicht auftauchen können.
- Seitenmodule dürfen keine globale CSS- oder Layoutlogik mitbringen; sie müssen auf dem zentralen Dashboard- und Widget-System aufsetzen.
8) V1-Datenmodell
- Für das generelle Nexus-System sollen neue zentrale Tabellen vorgesehen werden.
- Empfohlene V1-Struktur:
- `nexus_dashboards`
- Dashboard-Metadaten
- `nexus_dashboard_items`
- platzierte Elemente pro Dashboard
- `nexus_integrations`
- technische Integrationen und Verbindungsdaten
- `nexus_page_modules`
- on-the-fly angelegte Seitenmodule
- `nexus_dashboard_shares`
- optionale Freigaben für Benutzer oder Gruppen
- JSON-Konfigurationen sind für flexible Widget- oder Layoutoptionen erlaubt, aber Kerneigenschaften wie Eigentümer, Typ, Position und Sichtbarkeit sollen eigene Spalten behalten.
9) Kollisionsschutz zu Modulen
- Änderungen am Nexus-Kerngerüst dürfen nicht voraussetzen, dass bestehende Module sofort migriert werden.
- Neue Tabellen, Services und Seiten müssen unter globalen Namen aufgebaut werden, nicht unter einem Modulpräfix.
- Bestehende Modulrouten, Modul-Assets und Modul-Setups dürfen durch neue Dashboard-Funktionen nicht ersetzt werden.
- Integrationen sind global zu denken und dürfen nicht als versteckte Modul-Features in einzelne Module eingestreut werden.
- Wenn ein bestehendes Modul später Widgets für Dashboards anbietet, muss das über definierte Adapter oder Provider geschehen, nicht über direkte Eingriffe in das Modul-Layout.
10) Empfohlene Umsetzungsreihenfolge
- Phase 1:
- zentrale Begriffe und Datenmodelle festlegen
- globale Tabellen für Dashboards, Dashboard-Elemente, Integrationen und Seitenmodule einführen
- globale Seiten für Dashboard-Verwaltung anlegen
- Phase 2:
- erstes Dashboard pro Benutzer
- einfacher Grid-Editor
- erste Widget-Typen `link`, `iframe`, `bookmark_group`
- Phase 3:
- Integrationsverwaltung
- erste Integrationsadapter, zum Beispiel Home Assistant
- widgetfähige Abfrage von externen Daten
- Phase 4:
- Freigaben, Gruppenrechte, Dashboard-Duplikate
- spätere optionale Anbindung bestehender Module über definierte Widget-Provider

3
api key Normal file
View File

@@ -0,0 +1,3 @@
API Key Alphavantage.co
NEMNJQYA35TJ1W9R

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
// Example: a single "brand" domain name.
// In real deployments you might derive this from ENV or hostnames.
if (!defined('APP_DOMAIN_NAME')) {
define('APP_DOMAIN_NAME', 'nexus.int.kusche.berlin');
define('APP_DOMAIN_NAME', 'nexus.kusche.berlin');
}
if (!defined('APP_PREFIX')) {

View File

@@ -8,7 +8,6 @@
define('APP_DB_AUTO_INIT', true);
define('APP_KEA_DB_VERSION', '2.6.3');
define('APP_BASE_DB_ENABLED', true);
define('APP_BASIC_AUTH', false);
define('APP_SEARCH_DEBUG', false);
define('APP_AUTH_ENABLED', true);
define('APP_OIDC_ISSUER', 'https://auth.kusche.berlin/realms/KuscheBerlin');
@@ -18,8 +17,8 @@
define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/logout');
define('APP_OIDC_CLIENT_ID', 'nexus');
define('APP_OIDC_CLIENT_SECRET', 'c0swC5wjBV4yimJHf2p3R9OjHOr7rhHs');
define('APP_OIDC_REDIRECT_URI', 'https://nexus.int.kusche.berlin/auth/callback');
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://nexus.int.kusche.berlin/');
define('APP_OIDC_REDIRECT_URI', 'https://nexus.kusche.berlin/auth/callback');
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://nexus.kusche.berlin/');
define('APP_OIDC_GROUP_CLAIM', 'groups');
define('APP_OIDC_ADMIN_GROUP', 'appadmin');
define('APP_OIDC_USER_GROUP', 'internalfamily');

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
// Example: a single "brand" domain name.
// In real deployments you might derive this from ENV or hostnames.
if (!defined('APP_DOMAIN_NAME')) {
define('APP_DOMAIN_NAME', 'staging.nexus.int.kusche.berlin');
define('APP_DOMAIN_NAME', 'staging.nexus.kusche.berlin');
}
if (!defined('APP_PREFIX')) {

View File

@@ -8,7 +8,6 @@
define('APP_DB_AUTO_INIT', true);
define('APP_KEA_DB_VERSION', '2.6.3');
define('APP_BASE_DB_ENABLED', true);
define('APP_BASIC_AUTH', true);
define('APP_SEARCH_DEBUG', true);
define('APP_AUTH_ENABLED', true);
define('APP_OIDC_ISSUER', 'https://auth.kusche.berlin/realms/KuscheBerlin');
@@ -18,8 +17,8 @@
define('APP_OIDC_LOGOUT_ENDPOINT', 'https://auth.kusche.berlin/realms/KuscheBerlin/protocol/openid-connect/logout');
define('APP_OIDC_CLIENT_ID', 'nexus');
define('APP_OIDC_CLIENT_SECRET', 'c0swC5wjBV4yimJHf2p3R9OjHOr7rhHs');
define('APP_OIDC_REDIRECT_URI', 'https://staging.nexus.int.kusche.berlin/auth/callback');
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://staging.nexus.int.kusche.berlin/');
define('APP_OIDC_REDIRECT_URI', 'https://staging.nexus.kusche.berlin/auth/callback');
define('APP_OIDC_POST_LOGOUT_REDIRECT_URI', 'https://staging.nexus.kusche.berlin/');
define('APP_OIDC_GROUP_CLAIM', 'groups');
define('APP_OIDC_ADMIN_GROUP', 'appadmin');
define('APP_OIDC_USER_GROUP', 'internalfamily');

View File

@@ -0,0 +1,341 @@
.bc-page {
--bc-accent: var(--brand-accent);
--bc-accent-strong: var(--brand-accent-2);
--bc-ink: var(--text);
--bc-text: var(--text);
--bc-muted: var(--muted);
--bc-line: var(--line);
--bc-panel: rgba(255, 255, 255, 0.78);
--bc-panel-soft: rgba(248, 252, 252, 0.92);
--bc-positive: #137333;
--bc-negative: #c62828;
color: var(--bc-text);
}
.bc-page {
display: grid;
gap: 16px;
}
.bc-text {
color: var(--bc-muted);
margin: 0;
}
.bc-section-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
}
.bc-section-title {
margin: 0;
font-size: 1.45rem;
line-height: 1.15;
}
.bc-section-head p,
.bc-section-copy {
color: var(--bc-muted);
margin: 8px 0 0;
}
.bc-form-card,
.bc-panel,
.bc-stat,
.bc-chart-card,
.bc-position-row {
border: 1px solid var(--bc-line);
border-radius: 22px;
background: var(--bc-panel);
box-shadow: 0 10px 24px rgba(1, 22, 32, 0.06);
}
.bc-form-card,
.bc-panel,
.bc-chart-card {
padding: 18px;
}
.bc-stat {
padding: 16px;
}
.bc-field-label {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--bc-muted);
}
.bc-tabs {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.bc-button,
.bc-tabs a,
.bc-page button,
.bc-page input,
.bc-page select,
.bc-page textarea {
font: inherit;
}
.bc-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 16px;
padding: 12px 16px;
cursor: pointer;
text-decoration: none;
transition: 160ms ease;
}
.bc-button:hover {
transform: translateY(-1px);
}
.bc-button--tab {
background: rgba(255, 255, 255, 0.94);
color: var(--bc-ink);
font-weight: 700;
}
.bc-button--tab-active {
background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3));
color: #fff7fb;
font-weight: 700;
}
.bc-button--primary {
background: linear-gradient(135deg, var(--bc-accent), var(--brand-accent-3));
color: #fff7fb;
font-weight: 700;
}
.bc-button--secondary {
background: rgba(255, 255, 255, 0.94);
color: var(--bc-ink);
font-weight: 700;
}
.bc-button--ghost {
background: color-mix(in srgb, var(--bc-accent) 14%, transparent);
border-color: color-mix(in srgb, var(--bc-accent) 34%, transparent);
color: var(--bc-accent);
font-weight: 700;
}
.bc-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.bc-alert {
padding: 16px 18px;
border-radius: 20px;
border: 1px solid transparent;
}
.bc-alert--error {
background: rgba(127, 29, 29, 0.28);
border-color: rgba(252, 165, 165, 0.24);
color: #fecaca;
}
.bc-alert--success {
background: rgba(6, 78, 59, 0.28);
border-color: rgba(134, 239, 172, 0.24);
color: #bbf7d0;
}
.bc-toolbar,
.bc-overview-grid,
.bc-card-grid {
display: grid;
gap: 14px;
}
.bc-toolbar {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.bc-overview-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.bc-card-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.bc-stat-value {
margin-top: 8px;
font-size: 1.42rem;
font-weight: 700;
}
.bc-chart-shell {
position: relative;
min-height: 360px;
overflow: hidden;
}
.bc-chart-svg {
width: 100%;
height: 340px;
display: block;
}
.bc-chart-path {
fill: none;
stroke: var(--bc-accent);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 12px 24px color-mix(in srgb, var(--bc-accent) 18%, transparent));
}
.bc-chart-area {
fill: url(#bc-chart-fill);
}
.bc-chart-grid line {
stroke: rgba(16, 33, 43, 0.08);
stroke-dasharray: 4 6;
}
.bc-range-list {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.bc-range-button {
border: 1px solid color-mix(in srgb, var(--bc-accent) 26%, transparent);
background: rgba(255, 255, 255, 0.76);
color: var(--bc-text);
padding: 8px 12px;
border-radius: 999px;
cursor: pointer;
transition: transform .18s ease, background .18s ease, border-color .18s ease;
}
.bc-range-button:hover,
.bc-range-button[aria-pressed="true"] {
transform: translateY(-1px);
background: color-mix(in srgb, var(--bc-accent) 18%, transparent);
border-color: color-mix(in srgb, var(--bc-accent) 42%, transparent);
}
.bc-panel-fade {
animation: bcPanelFade .35s ease;
}
@keyframes bcPanelFade {
from { opacity: 0; transform: translateY(8px) scale(.99); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.bc-position-list {
display: grid;
gap: 14px;
}
.bc-position-row {
display: grid;
grid-template-columns: minmax(0, 1.8fr) repeat(4, minmax(96px, .72fr));
gap: 14px;
align-items: center;
padding: 16px 18px;
}
.bc-performance {
font-weight: 700;
}
.bc-performance.is-positive {
color: var(--bc-positive);
}
.bc-performance.is-negative {
color: var(--bc-negative);
}
.bc-pill-soft {
display: inline-flex;
align-items: center;
width: fit-content;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--bc-accent) 10%, transparent);
color: var(--bc-text);
font-size: .85rem;
}
.bc-table-shell {
overflow: auto;
border-radius: 18px;
border: 1px solid var(--bc-line);
}
.bc-table {
width: 100%;
border-collapse: collapse;
}
.bc-table th,
.bc-table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid rgba(255,255,255,0.06);
vertical-align: top;
}
.bc-table thead {
background: rgba(255,255,255,0.04);
}
.bc-page .setup-field {
display: grid;
gap: 6px;
}
.bc-page input,
.bc-page select,
.bc-page textarea {
width: 100%;
border: 1px solid var(--bc-line);
border-radius: 14px;
padding: 10px 12px;
background: var(--surface-strong);
color: var(--bc-text);
}
.bc-page input::placeholder,
.bc-page textarea::placeholder {
color: color-mix(in srgb, var(--bc-muted) 70%, transparent);
}
.bc-page a {
color: inherit;
}
.bc-page .muted {
color: var(--bc-muted);
}
@media (max-width: 980px) {
.bc-hero-top {
grid-template-columns: 1fr;
}
.bc-position-row {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,152 @@
(function () {
const app = document.querySelector('[data-bc-home]');
if (!app) return;
const chartShell = app.querySelector('[data-bc-chart]');
const instrumentSelect = app.querySelector('[data-bc-instrument]');
const instrumentNameNode = app.querySelector('[data-bc-instrument-name]');
const instrumentMetaNode = app.querySelector('[data-bc-instrument-meta]');
const rangeButtons = Array.from(app.querySelectorAll('[data-range]'));
const statusNode = app.querySelector('[data-bc-chart-status]');
const summaryNode = app.querySelector('[data-bc-chart-summary]');
const endpoint = app.getAttribute('data-chart-endpoint') || '';
const instrumentsScript = app.querySelector('[data-bc-instruments-json]');
const instrumentMap = new Map();
if (instrumentsScript?.textContent) {
try {
const items = JSON.parse(instrumentsScript.textContent);
if (Array.isArray(items)) {
items.forEach((item) => instrumentMap.set(String(item.instrument_id), item));
}
} catch (_error) {}
}
let activeRange = '1m';
let currentPayload = null;
function pointsForRange(payload, range) {
if (!payload) return [];
const daily = payload.daily || [];
const weekly = payload.weekly || [];
const monthly = payload.monthly || [];
switch (range) {
case '1d': return daily.slice(-2);
case '5d': return daily.slice(-5);
case '1m': return daily.slice(-22);
case '3m': return daily.slice(-66);
case '6m': return weekly.slice(-26);
case '1y': return weekly.slice(-52);
case '5y': return monthly.slice(-60);
default: return daily.slice(-22);
}
}
function renderChart(points) {
if (!chartShell) return;
chartShell.classList.remove('bc-panel-fade');
void chartShell.offsetWidth;
chartShell.classList.add('bc-panel-fade');
if (!points || points.length === 0) {
chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
return;
}
const values = points.map((point) => Number(point.close || 0));
const min = Math.min(...values);
const max = Math.max(...values);
const width = 920;
const height = 340;
const paddingX = 24;
const paddingY = 28;
const usableWidth = width - paddingX * 2;
const usableHeight = height - paddingY * 2;
const spread = max - min || 1;
const coords = points.map((point, index) => {
const x = paddingX + (usableWidth * index / Math.max(points.length - 1, 1));
const y = paddingY + usableHeight - ((Number(point.close || 0) - min) / spread) * usableHeight;
return { x, y };
});
const path = coords.map((coord, index) => `${index === 0 ? 'M' : 'L'}${coord.x.toFixed(2)},${coord.y.toFixed(2)}`).join(' ');
const area = `${path} L${coords[coords.length - 1].x.toFixed(2)},${height - paddingY} L${coords[0].x.toFixed(2)},${height - paddingY} Z`;
const grid = [0, 1, 2, 3].map((step) => {
const y = paddingY + (usableHeight * step / 3);
return `<line x1="${paddingX}" y1="${y}" x2="${width - paddingX}" y2="${y}"></line>`;
}).join('');
chartShell.innerHTML = `
<svg class="bc-chart-svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none">
<defs>
<linearGradient id="bc-chart-fill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="rgba(94,234,212,0.32)"></stop>
<stop offset="100%" stop-color="rgba(94,234,212,0.02)"></stop>
</linearGradient>
</defs>
<g class="bc-chart-grid">${grid}</g>
<path class="bc-chart-area" d="${area}"></path>
<path class="bc-chart-path" d="${path}"></path>
</svg>
`;
const first = values[0];
const last = values[values.length - 1];
const delta = last - first;
const percent = first !== 0 ? (delta / first) * 100 : 0;
if (summaryNode) {
summaryNode.textContent = `${last.toFixed(2)} | ${delta >= 0 ? '+' : ''}${delta.toFixed(2)} (${percent.toFixed(2)}%)`;
}
}
async function loadChart() {
const instrumentId = instrumentSelect ? instrumentSelect.value : '';
if (!instrumentId || !endpoint) {
if (statusNode) statusNode.textContent = 'Keine Aktie fuer den Chart ausgewaehlt.';
if (summaryNode) summaryNode.textContent = '-';
if (chartShell) chartShell.innerHTML = '<div class="muted">Keine Chartdaten verfuegbar.</div>';
return;
}
if (statusNode) statusNode.textContent = 'Chartdaten werden geladen...';
try {
const response = await fetch(`${endpoint}${endpoint.includes('?') ? '&' : '?'}instrument_id=${encodeURIComponent(instrumentId)}`, { headers: { Accept: 'application/json' } });
const payload = await response.json();
if (!payload.ok) {
throw new Error(payload.message || 'Chartdaten konnten nicht geladen werden.');
}
currentPayload = payload;
renderChart(pointsForRange(payload, activeRange));
if (statusNode) {
const sourceLabel = payload.source_label || payload.source || 'Lokale Kurshistorie';
const instrumentRef = payload.symbol || payload.isin || '';
statusNode.textContent = instrumentRef
? `Quelle: ${sourceLabel} | ${instrumentRef}`
: `Quelle: ${sourceLabel}`;
}
} catch (error) {
currentPayload = null;
chartShell.innerHTML = `<div class="muted">${error.message}</div>`;
if (statusNode) statusNode.textContent = 'Fehler beim Laden der Chartdaten.';
}
}
rangeButtons.forEach((button) => {
button.addEventListener('click', () => {
activeRange = button.getAttribute('data-range') || '1m';
rangeButtons.forEach((item) => item.setAttribute('aria-pressed', item === button ? 'true' : 'false'));
renderChart(pointsForRange(currentPayload, activeRange));
});
});
if (instrumentSelect) {
instrumentSelect.addEventListener('change', () => {
const meta = instrumentMap.get(String(instrumentSelect.value));
if (meta) {
if (instrumentNameNode) instrumentNameNode.textContent = meta.instrument_name || 'Keine Aktie ausgewaehlt';
if (instrumentMetaNode) instrumentMetaNode.textContent = `${meta.symbol || ''} · ${meta.isin || '-'}`;
}
loadChart();
});
}
loadChart();
})();

View File

@@ -0,0 +1,995 @@
<?php
declare(strict_types=1);
use App\ModuleConfigException;
spl_autoload_register(static function (string $class): void {
$prefix = 'Modules\\Boersenchecker\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($file)) {
require_once $file;
}
});
$moduleName = 'boersenchecker';
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
$mm->registerFunction($moduleName, 'table', static function (string $name): string {
$prefix = 'boersencheck_';
$sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return $prefix . $sanitized;
});
$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): \PDO {
$settings = modules()->settings($moduleName);
$useSeparate = !empty($settings['use_separate_db']);
if ($useSeparate) {
$module = modules()->get($moduleName);
$fallback = $module['db_defaults'] ?? [];
return modules()->modulePdo($moduleName, $fallback);
}
$base = app()->basePdo();
if ($base instanceof \PDO) {
return $base;
}
throw new ModuleConfigException(
$moduleName,
'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.'
);
});
$mm->registerFunction($moduleName, 'ensure_schema', function () use ($moduleName): void {
$pdo = module_fn($moduleName, 'pdo');
$table = static fn (string $name): string => module_fn($moduleName, 'table', $name);
$driver = strtolower((string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
$portfolioTable = $table('portfolios');
$instrumentTable = $table('instruments');
$positionTable = $table('positions');
$quoteTable = $table('quotes');
if ($driver === 'pgsql') {
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
id SERIAL PRIMARY KEY,
owner_sub VARCHAR(190) NOT NULL,
name VARCHAR(190) NOT NULL,
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
id SERIAL PRIMARY KEY,
isin VARCHAR(32) NULL,
wkn VARCHAR(32) NULL,
symbol VARCHAR(32) NULL,
name VARCHAR(255) NOT NULL,
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
market VARCHAR(120) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
id SERIAL PRIMARY KEY,
owner_sub VARCHAR(190) NOT NULL,
portfolio_id INTEGER NOT NULL,
instrument_id INTEGER NOT NULL,
quantity NUMERIC(20,6) NOT NULL,
purchase_price NUMERIC(20,8) NOT NULL,
purchase_currency VARCHAR(10) NOT NULL,
purchase_date DATE NOT NULL,
fees NUMERIC(20,8) NULL,
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
id SERIAL PRIMARY KEY,
instrument_id INTEGER NOT NULL,
price NUMERIC(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
quoted_at TIMESTAMP NOT NULL,
source VARCHAR(64) NOT NULL DEFAULT 'manual',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)");
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin) WHERE isin IS NOT NULL");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)");
} elseif ($driver === 'mysql') {
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
owner_sub VARCHAR(190) NOT NULL,
name VARCHAR(190) NOT NULL,
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY {$portfolioTable}_owner_idx (owner_sub)
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
isin VARCHAR(32) NULL,
wkn VARCHAR(32) NULL,
symbol VARCHAR(32) NULL,
name VARCHAR(255) NOT NULL,
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
market VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY {$instrumentTable}_isin_uniq (isin),
KEY {$instrumentTable}_symbol_idx (symbol)
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
owner_sub VARCHAR(190) NOT NULL,
portfolio_id INTEGER NOT NULL,
instrument_id INTEGER NOT NULL,
quantity DECIMAL(20,6) NOT NULL,
purchase_price DECIMAL(20,8) NOT NULL,
purchase_currency VARCHAR(10) NOT NULL,
purchase_date DATE NOT NULL,
fees DECIMAL(20,8) NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY {$positionTable}_owner_idx (owner_sub),
KEY {$positionTable}_portfolio_idx (portfolio_id),
KEY {$positionTable}_instrument_idx (instrument_id)
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
instrument_id INTEGER NOT NULL,
price DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
quoted_at DATETIME NOT NULL,
source VARCHAR(64) NOT NULL DEFAULT 'manual',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY {$quoteTable}_instrument_time_idx (instrument_id, quoted_at)
)");
} else {
$pdo->exec("CREATE TABLE IF NOT EXISTS {$portfolioTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_sub VARCHAR(190) NOT NULL,
name VARCHAR(190) NOT NULL,
base_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$instrumentTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
isin VARCHAR(32) NULL,
wkn VARCHAR(32) NULL,
symbol VARCHAR(32) NULL,
name VARCHAR(255) NOT NULL,
quote_currency VARCHAR(10) NOT NULL DEFAULT 'EUR',
market VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$positionTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_sub VARCHAR(190) NOT NULL,
portfolio_id INTEGER NOT NULL,
instrument_id INTEGER NOT NULL,
quantity DECIMAL(20,6) NOT NULL,
purchase_price DECIMAL(20,8) NOT NULL,
purchase_currency VARCHAR(10) NOT NULL,
purchase_date DATE NOT NULL,
fees DECIMAL(20,8) NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE TABLE IF NOT EXISTS {$quoteTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instrument_id INTEGER NOT NULL,
price DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
quoted_at DATETIME NOT NULL,
source VARCHAR(64) NOT NULL DEFAULT 'manual',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$portfolioTable}_owner_idx ON {$portfolioTable} (owner_sub)");
$pdo->exec("CREATE UNIQUE INDEX IF NOT EXISTS {$instrumentTable}_isin_uniq ON {$instrumentTable} (isin)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$instrumentTable}_symbol_idx ON {$instrumentTable} (symbol)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_owner_idx ON {$positionTable} (owner_sub)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_portfolio_idx ON {$positionTable} (portfolio_id)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$positionTable}_instrument_idx ON {$positionTable} (instrument_id)");
$pdo->exec("CREATE INDEX IF NOT EXISTS {$quoteTable}_instrument_time_idx ON {$quoteTable} (instrument_id, quoted_at DESC)");
}
});
$mm->registerFunction($moduleName, 'fx_service', static function (): ?object {
if (modules()->isEnabled('fx-rates') && modules()->hasFunction('fx-rates', 'service')) {
try {
return module_fn('fx-rates', 'service');
} catch (\Throwable) {
return null;
}
}
return null;
});
$mm->registerFunction($moduleName, 'fx_refresh', static function (string $baseCurrency = 'EUR', float $maxAgeHours = 6.0): array {
$service = module_fn('boersenchecker', 'fx_service');
if (!$service || !method_exists($service, 'ensureFreshLatestRates')) {
return [
'ok' => false,
'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.',
];
}
try {
$result = $service->ensureFreshLatestRates($maxAgeHours, strtoupper(trim($baseCurrency)) ?: 'EUR');
return [
'ok' => true,
'message' => !empty($result['reused'])
? 'Vorhandene FX-Daten weiterverwendet.'
: 'FX-Daten aktualisiert.',
'result' => $result,
];
} catch (\Throwable $e) {
return [
'ok' => false,
'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(),
];
}
});
$mm->registerFunction($moduleName, 'fx_prepare_fetch', static function (
string $baseCurrency = 'EUR',
array $currencies = [],
float $maxAgeHours = 6.0
): array {
$service = module_fn('boersenchecker', 'fx_service');
if (!$service || !method_exists($service, 'ensureFreshLatestRates')) {
return [
'ok' => false,
'message' => 'Das Modul fx-rates ist aktuell nicht verfuegbar oder nicht aktiviert.',
];
}
$baseCurrency = strtoupper(trim($baseCurrency)) ?: 'EUR';
$currencies = array_values(array_unique(array_filter(array_map(
static fn (mixed $code): string => strtoupper(trim((string) $code)),
$currencies
), static fn (string $code): bool => $code !== '')));
try {
$result = $service->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies);
return [
'ok' => true,
'message' => !empty($result['reused']) ? 'Vorhandene FX-Daten weiterverwendet.' : 'FX-Daten aktualisiert.',
'result' => $result,
'fetch_id' => is_numeric($result['fetch_id'] ?? null) ? (int) $result['fetch_id'] : null,
'reused' => !empty($result['reused']),
];
} catch (\Throwable $e) {
return [
'ok' => false,
'message' => 'FX-Aktualisierung fehlgeschlagen: ' . $e->getMessage(),
];
}
});
$mm->registerFunction($moduleName, 'fx_source_with_fetch_id', static function (string $source, ?int $fetchId = null): string {
$source = trim($source) !== '' ? trim($source) : 'manual';
if ($fetchId === null || $fetchId <= 0) {
return preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source;
}
$source = preg_replace('/\|fx_fetch:\d+$/', '', $source) ?: $source;
return $source . '|fx_fetch:' . $fetchId;
});
$mm->registerFunction($moduleName, 'fx_extract_fetch_id', static function (?string $source): ?int {
$source = trim((string) $source);
if ($source === '') {
return null;
}
if (preg_match('/\|fx_fetch:(\d+)$/', $source, $matches) === 1) {
$fetchId = (int) ($matches[1] ?? 0);
return $fetchId > 0 ? $fetchId : null;
}
return null;
});
$mm->registerFunction($moduleName, 'fx_convert_with_fetch', static function (
?float $amount,
?string $fromCurrency,
?string $toCurrency,
?int $fetchId = null
): ?float {
if ($amount === null) {
return null;
}
$from = strtoupper(trim((string) $fromCurrency));
$to = strtoupper(trim((string) $toCurrency));
if ($from === '' || $to === '') {
return null;
}
if ($from === $to) {
return $amount;
}
$service = module_fn('boersenchecker', 'fx_service');
if (!$service) {
return null;
}
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
if ($normalizedFetchId !== null && method_exists($service, 'snapshotByFetchId')) {
try {
$snapshot = $service->snapshotByFetchId($normalizedFetchId, null, [$from, $to]);
if (is_array($snapshot)) {
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
$fromRate = $from === $base ? 1.0 : (is_numeric($rates[$from] ?? null) ? (float) $rates[$from] : null);
$toRate = $to === $base ? 1.0 : (is_numeric($rates[$to] ?? null) ? (float) $rates[$to] : null);
if ($fromRate !== null && $fromRate > 0 && $toRate !== null && $toRate > 0) {
return $amount * ($toRate / $fromRate);
}
}
} catch (\Throwable) {
}
}
if (!method_exists($service, 'convert')) {
return null;
}
try {
$value = $service->convert($amount, $from, $to);
return is_numeric($value) ? (float) $value : null;
} catch (\Throwable) {
return null;
}
});
$mm->registerFunction($moduleName, 'alpha_vantage_request', static function (
string $functionName,
array $params = []
): array {
$settings = modules()->settings('boersenchecker');
$apiKey = trim((string) ($settings['alpha_vantage_api_key'] ?? ''));
$timeout = (int) (($settings['alpha_vantage_timeout_sec'] ?? null) ?: 12);
$timeout = $timeout > 0 ? $timeout : 12;
if ($apiKey === '') {
module_debug_push('boersenchecker', [
'label' => 'Alpha Vantage Request',
'type' => 'api:error',
'request' => [
'function' => $functionName,
'params' => $params,
],
'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.',
]);
return [
'ok' => false,
'message' => 'Alpha-Vantage-API-Key fehlt. Bitte im Modul-Setup hinterlegen.',
];
}
$url = 'https://www.alphavantage.co/query?' . http_build_query(array_merge([
'function' => $functionName,
'apikey' => $apiKey,
], $params), '', '&', PHP_QUERY_RFC3986);
$responseBody = null;
$httpCode = 0;
$curlError = '';
if (function_exists('curl_init')) {
$ch = curl_init($url);
if ($ch !== false) {
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => min(5, $timeout),
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$responseBody = curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
}
}
if (!is_string($responseBody) || $responseBody === '') {
$context = stream_context_create([
'http' => [
'method' => 'GET',
'timeout' => $timeout,
'header' => "Accept: application/json\r\n",
],
]);
$responseBody = @file_get_contents($url, false, $context);
}
if (!is_string($responseBody) || $responseBody === '') {
module_debug_push('boersenchecker', [
'label' => 'Alpha Vantage Request',
'type' => 'api:error',
'request' => [
'function' => $functionName,
'url' => $url,
'params' => $params,
],
'response' => [
'http_code' => $httpCode,
'curl_error' => $curlError,
'body' => null,
],
'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.',
]);
return [
'ok' => false,
'message' => 'Alpha-Vantage-Anfrage fehlgeschlagen.'
. ($curlError !== '' ? ' ' . $curlError : '')
. ($httpCode > 0 ? ' HTTP ' . $httpCode : ''),
];
}
$decoded = json_decode($responseBody, true);
if (!is_array($decoded)) {
module_debug_push('boersenchecker', [
'label' => 'Alpha Vantage Request',
'type' => 'api:error',
'request' => [
'function' => $functionName,
'url' => $url,
'params' => $params,
],
'response' => [
'http_code' => $httpCode,
'body_preview' => substr($responseBody, 0, 4000),
],
'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.',
]);
return [
'ok' => false,
'message' => 'Alpha-Vantage-Antwort ist kein gueltiges JSON.',
'raw_body' => $responseBody,
];
}
foreach (['Error Message', 'Information', 'Note'] as $errorKey) {
if (isset($decoded[$errorKey]) && is_string($decoded[$errorKey]) && trim($decoded[$errorKey]) !== '') {
module_debug_push('boersenchecker', [
'label' => 'Alpha Vantage Request',
'type' => 'api:error',
'request' => [
'function' => $functionName,
'url' => $url,
'params' => $params,
],
'response' => [
'http_code' => $httpCode,
'body' => $decoded,
],
'message' => trim((string) $decoded[$errorKey]),
]);
return [
'ok' => false,
'message' => trim((string) $decoded[$errorKey]),
'raw' => $decoded,
];
}
}
module_debug_push('boersenchecker', [
'label' => 'Alpha Vantage Request',
'type' => 'api:response',
'request' => [
'function' => $functionName,
'url' => $url,
'params' => $params,
],
'response' => [
'http_code' => $httpCode,
'body' => $decoded,
],
]);
return [
'ok' => true,
'data' => $decoded,
];
});
$mm->registerFunction($moduleName, 'display_timezone', static function (): \DateTimeZone {
return new \DateTimeZone(nexus_display_timezone_name());
});
$mm->registerFunction($moduleName, 'normalize_market_timestamp_utc', static function (mixed $value): string {
if (is_numeric($value)) {
return gmdate('Y-m-d H:i:s', (int) $value);
}
$raw = trim((string) $value);
if ($raw === '') {
return gmdate('Y-m-d H:i:s');
}
try {
$date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC'));
return $date->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
} catch (\Throwable) {
$timestamp = strtotime($raw);
return $timestamp !== false ? gmdate('Y-m-d H:i:s', $timestamp) : gmdate('Y-m-d H:i:s');
}
});
$mm->registerFunction($moduleName, 'format_datetime_for_display', static function (
?string $value,
?string $source = null,
string $format = 'Y-m-d H:i:s'
): string {
$raw = trim((string) $value);
if ($raw === '') {
return '';
}
$displayTimezone = new \DateTimeZone(nexus_display_timezone_name());
$source = trim((string) $source);
if (str_starts_with($source, 'bavest:') || str_starts_with($source, 'alphavantage:')) {
$date = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $raw, new \DateTimeZone('UTC'));
if (!$date instanceof \DateTimeImmutable) {
try {
$date = new \DateTimeImmutable($raw, new \DateTimeZone('UTC'));
} catch (\Throwable) {
return $raw;
}
}
return $date->setTimezone($displayTimezone)->format($format);
}
if (preg_match('/(Z|[+\-]\d{2}:\d{2})$/', $raw) === 1) {
try {
return (new \DateTimeImmutable($raw))->setTimezone($displayTimezone)->format($format);
} catch (\Throwable) {
return $raw;
}
}
return str_replace('T', ' ', $raw);
});
$mm->registerFunction($moduleName, 'local_now_input_value', static function (): string {
return (new \DateTimeImmutable('now', new \DateTimeZone(nexus_display_timezone_name())))->format('Y-m-d\TH:i');
});
$mm->registerFunction($moduleName, 'alpha_vantage_extract_global_quote', static function (array $entry): ?array {
$quote = is_array($entry['Global Quote'] ?? null) ? $entry['Global Quote'] : $entry;
$price = $quote['05. price'] ?? null;
if (!is_numeric($price)) {
return null;
}
return [
'symbol' => trim((string) ($quote['01. symbol'] ?? '')),
'price' => (float) $price,
'currency' => '',
'fetched_at' => gmdate('Y-m-d H:i:s'),
'market_date' => trim((string) ($quote['07. latest trading day'] ?? '')),
'source' => 'alphavantage:global_quote',
'raw' => $quote,
];
});
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quote_by_symbol', static function (string $symbol): array {
$symbol = strtoupper(trim($symbol));
if ($symbol === '') {
return [
'ok' => false,
'message' => 'Kein Symbol hinterlegt.',
];
}
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'GLOBAL_QUOTE', [
'symbol' => $symbol,
]);
if (empty($response['ok'])) {
return $response;
}
$quote = module_fn('boersenchecker', 'alpha_vantage_extract_global_quote', (array) ($response['data'] ?? []));
if (!is_array($quote)) {
return [
'ok' => false,
'message' => 'Alpha Vantage lieferte keinen Preis fuer das Symbol ' . $symbol . '.',
];
}
return ['ok' => true] + $quote;
});
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_quotes', static function (array $instruments): array {
$quotes = [];
$errors = [];
foreach ($instruments as $instrument) {
if (!is_array($instrument)) {
continue;
}
$instrumentId = (int) ($instrument['id'] ?? 0);
$symbol = strtoupper(trim((string) ($instrument['symbol'] ?? '')));
if ($instrumentId <= 0 || $symbol === '') {
continue;
}
$result = module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
if (empty($result['ok'])) {
$errors[] = $symbol . ': ' . (string) ($result['message'] ?? 'API-Abruf fehlgeschlagen.');
continue;
}
$quotes[$instrumentId] = $result + ['instrument_id' => $instrumentId];
}
return [
'ok' => true,
'quotes' => $quotes,
'errors' => $errors,
'message' => count($quotes) . ' Kurse ueber Alpha Vantage geladen.',
];
});
$mm->registerFunction($moduleName, 'scheduled_refresh_quotes', static function (array $context = []): array {
$pdo = module_fn('boersenchecker', 'pdo');
$settings = modules()->settings('boersenchecker');
$instrumentTable = module_fn('boersenchecker', 'table', 'instruments');
$positionTable = module_fn('boersenchecker', 'table', 'positions');
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
$defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
$minIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
if ($minIntervalMinutes <= 0) {
$minIntervalMinutes = 60;
}
$stmt = $pdo->query(
'SELECT DISTINCT
i.id,
i.name,
i.symbol,
i.quote_currency
FROM ' . $positionTable . ' p
INNER JOIN ' . $instrumentTable . ' i ON i.id = p.instrument_id
WHERE i.symbol IS NOT NULL
AND i.symbol <> \'\'
ORDER BY i.name ASC'
);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if ($rows === []) {
return [
'ok' => true,
'message' => 'Kein automatischer Kursabruf: keine Aktien mit Symbol vorhanden.',
];
}
$instrumentIds = array_values(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $rows));
$instrumentIds = array_values(array_filter($instrumentIds, static fn (int $id): bool => $id > 0));
$latestQuotes = [];
if ($instrumentIds !== []) {
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
$latestStmt = $pdo->prepare(
'SELECT *
FROM ' . $quoteTable . '
WHERE instrument_id IN (' . $placeholders . ')
AND source LIKE ?
ORDER BY quoted_at DESC, created_at DESC, id DESC'
);
$latestStmt->execute([...$instrumentIds, 'alphavantage:%']);
foreach ($latestStmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
$instrumentId = (int) ($row['instrument_id'] ?? 0);
if ($instrumentId > 0 && !isset($latestQuotes[$instrumentId])) {
$latestQuotes[$instrumentId] = $row;
}
}
}
$reused = 0;
$candidates = [];
foreach ($rows as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$latest = $latestQuotes[$instrumentId] ?? null;
$latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false;
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($minIntervalMinutes * 60)) {
$reused++;
continue;
}
$candidates[] = $row;
}
if ($candidates === []) {
return [
'ok' => true,
'message' => 'Automatischer Kursabruf uebersprungen: alle Kurse liegen noch innerhalb des Mindestabstands.',
];
}
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => strtoupper(trim((string) ($row['quote_currency'] ?? ''))),
$candidates
), static fn (string $code): bool => $code !== '')));
$fxResult = module_fn('boersenchecker', 'fx_prepare_fetch', $defaultReportCurrency, $quoteCurrencies, (float) (($settings['fx_max_age_hours'] ?? null) ?: 6));
if (empty($fxResult['ok'])) {
return $fxResult;
}
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
$bulkResult = module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $candidates);
if (empty($bulkResult['ok'])) {
return [
'ok' => false,
'message' => (string) ($bulkResult['message'] ?? 'Automatischer Alpha-Vantage-Abruf fehlgeschlagen.'),
];
}
$quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
$errors = is_array($bulkResult['errors'] ?? null) ? $bulkResult['errors'] : [];
$updated = 0;
foreach ($candidates as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$quote = $quotes[$instrumentId] ?? null;
if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) {
continue;
}
$storeResult = module_fn(
'boersenchecker',
'store_market_quote',
$instrumentId,
(float) $quote['price'],
strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $defaultReportCurrency))) ?: $defaultReportCurrency,
(string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')),
(string) module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId)
);
if (!empty($storeResult['inserted'])) {
$updated++;
} else {
$reused++;
}
}
$message = 'Automatischer Kursabruf: ' . $updated . ' neu, ' . $reused . ' wiederverwendet, ' . count($errors) . ' Fehler.';
if ($errors !== []) {
$message .= ' ' . implode(' | ', array_slice($errors, 0, 3));
}
module_debug_push('boersenchecker', [
'label' => 'Intervall-Aufgabe',
'type' => 'scheduler:run',
'task' => 'auto_refresh_quotes',
'context' => $context,
'message' => $message,
]);
return [
'ok' => $errors === [],
'message' => $message,
'updated' => $updated,
'reused' => $reused,
'errors' => $errors,
];
});
$mm->registerFunction($moduleName, 'alpha_vantage_search_symbols', static function (string $keywords): array {
$keywords = trim($keywords);
if ($keywords === '') {
return [
'ok' => false,
'message' => 'Bitte Suchbegriff angeben.',
'results' => [],
];
}
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'SYMBOL_SEARCH', [
'keywords' => $keywords,
]);
if (empty($response['ok'])) {
return $response + ['results' => []];
}
$data = is_array($response['data'] ?? null) ? $response['data'] : [];
$items = is_array($data['bestMatches'] ?? null) ? $data['bestMatches'] : [];
$results = [];
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$symbol = trim((string) ($item['1. symbol'] ?? ''));
$name = trim((string) ($item['2. name'] ?? ''));
$type = trim((string) ($item['3. type'] ?? ''));
$region = trim((string) ($item['4. region'] ?? ''));
$currency = strtoupper(trim((string) ($item['8. currency'] ?? '')));
$matchScore = trim((string) ($item['9. matchScore'] ?? ''));
if ($symbol === '' && $name === '') {
continue;
}
$results[] = [
'symbol' => $symbol,
'name' => $name,
'isin' => '',
'type' => $type,
'region' => $region,
'currency' => $currency,
'match_score' => $matchScore,
'raw' => $item,
];
}
return [
'ok' => true,
'message' => count($results) . ' Treffer gefunden.',
'results' => $results,
];
});
$mm->registerFunction($moduleName, 'alpha_vantage_fetch_chart_series', static function (string $symbol): array {
$symbol = strtoupper(trim($symbol));
if ($symbol === '') {
return ['ok' => false, 'message' => 'Kein Symbol angegeben.'];
}
$cacheDir = sys_get_temp_dir() . '/boersenchecker-alphavantage';
if (!is_dir($cacheDir)) {
@mkdir($cacheDir, 0775, true);
}
$cachePath = $cacheDir . '/' . md5('time_series_daily_adjusted|' . $symbol) . '.json';
$decoded = null;
if (is_file($cachePath) && (time() - filemtime($cachePath)) < (6 * 3600)) {
$cached = file_get_contents($cachePath);
$decoded = is_string($cached) ? json_decode($cached, true) : null;
}
if (!is_array($decoded)) {
$response = module_fn('boersenchecker', 'alpha_vantage_request', 'TIME_SERIES_DAILY_ADJUSTED', [
'symbol' => $symbol,
'outputsize' => 'full',
]);
if (empty($response['ok'])) {
return $response;
}
$decoded = is_array($response['data'] ?? null) ? $response['data'] : [];
@file_put_contents($cachePath, json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
$rows = is_array($decoded['Time Series (Daily)'] ?? null)
? $decoded['Time Series (Daily)']
: (is_array($decoded['Time Series (Daily) Adjusted'] ?? null) ? $decoded['Time Series (Daily) Adjusted'] : []);
$daily = [];
foreach ($rows as $date => $row) {
if (!is_array($row) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) $date)) {
continue;
}
$close = $row['5. adjusted close'] ?? $row['4. close'] ?? null;
if (!is_numeric($close)) {
continue;
}
$daily[] = [
'date' => $date,
'close' => (float) $close,
];
}
usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
if ($daily === []) {
return [
'ok' => false,
'message' => 'Keine historischen Schlusskurse fuer ' . $symbol . ' verfuegbar.',
];
}
$aggregate = static function (array $points, string $format): array {
$result = [];
foreach ($points as $point) {
$bucket = date($format, strtotime((string) $point['date']) ?: time());
$result[$bucket] = $point;
}
return array_values($result);
};
return [
'ok' => true,
'symbol' => $symbol,
'daily' => $daily,
'weekly' => $aggregate($daily, 'o-W'),
'monthly' => $aggregate($daily, 'Y-m'),
'source' => 'alphavantage:time_series_daily_adjusted',
];
});
$mm->registerFunction($moduleName, 'store_market_quote', static function (
int $instrumentId,
float $price,
string $currency,
string $quotedAt,
string $source
): array {
$pdo = module_fn('boersenchecker', 'pdo');
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
$quotedAt = trim($quotedAt);
$currency = strtoupper(trim($currency)) ?: 'EUR';
$source = trim($source) !== '' ? trim($source) : 'alphavantage:global_quote';
$checkStmt = $pdo->prepare(
'SELECT id
FROM ' . $quoteTable . '
WHERE instrument_id = :instrument_id
AND price = :price
AND currency = :currency
AND quoted_at = :quoted_at
AND source = :source
LIMIT 1'
);
$checkStmt->execute([
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => $currency,
'quoted_at' => $quotedAt,
'source' => $source,
]);
$existingId = (int) $checkStmt->fetchColumn();
if ($existingId > 0) {
module_debug_push('boersenchecker', [
'label' => 'Quote Store',
'type' => 'quote:reuse',
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => $currency,
'quoted_at' => $quotedAt,
'source' => $source,
'message' => 'Identischer Snapshot bereits vorhanden.',
]);
return ['ok' => true, 'inserted' => false, 'id' => $existingId];
}
$insertStmt = $pdo->prepare(
'INSERT INTO ' . $quoteTable . ' (instrument_id, price, currency, quoted_at, source)
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
);
$insertStmt->execute([
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => $currency,
'quoted_at' => $quotedAt,
'source' => $source,
]);
$insertedId = (int) $pdo->lastInsertId();
module_debug_push('boersenchecker', [
'label' => 'Quote Store',
'type' => 'quote:insert',
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => $currency,
'quoted_at' => $quotedAt,
'source' => $source,
'inserted_id' => $insertedId,
'message' => 'Neuer Snapshot gespeichert.',
]);
return ['ok' => true, 'inserted' => true, 'id' => $insertedId];
});

View File

@@ -0,0 +1,14 @@
{
"eyebrow": "Modul",
"title": "Boersenchecker",
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
"actions": [
{ "label": "Nexus Übersicht", "href": "/" },
{ "label": "Setup", "href": "/modules/setup/boersenchecker" }
],
"tabs": [
{ "label": "Ueberblick", "href": "/module/boersenchecker" },
{ "label": "Depotverwaltung", "href": "/module/boersenchecker/depotverwaltung" },
{ "label": "Aktienverwaltung", "href": "/module/boersenchecker/aktienverwaltung" }
]
}

View File

@@ -0,0 +1,51 @@
{
"title": "Börsenchecker",
"version": "0.2.0",
"description": "Depotverwaltung fuer Aktien, Kaufdaten, Kursverlauf und Waehrungsumrechnung.",
"enabled_by_default": false,
"setup": {
"fields": [
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" },
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
{ "name": "report_currency", "label": "Standard-Berichtswahrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Portfolio-Summen, z.B. EUR." },
{ "name": "fx_max_age_hours", "label": "Maximales FX-Alter (Stunden)", "type": "number", "required": false, "help": "Wird bei manueller Aktualisierung ueber das Modul fx-rates genutzt." },
{ "name": "alpha_vantage_api_key", "label": "Alpha Vantage API Key", "type": "password", "required": false, "help": "API Key fuer Aktienkursabrufe und Suche ueber Alpha Vantage." },
{ "name": "alpha_vantage_timeout_sec", "label": "Alpha Vantage Timeout (Sek.)", "type": "number", "required": false, "help": "HTTP-Timeout fuer API-Abrufe." },
{ "name": "alpha_vantage_min_interval_minutes", "label": "Alpha Vantage Mindestabstand (Min.)", "type": "number", "required": false, "help": "Wenn bereits ein frischer Alpha-Vantage-Kurs existiert, wird dieser wiederverwendet statt erneut abzurufen." },
{ "name": "auto_refresh_quotes_enabled", "label": "Automatischen Kursabruf aktivieren", "type": "checkbox", "required": false, "help": "Fuehrt Kursupdates automatisch beim ersten Modulaufruf nach Ablauf des Intervalls aus." },
{ "name": "auto_refresh_quotes_interval_hours", "label": "Intervall fuer automatischen Kursabruf (Stunden)", "type": "number", "required": false, "help": "Nach Ablauf dieses Intervalls wird beim naechsten Modulaufruf ein automatischer Kursabruf gestartet." }
]
},
"interval_tasks": [
{
"name": "auto_refresh_quotes",
"label": "Automatischer Kursabruf",
"callback": "scheduled_refresh_quotes",
"enabled_setting": "auto_refresh_quotes_enabled",
"interval_setting": "auto_refresh_quotes_interval_hours",
"default_enabled": false,
"default_interval_hours": 6,
"lock_minutes": 20
}
],
"db_defaults": {
"driver": "pgsql",
"host": "localhost",
"port": 5432,
"dbname": "",
"schema": "public",
"user": "",
"password": ""
},
"auth": {
"required": true,
"users": [],
"groups": []
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
require_auth();
$assets = app()->assets();
if ($assets) {
$assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css');
$assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true);
}
$page = new \Modules\Boersenchecker\Support\InstrumentPage();
module_tpl('boersenchecker', 'instruments', $page->handle());

View File

@@ -0,0 +1,30 @@
<?php
$file = (string)($_GET['file'] ?? '');
$base = realpath(__DIR__ . '/../assets');
$map = [
'boersenchecker.css' => $base . '/boersenchecker.css',
'boersenchecker.js' => $base . '/boersenchecker.js',
];
if (!isset($map[$file])) {
http_response_code(404);
exit('Not found');
}
$path = $map[$file];
if (!$base || !is_file($path) || !str_starts_with($path, $base)) {
http_response_code(404);
exit('Not found');
}
$ext = pathinfo($path, PATHINFO_EXTENSION);
if ($ext === 'css') {
header('Content-Type: text/css; charset=utf-8');
} elseif ($ext === 'js') {
header('Content-Type: application/javascript; charset=utf-8');
} else {
header('Content-Type: application/octet-stream');
}
readfile($path);
exit;

View File

@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
require_auth();
$user = auth_user() ?? [];
$ownerSub = trim((string) ($user['sub'] ?? 'local'));
$instrumentId = (int) ($_GET['instrument_id'] ?? 0);
if ($instrumentId <= 0) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'message' => 'instrument_id fehlt.'], JSON_UNESCAPED_UNICODE);
exit;
}
$pdo = module_fn('boersenchecker', 'pdo');
module_fn('boersenchecker', 'ensure_schema');
$instrumentTable = module_fn('boersenchecker', 'table', 'instruments');
$positionTable = module_fn('boersenchecker', 'table', 'positions');
$quoteTable = module_fn('boersenchecker', 'table', 'quotes');
$stmt = $pdo->prepare(
'SELECT i.id, i.name, i.symbol, i.isin, i.quote_currency
FROM ' . $instrumentTable . ' i
INNER JOIN ' . $positionTable . ' p ON p.instrument_id = i.id
WHERE i.id = :id AND p.owner_sub = :owner_sub
LIMIT 1'
);
$stmt->execute([
'id' => $instrumentId,
'owner_sub' => $ownerSub,
]);
$instrument = $stmt->fetch(PDO::FETCH_ASSOC);
header('Content-Type: application/json; charset=utf-8');
if (!is_array($instrument)) {
echo json_encode(['ok' => false, 'message' => 'Aktie nicht verfuegbar.'], JSON_UNESCAPED_UNICODE);
exit;
}
$quoteStmt = $pdo->prepare(
'SELECT id, price, currency, quoted_at, source, created_at
FROM ' . $quoteTable . '
WHERE instrument_id = :instrument_id
ORDER BY quoted_at ASC, created_at ASC, id ASC'
);
$quoteStmt->execute([
'instrument_id' => $instrumentId,
]);
$quotes = $quoteStmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if ($quotes === []) {
echo json_encode([
'ok' => false,
'message' => 'Keine lokalen Kursdaten fuer diese Aktie vorhanden.',
], JSON_UNESCAPED_UNICODE);
exit;
}
$dailyMap = [];
foreach ($quotes as $quote) {
$localDate = trim((string) module_fn(
'boersenchecker',
'format_datetime_for_display',
(string) ($quote['quoted_at'] ?? ''),
(string) ($quote['source'] ?? ''),
'Y-m-d'
));
$localDateTime = trim((string) module_fn(
'boersenchecker',
'format_datetime_for_display',
(string) ($quote['quoted_at'] ?? ''),
(string) ($quote['source'] ?? ''),
'Y-m-d H:i:s'
));
if ($localDate === '' || !is_numeric($quote['price'] ?? null)) {
continue;
}
$point = [
'date' => $localDate,
'close' => (float) $quote['price'],
'currency' => strtoupper(trim((string) ($quote['currency'] ?? ''))),
'quoted_at' => $localDateTime,
'source' => (string) ($quote['source'] ?? ''),
];
if (!isset($dailyMap[$localDate]) || strcmp($localDateTime, (string) ($dailyMap[$localDate]['quoted_at'] ?? '')) >= 0) {
$dailyMap[$localDate] = $point;
}
}
$daily = array_values($dailyMap);
usort($daily, static fn (array $left, array $right): int => strcmp((string) $left['date'], (string) $right['date']));
if ($daily === []) {
echo json_encode([
'ok' => false,
'message' => 'Keine gueltigen lokalen Schlusskurse fuer diese Aktie vorhanden.',
], JSON_UNESCAPED_UNICODE);
exit;
}
$aggregate = static function (array $points, string $format): array {
$result = [];
$timezone = new DateTimeZone(nexus_display_timezone_name());
foreach ($points as $point) {
$date = DateTimeImmutable::createFromFormat('Y-m-d', (string) ($point['date'] ?? ''), $timezone);
if (!$date instanceof DateTimeImmutable) {
continue;
}
$bucket = $date->format($format);
$result[$bucket] = $point;
}
return array_values($result);
};
echo json_encode([
'ok' => true,
'symbol' => strtoupper(trim((string) ($instrument['symbol'] ?? ''))),
'isin' => strtoupper(trim((string) ($instrument['isin'] ?? ''))),
'instrument_name' => (string) ($instrument['name'] ?? ''),
'currency' => strtoupper(trim((string) ($instrument['quote_currency'] ?? ''))),
'daily' => $daily,
'weekly' => $aggregate($daily, 'o-W'),
'monthly' => $aggregate($daily, 'Y-m'),
'source' => 'database:quotes',
'source_label' => 'Lokale Kurshistorie',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
require_auth();
$assets = app()->assets();
if ($assets) {
$assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css');
$assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true);
}
$page = new \Modules\Boersenchecker\Support\DashboardPage();
module_tpl('boersenchecker', 'dashboard', $page->handle());

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
require_auth();
$assets = app()->assets();
if ($assets) {
$assets->addStyle('/module/boersenchecker/asset?file=boersenchecker.css');
$assets->addScript('/module/boersenchecker/asset?file=boersenchecker.js', 'footer', true);
}
$page = new \Modules\Boersenchecker\Support\HomePage();
module_tpl('boersenchecker', 'home', $page->handle());

View File

@@ -0,0 +1,512 @@
<?php $ownerQuery = $isAdmin ? '?owner_sub=' . urlencode((string) $ownerSub) : ''; ?>
<?= module_shell_header('boersenchecker', [
'title' => 'Depotverwaltung',
]) ?>
<div class="bc-page">
<?php if ($error): ?>
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<?php if ($isAdmin): ?>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Benutzer-Scope</h2>
<p>Depots anderer Benutzer sind nur fuer `appadmin` sichtbar und bearbeitbar.</p>
</div>
</div>
<form method="get" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
<label class="setup-field muted" style="margin:0; min-width:260px;">
<span>Depots von Benutzer</span>
<select name="owner_sub">
<?php foreach ($availableOwners as $owner): ?>
<option value="<?= e((string) $owner['sub']) ?>" <?= (string) $ownerSub === (string) $owner['sub'] ? 'selected' : '' ?>>
<?= e((string) $owner['label']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<button class="bc-button bc-button--primary" type="submit">Anzeigen</button>
</form>
</section>
<?php endif; ?>
<div class="bc-card-grid">
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title"><?= $editPortfolio ? 'Depot bearbeiten' : 'Neues Depot' ?></h2>
<p>Stammdaten und Berichtswahrung fuer ein Depot pflegen.</p>
</div>
</div>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_portfolio">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="portfolio_id" value="<?= e((string) ($editPortfolio['id'] ?? '0')) ?>">
<label class="setup-field muted">
<span>Depotname</span>
<input type="text" name="portfolio_name" value="<?= e((string) ($editPortfolio['name'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>Berichtswahrung</span>
<input type="text" name="portfolio_base_currency" value="<?= e((string) ($editPortfolio['base_currency'] ?? $defaultReportCurrency)) ?>" required>
</label>
<label class="setup-field muted">
<span>Notizen</span>
<textarea name="portfolio_notes" rows="3"><?= e((string) ($editPortfolio['notes'] ?? '')) ?></textarea>
</label>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Depot speichern</button>
<?php if ($editPortfolio): ?>
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung<?= e($ownerQuery) ?>">Abbrechen</a>
<?php endif; ?>
</div>
</form>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">API / FX</h2>
<p>Kurs- und Waehrungsdaten zentral aktualisieren.</p>
</div>
</div>
<p class="muted" style="margin-top:16px;">
Die Umrechnung liest gespeicherte FX-Daten zentral aus dem Modul fx-rates. Eine Aktualisierung wird nur manuell
angestossen und respektiert die dortige Max-Age- und Reuse-Logik.
</p>
<p class="muted" style="margin-top:12px;">
Aktienkurse werden ueber Alpha Vantage anhand des hinterlegten Symbols abgerufen. Die ISIN bleibt als Stammdatum erhalten.
</p>
<div class="bc-actions" style="margin-top:16px;">
<form method="post">
<input type="hidden" name="action" value="refresh_fx">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<button class="bc-button bc-button--primary" type="submit">FX-Daten aktualisieren</button>
</form>
<form method="post">
<input type="hidden" name="action" value="refresh_market_data_all">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<button class="bc-button bc-button--secondary" type="submit">Alle API-Kurse abrufen</button>
</form>
</div>
<div class="muted" style="margin-top:12px;">
Alpha Vantage Mindestabstand: <?= e((string) $marketDataMinIntervalMinutes) ?> Min.
</div>
<div class="muted" style="margin-top:6px;">
API-Key und Timeout fuer Aktienkurse werden ueber <a href="/modules/setup/boersenchecker">dieses Modul-Setup</a> gepflegt.
</div>
<div class="muted" style="margin-top:6px;">
FX-Provider, API-Key und Waehrungskatalog werden im Modul <a href="/module/fx-rates">fx-rates</a> gepflegt.
</div>
<div class="muted" style="margin-top:12px;">
Standard-Berichtswahrung: <?= e($defaultReportCurrency) ?> · Max. Alter: <?= e((string) $fxMaxAgeHours) ?>h
</div>
</section>
</div>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title"><?= $editPosition ? 'Position bearbeiten' : 'Neue Position' ?></h2>
<p>Aktienpositionen fuer ein Depot mit Kaufdaten und Kurswaehrung verwalten.</p>
</div>
</div>
<?php if ($portfolios === []): ?>
<div class="muted" style="margin-top:16px;">Bitte zuerst ein Depot anlegen.</div>
<?php else: ?>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_position">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="position_id" value="<?= e((string) ($editPosition['id'] ?? '0')) ?>">
<input type="hidden" name="instrument_id" value="<?= e((string) ($editPosition['instrument_id'] ?? '0')) ?>">
<label class="setup-field muted">
<span>Depot</span>
<select name="portfolio_id" required>
<option value="">Bitte waehlen</option>
<?php foreach ($portfolios as $portfolio): ?>
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) ($editPosition['portfolio_id'] ?? '') === (string) $portfolio['id'] ? 'selected' : '' ?>>
<?= e((string) $portfolio['name']) ?> (<?= e((string) $portfolio['base_currency']) ?>)
</option>
<?php endforeach; ?>
</select>
</label>
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted">
<span>Aktienname</span>
<input type="text" name="instrument_name" value="<?= e((string) ($editPosition['instrument_name'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>API-Symbol / Ticker</span>
<input type="text" name="symbol" value="<?= e((string) ($editPosition['symbol'] ?? '')) ?>" placeholder="z.B. AAPL oder MBG.DE">
</label>
<label class="setup-field muted">
<span>ISIN</span>
<input type="text" name="isin" value="<?= e((string) ($editPosition['isin'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>WKN</span>
<input type="text" name="wkn" value="<?= e((string) ($editPosition['wkn'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>Boerse / Markt</span>
<input type="text" name="market" value="<?= e((string) ($editPosition['market'] ?? '')) ?>">
</label>
<label class="setup-field muted">
<span>Kurswaehrung</span>
<input type="text" name="quote_currency" value="<?= e((string) ($editPosition['quote_currency'] ?? $defaultReportCurrency)) ?>" required>
</label>
</div>
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted">
<span>Stueckzahl</span>
<input type="number" name="quantity" min="0" step="0.000001" value="<?= e((string) ($editPosition['quantity'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>Kaufpreis</span>
<input type="number" name="purchase_price" min="0" step="0.00000001" value="<?= e((string) ($editPosition['purchase_price'] ?? '')) ?>" required>
</label>
<label class="setup-field muted">
<span>Kaufwaehrung</span>
<input type="text" name="purchase_currency" value="<?= e((string) ($editPosition['purchase_currency'] ?? $defaultReportCurrency)) ?>" required>
</label>
<label class="setup-field muted">
<span>Kaufdatum</span>
<input type="date" name="purchase_date" value="<?= e((string) ($editPosition['purchase_date'] ?? date('Y-m-d'))) ?>" required>
</label>
<label class="setup-field muted">
<span>Gebuehren</span>
<input type="number" name="fees" min="0" step="0.00000001" value="<?= e((string) ($editPosition['fees'] ?? '')) ?>">
</label>
</div>
<label class="setup-field muted">
<span>Notizen</span>
<textarea name="position_notes" rows="3"><?= e((string) ($editPosition['notes'] ?? '')) ?></textarea>
</label>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Position speichern</button>
<?php if ($editPosition): ?>
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung<?= e($ownerQuery) ?>">Abbrechen</a>
<?php endif; ?>
</div>
</form>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Wertpapiersuche</h2>
<p>Alpha-Vantage-Suchergebnisse pruefen und Daten direkt ins Positionsformular uebernehmen.</p>
</div>
</div>
<div class="bc-section-copy">
<form method="post" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
<input type="hidden" name="action" value="search_symbol">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<label class="setup-field muted" style="margin:0; min-width:260px; flex:1;">
<span>Suchbegriff</span>
<input type="text" name="search_keywords" value="<?= e($symbolSearchKeywords) ?>" placeholder="z.B. Mercedes, AAPL, Allianz" required>
</label>
<button class="bc-button bc-button--primary" type="submit">Suchen</button>
</form>
</div>
<?php if ($symbolSearchResults !== []): ?>
<table class="bc-table">
<thead>
<tr>
<th>Symbol</th>
<th>Name</th>
<th>Typ</th>
<th>Region</th>
<th>Waehrung</th>
<th>Match</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($symbolSearchResults as $result): ?>
<tr>
<td><strong><?= e((string) ($result['symbol'] ?? '')) ?></strong></td>
<td><?= e((string) ($result['name'] ?? '')) ?></td>
<td><?= e((string) ($result['type'] ?? '')) ?></td>
<td><?= e((string) ($result['region'] ?? '')) ?></td>
<td><?= e((string) ($result['currency'] ?? '')) ?></td>
<td><?= e((string) ($result['match_score'] ?? '')) ?></td>
<td>
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&symbol_candidate=<?= urlencode((string) ($result['symbol'] ?? '')) ?>&instrument_name_candidate=<?= urlencode((string) ($result['name'] ?? '')) ?>&isin_candidate=<?= urlencode((string) ($result['isin'] ?? '')) ?>&market_candidate=<?= urlencode((string) ($result['region'] ?? '')) ?>&quote_currency_candidate=<?= urlencode((string) ($result['currency'] ?? '')) ?>">
In Formular uebernehmen
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<div class="bc-section-copy">
<div class="muted">Noch keine Symbolsuche ausgefuehrt.</div>
</div>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Manuellen Kurs erfassen</h2>
<p>Kurse mit Uhrzeit und Quelle direkt in die Historie schreiben.</p>
</div>
</div>
<?php if ($instrumentList === []): ?>
<div class="muted" style="margin-top:16px;">Sobald Positionen vorhanden sind, koennen hier Kurse mit Uhrzeit gespeichert werden.</div>
<?php else: ?>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_quote">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted">
<span>Aktie</span>
<select name="quote_instrument_id" required>
<option value="">Bitte waehlen</option>
<?php foreach ($instrumentList as $instrument): ?>
<option value="<?= e((string) $instrument['id']) ?>" <?= $selectedInstrumentForQuote === (int) $instrument['id'] ? 'selected' : '' ?>>
<?= e((string) $instrument['name']) ?><?= $instrument['symbol'] !== '' ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="setup-field muted">
<span>Kurs</span>
<input type="number" name="quote_price" min="0" step="0.00000001" required>
</label>
<label class="setup-field muted">
<span>Waehrung</span>
<input type="text" name="quote_currency" value="<?= e($selectedInstrumentQuoteCurrency) ?>" required>
</label>
<label class="setup-field muted">
<span>Zeitpunkt</span>
<input type="datetime-local" name="quoted_at" value="<?= e($localNowInputValue) ?>" required>
</label>
<label class="setup-field muted">
<span>Quelle</span>
<input type="text" name="quote_source" value="manual">
</label>
</div>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Kurs speichern</button>
</div>
</form>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Depots</h2>
<p>Uebersicht aller Depots mit Kennzahlen und Schnellaktionen.</p>
</div>
</div>
<?php if ($portfolios === []): ?>
<div class="muted" style="margin-top:16px;">Noch keine Depots vorhanden.</div>
<?php else: ?>
<div class="bc-card-grid" style="margin-top:16px;">
<?php foreach ($portfolios as $portfolio): ?>
<?php
$portfolioId = (int) $portfolio['id'];
$stats = $portfolioStats[$portfolioId] ?? ['positions' => 0, 'invested' => 0.0, 'current' => 0.0, 'gain' => null, 'has_invested' => false, 'has_current' => false];
?>
<section class="card-box">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<div>
<strong><?= e((string) $portfolio['name']) ?></strong>
<div class="muted"><?= e((string) $portfolio['base_currency']) ?> · <?= e((string) $stats['positions']) ?> Position(en)</div>
</div>
<div class="bc-actions">
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_portfolio=<?= e((string) $portfolioId) ?>">Bearbeiten</a>
<form method="post" onsubmit="return confirm('Depot wirklich loeschen?')">
<input type="hidden" name="action" value="delete_portfolio">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="portfolio_id" value="<?= e((string) $portfolioId) ?>">
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
</form>
</div>
</div>
<?php if (!empty($portfolio['notes'])): ?>
<div class="muted" style="margin-top:10px;"><?= e((string) $portfolio['notes']) ?></div>
<?php endif; ?>
<div class="grid" style="margin-top:16px; grid-template-columns:repeat(auto-fit, minmax(140px, 1fr)); gap:10px;">
<div class="bc-stat">
<div class="muted">Investiert</div>
<strong><?= $stats['has_invested'] ? e($fmtNumber((float) $stats['invested'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
</div>
<div class="bc-stat">
<div class="muted">Aktuell</div>
<strong><?= $stats['has_current'] ? e($fmtNumber((float) $stats['current'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
</div>
<div class="bc-stat">
<div class="muted">Gewinn / Verlust</div>
<strong><?= $stats['gain'] !== null ? e($fmtNumber((float) $stats['gain'])) . ' ' . e((string) $portfolio['base_currency']) : 'n/a' ?></strong>
</div>
</div>
</section>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Positionen</h2>
<p>Alle Positionen mit Kaufdaten, letztem Kurs und aktuellen Werten.</p>
</div>
</div>
<?php if ($positions === []): ?>
<div class="bc-section-copy">
<div class="muted">Noch keine Positionen vorhanden.</div>
</div>
<?php else: ?>
<table class="bc-table">
<thead>
<tr>
<th>Depot</th>
<th>Aktie</th>
<th>ISIN / WKN</th>
<th>Stueck</th>
<th>Kauf</th>
<th>Letzter Kurs</th>
<th>Wert</th>
<th>Delta</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($positions as $position): ?>
<tr>
<td><?= e((string) ($portfolioById[(int) $position['portfolio_id']]['name'] ?? '')) ?></td>
<td>
<strong><?= e((string) $position['instrument_name']) ?></strong>
<?php if (!empty($position['symbol'])): ?>
<div class="muted"><?= e((string) $position['symbol']) ?></div>
<?php endif; ?>
</td>
<td>
<?= e((string) ($position['isin'] ?: '-')) ?>
<div class="muted"><?= e((string) ($position['wkn'] ?: '-')) ?></div>
</td>
<td><?= e($fmtNumber((float) $position['quantity'], 6)) ?></td>
<td>
<?= e($fmtNumber((float) $position['purchase_price'], 4)) ?> <?= e((string) $position['purchase_currency']) ?>
<div class="muted"><?= e((string) $position['purchase_date']) ?></div>
</td>
<td>
<?php if ($position['latest_price'] !== null): ?>
<?= e($fmtNumber((float) $position['latest_price'], 4)) ?> <?= e((string) $position['latest_currency']) ?>
<div class="muted"><?= e($fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? ''))) ?></div>
<?php else: ?>
<span class="muted">kein Kurs</span>
<?php endif; ?>
</td>
<td>
<?php if ($position['current_total_base'] !== null): ?>
<?= e($fmtNumber((float) $position['current_total_base'])) ?> <?= e((string) $position['base_currency']) ?>
<?php else: ?>
<span class="muted">n/a</span>
<?php endif; ?>
</td>
<td>
<?php if ($position['gain_base'] !== null): ?>
<?= e($fmtNumber((float) $position['gain_base'])) ?> <?= e((string) $position['base_currency']) ?>
<?php else: ?>
<span class="muted">n/a</span>
<?php endif; ?>
</td>
<td>
<div class="bc-actions">
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&edit_position=<?= e((string) $position['id']) ?>">Bearbeiten</a>
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&instrument_id=<?= e((string) $position['instrument_id']) ?>">Kurs erfassen</a>
<form method="post">
<input type="hidden" name="action" value="refresh_market_data_position">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
<button class="bc-button bc-button--secondary" type="submit">API-Kurs</button>
</form>
<form method="post" onsubmit="return confirm('Position wirklich loeschen?')">
<input type="hidden" name="action" value="delete_position">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="position_id" value="<?= e((string) $position['id']) ?>">
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
</form>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Kursverlauf</h2>
<p>Historische Kurse pro Aktie mit Zeitstempel und Quelle.</p>
</div>
</div>
<?php if ($instrumentList === []): ?>
<div class="muted" style="margin-top:16px;">Noch keine Kursdaten vorhanden.</div>
<?php else: ?>
<div class="bc-card-grid" style="margin-top:16px;">
<?php foreach ($instrumentList as $instrumentId => $instrument): ?>
<?php $history = array_slice($quoteHistory[$instrumentId] ?? [], 0, 10); ?>
<section class="card-box">
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<div>
<strong><?= e((string) $instrument['name']) ?></strong>
<div class="muted">
<?= e((string) ($instrument['symbol'] ?: '-')) ?> · <?= e((string) ($instrument['isin'] ?: '-')) ?>
</div>
</div>
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/depotverwaltung?owner_sub=<?= urlencode((string) $ownerSub) ?>&instrument_id=<?= e((string) $instrumentId) ?>">Neuen Kurs erfassen</a>
</div>
<?php if ($history === []): ?>
<div class="muted" style="margin-top:12px;">Noch keine historischen Kurse vorhanden.</div>
<?php else: ?>
<div class="bc-table-shell" style="margin-top:12px;">
<table class="bc-table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Kurs</th>
<th>Quelle</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($history as $quote): ?>
<tr>
<td><?= e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?></td>
<td><?= e($fmtNumber((float) $quote['price'], 4)) ?> <?= e((string) $quote['currency']) ?></td>
<td><?= e((string) $quote['source']) ?></td>
<td>
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
<input type="hidden" name="action" value="delete_quote">
<input type="hidden" name="owner_sub" value="<?= e((string) $ownerSub) ?>">
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
</div>
<?= module_shell_footer() ?>

View File

@@ -0,0 +1,209 @@
<?= module_shell_header('boersenchecker', [
'title' => 'Depot-Ueberblick',
]) ?>
<div class="bc-page" data-bc-home data-chart-endpoint="<?= e($chartEndpoint) ?>">
<script type="application/json" data-bc-instruments-json><?= json_encode(array_map(static function (array $position): array {
return [
'instrument_id' => (int) ($position['instrument_id'] ?? 0),
'instrument_name' => (string) ($position['instrument_name'] ?? ''),
'symbol' => (string) ($position['symbol'] ?? ''),
'isin' => (string) ($position['isin'] ?? ''),
];
}, $positions), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?></script>
<?php if ($error): ?>
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Marktueberblick</h2>
<p>Depotauswahl, Aktienfokus und aktueller Kursabruf in einem Bereich.</p>
</div>
</div>
<div class="bc-toolbar" style="margin-top:16px;">
<form class="bc-panel" method="get">
<div class="bc-field-label">Depotauswahl</div>
<?php if ($portfolios === []): ?>
<div class="bc-text" style="margin-top:12px;">Keine Depots vorhanden.</div>
<?php else: ?>
<label class="setup-field" style="margin-top:12px;">
<span class="bc-text">Depot</span>
<select name="portfolio_id" onchange="this.form.submit()">
<?php foreach ($portfolios as $portfolio): ?>
<option value="<?= e((string) $portfolio['id']) ?>" <?= (string) $selectedPortfolioId === (string) $portfolio['id'] ? 'selected' : '' ?>>
<?= e((string) $portfolio['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
</form>
<form class="bc-panel" method="get">
<input type="hidden" name="portfolio_id" value="<?= e((string) $selectedPortfolioId) ?>">
<div class="bc-field-label">Aktienauswahl</div>
<?php if ($positions === []): ?>
<div class="bc-text" style="margin-top:12px;">Keine Aktien im ausgewaehlten Depot.</div>
<?php else: ?>
<label class="setup-field" style="margin-top:12px;">
<span class="bc-text">Aktie</span>
<select name="instrument_id" data-bc-instrument onchange="this.form.submit()">
<?php foreach ($positions as $position): ?>
<option value="<?= e((string) $position['instrument_id']) ?>" <?= (string) $selectedInstrumentId === (string) $position['instrument_id'] ? 'selected' : '' ?>>
<?= e((string) $position['instrument_name']) ?><?= !empty($position['symbol']) ? ' (' . e((string) $position['symbol']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
</form>
<form class="bc-panel" method="post">
<input type="hidden" name="action" value="refresh_current_quotes_home">
<input type="hidden" name="portfolio_id" value="<?= e((string) $selectedPortfolioId) ?>">
<div class="bc-field-label">Marktdaten</div>
<p class="bc-text" style="margin-top:12px;">Aktuelle Kurse fuer das gewaehlte Depot ueber Alpha Vantage anhand des hinterlegten Symbols abrufen.</p>
<div class="bc-actions" style="margin-top:16px;">
<button class="bc-button bc-button--primary" type="submit" <?= $selectedPortfolioId > 0 ? '' : 'disabled' ?>>Aktuelle Kurse abrufen</button>
</div>
</form>
</div>
</section>
<div class="bc-overview-grid">
<section class="card-box bc-stat">
<div class="bc-field-label">Positionen</div>
<div class="bc-stat-value"><?= e((string) ($summary['positions'] ?? 0)) ?></div>
<div class="bc-text" style="margin-top:6px;">Aktien im aktuell gewaehlten Depot</div>
</section>
<section class="card-box bc-stat">
<div class="bc-field-label">Investiert</div>
<div class="bc-stat-value"><?= isset($summary['invested']) && $summary['invested'] !== null ? e(number_format((float) $summary['invested'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
<div class="bc-text" style="margin-top:6px;">In Berichtswahrung bewertet</div>
</section>
<section class="card-box bc-stat">
<div class="bc-field-label">Aktueller Wert</div>
<div class="bc-stat-value"><?= isset($summary['current']) && $summary['current'] !== null ? e(number_format((float) $summary['current'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
<div class="bc-text" style="margin-top:6px;">Basierend auf dem letzten gespeicherten Kurs</div>
</section>
<section class="card-box bc-stat">
<div class="bc-field-label">Performance</div>
<div class="bc-stat-value"><?= isset($summary['gain']) && $summary['gain'] !== null ? e(number_format((float) $summary['gain'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?></div>
<div class="bc-text" style="margin-top:6px;"><?= !empty($summary['best']['instrument_name']) ? 'Top-Wert: ' . e((string) $summary['best']['instrument_name']) : 'Noch keine Vergleichsdaten' ?></div>
</section>
</div>
<div class="bc-card-grid">
<section class="card-box">
<div class="bc-field-label">Bester Wert</div>
<?php if (!empty($summary['best'])): ?>
<div class="bc-stat-value"><?= e((string) $summary['best']['instrument_name']) ?></div>
<div class="bc-pill-soft" style="margin-top:12px;"><?= e(number_format((float) ($summary['best']['gain_percent'] ?? 0), 2, ',', '.')) ?>%</div>
<?php else: ?>
<div class="bc-text" style="margin-top:12px;">Noch keine Performance verfuegbar.</div>
<?php endif; ?>
</section>
<section class="card-box">
<div class="bc-field-label">Schwaechster Wert</div>
<?php if (!empty($summary['worst'])): ?>
<div class="bc-stat-value"><?= e((string) $summary['worst']['instrument_name']) ?></div>
<div class="bc-pill-soft" style="margin-top:12px;"><?= e(number_format((float) ($summary['worst']['gain_percent'] ?? 0), 2, ',', '.')) ?>%</div>
<?php else: ?>
<div class="bc-text" style="margin-top:12px;">Noch keine Performance verfuegbar.</div>
<?php endif; ?>
</section>
<?php foreach (array_slice($positions, 0, 2) as $position): ?>
<section class="card-box bc-stat">
<div class="bc-field-label"><?= e((string) $position['instrument_name']) ?></div>
<div class="bc-stat-value"><?= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?></div>
<div class="bc-text" style="margin-top:6px;"><?= e((string) (($position['latest_quoted_at'] ?? '') !== '' ? $fmtDateTime((string) $position['latest_quoted_at'], (string) ($position['latest_source'] ?? '')) : 'kein Kurs')) ?></div>
</section>
<?php endforeach; ?>
</div>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Kursverlauf</h2>
<p>Schlusskurse ueber mehrere Zeitfenster fuer das aktuell gewaehlte Instrument.</p>
</div>
</div>
<div class="bc-chart-card" style="margin-top:16px;">
<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; align-items:center;">
<div>
<div class="bc-field-label">Aktie</div>
<div class="bc-stat-value" data-bc-instrument-name><?= e((string) ($selectedInstrument['instrument_name'] ?? 'Keine Aktie ausgewaehlt')) ?></div>
<?php if ($selectedInstrument): ?>
<div class="bc-text" data-bc-instrument-meta><?= e((string) ($selectedInstrument['symbol'] ?? '')) ?> · <?= e((string) ($selectedInstrument['isin'] ?? '-')) ?></div>
<?php endif; ?>
</div>
<div class="bc-range-list">
<button type="button" class="bc-range-button" data-range="1d" aria-pressed="false">Tag</button>
<button type="button" class="bc-range-button" data-range="5d" aria-pressed="false">5 Tage</button>
<button type="button" class="bc-range-button" data-range="1m" aria-pressed="true">Monat</button>
<button type="button" class="bc-range-button" data-range="3m" aria-pressed="false">3 Monate</button>
<button type="button" class="bc-range-button" data-range="6m" aria-pressed="false">6 Monate</button>
<button type="button" class="bc-range-button" data-range="1y" aria-pressed="false">Jahr</button>
<button type="button" class="bc-range-button" data-range="5y" aria-pressed="false">5 Jahre</button>
</div>
</div>
<div class="bc-text" data-bc-chart-status style="margin-top:12px;">Chartdaten werden geladen...</div>
<div class="bc-stat-value" data-bc-chart-summary style="margin-top:6px;">-</div>
<div class="bc-chart-shell" data-bc-chart style="margin-top:18px;"></div>
</div>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Aktien im Depot</h2>
<p>Stueckzahl, Kaufdaten, letzter Kurs und Performance auf einen Blick.</p>
</div>
</div>
<div class="bc-section-copy">
<?php if ($positions === []): ?>
<div class="bc-text" style="padding:0 0 18px;">Keine Aktien im ausgewaehlten Depot.</div>
<?php else: ?>
<div class="bc-position-list" style="padding:0 0 18px;">
<?php foreach ($positions as $position): ?>
<?php $gainClass = (($position['gain_report'] ?? 0) >= 0) ? 'is-positive' : 'is-negative'; ?>
<div class="bc-position-row">
<div>
<strong><?= e((string) $position['instrument_name']) ?></strong>
<div class="bc-text" style="margin-top:4px;"><?= e((string) ($position['symbol'] ?? '')) ?> · <?= e((string) ($position['isin'] ?? '-')) ?></div>
<?php if (!empty($position['market'])): ?>
<div class="bc-pill-soft" style="margin-top:10px;"><?= e((string) $position['market']) ?></div>
<?php endif; ?>
</div>
<div>
<div class="bc-field-label">Stueckzahl</div>
<div><?= e(number_format((float) $position['quantity'], 6, ',', '.')) ?></div>
</div>
<div>
<div class="bc-field-label">Kaufpreis</div>
<div><?= e(number_format((float) $position['purchase_price'], 2, ',', '.')) ?> <?= e((string) $position['purchase_currency']) ?></div>
</div>
<div>
<div class="bc-field-label">Letzter Kurs</div>
<div><?= $position['latest_price'] !== null ? e(number_format((float) $position['latest_price'], 2, ',', '.')) . ' ' . e((string) $position['latest_currency']) : 'n/a' ?></div>
</div>
<div>
<div class="bc-field-label">Performance</div>
<div class="bc-performance <?= e($gainClass) ?>">
<?= isset($position['gain_report']) && $position['gain_report'] !== null ? e(number_format((float) $position['gain_report'], 2, ',', '.')) . ' ' . e($defaultReportCurrency) : 'n/a' ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
</section>
</div>
<?= module_shell_footer() ?>

View File

@@ -0,0 +1,187 @@
<?= module_shell_header('boersenchecker', [
'title' => 'Aktienverwaltung',
]) ?>
<div class="bc-page">
<?php if ($error): ?>
<section class="section-box"><div class="bc-alert bc-alert--error"><?= e($error) ?></div></section>
<?php elseif ($notice): ?>
<section class="section-box"><div class="bc-alert bc-alert--success"><?= e($notice) ?></div></section>
<?php endif; ?>
<div class="bc-card-grid">
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Aktie waehlen</h2>
<p>Systemweit vorhandene Aktie aus allen Depots auswaehlen.</p>
</div>
</div>
<form method="get" style="margin-top:16px;">
<label class="setup-field muted">
<span>Aktien aller Depots</span>
<select name="instrument_id" onchange="this.form.submit()">
<?php foreach ($instruments as $instrument): ?>
<option value="<?= e((string) $instrument['id']) ?>" <?= (string) $selectedInstrumentId === (string) $instrument['id'] ? 'selected' : '' ?>>
<?= e((string) $instrument['name']) ?><?= !empty($instrument['symbol']) ? ' (' . e((string) $instrument['symbol']) . ')' : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
</form>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Wertpapiersuche</h2>
<p>Alpha-Vantage-Suchergebnisse finden und direkt fuer die Aktie uebernehmen.</p>
</div>
</div>
<div class="bc-section-copy">
<form method="post" style="margin-top:16px; display:flex; gap:10px; flex-wrap:wrap; align-items:end;">
<input type="hidden" name="action" value="search_symbol">
<input type="hidden" name="instrument_id" value="<?= e((string) $selectedInstrumentId) ?>">
<label class="setup-field muted" style="margin:0; min-width:260px; flex:1;">
<span>Suchbegriff</span>
<input type="text" name="search_keywords" value="<?= e($searchKeywords) ?>" placeholder="z.B. Apple, AAPL, Allianz" required>
</label>
<button class="bc-button bc-button--primary" type="submit">Suchen</button>
</form>
</div>
<?php if ($searchResults !== []): ?>
<table class="bc-table">
<thead>
<tr>
<th>Symbol</th>
<th>Name</th>
<th>Region</th>
<th>Waehrung</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($searchResults as $result): ?>
<tr>
<td><strong><?= e((string) ($result['symbol'] ?? '')) ?></strong></td>
<td><?= e((string) ($result['name'] ?? '')) ?></td>
<td><?= e((string) ($result['region'] ?? '')) ?></td>
<td><?= e((string) ($result['currency'] ?? '')) ?></td>
<td>
<a class="bc-button bc-button--secondary" href="/module/boersenchecker/aktienverwaltung?instrument_id=<?= e((string) $selectedInstrumentId) ?>&symbol_candidate=<?= urlencode((string) ($result['symbol'] ?? '')) ?>&instrument_name_candidate=<?= urlencode((string) ($result['name'] ?? '')) ?>&isin_candidate=<?= urlencode((string) ($result['isin'] ?? '')) ?>&market_candidate=<?= urlencode((string) ($result['region'] ?? '')) ?>&quote_currency_candidate=<?= urlencode((string) ($result['currency'] ?? '')) ?>">
Uebernehmen
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<div class="bc-section-copy">
<div class="muted">Noch keine Symbolsuche ausgefuehrt.</div>
</div>
<?php endif; ?>
</section>
</div>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Aktie bearbeiten</h2>
<p>Stammdaten, Markt und Kurswaehrung zentral fuer die Aktie pflegen.</p>
</div>
</div>
<?php if (!$selectedInstrument || empty($selectedInstrument['id'])): ?>
<div class="muted" style="margin-top:16px;">Keine Aktie vorhanden.</div>
<?php else: ?>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_instrument">
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted"><span>Name</span><input type="text" name="instrument_name" value="<?= e((string) (($selectedInstrument['name'] ?? '') ?: ($_GET['instrument_name_candidate'] ?? ''))) ?>" required></label>
<label class="setup-field muted"><span>Symbol</span><input type="text" name="symbol" value="<?= e((string) (($selectedInstrument['symbol'] ?? '') ?: ($_GET['symbol_candidate'] ?? ''))) ?>"></label>
<label class="setup-field muted"><span>ISIN</span><input type="text" name="isin" value="<?= e((string) $selectedInstrument['isin'] ?? '') ?>"></label>
<label class="setup-field muted"><span>WKN</span><input type="text" name="wkn" value="<?= e((string) $selectedInstrument['wkn'] ?? '') ?>"></label>
<label class="setup-field muted"><span>Markt</span><input type="text" name="market" value="<?= e((string) (($selectedInstrument['market'] ?? '') ?: ($_GET['market_candidate'] ?? ''))) ?>"></label>
<label class="setup-field muted"><span>Kurswaehrung</span><input type="text" name="quote_currency" value="<?= e((string) (($selectedInstrument['quote_currency'] ?? $defaultReportCurrency) ?: ($_GET['quote_currency_candidate'] ?? $defaultReportCurrency))) ?>"></label>
</div>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Aktie speichern</button>
</div>
</form>
<form method="post" style="margin-top:12px;">
<input type="hidden" name="action" value="refresh_market_data_instrument">
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
<button class="bc-button bc-button--secondary" type="submit">Aktuellen API-Kurs abrufen</button>
</form>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Manuellen Kurs eingeben</h2>
<p>Einzelne Kurse mit Zeitstempel und Quelle fuer die ausgewaehlte Aktie speichern.</p>
</div>
</div>
<?php if (!$selectedInstrument || empty($selectedInstrument['id'])): ?>
<div class="muted" style="margin-top:16px;">Keine Aktie vorhanden.</div>
<?php else: ?>
<form method="post" style="margin-top:16px; display:grid; gap:12px;">
<input type="hidden" name="action" value="save_quote">
<input type="hidden" name="instrument_id" value="<?= e((string) ($selectedInstrument['id'] ?? 0)) ?>">
<div class="grid" style="grid-template-columns:repeat(auto-fit, minmax(180px, 1fr)); gap:10px;">
<label class="setup-field muted"><span>Kurs</span><input type="number" name="quote_price" min="0" step="0.00000001" required></label>
<label class="setup-field muted"><span>Waehrung</span><input type="text" name="quote_currency" value="<?= e((string) ($selectedInstrument['quote_currency'] ?? $defaultReportCurrency)) ?>" required></label>
<label class="setup-field muted"><span>Zeitpunkt</span><input type="datetime-local" name="quoted_at" value="<?= e($localNowInputValue) ?>" required></label>
<label class="setup-field muted"><span>Quelle</span><input type="text" name="quote_source" value="manual"></label>
</div>
<div class="bc-actions">
<button class="bc-button bc-button--primary" type="submit">Kurs speichern</button>
</div>
</form>
<?php endif; ?>
</section>
<section class="section-box">
<div class="bc-section-head">
<div>
<h2 class="bc-section-title">Kursverlauf</h2>
<p>Gespeicherte Kursdaten der ausgewaehlten Aktie mit Quelle und Loeschoption.</p>
</div>
</div>
<?php if ($quotes === []): ?>
<div class="bc-section-copy">
<div class="muted">Keine Kursdaten vorhanden.</div>
</div>
<?php else: ?>
<table class="bc-table">
<thead>
<tr>
<th>Zeitpunkt</th>
<th>Kurs</th>
<th>Quelle</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($quotes as $quote): ?>
<tr>
<td><?= e($fmtDateTime((string) $quote['quoted_at'], (string) ($quote['source'] ?? ''))) ?></td>
<td><?= e(number_format((float) $quote['price'], 4, ',', '.')) ?> <?= e((string) $quote['currency']) ?></td>
<td><?= e((string) $quote['source']) ?></td>
<td>
<form method="post" onsubmit="return confirm('Kurseintrag wirklich loeschen?')">
<input type="hidden" name="action" value="delete_quote">
<input type="hidden" name="instrument_id" value="<?= e((string) $selectedInstrumentId) ?>">
<input type="hidden" name="quote_id" value="<?= e((string) $quote['id']) ?>">
<button class="bc-button bc-button--secondary" type="submit">Loeschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
</div>
<?= module_shell_footer() ?>

View File

@@ -0,0 +1,898 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class DashboardPage
{
private PDO $pdo;
private array $user;
private bool $isAdmin;
private string $ownerSub;
private array $moduleSettings;
private string $defaultReportCurrency;
private float $fxMaxAgeHours;
private int $marketDataMinIntervalMinutes;
private int $editPortfolioId;
private int $editPositionId;
private string $portfolioTable;
private string $instrumentTable;
private string $positionTable;
private string $quoteTable;
private InstrumentRegistry $instrumentRegistry;
private string $symbolSearchKeywords = '';
private array $symbolSearchResults = [];
private array $availableOwners = [];
public function __construct()
{
$this->pdo = \module_fn('boersenchecker', 'pdo');
\module_fn('boersenchecker', 'ensure_schema');
$this->user = \auth_user() ?? [];
$this->isAdmin = \auth_is_admin();
$this->ownerSub = trim((string) ($this->user['sub'] ?? 'local'));
$this->availableOwners = $this->buildAvailableOwners();
if ($this->isAdmin) {
$requestedOwner = trim((string) ($_GET['owner_sub'] ?? $_POST['owner_sub'] ?? ''));
if ($requestedOwner !== '' && isset($this->availableOwners[$requestedOwner])) {
$this->ownerSub = $requestedOwner;
}
}
$this->moduleSettings = \modules()->settings('boersenchecker');
$this->defaultReportCurrency = $this->normalizeCurrency((string) ($this->moduleSettings['report_currency'] ?? 'EUR'));
$this->fxMaxAgeHours = (float) ($this->moduleSettings['fx_max_age_hours'] ?? 6);
if ($this->fxMaxAgeHours <= 0) {
$this->fxMaxAgeHours = 6.0;
}
$this->marketDataMinIntervalMinutes = (int) (($this->moduleSettings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
if ($this->marketDataMinIntervalMinutes <= 0) {
$this->marketDataMinIntervalMinutes = 60;
}
$this->editPortfolioId = (int) ($_GET['edit_portfolio'] ?? 0);
$this->editPositionId = (int) ($_GET['edit_position'] ?? 0);
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
$this->portfolioTable = $table('portfolios');
$this->instrumentTable = $table('instruments');
$this->positionTable = $table('positions');
$this->quoteTable = $table('quotes');
$this->instrumentRegistry = new InstrumentRegistry(
$this->pdo,
$this->instrumentTable,
$this->positionTable,
$this->quoteTable,
);
}
public function handle(): array
{
$notice = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$notice = $this->handlePost();
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
$state = $this->loadState();
if ($notice === null && isset($state['notice_override']) && is_string($state['notice_override'])) {
$notice = $state['notice_override'];
}
if ($error === null && isset($state['error_override']) && is_string($state['error_override'])) {
$error = $state['error_override'];
}
return [
'notice' => $notice,
'error' => $error,
'isAdmin' => $this->isAdmin,
'ownerSub' => $this->ownerSub,
'availableOwners' => array_values($this->availableOwners),
'defaultReportCurrency' => $this->defaultReportCurrency,
'fxMaxAgeHours' => $this->fxMaxAgeHours,
'marketDataMinIntervalMinutes' => $this->marketDataMinIntervalMinutes,
'symbolSearchKeywords' => $this->symbolSearchKeywords,
'symbolSearchResults' => $this->symbolSearchResults,
'editPortfolio' => $state['editPortfolio'],
'editPosition' => $state['editPosition'],
'portfolios' => $state['portfolios'],
'portfolioById' => $state['portfolioById'],
'portfolioStats' => $state['portfolioStats'],
'positions' => $state['positions'],
'instrumentList' => $state['instrumentList'],
'quoteHistory' => $state['quoteHistory'],
'selectedInstrumentForQuote' => $state['selectedInstrumentForQuote'],
'selectedInstrumentQuoteCurrency' => $state['selectedInstrumentQuoteCurrency'],
'fmtNumber' => fn (?float $value, int $scale = 2): string => $this->formatNumber($value, $scale),
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'),
];
}
private function handlePost(): string
{
$action = trim((string) ($_POST['action'] ?? ''));
return match ($action) {
'save_portfolio' => $this->savePortfolio(),
'delete_portfolio' => $this->deletePortfolio(),
'save_position' => $this->savePosition(),
'delete_position' => $this->deletePosition(),
'save_quote' => $this->saveQuote(),
'refresh_market_data_position' => $this->refreshMarketDataPosition(),
'refresh_market_data_all' => $this->refreshMarketDataAll(),
'search_symbol' => $this->searchSymbol(),
'delete_quote' => $this->deleteQuote(),
'refresh_fx' => $this->refreshFx(),
default => '',
};
}
private function loadState(): array
{
$portfolios = $this->fetchPortfolios();
$positions = $this->fetchPositions();
$instrumentList = $this->buildInstrumentList($positions);
[$quoteHistory, $latestQuotes] = $this->fetchQuotes(array_keys($instrumentList));
$portfolioById = $this->buildPortfolioById($portfolios);
[$positions, $portfolioStats] = $this->enrichPositions($positions, $portfolioById, $latestQuotes);
$editPortfolio = null;
if ($this->editPortfolioId > 0 && isset($portfolioById[$this->editPortfolioId])) {
$editPortfolio = $portfolioById[$this->editPortfolioId];
}
$editPosition = null;
if ($this->editPositionId > 0) {
foreach ($positions as $position) {
if ((int) $position['id'] === $this->editPositionId) {
$editPosition = $position;
break;
}
}
}
$selectedInstrumentForQuote = $editPosition
? (int) $editPosition['instrument_id']
: (int) ($_GET['instrument_id'] ?? 0);
$selectedInstrumentQuoteCurrency = $this->defaultReportCurrency;
if ($selectedInstrumentForQuote > 0 && isset($instrumentList[$selectedInstrumentForQuote])) {
$selectedInstrumentQuoteCurrency = $this->normalizeCurrency(
(string) ($instrumentList[$selectedInstrumentForQuote]['quote_currency'] ?? $this->defaultReportCurrency)
);
}
$candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? ''));
$candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? ''));
$candidateIsin = trim((string) ($_GET['isin_candidate'] ?? ''));
$candidateMarket = trim((string) ($_GET['market_candidate'] ?? ''));
$candidateCurrency = $this->normalizeCurrency((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency));
if ($editPosition === null) {
$editPosition = [
'instrument_name' => $candidateName,
'symbol' => $candidateSymbol,
'isin' => $candidateIsin,
'market' => $candidateMarket,
'quote_currency' => $candidateCurrency,
'purchase_currency' => $this->defaultReportCurrency,
'purchase_date' => date('Y-m-d'),
];
} else {
if ($candidateName !== '') {
$editPosition['instrument_name'] = $candidateName;
}
if ($candidateSymbol !== '') {
$editPosition['symbol'] = $candidateSymbol;
}
if ($candidateIsin !== '') {
$editPosition['isin'] = $candidateIsin;
}
if ($candidateMarket !== '') {
$editPosition['market'] = $candidateMarket;
}
if ($candidateCurrency !== '') {
$editPosition['quote_currency'] = $candidateCurrency;
}
}
return [
'portfolios' => $portfolios,
'portfolioById' => $portfolioById,
'portfolioStats' => $portfolioStats,
'positions' => $positions,
'instrumentList' => $instrumentList,
'quoteHistory' => $quoteHistory,
'editPortfolio' => $editPortfolio,
'editPosition' => $editPosition,
'selectedInstrumentForQuote' => $selectedInstrumentForQuote,
'selectedInstrumentQuoteCurrency' => $selectedInstrumentQuoteCurrency,
];
}
private function savePortfolio(): string
{
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
$name = trim((string) ($_POST['portfolio_name'] ?? ''));
$baseCurrency = $this->normalizeCurrency((string) ($_POST['portfolio_base_currency'] ?? $this->defaultReportCurrency));
$notes = trim((string) ($_POST['portfolio_notes'] ?? ''));
if ($name === '') {
throw new RuntimeException('Bitte einen Depotnamen angeben.');
}
if ($portfolioId > 0) {
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->portfolioTable . '
SET name = :name, base_currency = :base_currency, notes = :notes, updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND owner_sub = :owner_sub'
);
$stmt->execute([
'id' => $portfolioId,
'owner_sub' => $this->ownerSub,
'name' => $name,
'base_currency' => $baseCurrency,
'notes' => $notes !== '' ? $notes : null,
]);
return 'Depot aktualisiert.';
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->portfolioTable . ' (owner_sub, name, base_currency, notes)
VALUES (:owner_sub, :name, :base_currency, :notes)'
);
$stmt->execute([
'owner_sub' => $this->ownerSub,
'name' => $name,
'base_currency' => $baseCurrency,
'notes' => $notes !== '' ? $notes : null,
]);
return 'Depot angelegt.';
}
private function deletePortfolio(): string
{
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
$countStmt = $this->pdo->prepare('SELECT COUNT(*) FROM ' . $this->positionTable . ' WHERE portfolio_id = :portfolio_id AND owner_sub = :owner_sub');
$countStmt->execute([
'portfolio_id' => $portfolioId,
'owner_sub' => $this->ownerSub,
]);
if ((int) $countStmt->fetchColumn() > 0) {
throw new RuntimeException('Depot kann erst geloescht werden, wenn alle Positionen entfernt wurden.');
}
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub');
$stmt->execute([
'id' => $portfolioId,
'owner_sub' => $this->ownerSub,
]);
return 'Depot geloescht.';
}
private function savePosition(): string
{
$positionId = (int) ($_POST['position_id'] ?? 0);
$portfolioId = (int) ($_POST['portfolio_id'] ?? 0);
$quantity = (float) ($_POST['quantity'] ?? 0);
$purchasePrice = (float) ($_POST['purchase_price'] ?? 0);
$purchaseCurrency = $this->normalizeCurrency((string) ($_POST['purchase_currency'] ?? $this->defaultReportCurrency));
$purchaseDate = trim((string) ($_POST['purchase_date'] ?? ''));
$fees = trim((string) ($_POST['fees'] ?? ''));
$notes = trim((string) ($_POST['position_notes'] ?? ''));
if ($portfolioId <= 0) {
throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.');
}
if ($quantity <= 0 || $purchasePrice <= 0 || $purchaseDate === '') {
throw new RuntimeException('Bitte Stueckzahl, Kaufpreis und Kaufdatum angeben.');
}
$portfolioOwnerStmt = $this->pdo->prepare('SELECT id FROM ' . $this->portfolioTable . ' WHERE id = :id AND owner_sub = :owner_sub LIMIT 1');
$portfolioOwnerStmt->execute([
'id' => $portfolioId,
'owner_sub' => $this->ownerSub,
]);
if ($portfolioOwnerStmt->fetchColumn() === false) {
throw new RuntimeException('Das ausgewaehlte Depot ist nicht verfuegbar.');
}
$instrumentId = $this->upsertInstrument([
'id' => (int) ($_POST['instrument_id'] ?? 0),
'isin' => $_POST['isin'] ?? '',
'wkn' => $_POST['wkn'] ?? '',
'symbol' => $_POST['symbol'] ?? '',
'name' => $_POST['instrument_name'] ?? '',
'quote_currency' => $_POST['quote_currency'] ?? $purchaseCurrency,
'market' => $_POST['market'] ?? '',
]);
$payload = [
'owner_sub' => $this->ownerSub,
'portfolio_id' => $portfolioId,
'instrument_id' => $instrumentId,
'quantity' => $quantity,
'purchase_price' => $purchasePrice,
'purchase_currency' => $purchaseCurrency,
'purchase_date' => $purchaseDate,
'fees' => $fees !== '' ? (float) $fees : null,
'notes' => $notes !== '' ? $notes : null,
];
if ($positionId > 0) {
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->positionTable . '
SET portfolio_id = :portfolio_id,
instrument_id = :instrument_id,
quantity = :quantity,
purchase_price = :purchase_price,
purchase_currency = :purchase_currency,
purchase_date = :purchase_date,
fees = :fees,
notes = :notes,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id AND owner_sub = :owner_sub'
);
$stmt->execute($payload + ['id' => $positionId]);
return 'Position aktualisiert.';
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->positionTable . ' (
owner_sub, portfolio_id, instrument_id, quantity, purchase_price, purchase_currency, purchase_date, fees, notes
) VALUES (
:owner_sub, :portfolio_id, :instrument_id, :quantity, :purchase_price, :purchase_currency, :purchase_date, :fees, :notes
)'
);
$stmt->execute($payload);
return 'Position gespeichert.';
}
private function deletePosition(): string
{
$positionId = (int) ($_POST['position_id'] ?? 0);
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->positionTable . ' WHERE id = :id AND owner_sub = :owner_sub');
$stmt->execute([
'id' => $positionId,
'owner_sub' => $this->ownerSub,
]);
return 'Position geloescht.';
}
private function saveQuote(): string
{
$instrumentId = (int) ($_POST['quote_instrument_id'] ?? 0);
$price = (float) ($_POST['quote_price'] ?? 0);
$currency = $this->normalizeCurrency((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency));
$quotedAt = $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? ''));
$source = trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual';
if ($instrumentId <= 0 || $price <= 0) {
throw new RuntimeException('Bitte Aktie und Kurs angeben.');
}
$this->storeQuote($instrumentId, $price, $currency, $quotedAt, $source);
return 'Kurs gespeichert.';
}
private function refreshMarketDataPosition(): string
{
$positionId = (int) ($_POST['position_id'] ?? 0);
$stmt = $this->pdo->prepare(
'SELECT
p.instrument_id,
i.name AS instrument_name,
i.symbol,
i.isin,
i.quote_currency
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.id = :id AND p.owner_sub = :owner_sub
LIMIT 1'
);
$stmt->execute([
'id' => $positionId,
'owner_sub' => $this->ownerSub,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($row)) {
throw new RuntimeException('Position nicht gefunden.');
}
$instrumentId = (int) $row['instrument_id'];
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
$quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency));
if ($symbol === '') {
throw new RuntimeException('Fuer diese Aktie ist noch kein Symbol hinterlegt.');
}
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
$latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false;
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
return 'Vorhandener Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.';
}
$apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
if (empty($apiResult['ok'])) {
throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'));
}
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours);
if (empty($fxResult['ok'])) {
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
}
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
$displayTime = (string) \module_fn(
'boersenchecker',
'format_datetime_for_display',
(string) ($apiResult['fetched_at'] ?? ''),
(string) ($apiResult['source'] ?? 'alphavantage:global_quote')
);
return 'Alpha Vantage lieferte fuer ' . (string) $row['instrument_name'] . ' keinen neueren Snapshot als ' . $displayTime . '.';
}
$storeResult = \module_fn(
'boersenchecker',
'store_market_quote',
$instrumentId,
(float) $apiResult['price'],
$quoteCurrency,
(string) $apiResult['fetched_at'],
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
);
if (!empty($storeResult['inserted'])) {
return 'Alpha-Vantage-Kurs fuer ' . (string) $row['instrument_name'] . ' gespeichert.';
}
return 'Vorhandener Alpha-Vantage-Snapshot fuer ' . (string) $row['instrument_name'] . ' wiederverwendet.';
}
private function refreshMarketDataAll(): string
{
$stmt = $this->pdo->prepare(
'SELECT DISTINCT
i.id,
i.name,
i.symbol,
i.isin,
i.quote_currency
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.owner_sub = :owner_sub
ORDER BY i.name ASC'
);
$stmt->execute(['owner_sub' => $this->ownerSub]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if ($rows === []) {
throw new RuntimeException('Keine Positionen fuer den API-Abruf vorhanden.');
}
$fetched = 0;
$reused = 0;
$stale = 0;
$skipped = 0;
$failed = 0;
$errors = [];
$bulkRows = [];
foreach ($rows as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
if ($instrumentId <= 0 || $symbol === '') {
$skipped++;
continue;
}
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
$latestTimestamp = is_array($latestApiQuote) ? strtotime((string) ($latestApiQuote['quoted_at'] ?? '')) : false;
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
$reused++;
continue;
}
$bulkRows[] = $row;
}
if ($bulkRows !== []) {
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')),
$bulkRows
))));
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours);
if (empty($fxResult['ok'])) {
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
}
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
$bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkRows);
if (empty($bulkResult['ok'])) {
throw new RuntimeException((string) ($bulkResult['message'] ?? 'Alpha-Vantage-Abruf fehlgeschlagen.'));
}
$bulkQuotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
foreach ($bulkRows as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$quoteCurrency = $this->normalizeCurrency((string) ($row['quote_currency'] ?? $this->defaultReportCurrency));
$apiResult = $bulkQuotes[$instrumentId] ?? null;
if (!is_array($apiResult) || !is_numeric($apiResult['price'] ?? null)) {
$failed++;
$errors[] = (string) ($row['name'] ?? $instrumentId) . ': kein Preis in der Alpha-Vantage-Antwort.';
continue;
}
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
$stale++;
continue;
}
$storeResult = \module_fn(
'boersenchecker',
'store_market_quote',
$instrumentId,
(float) $apiResult['price'],
$quoteCurrency,
(string) $apiResult['fetched_at'],
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
);
if (!empty($storeResult['inserted'])) {
$fetched++;
} else {
$reused++;
}
}
}
if ($errors !== []) {
throw new RuntimeException(
'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler. '
. implode(' | ', array_slice($errors, 0, 3))
);
}
return 'Alpha Vantage: ' . $fetched . ' neu, ' . $reused . ' wiederverwendet, ' . $stale . ' nicht neuer, ' . $skipped . ' ohne Symbol, ' . $failed . ' Fehler.';
}
private function searchSymbol(): string
{
$keywords = trim((string) ($_POST['search_keywords'] ?? ''));
$this->symbolSearchKeywords = $keywords;
$result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $keywords);
$this->symbolSearchResults = is_array($result['results'] ?? null) ? $result['results'] : [];
if (empty($result['ok'])) {
throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.'));
}
return (string) ($result['message'] ?? 'Suche abgeschlossen.');
}
private function deleteQuote(): string
{
$quoteId = (int) ($_POST['quote_id'] ?? 0);
$stmt = $this->pdo->prepare(
'DELETE FROM ' . $this->quoteTable . '
WHERE id = :id
AND instrument_id IN (
SELECT DISTINCT instrument_id
FROM ' . $this->positionTable . '
WHERE owner_sub = :owner_sub
)'
);
$stmt->execute([
'id' => $quoteId,
'owner_sub' => $this->ownerSub,
]);
return 'Kurseintrag geloescht.';
}
private function refreshFx(): string
{
$result = \module_fn('boersenchecker', 'fx_refresh', $this->defaultReportCurrency, $this->fxMaxAgeHours);
if (empty($result['ok'])) {
throw new RuntimeException((string) ($result['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
}
return (string) ($result['message'] ?? 'FX-Daten aktualisiert.');
}
private function fetchPortfolios(): array
{
$stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC');
$stmt->execute(['owner_sub' => $this->ownerSub]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
private function fetchPositions(): array
{
$stmt = $this->pdo->prepare(
'SELECT
p.*,
i.isin,
i.wkn,
i.symbol,
i.name AS instrument_name,
i.quote_currency,
i.market
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.owner_sub = :owner_sub
ORDER BY p.purchase_date DESC, p.id DESC'
);
$stmt->execute(['owner_sub' => $this->ownerSub]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
private function buildInstrumentList(array $positions): array
{
$instrumentList = [];
foreach ($positions as $position) {
$instrumentId = (int) $position['instrument_id'];
if (!isset($instrumentList[$instrumentId])) {
$instrumentList[$instrumentId] = [
'id' => $instrumentId,
'name' => (string) $position['instrument_name'],
'symbol' => (string) ($position['symbol'] ?? ''),
'isin' => (string) ($position['isin'] ?? ''),
'quote_currency' => (string) ($position['quote_currency'] ?? ''),
];
}
}
return $instrumentList;
}
private function fetchQuotes(array $instrumentIds): array
{
$quoteHistory = [];
$latestQuotes = [];
if ($instrumentIds === []) {
return [$quoteHistory, $latestQuotes];
}
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id IN (' . $placeholders . ')
ORDER BY quoted_at DESC, created_at DESC, id DESC'
);
$stmt->execute($instrumentIds);
$quotes = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
foreach ($quotes as $quote) {
$instrumentId = (int) $quote['instrument_id'];
$quoteHistory[$instrumentId][] = $quote;
if (!isset($latestQuotes[$instrumentId])) {
$latestQuotes[$instrumentId] = $quote;
}
}
return [$quoteHistory, $latestQuotes];
}
private function buildPortfolioById(array $portfolios): array
{
$portfolioById = [];
foreach ($portfolios as $portfolio) {
$portfolio['base_currency'] = $this->normalizeCurrency((string) ($portfolio['base_currency'] ?? $this->defaultReportCurrency));
$portfolioById[(int) $portfolio['id']] = $portfolio;
}
return $portfolioById;
}
private function enrichPositions(array $positions, array $portfolioById, array $latestQuotes): array
{
$portfolioStats = [];
foreach ($portfolioById as $portfolioId => $portfolio) {
$portfolioStats[$portfolioId] = [
'invested' => 0.0,
'current' => 0.0,
'gain' => 0.0,
'positions' => 0,
'has_invested' => false,
'has_current' => false,
];
}
foreach ($positions as &$position) {
$portfolioId = (int) $position['portfolio_id'];
$baseCurrency = (string) ($portfolioById[$portfolioId]['base_currency'] ?? $this->defaultReportCurrency);
$quantity = (float) $position['quantity'];
$purchasePrice = (float) $position['purchase_price'];
$fees = is_numeric($position['fees'] ?? null) ? (float) $position['fees'] : 0.0;
$purchaseTotal = ($quantity * $purchasePrice) + $fees;
$purchaseTotalBase = $this->convertAmount($purchaseTotal, (string) $position['purchase_currency'], $baseCurrency);
$latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null;
$currentTotalBase = null;
if (is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null)) {
$currentOriginal = $quantity * (float) $latestQuote['price'];
$currentTotalBase = $this->convertAmount($currentOriginal, (string) $latestQuote['currency'], $baseCurrency, (string) ($latestQuote['source'] ?? ''));
$position['latest_price'] = (float) $latestQuote['price'];
$position['latest_currency'] = (string) $latestQuote['currency'];
$position['latest_quoted_at'] = (string) $latestQuote['quoted_at'];
$position['latest_source'] = (string) ($latestQuote['source'] ?? '');
$position['current_total_base'] = $currentTotalBase;
} else {
$position['latest_price'] = null;
$position['latest_currency'] = null;
$position['latest_quoted_at'] = null;
$position['latest_source'] = null;
$position['current_total_base'] = null;
}
$position['purchase_total'] = $purchaseTotal;
$position['purchase_total_base'] = $purchaseTotalBase;
$position['base_currency'] = $baseCurrency;
$position['gain_base'] = $currentTotalBase !== null && $purchaseTotalBase !== null
? $currentTotalBase - $purchaseTotalBase
: null;
if (isset($portfolioStats[$portfolioId])) {
$portfolioStats[$portfolioId]['positions']++;
if ($purchaseTotalBase !== null) {
$portfolioStats[$portfolioId]['invested'] += $purchaseTotalBase;
$portfolioStats[$portfolioId]['has_invested'] = true;
}
if ($currentTotalBase !== null) {
$portfolioStats[$portfolioId]['current'] += $currentTotalBase;
$portfolioStats[$portfolioId]['has_current'] = true;
}
}
}
unset($position);
foreach ($portfolioStats as &$stats) {
$stats['gain'] = ($stats['has_invested'] && $stats['has_current'])
? $stats['current'] - $stats['invested']
: null;
}
unset($stats);
return [$positions, $portfolioStats];
}
private function latestApiQuoteForInstrument(int $instrumentId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id = :instrument_id
AND source LIKE :source
ORDER BY quoted_at DESC, created_at DESC, id DESC
LIMIT 1'
);
$stmt->execute([
'instrument_id' => $instrumentId,
'source' => 'alphavantage:%',
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
{
if (!is_array($latestQuote)) {
return false;
}
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
if ($latestTimestamp === false || $incomingTimestamp === false) {
return false;
}
return $incomingTimestamp <= $latestTimestamp;
}
private function upsertInstrument(array $payload): int
{
return $this->instrumentRegistry->save($payload);
}
private function storeQuote(int $instrumentId, float $price, string $currency, string $quotedAt, string $source): void
{
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source)
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
);
$stmt->execute([
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => $currency,
'quoted_at' => $quotedAt,
'source' => $source,
]);
}
private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float
{
if ($amount === null) {
return null;
}
$from = $this->normalizeCurrency($from);
$to = $this->normalizeCurrency($to);
if ($from === $to) {
return $amount;
}
try {
$fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0);
$value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null);
return is_numeric($value) ? (float) $value : null;
} catch (\Throwable) {
return null;
}
}
private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string
{
$normalized = strtoupper(trim((string) $value));
return $normalized !== '' ? $normalized : $fallback;
}
private function normalizeDateTimeLocal(?string $value): string
{
$timezone = new \DateTimeZone(nexus_display_timezone_name());
$value = trim((string) $value);
if ($value === '') {
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
}
$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone);
if ($date instanceof \DateTimeImmutable) {
return $date->format('Y-m-d H:i:s');
}
try {
return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s');
} catch (\Throwable) {
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
}
}
private function formatNumber(?float $value, int $scale = 2): string
{
if ($value === null) {
return 'n/a';
}
return number_format($value, $scale, ',', '.');
}
private function buildAvailableOwners(): array
{
$owners = [];
$currentSub = trim((string) ($this->user['sub'] ?? 'local'));
$owners[$currentSub] = [
'sub' => $currentSub,
'label' => trim((string) ($this->user['name'] ?? $this->user['email'] ?? $currentSub)) ?: $currentSub,
];
if (!$this->isAdmin) {
return $owners;
}
foreach (\modules()->knownAuthUsers() as $knownUser) {
$sub = trim((string) ($knownUser['sub'] ?? ''));
if ($sub === '') {
continue;
}
$label = trim((string) ($knownUser['name'] ?? $knownUser['email'] ?? $knownUser['username'] ?? $sub));
$owners[$sub] = [
'sub' => $sub,
'label' => $label !== '' ? $label : $sub,
];
}
uasort($owners, static fn (array $left, array $right): int => strcmp((string) $left['label'], (string) $right['label']));
return $owners;
}
}

View File

@@ -0,0 +1,364 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class HomePage
{
private PDO $pdo;
private string $ownerSub;
private string $portfolioTable;
private string $instrumentTable;
private string $positionTable;
private string $quoteTable;
private string $defaultReportCurrency;
private float $fxMaxAgeHours;
private int $marketDataMinIntervalMinutes;
public function __construct()
{
$this->pdo = \module_fn('boersenchecker', 'pdo');
\module_fn('boersenchecker', 'ensure_schema');
$user = \auth_user() ?? [];
$this->ownerSub = trim((string) ($user['sub'] ?? 'local'));
$settings = \modules()->settings('boersenchecker');
$this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
$this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6);
if ($this->fxMaxAgeHours <= 0) {
$this->fxMaxAgeHours = 6.0;
}
$this->marketDataMinIntervalMinutes = (int) (($settings['alpha_vantage_min_interval_minutes'] ?? null) ?: 60);
if ($this->marketDataMinIntervalMinutes <= 0) {
$this->marketDataMinIntervalMinutes = 60;
}
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
$this->portfolioTable = $table('portfolios');
$this->instrumentTable = $table('instruments');
$this->positionTable = $table('positions');
$this->quoteTable = $table('quotes');
}
public function handle(): array
{
$notice = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST' && (string) ($_POST['action'] ?? '') === 'refresh_current_quotes_home') {
try {
$notice = $this->refreshCurrentQuotesForPortfolio((int) ($_POST['portfolio_id'] ?? 0));
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
$portfolios = $this->fetchPortfolios();
$selectedPortfolioId = (int) ($_GET['portfolio_id'] ?? ($_POST['portfolio_id'] ?? 0));
if ($selectedPortfolioId <= 0 && $portfolios !== []) {
$selectedPortfolioId = (int) $portfolios[0]['id'];
}
$positions = $selectedPortfolioId > 0 ? $this->fetchPortfolioPositions($selectedPortfolioId) : [];
$selectedInstrumentId = (int) ($_GET['instrument_id'] ?? 0);
if ($selectedInstrumentId <= 0 && $positions !== []) {
$selectedInstrumentId = (int) $positions[0]['instrument_id'];
}
$latestQuotes = $this->fetchLatestQuotes(array_values(array_unique(array_map(static fn (array $row): int => (int) $row['instrument_id'], $positions))));
foreach ($positions as &$position) {
$latestQuote = $latestQuotes[(int) $position['instrument_id']] ?? null;
$position['latest_price'] = is_array($latestQuote) && is_numeric($latestQuote['price'] ?? null) ? (float) $latestQuote['price'] : null;
$position['latest_currency'] = is_array($latestQuote) ? (string) ($latestQuote['currency'] ?? '') : '';
$position['latest_quoted_at'] = is_array($latestQuote) ? (string) ($latestQuote['quoted_at'] ?? '') : '';
$position['latest_source'] = is_array($latestQuote) ? (string) ($latestQuote['source'] ?? '') : '';
$position['current_total_report'] = null;
$position['gain_report'] = null;
$position['gain_percent'] = null;
if ($position['latest_price'] !== null) {
$currentNative = (float) $position['latest_price'] * (float) ($position['quantity'] ?? 0);
$currentReport = $this->convertAmount(
$currentNative,
(string) ($position['latest_currency'] ?: ($position['quote_currency'] ?? $this->defaultReportCurrency)),
$this->defaultReportCurrency,
(string) ($position['latest_source'] ?? '')
);
$position['current_total_report'] = $currentReport;
if ($position['purchase_total_report'] !== null && $currentReport !== null) {
$gain = $currentReport - (float) $position['purchase_total_report'];
$position['gain_report'] = $gain;
$base = (float) $position['purchase_total_report'];
$position['gain_percent'] = $base > 0 ? ($gain / $base) * 100 : null;
}
}
}
unset($position);
$selectedInstrument = null;
foreach ($positions as $position) {
if ((int) $position['instrument_id'] === $selectedInstrumentId) {
$selectedInstrument = $position;
break;
}
}
return [
'notice' => $notice,
'error' => $error,
'portfolios' => $portfolios,
'selectedPortfolioId' => $selectedPortfolioId,
'positions' => $positions,
'selectedInstrumentId' => $selectedInstrumentId,
'selectedInstrument' => $selectedInstrument,
'summary' => $this->buildSummary($positions),
'defaultReportCurrency' => $this->defaultReportCurrency,
'chartEndpoint' => '/module/boersenchecker/chart_data',
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
];
}
private function refreshCurrentQuotesForPortfolio(int $portfolioId): string
{
if ($portfolioId <= 0) {
throw new RuntimeException('Bitte zuerst ein Depot auswaehlen.');
}
$stmt = $this->pdo->prepare(
'SELECT DISTINCT i.id, i.name, i.symbol, i.isin, i.quote_currency
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id'
);
$stmt->execute([
'owner_sub' => $this->ownerSub,
'portfolio_id' => $portfolioId,
]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
if ($rows === []) {
throw new RuntimeException('In diesem Depot sind keine Aktien vorhanden.');
}
$updated = 0;
$reused = 0;
$stale = 0;
$bulkCandidates = [];
foreach ($rows as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$symbol = strtoupper(trim((string) ($row['symbol'] ?? '')));
if ($instrumentId <= 0 || $symbol === '') {
continue;
}
$latest = $this->latestApiQuoteForInstrument($instrumentId);
$latestTimestamp = is_array($latest) ? strtotime((string) ($latest['quoted_at'] ?? '')) : false;
if ($latestTimestamp !== false && (time() - $latestTimestamp) < ($this->marketDataMinIntervalMinutes * 60)) {
$reused++;
continue;
}
$bulkCandidates[] = $row;
}
if ($bulkCandidates !== []) {
$quoteCurrencies = array_values(array_unique(array_filter(array_map(
fn (array $row): string => $this->normalizeCurrency((string) ($row['quote_currency'] ?? '')),
$bulkCandidates
))));
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, $quoteCurrencies, $this->fxMaxAgeHours);
if (empty($fxResult['ok'])) {
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
}
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
$bulkResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quotes', $bulkCandidates);
$quotes = is_array($bulkResult['quotes'] ?? null) ? $bulkResult['quotes'] : [];
foreach ($bulkCandidates as $row) {
$instrumentId = (int) ($row['id'] ?? 0);
$quote = $quotes[$instrumentId] ?? null;
if (!is_array($quote) || !is_numeric($quote['price'] ?? null)) {
continue;
}
$latest = $this->latestApiQuoteForInstrument($instrumentId);
if ($this->isApiSnapshotStale($latest, (string) ($quote['fetched_at'] ?? ''))) {
$stale++;
continue;
}
$storeResult = \module_fn(
'boersenchecker',
'store_market_quote',
$instrumentId,
(float) $quote['price'],
strtoupper(trim((string) ($quote['currency'] ?? $row['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency,
(string) ($quote['fetched_at'] ?? gmdate('Y-m-d H:i:s')),
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) ($quote['source'] ?? 'alphavantage:global_quote'), $fxFetchId)
);
if (!empty($storeResult['inserted'])) {
$updated++;
} else {
$reused++;
}
}
}
return 'Aktuelle Kurse: ' . $updated . ' aktualisiert, ' . $reused . ' wiederverwendet, ' . $stale . ' API-Snapshots waren nicht neuer.';
}
private function fetchPortfolios(): array
{
$stmt = $this->pdo->prepare('SELECT * FROM ' . $this->portfolioTable . ' WHERE owner_sub = :owner_sub ORDER BY name ASC');
$stmt->execute(['owner_sub' => $this->ownerSub]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
private function fetchPortfolioPositions(int $portfolioId): array
{
$stmt = $this->pdo->prepare(
'SELECT p.*, i.name AS instrument_name, i.symbol, i.isin, i.wkn, i.quote_currency, i.market
FROM ' . $this->positionTable . ' p
INNER JOIN ' . $this->instrumentTable . ' i ON i.id = p.instrument_id
WHERE p.owner_sub = :owner_sub AND p.portfolio_id = :portfolio_id
ORDER BY i.name ASC'
);
$stmt->execute([
'owner_sub' => $this->ownerSub,
'portfolio_id' => $portfolioId,
]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
foreach ($rows as &$row) {
$quantity = (float) ($row['quantity'] ?? 0);
$purchasePrice = (float) ($row['purchase_price'] ?? 0);
$fees = is_numeric($row['fees'] ?? null) ? (float) $row['fees'] : 0.0;
$purchaseTotal = ($quantity * $purchasePrice) + $fees;
$row['purchase_total'] = $purchaseTotal;
$row['purchase_total_report'] = $this->convertAmount(
$purchaseTotal,
(string) ($row['purchase_currency'] ?? $this->defaultReportCurrency),
$this->defaultReportCurrency
);
}
unset($row);
return $rows;
}
private function fetchLatestQuotes(array $instrumentIds): array
{
$result = [];
if ($instrumentIds === []) {
return $result;
}
$placeholders = implode(',', array_fill(0, count($instrumentIds), '?'));
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id IN (' . $placeholders . ')
ORDER BY quoted_at DESC, created_at DESC, id DESC'
);
$stmt->execute($instrumentIds);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
$instrumentId = (int) $row['instrument_id'];
if (!isset($result[$instrumentId])) {
$result[$instrumentId] = $row;
}
}
return $result;
}
private function latestApiQuoteForInstrument(int $instrumentId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id = :instrument_id AND source LIKE :source
ORDER BY quoted_at DESC, created_at DESC, id DESC
LIMIT 1'
);
$stmt->execute([
'instrument_id' => $instrumentId,
'source' => 'alphavantage:%',
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
{
if (!is_array($latestQuote)) {
return false;
}
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
if ($latestTimestamp === false || $incomingTimestamp === false) {
return false;
}
return $incomingTimestamp <= $latestTimestamp;
}
private function buildSummary(array $positions): array
{
$invested = 0.0;
$current = 0.0;
$hasInvested = false;
$hasCurrent = false;
$best = null;
$worst = null;
foreach ($positions as $position) {
if (is_numeric($position['purchase_total_report'] ?? null)) {
$invested += (float) $position['purchase_total_report'];
$hasInvested = true;
}
if (is_numeric($position['current_total_report'] ?? null)) {
$current += (float) $position['current_total_report'];
$hasCurrent = true;
}
if (is_numeric($position['gain_percent'] ?? null)) {
if ($best === null || (float) $position['gain_percent'] > (float) ($best['gain_percent'] ?? 0)) {
$best = $position;
}
if ($worst === null || (float) $position['gain_percent'] < (float) ($worst['gain_percent'] ?? 0)) {
$worst = $position;
}
}
}
return [
'positions' => count($positions),
'invested' => $hasInvested ? $invested : null,
'current' => $hasCurrent ? $current : null,
'gain' => ($hasInvested && $hasCurrent) ? $current - $invested : null,
'best' => $best,
'worst' => $worst,
];
}
private function convertAmount(?float $amount, string $from, string $to, ?string $quoteSource = null): ?float
{
if ($amount === null) {
return null;
}
$from = $this->normalizeCurrency($from, $this->defaultReportCurrency);
$to = $this->normalizeCurrency($to, $this->defaultReportCurrency);
if ($from === $to) {
return $amount;
}
try {
$fetchId = (int) (\module_fn('boersenchecker', 'fx_extract_fetch_id', (string) $quoteSource) ?? 0);
$value = \module_fn('boersenchecker', 'fx_convert_with_fetch', $amount, $from, $to, $fetchId > 0 ? $fetchId : null);
return is_numeric($value) ? (float) $value : null;
} catch (\Throwable) {
return null;
}
}
private function normalizeCurrency(?string $value, string $fallback = 'EUR'): string
{
$normalized = strtoupper(trim((string) $value));
return $normalized !== '' ? $normalized : $fallback;
}
}

View File

@@ -0,0 +1,351 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class InstrumentPage
{
private PDO $pdo;
private string $ownerSub;
private string $instrumentTable;
private string $positionTable;
private string $quoteTable;
private string $defaultReportCurrency;
private float $fxMaxAgeHours;
private string $searchKeywords = '';
private array $searchResults = [];
private int $selectedInstrumentOverrideId = 0;
private InstrumentRegistry $instrumentRegistry;
public function __construct()
{
$this->pdo = \module_fn('boersenchecker', 'pdo');
\module_fn('boersenchecker', 'ensure_schema');
$user = \auth_user() ?? [];
$this->ownerSub = trim((string) ($user['sub'] ?? 'local'));
$settings = \modules()->settings('boersenchecker');
$this->defaultReportCurrency = strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR';
$this->fxMaxAgeHours = (float) ($settings['fx_max_age_hours'] ?? 6);
if ($this->fxMaxAgeHours <= 0) {
$this->fxMaxAgeHours = 6.0;
}
$table = static fn (string $name): string => \module_fn('boersenchecker', 'table', $name);
$this->instrumentTable = $table('instruments');
$this->positionTable = $table('positions');
$this->quoteTable = $table('quotes');
$this->instrumentRegistry = new InstrumentRegistry(
$this->pdo,
$this->instrumentTable,
$this->positionTable,
$this->quoteTable,
);
}
public function handle(): array
{
$notice = null;
$error = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$notice = $this->handlePost();
} catch (\Throwable $e) {
$error = $e->getMessage();
}
}
$instruments = $this->fetchInstruments();
$selectedInstrumentId = $this->selectedInstrumentOverrideId > 0
? $this->selectedInstrumentOverrideId
: (int) ($_GET['instrument_id'] ?? ($_POST['instrument_id'] ?? 0));
if ($selectedInstrumentId <= 0 && $instruments !== []) {
$selectedInstrumentId = (int) $instruments[0]['id'];
}
$selectedInstrument = null;
foreach ($instruments as $instrument) {
if ((int) $instrument['id'] === $selectedInstrumentId) {
$selectedInstrument = $instrument;
break;
}
}
$quotes = $selectedInstrumentId > 0 ? $this->fetchQuotes($selectedInstrumentId) : [];
$candidateName = trim((string) ($_GET['instrument_name_candidate'] ?? ''));
$candidateSymbol = trim((string) ($_GET['symbol_candidate'] ?? ''));
$candidateIsin = trim((string) ($_GET['isin_candidate'] ?? ''));
$candidateMarket = trim((string) ($_GET['market_candidate'] ?? ''));
$candidateCurrency = strtoupper(trim((string) ($_GET['quote_currency_candidate'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency;
if ($selectedInstrument === null && ($candidateName !== '' || $candidateSymbol !== '' || $candidateMarket !== '')) {
$selectedInstrument = [
'id' => 0,
'name' => $candidateName,
'symbol' => $candidateSymbol,
'isin' => $candidateIsin,
'market' => $candidateMarket,
'quote_currency' => $candidateCurrency,
'wkn' => '',
];
}
return [
'notice' => $notice,
'error' => $error,
'instruments' => $instruments,
'selectedInstrument' => $selectedInstrument,
'selectedInstrumentId' => $selectedInstrumentId,
'quotes' => $quotes,
'searchKeywords' => $this->searchKeywords,
'searchResults' => $this->searchResults,
'defaultReportCurrency' => $this->defaultReportCurrency,
'fmtDateTime' => fn (?string $value, ?string $source = null): string => (string) \module_fn('boersenchecker', 'format_datetime_for_display', $value, $source),
'localNowInputValue' => (string) \module_fn('boersenchecker', 'local_now_input_value'),
];
}
private function handlePost(): string
{
$action = trim((string) ($_POST['action'] ?? ''));
return match ($action) {
'save_instrument' => $this->saveInstrument(),
'save_quote' => $this->saveQuote(),
'delete_quote' => $this->deleteQuote(),
'refresh_market_data_instrument' => $this->refreshInstrumentQuote(),
'search_symbol' => $this->searchSymbol(),
default => '',
};
}
private function fetchInstruments(): array
{
$stmt = $this->pdo->prepare(
'SELECT DISTINCT i.*
FROM ' . $this->instrumentTable . ' i
INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id
WHERE p.owner_sub = :owner_sub
ORDER BY i.name ASC'
);
$stmt->execute(['owner_sub' => $this->ownerSub]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
private function fetchQuotes(int $instrumentId): array
{
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id = :instrument_id
ORDER BY quoted_at DESC, created_at DESC, id DESC
LIMIT 30'
);
$stmt->execute(['instrument_id' => $instrumentId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
}
private function saveInstrument(): string
{
$instrumentId = (int) ($_POST['instrument_id'] ?? 0);
if ($instrumentId <= 0) {
throw new RuntimeException('Bitte eine Aktie auswaehlen.');
}
$this->assertInstrumentAccessible($instrumentId);
$resolvedId = $this->instrumentRegistry->save([
'id' => $instrumentId,
'name' => $_POST['instrument_name'] ?? '',
'symbol' => $_POST['symbol'] ?? '',
'isin' => $_POST['isin'] ?? '',
'wkn' => $_POST['wkn'] ?? '',
'market' => $_POST['market'] ?? '',
'quote_currency' => $_POST['quote_currency'] ?? $this->defaultReportCurrency,
]);
$this->selectedInstrumentOverrideId = $resolvedId;
return $resolvedId === $instrumentId
? 'Aktie aktualisiert.'
: 'Aktie aktualisiert und mit bestehendem Systemeintrag zusammengefuehrt.';
}
private function saveQuote(): string
{
$instrumentId = (int) ($_POST['instrument_id'] ?? 0);
$price = (float) ($_POST['quote_price'] ?? 0);
if ($instrumentId <= 0 || $price <= 0) {
throw new RuntimeException('Bitte Aktie und Kurs angeben.');
}
$this->assertInstrumentAccessible($instrumentId);
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->quoteTable . ' (instrument_id, price, currency, quoted_at, source)
VALUES (:instrument_id, :price, :currency, :quoted_at, :source)'
);
$stmt->execute([
'instrument_id' => $instrumentId,
'price' => $price,
'currency' => strtoupper(trim((string) ($_POST['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency,
'quoted_at' => $this->normalizeDateTimeLocal((string) ($_POST['quoted_at'] ?? '')),
'source' => trim((string) ($_POST['quote_source'] ?? 'manual')) ?: 'manual',
]);
return 'Kurs gespeichert.';
}
private function deleteQuote(): string
{
$quoteId = (int) ($_POST['quote_id'] ?? 0);
if ($quoteId <= 0) {
throw new RuntimeException('Bitte einen Kurseintrag auswaehlen.');
}
$stmt = $this->pdo->prepare(
'SELECT q.instrument_id
FROM ' . $this->quoteTable . ' q
WHERE q.id = :id
LIMIT 1'
);
$stmt->execute(['id' => $quoteId]);
$instrumentId = (int) $stmt->fetchColumn();
$this->assertInstrumentAccessible($instrumentId);
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->quoteTable . ' WHERE id = :id');
$stmt->execute(['id' => $quoteId]);
return 'Kurs geloescht.';
}
private function refreshInstrumentQuote(): string
{
$instrumentId = (int) ($_POST['instrument_id'] ?? 0);
$instrument = $this->assertInstrumentAccessible($instrumentId);
$symbol = strtoupper(trim((string) ($instrument['symbol'] ?? '')));
if ($symbol === '') {
throw new RuntimeException('Fuer diese Aktie ist kein Symbol hinterlegt.');
}
$apiResult = \module_fn('boersenchecker', 'alpha_vantage_fetch_quote_by_symbol', $symbol);
if (empty($apiResult['ok'])) {
throw new RuntimeException((string) ($apiResult['message'] ?? 'API-Abruf fehlgeschlagen.'));
}
$quoteCurrency = strtoupper(trim((string) ($instrument['quote_currency'] ?? $this->defaultReportCurrency))) ?: $this->defaultReportCurrency;
$fxResult = \module_fn('boersenchecker', 'fx_prepare_fetch', $this->defaultReportCurrency, [$quoteCurrency], $this->fxMaxAgeHours);
if (empty($fxResult['ok'])) {
throw new RuntimeException((string) ($fxResult['message'] ?? 'FX-Aktualisierung fehlgeschlagen.'));
}
$fxFetchId = is_numeric($fxResult['fetch_id'] ?? null) ? (int) $fxResult['fetch_id'] : null;
$latestApiQuote = $this->latestApiQuoteForInstrument($instrumentId);
if ($this->isApiSnapshotStale($latestApiQuote, (string) ($apiResult['fetched_at'] ?? ''))) {
$displayTime = (string) \module_fn(
'boersenchecker',
'format_datetime_for_display',
(string) ($apiResult['fetched_at'] ?? ''),
(string) ($apiResult['source'] ?? 'alphavantage:global_quote')
);
return 'Alpha Vantage lieferte keinen neueren Snapshot als ' . $displayTime . '.';
}
$storeResult = \module_fn(
'boersenchecker',
'store_market_quote',
$instrumentId,
(float) $apiResult['price'],
$quoteCurrency,
(string) $apiResult['fetched_at'],
(string) \module_fn('boersenchecker', 'fx_source_with_fetch_id', (string) $apiResult['source'], $fxFetchId)
);
return !empty($storeResult['inserted'])
? 'Alpha-Vantage-Kurs gespeichert.'
: 'Vorhandener Alpha-Vantage-Snapshot wiederverwendet.';
}
private function searchSymbol(): string
{
$this->searchKeywords = trim((string) ($_POST['search_keywords'] ?? ''));
$result = \module_fn('boersenchecker', 'alpha_vantage_search_symbols', $this->searchKeywords);
$this->searchResults = is_array($result['results'] ?? null) ? $result['results'] : [];
if (empty($result['ok'])) {
throw new RuntimeException((string) ($result['message'] ?? 'Symbolsuche fehlgeschlagen.'));
}
return (string) ($result['message'] ?? 'Suche abgeschlossen.');
}
private function assertInstrumentAccessible(int $instrumentId): array
{
if ($instrumentId <= 0) {
throw new RuntimeException('Aktie nicht gefunden.');
}
$stmt = $this->pdo->prepare(
'SELECT DISTINCT i.*
FROM ' . $this->instrumentTable . ' i
INNER JOIN ' . $this->positionTable . ' p ON p.instrument_id = i.id
WHERE i.id = :id AND p.owner_sub = :owner_sub
LIMIT 1'
);
$stmt->execute([
'id' => $instrumentId,
'owner_sub' => $this->ownerSub,
]);
$instrument = $stmt->fetch(PDO::FETCH_ASSOC);
if (!is_array($instrument)) {
throw new RuntimeException('Aktie ist nicht verfuegbar.');
}
return $instrument;
}
private function normalizeDateTimeLocal(?string $value): string
{
$timezone = new \DateTimeZone(nexus_display_timezone_name());
$value = trim((string) $value);
if ($value === '') {
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
}
$date = \DateTimeImmutable::createFromFormat('Y-m-d\TH:i', $value, $timezone);
if ($date instanceof \DateTimeImmutable) {
return $date->format('Y-m-d H:i:s');
}
try {
return (new \DateTimeImmutable($value, $timezone))->format('Y-m-d H:i:s');
} catch (\Throwable) {
return (new \DateTimeImmutable('now', $timezone))->format('Y-m-d H:i:s');
}
}
private function latestApiQuoteForInstrument(int $instrumentId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT *
FROM ' . $this->quoteTable . '
WHERE instrument_id = :instrument_id
AND source LIKE :source
ORDER BY quoted_at DESC, created_at DESC, id DESC
LIMIT 1'
);
$stmt->execute([
'instrument_id' => $instrumentId,
'source' => 'alphavantage:%',
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $row : null;
}
private function isApiSnapshotStale(?array $latestQuote, string $incomingQuotedAt): bool
{
if (!is_array($latestQuote)) {
return false;
}
$latestTimestamp = strtotime((string) ($latestQuote['quoted_at'] ?? ''));
$incomingTimestamp = strtotime(trim($incomingQuotedAt));
if ($latestTimestamp === false || $incomingTimestamp === false) {
return false;
}
return $incomingTimestamp <= $latestTimestamp;
}
}

View File

@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace Modules\Boersenchecker\Support;
use PDO;
use RuntimeException;
final class InstrumentRegistry
{
public function __construct(
private PDO $pdo,
private string $instrumentTable,
private string $positionTable,
private string $quoteTable,
) {
}
public function save(array $payload): int
{
$currentId = (int) ($payload['id'] ?? 0);
$data = $this->normalizePayload($payload);
$matchingId = $this->findMatchingInstrumentId($data, $currentId);
if ($currentId > 0 && $matchingId > 0 && $matchingId !== $currentId) {
return $this->mergeIntoExistingInstrument($currentId, $matchingId, $data);
}
if ($currentId > 0) {
$this->updateInstrument($currentId, $data);
return $currentId;
}
if ($matchingId > 0) {
$this->updateInstrument($matchingId, $data);
return $matchingId;
}
return $this->insertInstrument($data);
}
public function findMatchingInstrumentId(array $payload, int $excludeId = 0): ?int
{
$data = $this->normalizePayload($payload);
$conditions = [];
$excludeSql = $excludeId > 0 ? ' AND id <> :exclude_id' : '';
if ($data['isin'] !== null) {
$conditions[] = [
'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE isin = :isin' . $excludeSql . ' LIMIT 1',
'params' => ['isin' => $data['isin']],
];
}
if ($data['symbol'] !== null && $data['market'] !== null) {
$conditions[] = [
'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND market = :market' . $excludeSql . ' LIMIT 1',
'params' => ['symbol' => $data['symbol'], 'market' => $data['market']],
];
}
if ($data['symbol'] !== null && $data['name'] !== '') {
$conditions[] = [
'sql' => 'SELECT id FROM ' . $this->instrumentTable . ' WHERE symbol = :symbol AND name = :name' . $excludeSql . ' LIMIT 1',
'params' => ['symbol' => $data['symbol'], 'name' => $data['name']],
];
}
foreach ($conditions as $condition) {
$params = $condition['params'];
if ($excludeId > 0) {
$params['exclude_id'] = $excludeId;
}
$stmt = $this->pdo->prepare($condition['sql']);
$stmt->execute($params);
$id = $stmt->fetchColumn();
if ($id !== false) {
return (int) $id;
}
}
return null;
}
private function normalizePayload(array $payload): array
{
$data = [
'isin' => $this->normalizeUpper($payload['isin'] ?? null),
'wkn' => $this->normalizeUpper($payload['wkn'] ?? null),
'symbol' => $this->normalizeUpper($payload['symbol'] ?? null),
'name' => trim((string) ($payload['name'] ?? '')),
'quote_currency' => $this->normalizeUpper($payload['quote_currency'] ?? 'EUR', 'EUR'),
'market' => trim((string) ($payload['market'] ?? '')) ?: null,
];
if ($data['name'] === '') {
throw new RuntimeException('Bitte mindestens einen Aktiennamen angeben.');
}
return $data;
}
private function normalizeUpper(mixed $value, string $fallback = ''): ?string
{
$normalized = strtoupper(trim((string) $value));
if ($normalized !== '') {
return $normalized;
}
return $fallback !== '' ? $fallback : null;
}
private function updateInstrument(int $instrumentId, array $data): void
{
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->instrumentTable . '
SET isin = :isin,
wkn = :wkn,
symbol = :symbol,
name = :name,
quote_currency = :quote_currency,
market = :market,
updated_at = CURRENT_TIMESTAMP
WHERE id = :id'
);
$stmt->execute($data + ['id' => $instrumentId]);
}
private function insertInstrument(array $data): int
{
$driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
if ($driver === 'pgsql') {
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)
RETURNING id'
);
$stmt->execute($data);
return (int) $stmt->fetchColumn();
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->instrumentTable . ' (isin, wkn, symbol, name, quote_currency, market)
VALUES (:isin, :wkn, :symbol, :name, :quote_currency, :market)'
);
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
private function mergeIntoExistingInstrument(int $sourceId, int $targetId, array $data): int
{
$this->pdo->beginTransaction();
try {
$this->updateInstrument($targetId, $data);
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->positionTable . '
SET instrument_id = :target_id, updated_at = CURRENT_TIMESTAMP
WHERE instrument_id = :source_id'
);
$stmt->execute([
'target_id' => $targetId,
'source_id' => $sourceId,
]);
$stmt = $this->pdo->prepare(
'UPDATE ' . $this->quoteTable . '
SET instrument_id = :target_id
WHERE instrument_id = :source_id'
);
$stmt->execute([
'target_id' => $targetId,
'source_id' => $sourceId,
]);
$stmt = $this->pdo->prepare('DELETE FROM ' . $this->instrumentTable . ' WHERE id = :id');
$stmt->execute(['id' => $sourceId]);
$this->pdo->commit();
} catch (\Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
return $targetId;
}
}

View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$service = module_fn('fx-rates', 'service');
(new Modules\FxRates\Api\Router($service))->handle($_GET['path'] ?? '');

View File

@@ -0,0 +1,168 @@
(() => {
const root = document.getElementById('fx-rates-currencies');
if (!root) {
return;
}
const page = JSON.parse(root.dataset.page || '{}');
const currencies = Array.isArray(page.currencies) ? page.currencies : [];
const selected = new Set(
(Array.isArray(page.preferred_currencies) ? page.preferred_currencies : [])
.map((code) => String(code || '').trim().toUpperCase())
.filter(Boolean)
);
let displayBase = String(page.display_base_currency || '').trim().toUpperCase();
const savedDisplayBase = String(page.saved_display_base_currency || displayBase || '').trim().toUpperCase();
const nodes = {
tokenList: root.querySelector('[data-fx-token-list]'),
searchInput: root.querySelector('[data-fx-search-input]'),
suggestions: root.querySelector('[data-fx-suggestions]'),
displayBaseSelect: root.querySelector('[data-fx-display-base-select]'),
displayBaseHidden: root.querySelector('[data-fx-display-base-hidden]'),
hiddenPreferred: root.querySelector('[data-fx-hidden-preferred]'),
};
const escapeHtml = (value) => String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const currencyByCode = new Map(
currencies.map((currency) => [String(currency.code || '').toUpperCase(), currency])
);
const sortedSelectedCodes = () => Array.from(selected).sort((left, right) => left.localeCompare(right));
const ensureDisplayBase = () => {
const available = sortedSelectedCodes();
if (available.length === 0) {
displayBase = '';
return;
}
if (!displayBase || !selected.has(displayBase)) {
displayBase = available[0];
}
};
const renderHiddenInputs = () => {
if (!nodes.hiddenPreferred) {
return;
}
nodes.hiddenPreferred.innerHTML = sortedSelectedCodes()
.map((code) => `<input type="hidden" name="preferred_currencies[]" value="${escapeHtml(code)}">`)
.join('');
if (nodes.displayBaseHidden) {
nodes.displayBaseHidden.value = displayBase;
}
};
const renderDisplayBase = () => {
if (!nodes.displayBaseSelect) {
return;
}
ensureDisplayBase();
const available = sortedSelectedCodes();
nodes.displayBaseSelect.innerHTML = available.length
? available.map((code) => `<option value="${escapeHtml(code)}" ${code === displayBase ? 'selected' : ''}>${escapeHtml(code)}</option>`).join('')
: '<option value="">Keine Waehrungen ausgewaehlt</option>';
nodes.displayBaseSelect.disabled = available.length === 0;
};
const removeCode = (code) => {
selected.delete(code);
renderAll();
};
const renderTokens = () => {
if (!nodes.tokenList) {
return;
}
const selectedCodes = sortedSelectedCodes();
if (selectedCodes.length === 0) {
nodes.tokenList.innerHTML = '<div class="fx-text">Noch keine bevorzugten Waehrungen ausgewaehlt.</div>';
return;
}
nodes.tokenList.innerHTML = selectedCodes.map((code) => {
const currency = currencyByCode.get(code) || { code, name: code };
return `
<button type="button" class="fx-token" data-remove-code="${escapeHtml(code)}" title="${escapeHtml(code)} entfernen">
<span>${escapeHtml(`${code} (${currency.name || code})`)}</span>
<span class="fx-token-close">x</span>
</button>
`;
}).join('');
nodes.tokenList.querySelectorAll('[data-remove-code]').forEach((button) => {
button.addEventListener('click', () => {
removeCode(String(button.getAttribute('data-remove-code') || '').toUpperCase());
});
});
};
const renderSuggestions = () => {
if (!nodes.suggestions || !nodes.searchInput) {
return;
}
const needle = String(nodes.searchInput.value || '').trim().toLowerCase();
if (!needle) {
nodes.suggestions.innerHTML = '';
return;
}
const matches = currencies
.filter((currency) => !selected.has(String(currency.code || '').toUpperCase()))
.filter((currency) => {
const code = String(currency.code || '').toLowerCase();
const name = String(currency.name || '').toLowerCase();
return code.includes(needle) || name.includes(needle);
})
.slice(0, 12);
nodes.suggestions.innerHTML = matches.map((currency) => `
<button type="button" class="fx-suggestion" data-add-code="${escapeHtml(String(currency.code || '').toUpperCase())}">
<strong>${escapeHtml(String(currency.code || '').toUpperCase())}</strong>
<span>${escapeHtml(String(currency.name || ''))}</span>
</button>
`).join('');
nodes.suggestions.querySelectorAll('[data-add-code]').forEach((button) => {
button.addEventListener('click', () => {
const code = String(button.getAttribute('data-add-code') || '').toUpperCase();
if (code) {
selected.add(code);
if (nodes.searchInput) {
nodes.searchInput.value = '';
}
renderAll();
}
});
});
};
const renderAll = () => {
ensureDisplayBase();
renderTokens();
renderDisplayBase();
renderHiddenInputs();
renderSuggestions();
};
nodes.searchInput?.addEventListener('input', renderSuggestions);
nodes.displayBaseSelect?.addEventListener('change', () => {
displayBase = String(nodes.displayBaseSelect?.value || '').trim().toUpperCase();
renderHiddenInputs();
const url = new URL(window.location.href);
if (displayBase) {
url.searchParams.set('base', displayBase);
} else if (savedDisplayBase) {
url.searchParams.set('base', savedDisplayBase);
} else {
url.searchParams.delete('base');
}
window.location.href = url.toString();
});
renderAll();
})();

View File

@@ -0,0 +1,287 @@
#fx-rates-app,
#fx-rates-currencies {
display: contents;
}
.fx-section-head {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
flex-wrap: wrap;
}
.fx-section-head h2,
.section-box h2,
.card-box h2 {
margin: 0 0 0.5rem;
}
.fx-section-head p,
.section-box p,
.card-box p {
margin: 0 0 0.75rem;
color: var(--muted);
}
.fx-card-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.fx-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.fx-form-grid {
display: grid;
gap: 0.75rem;
}
.fx-form-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.fx-form-grid label,
.fx-block {
display: grid;
gap: 0.35rem;
}
.fx-form-grid span,
.fx-block span {
font-size: 0.9rem;
color: var(--muted);
}
.fx-form-grid input,
.fx-form-grid select,
.fx-block input {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.8rem;
background: var(--surface-strong);
color: var(--text);
}
.fx-message {
color: var(--text);
}
.fx-message.is-error {
color: #d92d20;
}
.fx-message.is-success {
color: color-mix(in srgb, var(--accent-green) 78%, var(--text));
}
.fx-table-wrap {
overflow-x: auto;
}
.fx-table {
width: 100%;
border-collapse: collapse;
}
.fx-table th,
.fx-table td {
text-align: left;
border-bottom: 1px solid var(--line);
padding: 0.65rem 0.4rem;
}
.fx-api-note {
margin-top: 0.75rem;
font-size: 0.95rem;
}
.fx-convert-result {
margin-top: 1rem;
min-height: 1.5rem;
font-size: 1rem;
font-weight: 700;
color: var(--text);
}
.fx-card-meta {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-size: 0.95rem;
text-align: right;
}
.fx-action-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.fx-action-row form {
margin: 0;
}
.fx-save-row {
margin-top: 1rem;
}
.fx-save-row form {
margin: 0;
}
.fx-mini-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.fx-card-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.fx-mini-label,
.fx-field-label {
font-size: 0.9rem;
color: var(--muted);
letter-spacing: 0.18em;
text-transform: uppercase;
}
.fx-card-value {
font-size: 1.2rem;
font-weight: 700;
}
.fx-currency-selection-row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
}
.fx-token-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.fx-token-list--inline {
flex: 1 1 auto;
}
.fx-currency-search {
flex: 0 1 360px;
min-width: 260px;
}
.fx-input,
.fx-select {
width: 100%;
border: 1px solid var(--line);
border-radius: 18px;
padding: 0.8rem 1rem;
background: var(--surface-strong);
color: var(--text);
}
.fx-field {
display: grid;
gap: 0.45rem;
margin-top: 1rem;
}
.fx-token,
.fx-suggestion {
display: inline-flex;
align-items: center;
gap: 0.6rem;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.7rem 1rem;
background: var(--surface-strong);
color: var(--text);
}
.fx-token {
cursor: pointer;
}
.fx-token:hover,
.fx-suggestion:hover {
border-color: color-mix(in srgb, var(--brand-accent-3) 45%, transparent);
background: color-mix(in srgb, var(--brand-accent) 6%, var(--surface-strong));
}
.fx-token-close {
color: var(--brand-accent-3);
font-weight: 700;
text-transform: uppercase;
}
.fx-suggestion-list {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.9rem;
}
.fx-suggestion {
cursor: pointer;
}
.fx-suggestion strong {
color: var(--text);
}
.fx-text {
color: var(--muted);
}
.fx-history-date {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.fx-info-button {
width: 1.4rem;
height: 1.4rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-strong);
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
line-height: 1;
cursor: help;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
@media (max-width: 860px) {
.fx-currency-selection-row {
flex-direction: column;
}
.fx-card-head {
align-items: flex-start;
}
.fx-currency-search {
flex: 1 1 auto;
width: 100%;
min-width: 0;
}
}

View File

@@ -0,0 +1,286 @@
(() => {
const root = document.getElementById('fx-rates-app');
if (!root) {
return;
}
const page = JSON.parse(root.dataset.page || '{}');
const settings = page.settings || {};
const nodes = {
historyHead: root.querySelector('[data-bind="history-head"]'),
historyBody: root.querySelector('[data-bind="history-body"]'),
convertResult: root.querySelector('[data-bind="convert-result"]'),
convertFrom: root.querySelector('select[name="convert_from"]'),
convertTo: root.querySelector('select[name="convert_to"]'),
convertAmount: root.querySelector('input[name="convert_amount"]'),
};
const apiBase = '/api/fx-rates/v1';
const preferredCurrencies = Array.isArray(page.preferred_currencies)
? page.preferred_currencies
.map((item) => String(item || '').trim().toUpperCase())
.filter(Boolean)
: [];
const refreshMaxAgeMinutes = Math.max(1, Number(settings.refresh_max_age_minutes || 60));
const parseDateValue = (value) => {
const raw = String(value || '').trim();
if (!raw) {
return null;
}
let normalized = raw.replace(' ', 'T');
normalized = normalized.replace(/([+-]\d{2})$/, '$1:00');
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? null : parsed;
};
const latestFetchedAt = () => {
const latest = page.latest && typeof page.latest === 'object' ? page.latest : null;
const direct = parseDateValue(latest?.fetched_at);
if (direct) {
return direct;
}
const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : [];
for (const entry of recentFetches) {
const parsed = parseDateValue(entry?.fetched_at);
if (parsed) {
return parsed;
}
}
return null;
};
const bindManualRefreshAction = () => {
const refreshLink = Array.from(document.querySelectorAll('a[href]')).find((link) => {
try {
const url = new URL(link.href, window.location.origin);
return url.pathname === '/module/fx-rates' && url.searchParams.get('refresh') === '1';
} catch (_error) {
return false;
}
});
if (!refreshLink) {
return;
}
refreshLink.addEventListener('click', (event) => {
const url = new URL(refreshLink.href, window.location.origin);
if (url.searchParams.get('force') === '1') {
return;
}
const lastFetch = latestFetchedAt();
if (!lastFetch) {
return;
}
const ageMinutes = (Date.now() - lastFetch.getTime()) / 60000;
if (!Number.isFinite(ageMinutes) || ageMinutes >= refreshMaxAgeMinutes) {
return;
}
event.preventDefault();
const confirmed = window.confirm(
`Der letzte gespeicherte Abruf ist juenger als ${refreshMaxAgeMinutes} Minuten. ` +
'Ein manueller Abruf wuerde die externe API trotzdem erneut aufrufen. Jetzt trotzdem abrufen?'
);
if (!confirmed) {
return;
}
url.searchParams.set('force', '1');
window.location.href = url.toString();
});
};
const escapeHtml = (value) => String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const renderHistory = (rows, currencies) => {
if (!nodes.historyHead || !nodes.historyBody) {
return;
}
const series = Array.isArray(currencies) ? currencies : [];
if (!series.length) {
nodes.historyHead.innerHTML = '<tr><th>Datum</th><th>Kurse</th></tr>';
nodes.historyBody.innerHTML = '<tr><td colspan="2">Keine bevorzugten Waehrungen fuer den Verlauf vorhanden.</td></tr>';
return;
}
nodes.historyHead.innerHTML = `
<tr>
<th>Datum</th>
${series.map((currency) => `<th>${currency}</th>`).join('')}
</tr>
`;
const entries = Array.isArray(rows) ? rows : [];
if (!entries.length) {
nodes.historyBody.innerHTML = `<tr><td colspan="${series.length + 1}">Noch keine Verlaufsdaten vorhanden.</td></tr>`;
return;
}
nodes.historyBody.innerHTML = entries.map((entry) => `
<tr>
<td>
<div class="fx-history-date">
<span>${entry.label}</span>
${entry.fetch ? `
<button
type="button"
class="fx-info-button"
title="${escapeHtml(`Basis: ${entry.fetch.base_currency || '-'} | Provider: ${entry.fetch.provider || '-'} | Ausloeser: ${entry.fetch.trigger_source_label || entry.fetch.trigger_source || '-'}`)}"
aria-label="${escapeHtml(`Abrufinfo fuer ${entry.label}`)}"
>i</button>
` : ''}
</div>
</td>
${series.map((currency) => {
const value = entry.rates?.[currency];
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '<td></td>';
}
return `<td>${value.toLocaleString('de-DE', { maximumFractionDigits: 8 })}</td>`;
}).join('')}
</tr>
`).join('');
};
const request = async (path, options = {}) => {
const response = await fetch(`${apiBase}${path}`, {
credentials: 'same-origin',
headers: {
Accept: 'application/json',
...(options.body ? { 'Content-Type': 'application/json' } : {}),
...(options.headers || {}),
},
...options,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.context?.message || payload?.error || `HTTP ${response.status}`);
}
return payload.data;
};
const loadHistory = async () => {
const base = String(
settings.display_base_currency || settings.default_base_currency || 'EUR'
).trim().toUpperCase();
const selectedCurrencies = preferredCurrencies.length
? preferredCurrencies
: [base];
const historyCurrencies = selectedCurrencies.filter((currency) => currency !== base);
if (!selectedCurrencies.length) {
renderHistory([], []);
return;
}
const histories = await Promise.all(historyCurrencies.map(async (currency) => {
const query = new URLSearchParams({ from: base, to: currency, limit: '20' });
const rows = await request(`/history?${query.toString()}`);
return { currency, rows: Array.isArray(rows) ? rows : [] };
}));
const recentFetches = Array.isArray(page.recent_fetches) ? page.recent_fetches : [];
const byDate = new Map();
recentFetches.forEach((fetch) => {
const key = String(fetch?.fetched_at || '').trim();
if (!key || byDate.has(key)) {
return;
}
byDate.set(key, {
sortKey: key,
label: fetch?.fetched_at_display || fetch?.fetched_at || key,
fetch,
rates: base !== '' ? { [base]: 1 } : {},
});
});
histories.forEach(({ currency, rows }) => {
rows.forEach((row) => {
const key = String(row?.fetched_at || row?.rate_date || '').trim();
if (!key) {
return;
}
if (!byDate.has(key)) {
byDate.set(key, {
sortKey: key,
label: row?.fetched_at_display || row?.fetched_at || row?.rate_date || key,
fetch: recentFetches.find((fetch) => String(fetch?.fetched_at || '').trim() === key) || null,
rates: base !== '' ? { [base]: 1 } : {},
});
}
const entry = byDate.get(key);
if (entry && typeof row?.rate === 'number' && Number.isFinite(row.rate)) {
entry.rates[currency] = row.rate;
}
});
});
const mergedRows = Array.from(byDate.values())
.sort((left, right) => String(right.sortKey).localeCompare(String(left.sortKey)))
.slice(0, 15);
renderHistory(mergedRows, selectedCurrencies);
};
const calculateConversion = async () => {
if (!nodes.convertFrom || !nodes.convertTo || !nodes.convertAmount || !nodes.convertResult) {
return;
}
const from = String(nodes.convertFrom.value || '').trim().toUpperCase();
const to = String(nodes.convertTo.value || '').trim().toUpperCase();
const amount = Number(nodes.convertAmount.value || '0');
if (!from || !to || !Number.isFinite(amount)) {
nodes.convertResult.textContent = 'Bitte Quellwaehrung, Zielwaehrung und Betrag angeben.';
return;
}
if (from === to) {
nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to}`;
return;
}
try {
const query = new URLSearchParams({ from, to });
const data = await request(`/rate?${query.toString()}`);
const rate = Number(data?.rate || 0);
if (!Number.isFinite(rate) || rate <= 0) {
throw new Error('Kein Kurs verfuegbar.');
}
const converted = amount * rate;
nodes.convertResult.textContent = `${amount.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${from} = ${converted.toLocaleString('de-DE', { maximumFractionDigits: 8 })} ${to} | Kurs ${rate.toLocaleString('de-DE', { maximumFractionDigits: 8 })}`;
} catch (error) {
nodes.convertResult.textContent = error.message || 'Umrechnung konnte nicht berechnet werden.';
}
};
[nodes.convertFrom, nodes.convertTo, nodes.convertAmount].forEach((node) => {
node?.addEventListener('change', () => {
calculateConversion().catch(() => {});
});
node?.addEventListener('input', () => {
calculateConversion().catch(() => {});
});
});
bindManualRefreshAction();
loadHistory().catch(() => {
renderHistory([], preferredCurrencies);
});
calculateConversion().catch(() => {});
})();

View File

@@ -0,0 +1,269 @@
<?php
declare(strict_types=1);
use App\ModuleConfigException;
use Modules\FxRates\Domain\FxRatesService;
use Modules\FxRates\Infrastructure\FxRatesRepository;
spl_autoload_register(static function (string $class): void {
$prefix = 'Modules\\FxRates\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$file = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($file)) {
require_once $file;
}
});
$moduleName = 'fx-rates';
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
$mm->registerFunction($moduleName, 'table', static function (string $name): string {
$prefix = 'fxrate_';
$sanitized = preg_replace('/[^a-zA-Z0-9_]/', '', $name);
return $prefix . $sanitized;
});
$mm->registerFunction($moduleName, 'settings', static function (): array {
$saved = modules()->settings('fx-rates');
$provider = trim((string) ($saved['provider'] ?? (getenv('FX_RATES_PROVIDER') ?: getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi')));
$apiVersion = strtolower(trim((string) ($saved['api_version'] ?? 'v2')));
$apiUrl = rtrim((string) ($saved['api_url'] ?? (getenv('FX_RATES_API_URL') ?: getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net')), '/');
$apiKey = trim((string) ($saved['api_key'] ?? (getenv('FX_RATES_API_KEY') ?: getenv('MINING_CHECKER_FX_API_KEY') ?: '')));
$timeout = max(2, (int) ($saved['timeout_sec'] ?? (getenv('FX_RATES_TIMEOUT') ?: getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10)));
$preferredCurrencies = $saved['preferred_currencies'] ?? ['EUR', 'USD', 'DOGE'];
if (is_string($preferredCurrencies)) {
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
}
$preferredCurrencies = array_values(array_unique(array_filter(array_map(
static fn (mixed $code): string => strtoupper(trim((string) $code)),
is_array($preferredCurrencies) ? $preferredCurrencies : []
), static fn (string $code): bool => $code !== '')));
$currencyCatalog = $saved['currency_catalog'] ?? [];
$currencyCatalog = array_values(array_filter(array_map(static function (mixed $item): ?array {
if (!is_array($item)) {
return null;
}
$code = strtoupper(trim((string) ($item['code'] ?? '')));
$name = trim((string) ($item['name'] ?? ''));
if ($code === '' || $name === '') {
return null;
}
return ['code' => $code, 'name' => $name];
}, is_array($currencyCatalog) ? $currencyCatalog : [])));
return [
'provider' => $provider !== '' ? $provider : 'currencyapi',
'api_version' => in_array($apiVersion, ['v2', 'v3'], true) ? $apiVersion : 'v2',
'api_url' => $apiUrl,
'api_key' => $apiKey,
'timeout_sec' => $timeout,
'refresh_max_age_minutes' => max(1, (int) ($saved['refresh_max_age_minutes'] ?? 60)),
'default_base_currency' => strtoupper(trim((string) ($saved['default_base_currency'] ?? 'EUR'))) ?: 'EUR',
'display_base_currency' => strtoupper(trim((string) ($saved['display_base_currency'] ?? ($saved['default_base_currency'] ?? 'EUR')))) ?: 'EUR',
'preferred_currencies' => $preferredCurrencies,
'currency_catalog' => $currencyCatalog,
'currency_catalog_synced_at' => trim((string) ($saved['currency_catalog_synced_at'] ?? '')),
'schedule_timezone' => trim((string) ($saved['schedule_timezone'] ?? nexus_cron_timezone_name())) ?: nexus_cron_timezone_name(),
];
});
$mm->registerFunction($moduleName, 'save_runtime_settings', static function (array $payload): array {
$current = modules()->settings('fx-rates');
$normalized = module_fn('fx-rates', 'settings');
if (array_key_exists('default_base_currency', $payload)) {
$normalized['default_base_currency'] = strtoupper(trim((string) $payload['default_base_currency'])) ?: $normalized['default_base_currency'];
}
if (array_key_exists('display_base_currency', $payload)) {
$normalized['display_base_currency'] = strtoupper(trim((string) $payload['display_base_currency'])) ?: $normalized['display_base_currency'];
}
if (array_key_exists('preferred_currencies', $payload)) {
$preferredCurrencies = $payload['preferred_currencies'];
if (is_string($preferredCurrencies)) {
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
}
$normalized['preferred_currencies'] = array_values(array_unique(array_filter(array_map(
static fn (mixed $code): string => strtoupper(trim((string) $code)),
is_array($preferredCurrencies) ? $preferredCurrencies : []
), static fn (string $code): bool => $code !== '')));
}
$catalogCodes = [];
foreach (($normalized['currency_catalog'] ?? []) as $currency) {
if (is_array($currency)) {
$code = strtoupper(trim((string) ($currency['code'] ?? '')));
if ($code !== '') {
$catalogCodes[$code] = true;
}
}
}
if ($catalogCodes !== [] && !isset($catalogCodes[$normalized['display_base_currency']])) {
$normalized['display_base_currency'] = $normalized['default_base_currency'];
}
if ($catalogCodes !== []) {
$normalized['preferred_currencies'] = array_values(array_filter(
$normalized['preferred_currencies'],
static fn (string $code): bool => isset($catalogCodes[$code])
));
}
$toSave = array_merge($current, [
'default_base_currency' => $normalized['default_base_currency'],
'display_base_currency' => $normalized['display_base_currency'],
'preferred_currencies' => $normalized['preferred_currencies'],
]);
modules()->saveSettings('fx-rates', $toSave);
return module_fn('fx-rates', 'settings');
});
$mm->registerFunction($moduleName, 'pdo', function () use ($moduleName): PDO {
$settings = modules()->settings($moduleName);
$useSeparate = !empty($settings['use_separate_db']);
if ($useSeparate) {
$module = modules()->get($moduleName);
$fallback = $module['db_defaults'] ?? [];
return modules()->modulePdo($moduleName, $fallback);
}
$base = app()->basePdo();
if ($base instanceof PDO) {
return $base;
}
throw new ModuleConfigException(
$moduleName,
'Base-DB ist deaktiviert. Bitte Base-DB aktivieren oder eine eigene Modul-DB konfigurieren.'
);
});
$mm->registerFunction($moduleName, 'repository', static function (): FxRatesRepository {
return new FxRatesRepository(module_fn('fx-rates', 'pdo'), 'fxrate_');
});
$mm->registerFunction($moduleName, 'ensure_schema', static function (): void {
module_fn('fx-rates', 'repository')->ensureSchema();
});
$mm->registerFunction($moduleName, 'service', static function (): FxRatesService {
module_fn('fx-rates', 'ensure_schema');
return new FxRatesService(
module_fn('fx-rates', 'repository'),
module_fn('fx-rates', 'settings')
);
});
$mm->registerFunction($moduleName, 'setup_actions', static function (): array {
return [
[
'name' => 'sync_currency_catalog',
'label' => 'Waehrungskatalog synchronisieren',
'help' => 'Laedt die verfuegbaren Waehrungen einmalig aus dem konfigurierten FX-Provider.',
],
];
});
$mm->registerFunction($moduleName, 'run_setup_action', static function (string $action): array {
return match ($action) {
'sync_currency_catalog' => (static function (): array {
$result = module_fn('fx-rates', 'service')->refreshCurrencyCatalog();
$current = modules()->settings('fx-rates');
$catalog = [];
foreach (is_array($result['currencies'] ?? null) ? $result['currencies'] : [] as $item) {
if (!is_array($item)) {
continue;
}
$code = strtoupper(trim((string) ($item['code'] ?? '')));
$name = trim((string) ($item['name'] ?? ''));
if ($code === '' || $name === '') {
continue;
}
$catalog[$code] = ['code' => $code, 'name' => $name];
}
$latest = module_fn('fx-rates', 'service')->latestStatus();
if (is_array($latest) && !empty($latest['id'])) {
$snapshot = module_fn('fx-rates', 'snapshot', null, null, null, null);
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
foreach (array_keys($rates) as $code) {
$code = strtoupper(trim((string) $code));
if ($code !== '' && !isset($catalog[$code])) {
$catalog[$code] = ['code' => $code, 'name' => $code];
}
}
$baseCode = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
if ($baseCode !== '' && !isset($catalog[$baseCode])) {
$catalog[$baseCode] = ['code' => $baseCode, 'name' => $baseCode];
}
}
foreach ([
(string) ($current['default_base_currency'] ?? ''),
(string) ($current['display_base_currency'] ?? ''),
...((is_array($current['preferred_currencies'] ?? null) ? $current['preferred_currencies'] : [])),
] as $code) {
$code = strtoupper(trim((string) $code));
if ($code !== '' && !isset($catalog[$code])) {
$catalog[$code] = ['code' => $code, 'name' => $code];
}
}
ksort($catalog);
$current['currency_catalog'] = array_values($catalog);
$current['currency_catalog_synced_at'] = gmdate('Y-m-d H:i:s');
modules()->saveSettings('fx-rates', $current);
return $result + [
'currencies' => array_values($catalog),
'synced_count' => count($catalog),
'message' => 'Waehrungskatalog synchronisiert. ' . count($catalog) . ' Waehrungen verarbeitet.',
];
})(),
default => throw new \RuntimeException('Unbekannte Setup-Aktion.'),
};
});
$mm->registerFunction($moduleName, 'refresh_latest', static function (?array $currencies = null, ?string $baseCurrency = null): array {
return module_fn('fx-rates', 'service')->refreshLatestRates($currencies, $baseCurrency);
});
$mm->registerFunction($moduleName, 'ensure_fresh_latest_rates', static function (float $maxAgeHours = 24.0, ?string $baseCurrency = null, ?array $currencies = null): array {
return module_fn('fx-rates', 'service')->ensureFreshLatestRates($maxAgeHours, $baseCurrency, $currencies);
});
$mm->registerFunction($moduleName, 'rate', static function (string $fromCurrency, string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?array {
return module_fn('fx-rates', 'service')->findRate($fromCurrency, $toCurrency, $at, $windowMinutes);
});
$mm->registerFunction($moduleName, 'convert', static function (?float $amount, ?string $fromCurrency, ?string $toCurrency, ?string $at = null, ?int $windowMinutes = null): ?float {
return module_fn('fx-rates', 'service')->convert($amount, $fromCurrency, $toCurrency, $at, $windowMinutes);
});
$mm->registerFunction($moduleName, 'snapshot', static function (?string $baseCurrency = null, ?string $at = null, ?array $symbols = null, ?int $windowMinutes = null): ?array {
return module_fn('fx-rates', 'service')->snapshot($baseCurrency, $at, $symbols, $windowMinutes);
});
$mm->registerFunction($moduleName, 'recent_fetches', static function (int $limit = 20): array {
return module_fn('fx-rates', 'service')->recentFetches($limit);
});
$mm->registerFunction($moduleName, 'scheduled_refresh', static function (array $context = []): array {
$result = module_fn('fx-rates', 'service')->runScheduledRefresh($context);
if (function_exists('module_debug_push')) {
module_debug_push('fx-rates', [
'label' => 'Intervall-Aufgabe',
'type' => 'scheduler:run',
'task' => 'daily_refresh',
'context' => $context,
'message' => (string) ($result['message'] ?? ''),
]);
}
return $result;
});

View File

@@ -0,0 +1,12 @@
{
"eyebrow": "Modul",
"title": "Waehrungskurse",
"description": "Zentrale Verwaltung fuer Waehrungskurse, Snapshots und FX-API-Abrufe.",
"actions": [
{ "label": "Setup", "href": "/modules/setup/fx-rates", "variant": "secondary" }
],
"tabs": [
{ "label": "Ueberblick", "href": "/module/fx-rates" },
{ "label": "Waehrungen", "href": "/module/fx-rates/currencies", "match_prefixes": ["/module/fx-rates/currencies"] }
]
}

View File

@@ -0,0 +1,58 @@
{
"title": "Waehrungskurse",
"version": "0.1.5",
"description": "Zentrales Modul fuer Waehrungskurse, Historie und API-Abrufe.",
"enabled_by_default": true,
"setup": {
"fields": [
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql, mysql, sqlite" },
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
{ "name": "provider", "label": "FX Provider", "type": "text", "required": false, "help": "Unterstuetzt legacy currencyapi.net und currencyapi.com v3." },
{ "name": "api_version", "label": "FX API Version", "type": "select", "required": false, "help": "Steuert die Endpoint-Version unabhaengig von der Domain." },
{ "name": "api_url", "label": "FX API URL", "type": "text", "required": false, "help": "Nur die Basis-URL eintragen, z.B. https://api.currencyapi.com oder https://currencyapi.net." },
{ "name": "api_key", "label": "FX API Key", "type": "password", "required": false },
{ "name": "timeout_sec", "label": "Timeout (Sek.)", "type": "number", "required": false },
{ "name": "refresh_max_age_minutes", "label": "Max. Alter fuer API-Refresh (Min.)", "type": "number", "required": false, "help": "Blockiert neue API-Refresh-Aufrufe, solange der letzte gespeicherte Abruf juenger ist. Manuelle Abrufe koennen nach Hinweis trotzdem erzwungen werden; Cron ignoriert diesen Wert." },
{ "name": "default_base_currency", "label": "Standard-Basiswaehrung", "type": "text", "required": false, "help": "Wird fuer taegliche Abrufe und Snapshot-Abfragen verwendet." },
{ "name": "schedule_timezone", "label": "Scheduler-Zeitzone", "type": "text", "required": false, "help": "z.B. Europe/Berlin" }
]
},
"scheduler_jobs": [
{
"name": "rates_refresh",
"label": "Kursabruf",
"callback": "scheduled_refresh",
"mode": "multi",
"default_enabled": true,
"default_cron": "0 18 * * *",
"default_timezone": "Europe/Berlin",
"timezone_setting": "schedule_timezone",
"lock_minutes": 120,
"help": "Zeitgesteuerter Abruf und das Speichern neuer FX-Snapshots.",
"builder": {
"allow_manual": true,
"presets": ["daily", "every_x_days", "weekly", "monthly_day", "every_x_hours"]
}
}
],
"db_defaults": {
"driver": "pgsql",
"host": "localhost",
"port": 5432,
"dbname": "",
"schema": "public",
"user": "",
"password": ""
},
"auth": {
"required": true,
"users": [],
"groups": []
}
}

View File

@@ -0,0 +1,31 @@
<?php
$file = (string) ($_GET['file'] ?? '');
$base = realpath(__DIR__ . '/../assets');
$map = [
'fx-rates.css' => $base . '/fx-rates.css',
'fx-rates.js' => $base . '/fx-rates.js',
'fx-rates-currencies.js' => $base . '/fx-rates-currencies.js',
];
if (!isset($map[$file])) {
http_response_code(404);
exit('Not found');
}
$path = $map[$file];
if (!$base || !is_file($path) || !str_starts_with($path, $base)) {
http_response_code(404);
exit('Not found');
}
$ext = pathinfo($path, PATHINFO_EXTENSION);
if ($ext === 'css') {
header('Content-Type: text/css; charset=utf-8');
} elseif ($ext === 'js') {
header('Content-Type: application/javascript; charset=utf-8');
} else {
header('Content-Type: application/octet-stream');
}
readfile($path);
exit;

View File

@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$assets = app()->assets();
if ($assets) {
$assets->addStyle('/module/fx-rates/asset?file=fx-rates.css');
$assets->addScript('/module/fx-rates/asset?file=fx-rates-currencies.js', 'footer', true);
}
$settings = module_fn('fx-rates', 'settings');
$service = module_fn('fx-rates', 'service');
$notice = trim((string) ($_GET['notice'] ?? ''));
$error = trim((string) ($_GET['error'] ?? ''));
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
try {
$action = trim((string) ($_POST['fx_action'] ?? ''));
if ($action === 'save_selection') {
$payload = [
'display_base_currency' => (string) ($_POST['display_base_currency'] ?? ''),
'preferred_currencies' => $_POST['preferred_currencies'] ?? [],
];
$saved = module_fn('fx-rates', 'save_runtime_settings', $payload);
$params = ['notice' => 'Waehrungs-Auswahl gespeichert.'];
if (is_array($saved) && !empty($saved['display_base_currency'])) {
$params['base'] = (string) $saved['display_base_currency'];
}
redirect('/module/fx-rates/currencies?' . http_build_query($params));
}
if ($action === 'sync_catalog') {
$result = module_fn('fx-rates', 'run_setup_action', 'sync_currency_catalog');
redirect('/module/fx-rates/currencies?' . http_build_query([
'notice' => sprintf('Waehrungskatalog synchronisiert. %d Waehrungen verarbeitet.', (int) ($result['synced_count'] ?? 0)),
]));
}
if ($action === 'refresh_rates') {
$result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual');
redirect('/module/fx-rates/currencies?' . http_build_query([
'notice' => sprintf('Alle Wechselkurse aktualisiert. %d Werte gespeichert.', (int) ($result['updated_count'] ?? 0)),
]));
}
} catch (\Throwable $exception) {
redirect('/module/fx-rates/currencies?' . http_build_query([
'error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Aktion konnte nicht ausgefuehrt werden.',
]));
}
}
$catalog = is_array($settings['currency_catalog'] ?? null) ? $settings['currency_catalog'] : [];
$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : [];
$savedDisplayBaseCurrency = strtoupper(trim((string) ($settings['display_base_currency'] ?? $settings['default_base_currency'] ?? 'EUR')));
$requestedDisplayBaseCurrency = strtoupper(trim((string) ($_GET['base'] ?? '')));
$latest = $service->latestStatus();
$recentFetches = $service->recentFetches(15);
$currencies = [];
foreach ($catalog as $item) {
if (!is_array($item)) {
continue;
}
$code = strtoupper(trim((string) ($item['code'] ?? '')));
$name = trim((string) ($item['name'] ?? ''));
if ($code === '' || $name === '') {
continue;
}
$currencies[] = [
'code' => $code,
'name' => $name,
];
}
$cryptoCodes = array_fill_keys([
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
'SOL', 'USDC', 'USDT', 'XAG', 'XAU', 'XRP',
], true);
$fiatCount = 0;
$cryptoCount = 0;
foreach ($currencies as $currency) {
if (isset($cryptoCodes[$currency['code']])) {
$cryptoCount++;
} else {
$fiatCount++;
}
}
$catalogCodes = [];
foreach ($currencies as $currency) {
$catalogCodes[(string) $currency['code']] = true;
}
$displayBaseCurrency = $requestedDisplayBaseCurrency !== '' ? $requestedDisplayBaseCurrency : $savedDisplayBaseCurrency;
if ($displayBaseCurrency === '' || (!isset($catalogCodes[$displayBaseCurrency]) && $preferredCurrencies !== [])) {
$displayBaseCurrency = $savedDisplayBaseCurrency !== '' ? $savedDisplayBaseCurrency : (string) ($preferredCurrencies[0] ?? 'EUR');
}
$tableCurrencies = [];
foreach ([$displayBaseCurrency, ...$preferredCurrencies] as $currency) {
$currency = strtoupper(trim((string) $currency));
if ($currency !== '' && !in_array($currency, $tableCurrencies, true)) {
$tableCurrencies[] = $currency;
}
}
$currencyPageData = json_encode([
'currencies' => $currencies,
'preferred_currencies' => array_values(array_unique(array_map(static fn (mixed $code): string => strtoupper(trim((string) $code)), $preferredCurrencies))),
'display_base_currency' => $displayBaseCurrency,
'saved_display_base_currency' => $savedDisplayBaseCurrency,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$tabs = [
['label' => 'Ueberblick', 'href' => '/module/fx-rates'],
['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies', 'active' => true],
];
?>
<?= module_shell_header('fx-rates', [
'title' => 'Waehrungen',
'tabs' => $tabs,
'actions' => [
['label' => 'Nexus Übersicht', 'href' => '/', 'variant' => 'secondary', 'size' => 'sm'],
['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'],
],
]) ?>
<div id="fx-rates-currencies" data-page='<?= e(is_string($currencyPageData) ? $currencyPageData : '{}') ?>'>
<?php if ($notice !== ''): ?>
<section class="section-box">
<div class="fx-message is-success"><?= e($notice) ?></div>
</section>
<?php elseif ($error !== ''): ?>
<section class="section-box">
<div class="fx-message is-error"><?= e($error) ?></div>
</section>
<?php endif; ?>
<section class="section-box">
<div class="fx-section-head">
<div>
<h2>Waehrungs-Update</h2>
<p>Auswahl wird in den Waehrungskurs-Einstellungen gespeichert und steht damit auf Handy und Desktop gleich zur Verfuegung.</p>
</div>
</div>
<div class="fx-action-row">
<form method="post">
<input type="hidden" name="fx_action" value="refresh_rates">
<button type="submit" class="module-button module-button--primary">Alle Wechselkurse aktualisieren</button>
</form>
<form method="post">
<input type="hidden" name="fx_action" value="sync_catalog">
<button type="submit" class="module-button module-button--ghost">Waehrungskatalog sync</button>
</form>
</div>
</section>
<div class="fx-card-grid">
<section class="card-box">
<div class="fx-mini-label">Fiat</div>
<div class="fx-card-value"><?= e((string) $fiatCount) ?> Waehrungen</div>
</section>
<section class="card-box">
<div class="fx-mini-label">Krypto</div>
<div class="fx-card-value"><?= e((string) $cryptoCount) ?> Waehrungen</div>
</section>
</div>
<section class="section-box">
<div class="fx-field-label">Bevorzugte Waehrungen fuer Anzeige</div>
<div class="fx-currency-selection-row">
<div class="fx-token-list fx-token-list--inline" data-fx-token-list></div>
<div class="fx-currency-search">
<input type="text" class="fx-input" value="" placeholder="Waehrung hinzufuegen: EUR, USD, DOGE oder Euro" autocomplete="off" data-fx-search-input>
</div>
</div>
<div class="fx-suggestion-list" data-fx-suggestions></div>
<div class="fx-field fx-currency-search">
<label class="fx-field-label" for="fx-display-base-select">Darstellung auf Basis von</label>
<select id="fx-display-base-select" class="fx-select" data-fx-display-base-select></select>
</div>
<div class="fx-save-row">
<form method="post">
<input type="hidden" name="fx_action" value="save_selection">
<input type="hidden" name="display_base_currency" value="<?= e($displayBaseCurrency) ?>" data-fx-display-base-hidden>
<div data-fx-hidden-preferred></div>
<button type="submit" class="module-button module-button--ghost">Auswahl speichern</button>
</form>
</div>
</section>
<section class="section-box">
<div class="fx-card-head">
<div>
<h2>Letzte 15 Kurs-Uploads</h2>
<p>Zeigt die zuletzt gespeicherten Wechselkurse aus der Datenbank.</p>
</div>
<div class="fx-card-meta">
<div><strong>Anzeige-Basis:</strong> <?= e($displayBaseCurrency) ?></div>
<div><strong>Letzter Abruf:</strong> <?= e((string) ($latest['fetched_at_display'] ?? $latest['fetched_at'] ?? 'noch keiner')) ?></div>
</div>
</div>
<div class="fx-table-wrap">
<table class="fx-table">
<thead>
<tr>
<th>Zeit</th>
<?php foreach ($tableCurrencies as $currency): ?>
<th><?= e((string) $currency) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php if ($recentFetches === []): ?>
<tr><td colspan="<?= 1 + count($tableCurrencies) ?>">Noch keine Abrufe vorhanden.</td></tr>
<?php else: ?>
<?php foreach ($recentFetches as $fetch): ?>
<?php
$fetchBaseCurrency = strtoupper(trim((string) ($fetch['base_currency'] ?? '')));
$snapshot = $service->snapshotByFetchId((int) ($fetch['id'] ?? 0), $fetchBaseCurrency, $tableCurrencies);
$originalRates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
$displayBaseRate = $displayBaseCurrency === $fetchBaseCurrency
? 1.0
: (is_numeric($originalRates[$displayBaseCurrency] ?? null) ? (float) $originalRates[$displayBaseCurrency] : null);
$tableRates = [];
foreach ($tableCurrencies as $currency) {
$currency = strtoupper(trim((string) $currency));
if ($currency === '') {
continue;
}
if ($currency === $displayBaseCurrency) {
$tableRates[$currency] = 1.0;
continue;
}
if ($displayBaseRate === null || $displayBaseRate <= 0) {
$tableRates[$currency] = null;
continue;
}
if ($currency === $fetchBaseCurrency) {
$tableRates[$currency] = 1 / $displayBaseRate;
continue;
}
$rawRate = $originalRates[$currency] ?? null;
$tableRates[$currency] = is_numeric($rawRate) ? ((float) $rawRate / $displayBaseRate) : null;
}
$infoTitle = sprintf(
'Basis: %s | Provider: %s | Ausloeser: %s',
(string) ($fetch['base_currency'] ?? '-'),
(string) ($fetch['provider'] ?? '-'),
(string) ($fetch['trigger_source_label'] ?? $fetch['trigger_source'] ?? '-')
);
?>
<tr>
<td>
<div class="fx-history-date">
<span><?= e((string) ($fetch['fetched_at_display'] ?? $fetch['fetched_at'] ?? '')) ?></span>
<button
type="button"
class="fx-info-button"
title="<?= e($infoTitle) ?>"
aria-label="<?= e('Abrufinfo fuer ' . (string) ($fetch['fetched_at_display'] ?? $fetch['fetched_at'] ?? '')) ?>"
>i</button>
</div>
</td>
<?php foreach ($tableCurrencies as $currency): ?>
<?php $value = $tableRates[(string) $currency] ?? null; ?>
<td><?= is_numeric($value) ? e(number_format((float) $value, 8, ',', '')) : '' ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</div>
<?= module_shell_footer() ?>

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$assets = app()->assets();
if ($assets) {
$assets->addStyle('/module/fx-rates/asset?file=fx-rates.css');
$assets->addScript('/module/fx-rates/asset?file=fx-rates.js', 'footer', true);
}
$settings = module_fn('fx-rates', 'settings');
$service = module_fn('fx-rates', 'service');
$preferredCurrencies = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : [];
$apiDescribeUrl = APP_API_BASE . '/fx-rates/v1/endpoints';
$notice = trim((string) ($_GET['notice'] ?? ''));
$error = trim((string) ($_GET['error'] ?? ''));
if ((string) ($_GET['refresh'] ?? '') === '1') {
try {
$force = !empty($_GET['force']);
if ($force) {
$result = $service->refreshLatestRates(null, (string) ($settings['default_base_currency'] ?? ''), 'manual');
} else {
$result = $service->autoRefreshLatestRates(
(string) ($settings['default_base_currency'] ?? ''),
null,
(int) ($settings['refresh_max_age_minutes'] ?? 60),
'manual'
);
}
$params = !empty($result['reused'])
? [
'notice' => sprintf(
'Kein neuer API-Abruf. Der letzte gespeicherte Snapshot ist juenger als %d Minuten. Fuer einen erzwungenen Abruf bitte bestaetigen.',
(int) ($settings['refresh_max_age_minutes'] ?? 60)
),
]
: [
'notice' => sprintf(
'Aktuelle Kurse gespeichert. %d Werte aktualisiert.',
(int) ($result['updated_count'] ?? 0)
),
];
} catch (\Throwable $exception) {
$params = ['error' => $exception->getMessage() !== '' ? $exception->getMessage() : 'Kurse konnten nicht aktualisiert werden.'];
}
redirect('/module/fx-rates?' . http_build_query($params));
}
$latest = $service->latestStatus();
$recentFetches = $service->recentFetches(15);
$pageData = json_encode([
'settings' => $settings,
'latest' => $latest,
'preferred_currencies' => $preferredCurrencies,
'recent_fetches' => $recentFetches,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$tabs = [
['label' => 'Ueberblick', 'href' => '/module/fx-rates', 'active' => true],
['label' => 'Waehrungen', 'href' => '/module/fx-rates/currencies'],
];
?>
<?= module_shell_header('fx-rates', [
'title' => 'Ueberblick',
'tabs' => $tabs,
'actions' => [
['label' => 'Nexus Übersicht', 'href' => '/', 'variant' => 'secondary', 'size' => 'sm'],
['label' => 'Setup', 'href' => '/modules/setup/fx-rates', 'variant' => 'secondary', 'size' => 'sm'],
['label' => 'Aktuelle Kurse abrufen', 'href' => '/module/fx-rates?refresh=1', 'variant' => 'secondary', 'size' => 'sm'],
],
]) ?>
<div id="fx-rates-app" data-page='<?= e(is_string($pageData) ? $pageData : '{}') ?>'>
<?php if ($notice !== ''): ?>
<section class="section-box">
<div class="fx-message is-success"><?= e($notice) ?></div>
</section>
<?php elseif ($error !== ''): ?>
<section class="section-box">
<div class="fx-message is-error"><?= e($error) ?></div>
</section>
<?php endif; ?>
<section class="section-box">
<div class="fx-section-head">
<div>
<h2>Umrechnung</h2>
<p>Umrechnung auf Basis des letzten verfuegbaren Kurses zwischen den bevorzugten Waehrungen.</p>
</div>
</div>
<p class="fx-api-note">
API-Self-Describe-Endpoint:
<a href="<?= e($apiDescribeUrl) ?>" target="_blank" rel="noopener noreferrer"><?= e($apiDescribeUrl) ?></a>
</p>
<div class="fx-form-grid">
<label>
<span>Quellwaehrung</span>
<select name="convert_from">
<?php foreach ($preferredCurrencies as $currency): ?>
<option value="<?= e((string) $currency) ?>"><?= e((string) $currency) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Zielwaehrung</span>
<select name="convert_to">
<?php foreach ($preferredCurrencies as $currency): ?>
<option value="<?= e((string) $currency) ?>" <?= (string) $currency === (string) ($preferredCurrencies[1] ?? $preferredCurrencies[0] ?? '') ? 'selected' : '' ?>><?= e((string) $currency) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
<span>Betrag</span>
<input type="number" name="convert_amount" min="0" step="0.00000001" value="1">
</label>
</div>
<div class="fx-convert-result" data-bind="convert-result">Noch keine Umrechnung berechnet.</div>
</section>
<section class="section-box">
<div class="fx-card-head">
<div>
<h2>Kursverlauf</h2>
<p>Neueste Abrufe zuerst. Verlauf der bevorzugten Waehrungen relativ zur Anzeige-Basiswaehrung.</p>
</div>
<div class="fx-card-meta">
<div><strong>Anzeige-Basis:</strong> <?= e((string) ($settings['display_base_currency'] ?? $settings['default_base_currency'] ?? '')) ?></div>
<div><strong>Letzter Abruf:</strong> <?= e((string) ($latest['fetched_at_display'] ?? $latest['fetched_at'] ?? 'noch keiner')) ?></div>
</div>
</div>
<div class="fx-table-wrap">
<table class="fx-table">
<thead data-bind="history-head">
<tr>
<th>Datum</th>
<th>Kurse</th>
</tr>
</thead>
<tbody data-bind="history-body">
<tr><td colspan="2">Noch keine Verlaufsdaten geladen.</td></tr>
</tbody>
</table>
</div>
</section>
</div>
<?= module_shell_footer() ?>

View File

@@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Api;
use Modules\FxRates\Domain\FxRatesService;
final class Router
{
public function __construct(
private FxRatesService $service
) {
}
public function handle(string $relativePath): never
{
$method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
$path = trim($relativePath, '/');
try {
if ($path === 'v1/health' && $method === 'GET') {
$this->respond(['ok' => true, 'module' => 'fx-rates']);
}
if ($path === 'v1/endpoints' && $method === 'GET') {
$this->respond(['data' => $this->endpointCatalog()]);
}
if ($path === 'v1/status' && $method === 'GET') {
$this->respond(['data' => $this->service->latestStatuses()]);
}
if ($path === 'v1/recent-fetches' && $method === 'GET') {
$limit = max(1, min(50, (int) ($_GET['limit'] ?? 12)));
$this->respond(['data' => $this->service->recentFetches($limit)]);
}
if ($path === 'v1/latest' && $method === 'GET') {
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
$base = $this->stringOrNull($_GET['base'] ?? null);
if ($symbols === null) {
$settings = module_fn('fx-rates', 'settings');
$symbols = is_array($settings['preferred_currencies'] ?? null) ? $settings['preferred_currencies'] : null;
}
$snapshot = $this->service->snapshot($base, null, $symbols, null);
$this->respond(['data' => $snapshot]);
}
if ($path === 'v1/fetch' && $method === 'GET') {
$fetchId = max(0, (int) ($_GET['fetch_id'] ?? 0));
$base = $this->stringOrNull($_GET['base'] ?? null);
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
$snapshot = $this->service->snapshotByFetchId($fetchId, $base, $symbols);
$this->respond(['data' => $snapshot]);
}
if ($path === 'v1/nearest' && $method === 'GET') {
$base = $this->stringOrNull($_GET['base'] ?? null);
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
$at = $this->stringOrNull($_GET['at'] ?? null);
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
$snapshot = $this->service->nearestSnapshot($base, (string) $at, $symbols, $windowMinutes);
$this->respond(['data' => $snapshot]);
}
if ($path === 'v1/snapshot' && $method === 'GET') {
$symbols = $this->parseCsv($_GET['symbols'] ?? null);
$base = $this->stringOrNull($_GET['base'] ?? null);
$at = $this->stringOrNull($_GET['at'] ?? null);
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
$snapshot = $this->service->snapshot($base, $at, $symbols, $windowMinutes);
$this->respond(['data' => $snapshot]);
}
if ($path === 'v1/rate' && $method === 'GET') {
$from = $this->stringOrNull($_GET['from'] ?? null);
$to = $this->stringOrNull($_GET['to'] ?? null);
$at = $this->stringOrNull($_GET['at'] ?? null);
$windowMinutes = $this->intOrNull($_GET['window_minutes'] ?? null);
$rate = $this->service->findRate($from, $to, $at, $windowMinutes);
$this->respond(['data' => $rate]);
}
if ($path === 'v1/history' && $method === 'GET') {
$from = $this->stringOrNull($_GET['from'] ?? null);
$to = $this->stringOrNull($_GET['to'] ?? null);
$fromAt = $this->stringOrNull($_GET['from_at'] ?? null);
$toAt = $this->stringOrNull($_GET['to_at'] ?? null);
$limit = max(1, min(1000, (int) ($_GET['limit'] ?? 200)));
$history = $this->service->history((string) $from, (string) $to, $fromAt, $toAt, $limit);
$this->respond(['data' => $history]);
}
if ($path === 'v1/refresh' && $method === 'POST') {
$input = $this->input();
$base = $this->stringOrNull($input['base'] ?? null);
$force = !empty($input['force']);
$maxAgeMinutes = is_numeric($input['max_age_minutes'] ?? null) ? (int) $input['max_age_minutes'] : null;
$result = $force
? $this->service->refreshLatestRates(null, $base, 'api')
: $this->service->autoRefreshLatestRates($base, null, $maxAgeMinutes, 'api');
$this->respond(['data' => $result], 201);
}
if ($path === 'v1/probe' && $method === 'GET') {
$base = $this->stringOrNull($_GET['base'] ?? null);
$this->respond(['data' => $this->service->probeLatestRates($base)]);
}
if ($path === 'v1/settings' && $method === 'GET') {
$this->respond(['data' => module_fn('fx-rates', 'settings')]);
}
if ($path === 'v1/settings' && $method === 'PUT') {
$this->respond(['data' => module_fn('fx-rates', 'save_runtime_settings', $this->input())]);
}
$this->respond(['error' => 'Unbekannter API-Pfad.'], 404);
} catch (\Throwable $exception) {
$this->respond([
'error' => 'FX-API Fehler.',
'context' => ['message' => $exception->getMessage()],
], 500);
}
}
private function respond(array $payload, int $statusCode = 200): never
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
private function input(): array
{
$raw = file_get_contents('php://input');
$decoded = json_decode((string) $raw, true);
return is_array($decoded) ? $decoded : [];
}
private function parseCsv(mixed $value): ?array
{
if (is_array($value)) {
$items = $value;
} else {
$value = trim((string) $value);
if ($value === '') {
return null;
}
$items = explode(',', $value);
}
$result = [];
foreach ($items as $item) {
$item = strtoupper(trim((string) $item));
if ($item !== '') {
$result[] = $item;
}
}
$result = array_values(array_unique($result));
return $result !== [] ? $result : null;
}
private function stringOrNull(mixed $value): ?string
{
$value = trim((string) $value);
return $value !== '' ? $value : null;
}
private function intOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
private function endpointCatalog(): array
{
return [
'module' => 'fx-rates',
'version' => 'v1',
'languages' => ['de', 'en'],
'endpoints' => [
[
'path' => '/api/fx-rates/v1/endpoints',
'method' => 'GET',
'description_de' => 'Gibt alle verfuegbaren FX-API-Endpunkte mit deutscher und englischer Erklaerung zurueck.',
'description_en' => 'Returns all available FX API endpoints with German and English explanations.',
],
[
'path' => '/api/fx-rates/v1/latest',
'method' => 'GET',
'params' => ['base', 'symbols'],
'description_de' => 'Liefert den neuesten gespeicherten Snapshot, optional auf eine Zielbasis umgerechnet und auf ausgewaehlte Waehrungen gefiltert.',
'description_en' => 'Returns the latest stored snapshot, optionally rebased to a target currency and filtered to selected symbols.',
],
[
'path' => '/api/fx-rates/v1/fetch',
'method' => 'GET',
'params' => ['fetch_id', 'base', 'symbols'],
'description_de' => 'Liefert einen gespeicherten Snapshot anhand der fetch_id, optional umgerechnet auf eine Zielbasis und gefiltert auf einzelne Waehrungen.',
'description_en' => 'Returns a stored snapshot by fetch_id, optionally rebased to a target currency and filtered to selected symbols.',
],
[
'path' => '/api/fx-rates/v1/nearest',
'method' => 'GET',
'params' => ['at', 'base', 'symbols', 'window_minutes'],
'description_de' => 'Liefert den zeitlich naechsten gespeicherten Snapshot zu einem Datum/Uhrzeit-Wert.',
'description_en' => 'Returns the stored snapshot nearest to a given date/time value.',
],
[
'path' => '/api/fx-rates/v1/snapshot',
'method' => 'GET',
'params' => ['at', 'base', 'symbols', 'window_minutes'],
'description_de' => 'Liefert einen Snapshot zur Zielbasis und sucht fuer einen Zeitpunkt den naechsten passenden gespeicherten Kurs.',
'description_en' => 'Returns a snapshot for the requested base and finds the nearest matching stored rate for a given timestamp.',
],
[
'path' => '/api/fx-rates/v1/rate',
'method' => 'GET',
'params' => ['from', 'to', 'at', 'window_minutes'],
'description_de' => 'Liefert einen Einzelkurs zwischen zwei Waehrungen, direkt oder als Kreuzkurs aus gespeicherten Snapshots.',
'description_en' => 'Returns a single rate between two currencies, directly or as a cross-rate from stored snapshots.',
],
[
'path' => '/api/fx-rates/v1/history',
'method' => 'GET',
'params' => ['from', 'to', 'from_at', 'to_at', 'limit'],
'description_de' => 'Liefert den gespeicherten Kursverlauf zwischen zwei Waehrungen fuer einen Zeitraum.',
'description_en' => 'Returns the stored rate history between two currencies for a given time range.',
],
[
'path' => '/api/fx-rates/v1/refresh',
'method' => 'POST',
'body' => ['base', 'force', 'max_age_minutes'],
'description_de' => 'Aktualisiert Kurse nur dann neu, wenn der letzte Abruf aelter als die erlaubte Zeitspanne ist. Die Antwort enthaelt immer die fetch_id des verwendeten Snapshots.',
'description_en' => 'Refreshes rates only if the last fetch is older than the allowed age. The response always includes the fetch_id of the snapshot used.',
],
[
'path' => '/api/fx-rates/v1/status',
'method' => 'GET',
'description_de' => 'Liefert den neuesten gespeicherten Abruf je Basiswaehrung.',
'description_en' => 'Returns the most recent stored fetch per base currency.',
],
[
'path' => '/api/fx-rates/v1/recent-fetches',
'method' => 'GET',
'params' => ['limit'],
'description_de' => 'Liefert die zuletzt gespeicherten Abrufe mit fetch_id und Zeitstempel.',
'description_en' => 'Returns the most recently stored fetches including fetch_id and timestamp.',
],
[
'path' => '/api/fx-rates/v1/probe',
'method' => 'GET',
'params' => ['base'],
'description_de' => 'Prueft, ob der konfigurierte Provider aktuelle Kurse liefern kann.',
'description_en' => 'Checks whether the configured provider can return current rates.',
],
[
'path' => '/api/fx-rates/v1/settings',
'method' => 'GET',
'description_de' => 'Liefert die aktuellen Modul-Settings.',
'description_en' => 'Returns the current module settings.',
],
],
];
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,546 @@
<?php
declare(strict_types=1);
namespace Modules\FxRates\Infrastructure;
use PDO;
final class FxRatesRepository
{
private string $driver;
public function __construct(
private PDO $pdo,
private string $tablePrefix = 'fxrate_'
) {
$this->driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME));
}
public function ensureSchema(): void
{
$fetchTable = $this->table('fetches');
$rateTable = $this->table('rates');
if ($this->driver === 'pgsql') {
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
id SERIAL PRIMARY KEY,
provider VARCHAR(64) NOT NULL,
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$this->pdo->exec("ALTER TABLE {$fetchTable} ADD COLUMN IF NOT EXISTS trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
id SERIAL PRIMARY KEY,
fetch_id INTEGER NOT NULL REFERENCES {$fetchTable}(id) ON DELETE CASCADE,
currency_code VARCHAR(10) NOT NULL,
current_value NUMERIC(20,10) NOT NULL
)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)");
} elseif ($this->driver === 'mysql') {
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
provider VARCHAR(64) NOT NULL,
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY {$fetchTable}_base_fetch_idx (base_currency, fetched_at, id),
KEY {$fetchTable}_rate_date_idx (rate_date)
)");
$this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
id INTEGER PRIMARY KEY AUTO_INCREMENT,
fetch_id INTEGER NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value DECIMAL(20,10) NOT NULL,
KEY {$rateTable}_fetch_idx (fetch_id),
KEY {$rateTable}_currency_idx (currency_code)
)");
} else {
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$fetchTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider VARCHAR(64) NOT NULL,
trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)");
$this->ensureColumn($fetchTable, 'trigger_source', "ALTER TABLE {$fetchTable} ADD COLUMN trigger_source VARCHAR(32) NOT NULL DEFAULT 'manual'");
$this->pdo->exec("CREATE TABLE IF NOT EXISTS {$rateTable} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fetch_id INTEGER NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value DECIMAL(20,10) NOT NULL
)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_base_fetch_idx ON {$fetchTable} (base_currency, fetched_at DESC, id DESC)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$fetchTable}_rate_date_idx ON {$fetchTable} (rate_date DESC)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_fetch_idx ON {$rateTable} (fetch_id)");
$this->pdo->exec("CREATE INDEX IF NOT EXISTS {$rateTable}_currency_idx ON {$rateTable} (currency_code)");
}
}
public function getLatestFetch(?string $baseCurrency = null): ?array
{
$sql = 'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at FROM ' . $this->table('fetches');
$params = [];
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
$sql .= ' WHERE base_currency = :base_currency';
$params['base_currency'] = strtoupper(trim($baseCurrency));
}
$sql .= ' ORDER BY fetched_at DESC, id DESC LIMIT 1';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->normalizeFetch($row) : null;
}
public function listLatestFetches(): array
{
$stmt = $this->pdo->query(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
ORDER BY fetched_at DESC, id DESC'
);
$latestByBase = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
$base = strtoupper(trim((string) ($row['base_currency'] ?? '')));
if ($base === '' || isset($latestByBase[$base])) {
continue;
}
$latestByBase[$base] = $this->normalizeFetch($row);
}
ksort($latestByBase);
return array_values($latestByBase);
}
public function listRecentFetches(int $limit = 20): array
{
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
ORDER BY fetched_at DESC, id DESC
LIMIT :limit'
);
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$stmt->execute();
return array_map(
fn (array $row): array => $this->normalizeFetch($row),
$stmt->fetchAll(PDO::FETCH_ASSOC) ?: []
);
}
public function getSnapshotByFetchId(int $fetchId, ?array $symbols = null): ?array
{
$fetch = $this->getFetchById($fetchId);
if ($fetch === null) {
return null;
}
return $fetch + [
'rates' => $this->ratesForFetch($fetchId, $symbols),
];
}
public function findNearestFetch(?string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array
{
$targetTs = strtotime($timestamp);
if ($targetTs === false) {
return null;
}
if ($baseCurrency !== null && trim($baseCurrency) !== '') {
return $this->getNearestFetch(strtoupper(trim($baseCurrency)), $timestamp, $windowMinutes);
}
$candidates = [];
foreach (['<=', '>='] as $operator) {
$order = $operator === '<=' ? 'DESC' : 'ASC';
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
WHERE fetched_at ' . $operator . ' :target_at
ORDER BY fetched_at ' . $order . ', id ' . $order . '
LIMIT 1'
);
$stmt->execute(['target_at' => $timestamp]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (is_array($row)) {
$candidate = $this->normalizeFetch($row);
$candidateTs = strtotime((string) ($candidate['fetched_at'] ?? ''));
if ($candidateTs !== false) {
$candidate['distance_seconds'] = abs($candidateTs - $targetTs);
$candidates[] = $candidate;
}
}
}
if ($candidates === []) {
return null;
}
usort($candidates, static function (array $left, array $right): int {
return ((int) ($left['distance_seconds'] ?? PHP_INT_MAX)) <=> ((int) ($right['distance_seconds'] ?? PHP_INT_MAX));
});
$selected = $candidates[0];
if ($windowMinutes !== null && $windowMinutes > 0 && (int) ($selected['distance_seconds'] ?? 0) > ($windowMinutes * 60)) {
return null;
}
return $selected;
}
public function getNearestFetch(string $baseCurrency, string $timestamp, ?int $windowMinutes = null): ?array
{
$baseCurrency = strtoupper(trim($baseCurrency));
if ($baseCurrency === '') {
return null;
}
$before = $this->findNeighborFetch($baseCurrency, $timestamp, '<=');
$after = $this->findNeighborFetch($baseCurrency, $timestamp, '>=');
$targetTs = strtotime($timestamp);
if ($targetTs === false) {
return null;
}
$selected = null;
$selectedDiff = null;
foreach ([$before, $after] as $candidate) {
if (!is_array($candidate)) {
continue;
}
$candidateTs = strtotime((string) ($candidate['fetched_at'] ?? ''));
if ($candidateTs === false) {
continue;
}
$diffSeconds = abs($candidateTs - $targetTs);
if ($selected === null || $diffSeconds < (int) $selectedDiff) {
$selected = $candidate;
$selectedDiff = $diffSeconds;
}
}
if ($selected === null) {
return null;
}
if ($windowMinutes !== null && $windowMinutes > 0 && $selectedDiff !== null && $selectedDiff > ($windowMinutes * 60)) {
return null;
}
return $selected + ['distance_seconds' => $selectedDiff];
}
public function listDirectHistory(string $baseCurrency, string $targetCurrency, ?string $from = null, ?string $to = null, int $limit = 200): array
{
$sql = 'SELECT
r.id,
f.id AS fetch_id,
f.base_currency,
r.currency_code AS target_currency,
r.current_value AS rate,
f.rate_date,
f.provider,
f.fetched_at
FROM ' . $this->table('rates') . ' r
INNER JOIN ' . $this->table('fetches') . ' f ON f.id = r.fetch_id
WHERE f.base_currency = :base_currency
AND r.currency_code = :target_currency';
$params = [
'base_currency' => strtoupper(trim($baseCurrency)),
'target_currency' => strtoupper(trim($targetCurrency)),
];
if ($from !== null && trim($from) !== '') {
$sql .= ' AND f.fetched_at >= :from_at';
$params['from_at'] = $from;
}
if ($to !== null && trim($to) !== '') {
$sql .= ' AND f.fetched_at <= :to_at';
$params['to_at'] = $to;
}
$sql .= ' ORDER BY f.fetched_at DESC, r.id DESC LIMIT :limit';
$stmt = $this->pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue(':' . $key, $value);
}
$stmt->bindValue(':limit', max(1, $limit), PDO::PARAM_INT);
$stmt->execute();
return array_map(fn (array $row): array => $this->normalizeRate($row), $stmt->fetchAll(PDO::FETCH_ASSOC) ?: []);
}
public function saveFetch(string $baseCurrency, string $provider, string $rateDate, array $rates, ?string $fetchedAt = null, string $triggerSource = 'manual'): array
{
$baseCurrency = strtoupper(trim($baseCurrency));
$provider = trim($provider) !== '' ? trim($provider) : 'currencyapi';
$fetchedAt = trim((string) $fetchedAt) !== '' ? trim((string) $fetchedAt) : gmdate('Y-m-d H:i:s');
$triggerSource = $this->normalizeTriggerSource($triggerSource);
$normalizedRates = [];
foreach ($rates as $currencyCode => $rate) {
$currencyCode = strtoupper(trim((string) $currencyCode));
if ($currencyCode === '' || $currencyCode === $baseCurrency || !is_numeric($rate)) {
continue;
}
$normalizedRates[$currencyCode] = (float) $rate;
}
$startedTransaction = false;
if (!$this->pdo->inTransaction()) {
$this->pdo->beginTransaction();
$startedTransaction = true;
}
try {
if ($this->driver === 'pgsql') {
$fetchStmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('fetches') . ' (
provider, trigger_source, base_currency, rate_date, fetched_at
) VALUES (
:provider, :trigger_source, :base_currency, :rate_date, :fetched_at
)
RETURNING *'
);
$fetchStmt->execute([
'provider' => $provider,
'trigger_source' => $triggerSource,
'base_currency' => $baseCurrency,
'rate_date' => $rateDate,
'fetched_at' => $fetchedAt,
]);
$fetch = $this->normalizeFetch($fetchStmt->fetch(PDO::FETCH_ASSOC) ?: []);
} else {
$fetchStmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('fetches') . ' (
provider, trigger_source, base_currency, rate_date, fetched_at
) VALUES (
:provider, :trigger_source, :base_currency, :rate_date, :fetched_at
)'
);
$fetchStmt->execute([
'provider' => $provider,
'trigger_source' => $triggerSource,
'base_currency' => $baseCurrency,
'rate_date' => $rateDate,
'fetched_at' => $fetchedAt,
]);
$fetch = $this->getFetchById((int) $this->pdo->lastInsertId()) ?? [];
}
$savedRates = [];
if ($normalizedRates !== []) {
$placeholders = [];
$params = ['fetch_id' => (int) ($fetch['id'] ?? 0)];
$index = 0;
foreach ($normalizedRates as $currencyCode => $rate) {
$codeKey = 'currency_code_' . $index;
$valueKey = 'current_value_' . $index;
$placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})";
$params[$codeKey] = $currencyCode;
$params[$valueKey] = $rate;
$savedRates[] = [
'fetch_id' => $fetch['id'] ?? null,
'base_currency' => $baseCurrency,
'target_currency' => $currencyCode,
'rate' => $rate,
'rate_date' => $rateDate,
'provider' => $provider,
'fetched_at' => $fetchedAt,
];
$index++;
}
$insert = $this->pdo->prepare(
'INSERT INTO ' . $this->table('rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders)
);
$insert->execute($params);
}
if ($startedTransaction) {
$this->pdo->commit();
}
return [
'fetch' => $fetch,
'rates' => $savedRates,
];
} catch (\Throwable $exception) {
if ($startedTransaction && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $exception;
}
}
public function findFetchByBaseAndFetchedAt(string $baseCurrency, string $fetchedAt): ?array
{
$baseCurrency = strtoupper(trim($baseCurrency));
$fetchedAt = trim($fetchedAt);
if ($baseCurrency === '' || $fetchedAt === '') {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
WHERE base_currency = :base_currency
AND fetched_at = :fetched_at
ORDER BY id ASC
LIMIT 1'
);
$stmt->execute([
'base_currency' => $baseCurrency,
'fetched_at' => $fetchedAt,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->normalizeFetch($row) : null;
}
private function getFetchById(int $fetchId): ?array
{
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
WHERE id = :id
LIMIT 1'
);
$stmt->execute(['id' => $fetchId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->normalizeFetch($row) : null;
}
private function findNeighborFetch(string $baseCurrency, string $timestamp, string $operator): ?array
{
$order = $operator === '<=' ? 'DESC' : 'ASC';
$stmt = $this->pdo->prepare(
'SELECT id, provider, trigger_source, base_currency, rate_date, fetched_at, created_at
FROM ' . $this->table('fetches') . '
WHERE base_currency = :base_currency
AND fetched_at ' . $operator . ' :target_at
ORDER BY fetched_at ' . $order . ', id ' . $order . '
LIMIT 1'
);
$stmt->execute([
'base_currency' => $baseCurrency,
'target_at' => $timestamp,
]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return is_array($row) ? $this->normalizeFetch($row) : null;
}
private function ratesForFetch(int $fetchId, ?array $symbols = null): array
{
$sql = 'SELECT currency_code, current_value FROM ' . $this->table('rates') . ' WHERE fetch_id = :fetch_id';
$params = ['fetch_id' => $fetchId];
$normalizedSymbols = [];
if (is_array($symbols)) {
foreach ($symbols as $symbol) {
$symbol = strtoupper(trim((string) $symbol));
if ($symbol !== '') {
$normalizedSymbols[] = $symbol;
}
}
$normalizedSymbols = array_values(array_unique($normalizedSymbols));
}
if ($normalizedSymbols !== []) {
$placeholders = [];
foreach ($normalizedSymbols as $index => $symbol) {
$key = 'symbol_' . $index;
$placeholders[] = ':' . $key;
$params[$key] = $symbol;
}
$sql .= ' AND currency_code IN (' . implode(', ', $placeholders) . ')';
}
$sql .= ' ORDER BY currency_code ASC';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$rates = [];
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) ?: [] as $row) {
$code = strtoupper(trim((string) ($row['currency_code'] ?? '')));
$rate = $row['current_value'] ?? null;
if ($code === '' || !is_numeric($rate)) {
continue;
}
$rates[$code] = (float) $rate;
}
return $rates;
}
private function normalizeFetch(array $row): array
{
return [
'id' => isset($row['id']) ? (int) $row['id'] : null,
'provider' => (string) ($row['provider'] ?? ''),
'trigger_source' => (string) ($row['trigger_source'] ?? 'manual'),
'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
'rate_date' => (string) ($row['rate_date'] ?? ''),
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
'created_at' => (string) ($row['created_at'] ?? ''),
];
}
private function ensureColumn(string $table, string $column, string $alterSql): void
{
try {
$stmt = $this->pdo->query('SELECT * FROM ' . $table . ' LIMIT 1');
if ($stmt instanceof \PDOStatement) {
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: [];
if (in_array(strtolower($column), array_map('strtolower', array_keys($row)), true)) {
return;
}
}
} catch (\Throwable) {
}
try {
$this->pdo->exec($alterSql);
} catch (\Throwable) {
}
}
private function normalizeTriggerSource(string $source): string
{
$source = strtolower(trim($source));
return match ($source) {
'cron', 'manual', 'api', 'migration' => $source,
default => 'manual',
};
}
private function normalizeRate(array $row): array
{
return [
'id' => isset($row['id']) ? (int) $row['id'] : null,
'fetch_id' => isset($row['fetch_id']) ? (int) $row['fetch_id'] : null,
'base_currency' => strtoupper((string) ($row['base_currency'] ?? '')),
'target_currency' => strtoupper((string) ($row['target_currency'] ?? '')),
'rate' => is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null,
'rate_date' => (string) ($row['rate_date'] ?? ''),
'provider' => (string) ($row['provider'] ?? ''),
'fetched_at' => (string) ($row['fetched_at'] ?? ''),
];
}
private function table(string $logicalName): string
{
return $this->tablePrefix . preg_replace('/[^a-zA-Z0-9_]/', '', $logicalName);
}
}

12
modules/kea/design.json Normal file
View File

@@ -0,0 +1,12 @@
{
"eyebrow": "Modul",
"title": "KEA DHCP",
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
"actions": [
{ "label": "Setup", "href": "/modules/setup/kea", "variant": "secondary" }
],
"tabs": [
{ "label": "Hosts", "href": "/module/kea", "match_prefixes": ["/module/kea", "/module/kea/edit"] },
{ "label": "Gruppen", "href": "/module/kea/groups", "match_prefixes": ["/module/kea/groups"] }
]
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\Database;
use App\ModuleMigrationContext;
use App\Repository\KeaHostMetadataRepository;
return new class {
public function up(ModuleMigrationContext $context): void
{
$settings = $context->settings();
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
? $context->module['metadata_db_defaults']
: [];
$config = is_array($settings['metadata_db'] ?? null)
? array_replace($fallback, $settings['metadata_db'])
: $fallback;
if (empty($config['driver']) || empty($config['dbname'])) {
return;
}
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
$repo->ensureSchema();
}
};

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\Database;
use App\ModuleMigrationContext;
use App\Repository\KeaHostMetadataRepository;
return new class {
public function up(ModuleMigrationContext $context): void
{
$settings = $context->settings();
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
? $context->module['metadata_db_defaults']
: [];
$config = is_array($settings['metadata_db'] ?? null)
? array_replace($fallback, $settings['metadata_db'])
: $fallback;
if (empty($config['driver']) || empty($config['dbname'])) {
return;
}
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
$repo->ensureSchema();
}
};

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\Database;
use App\ModuleMigrationContext;
use App\Repository\KeaHostMetadataRepository;
return new class {
public function up(ModuleMigrationContext $context): void
{
$settings = $context->settings();
$fallback = is_array($context->module['metadata_db_defaults'] ?? null)
? $context->module['metadata_db_defaults']
: [];
$config = is_array($settings['metadata_db'] ?? null)
? array_replace($fallback, $settings['metadata_db'])
: $fallback;
if (empty($config['driver']) || empty($config['dbname'])) {
return;
}
$repo = new KeaHostMetadataRepository(Database::createFromArray($config));
$repo->ensureSchema();
}
};

View File

@@ -1,29 +1,24 @@
{
"title": "KEA DHCP",
"version": "1.0.0",
"version": "1.2.0",
"schema_version": 3,
"description": "Verwaltung von KEA DHCP Hosts und Reservierungen.",
"menu": [
{ "label": "Hosts", "href": "/module/kea" },
{ "label": "Setup", "href": "/modules/setup/kea" }
],
"sidebar": {
"enabled": true,
"collapsible": true,
"default": "collapsed",
"items": [
{ "label": "Hosts", "href": "/module/kea" },
{ "label": "Setup", "href": "/modules/setup/kea" }
]
},
"setup": {
"fields": [
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": true },
{ "name": "db.host", "label": "DB Host", "type": "text", "required": true },
{ "name": "db.port", "label": "DB Port", "type": "number", "required": true },
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": true },
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "DB User", "type": "text", "required": true },
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": true },
{ "name": "db.driver", "label": "KEA DB Driver", "type": "text", "required": true, "help": "Standard-KEA-Datenbank, die auch vom KEA-Dienst selbst genutzt wird." },
{ "name": "db.host", "label": "KEA DB Host", "type": "text", "required": true },
{ "name": "db.port", "label": "KEA DB Port", "type": "number", "required": true },
{ "name": "db.dbname", "label": "KEA DB Name", "type": "text", "required": true },
{ "name": "db.schema", "label": "KEA DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "KEA DB User", "type": "text", "required": true },
{ "name": "db.password", "label": "KEA DB Passwort", "type": "password", "required": true },
{ "name": "metadata_db.driver", "label": "Nexus DHCP DB Driver", "type": "text", "required": true, "help": "Separate Datenbank fuer Nexus-eigene DHCP-Zusatzinfos, nicht fuer KEA-Standardtabellen." },
{ "name": "metadata_db.host", "label": "Nexus DHCP DB Host", "type": "text", "required": true },
{ "name": "metadata_db.port", "label": "Nexus DHCP DB Port", "type": "number", "required": true },
{ "name": "metadata_db.dbname", "label": "Nexus DHCP DB Name", "type": "text", "required": true },
{ "name": "metadata_db.schema", "label": "Nexus DHCP DB Schema", "type": "text", "required": false },
{ "name": "metadata_db.user", "label": "Nexus DHCP DB User", "type": "text", "required": true },
{ "name": "metadata_db.password", "label": "Nexus DHCP DB Passwort", "type": "password", "required": true },
{ "name": "kea_db_version", "label": "KEA DB Version", "type": "text", "required": false },
{ "name": "kea_init_script", "label": "KEA Init Script", "type": "text", "required": false },
{ "name": "kea_init_cmd", "label": "KEA Init Command", "type": "text", "required": false },
@@ -38,5 +33,14 @@
"schema": "public",
"user": "",
"password": ""
},
"metadata_db_defaults": {
"driver": "mysql",
"host": "192.168.178.10",
"port": 3306,
"dbname": "",
"schema": "",
"user": "",
"password": ""
}
}

View File

@@ -0,0 +1,91 @@
<?php
use App\Database;
use App\Repository\KeaHostMetadataRepository;
use App\Repository\KeaHostRepository;
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$module = modules()->get('kea');
$fallback = $module['db_defaults'] ?? [];
$settings = modules()->settings('kea');
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
$metadataConfig = is_array($settings['metadata_db'] ?? null)
? array_replace($metadataFallback, $settings['metadata_db'])
: $metadataFallback;
try {
$metadataRepo = null;
$pdo = modules()->modulePdo('kea', $fallback);
if (!empty($metadataConfig['driver']) && !empty($metadataConfig['dbname'])) {
try {
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
$metadataRepo->ensureSchema();
} catch (\Throwable) {
$metadataRepo = null;
}
}
$repo = new KeaHostRepository($pdo, $metadataRepo);
$hosts = $repo->findAll(200);
$stats = [
'total' => $repo->countReservations() + $repo->countLeases(),
'reservations' => $repo->countReservations(),
'leases' => $repo->countLeases(),
'groups' => [],
'free_ips' => [],
];
foreach ($hosts as $host) {
$group = trim((string)($host['metadata']['group_name'] ?? ''));
if ($group !== '') {
$stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1;
}
}
if ($metadataRepo !== null) {
$stats['free_ips'] = array_map(
static fn(array $ips): int => count($ips),
$metadataRepo->availableIpsByGroup(
array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()),
4096
)
);
}
$rows = array_map(static function (array $host): array {
return [
'source' => (string)($host['source'] ?? 'reservation'),
'host_id' => (string)($host['host_id'] ?? '0'),
'display_name' => (string)($host['metadata']['device_name'] ?? $host['metadata']['real_name'] ?? $host['display_name'] ?? $host['hostname'] ?? 'Unbekannt'),
'ipv4_address' => (string)($host['ipv4_address'] ?? ''),
'dhcp_identifier' => (string)($host['dhcp_identifier'] ?? ''),
'last_seen_at' => (string)($host['last_seen_at'] ?? '-'),
'lease_expires_at' => (string)($host['lease_expires_at'] ?? '-'),
'real_name' => (string)($host['metadata']['real_name'] ?? '-'),
'location' => (string)($host['metadata']['location'] ?? '-'),
'group_name' => (string)($host['metadata']['group_name'] ?? '-'),
];
}, $hosts);
echo json_encode([
'ok' => true,
'stats' => [
'total' => (int)$stats['total'],
'reservations' => (int)$stats['reservations'],
'leases' => (int)$stats['leases'],
'groups' => count($stats['groups']),
'free_ips' => array_sum($stats['free_ips']),
],
'rows' => $rows,
'updated_at' => date('H:i:s'),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
exit;

266
modules/kea/pages/edit.php Normal file
View File

@@ -0,0 +1,266 @@
<?php
use App\Database;
use App\Repository\KeaHostMetadataRepository;
use App\Repository\KeaHostRepository;
$module = modules()->get('kea');
$settings = modules()->settings('kea');
$fallback = $module['db_defaults'] ?? [];
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
$metadataConfig = is_array($settings['metadata_db'] ?? null)
? array_replace($metadataFallback, $settings['metadata_db'])
: $metadataFallback;
$source = (string)($_GET['source'] ?? $_POST['source'] ?? 'reservation');
$source = $source === 'lease' ? 'lease' : 'reservation';
$id = (int)($_GET['id'] ?? $_POST['id'] ?? 0);
$error = null;
$notice = null;
$host = null;
$metadataRepo = null;
$groups = [];
$availableIpsByGroup = [];
$checks = [];
$hostKey = '';
try {
$pdo = modules()->modulePdo('kea', $fallback);
if (empty($metadataConfig['driver']) || empty($metadataConfig['dbname'])) {
throw new RuntimeException('Nexus DHCP Zusatzdatenbank ist nicht konfiguriert.');
}
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
$metadataRepo->ensureSchema();
$groups = $metadataRepo->listGroups();
$repo = new KeaHostRepository($pdo, $metadataRepo);
$host = $repo->findDisplayByKey($source, $id);
if (!$host) {
throw new RuntimeException('KEA Eintrag wurde nicht gefunden.');
}
$hostKey = $source . ':' . (string)($host['host_id'] ?? $id);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = (string)($_POST['action'] ?? 'save_metadata');
if ($action === 'dns_lookup') {
$ip = (string)($host['ipv4_address'] ?? '');
$hostname = trim((string)($host['metadata']['device_name'] ?? $host['hostname'] ?? ''));
$reverse = $ip !== '' ? gethostbyaddr($ip) : '';
$forward = $hostname !== '' ? gethostbyname($hostname) : '';
$success = ($reverse !== '' && $reverse !== $ip) || ($forward !== '' && $forward !== $hostname);
$metadataRepo->saveCheck($hostKey, 'dns', $success ? 'success' : 'warning', [
'ip' => $ip,
'hostname' => $hostname,
'reverse' => $reverse,
'forward' => $forward,
]);
$notice = $success ? 'DNS-Pruefung gespeichert.' : 'DNS-Pruefung gespeichert, aber kein eindeutiger Name gefunden.';
} else {
$metadata = [
'real_name' => $_POST['real_name'] ?? '',
'device_name' => $_POST['device_name'] ?? '',
'owner' => $_POST['owner'] ?? '',
'location' => $_POST['location'] ?? '',
'device_type' => $_POST['device_type'] ?? '',
'group_name' => $_POST['group_name'] ?? '',
'desired_ip' => $_POST['desired_ip'] ?? '',
'notes' => $_POST['notes'] ?? '',
'tags' => [],
];
$desiredIp = trim((string)$metadata['desired_ip']);
if ($desiredIp !== '') {
$newHostId = $repo->reserveDisplayEntry($host, $desiredIp, $metadata);
$source = 'reservation';
$id = $newHostId;
$notice = 'Zusatzdaten gespeichert und KEA-Reservierung gesetzt.';
} else {
$metadataRepo->saveForHost(
$id,
(string)($host['dhcp_identifier'] ?? ''),
(string)($host['ipv4_address'] ?? ''),
$metadata
);
$notice = 'Zusatzdaten gespeichert.';
}
}
$host = $repo->findDisplayByKey($source, $id) ?: $host;
$hostKey = $source . ':' . (string)($host['host_id'] ?? $id);
}
$usedIps = array_diff(
array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()),
[(string)($host['ipv4_address'] ?? ''), (string)($host['metadata']['desired_ip'] ?? '')]
);
$availableIpsByGroup = $metadataRepo->availableIpsByGroup($usedIps);
$checks = $metadataRepo->latestChecks([$hostKey])[$hostKey] ?? [];
} catch (Throwable $e) {
$error = $e->getMessage();
}
$metadata = is_array($host['metadata'] ?? null) ? $host['metadata'] : [];
$selectedGroup = (string)($metadata['group_name'] ?? '');
$selectedIp = (string)($metadata['desired_ip'] ?? '');
?>
<?= module_shell_header('kea', [
'title' => 'KEA Eintrag bearbeiten',
]) ?>
<div class="module-flow kea-page">
<section class="module-box">
<div class="module-box-head">
<div>
<h2 class="module-box-title">KEA Eintrag bearbeiten</h2>
<p>Zusatzdaten werden separat von der KEA-Datenbank gespeichert.</p>
</div>
<div class="setup-actions">
<a class="module-button module-button--secondary" href="/module/kea/groups">Gruppen verwalten</a>
<a class="module-button module-button--secondary" href="/module/kea">Zurueck</a>
</div>
</div>
</section>
<?php if ($error): ?>
<div class="kea-message kea-message--error" role="alert">
<strong>Fehler</strong>
<p><?= e($error) ?></p>
</div>
<?php elseif ($host): ?>
<?php if ($notice): ?>
<div class="kea-message kea-message--success">
<?= e($notice) ?>
</div>
<?php endif; ?>
<section class="module-box kea-panel">
<div class="module-box-head kea-panel__head">
<div>
<span class="pill"><?= ($source === 'lease') ? 'Lease' : 'Reservierung' ?></span>
<h2 class="module-box-title"><?= e((string)($host['hostname'] ?: 'Unbekannt')) ?></h2>
<p class="muted">
IP <?= e((string)($host['ipv4_address'] ?? '')) ?> · MAC <?= e((string)($host['dhcp_identifier'] ?? '')) ?>
</p>
</div>
</div>
<form method="post" class="kea-edit-form">
<input type="hidden" name="action" value="save_metadata">
<input type="hidden" name="source" value="<?= e($source) ?>">
<input type="hidden" name="id" value="<?= e((string)$id) ?>">
<label class="setup-field">
<span>Echter Name</span>
<input type="text" name="real_name" value="<?= e((string)($metadata['real_name'] ?? '')) ?>">
</label>
<label class="setup-field">
<span>Gerätename</span>
<input type="text" name="device_name" value="<?= e((string)($metadata['device_name'] ?? '')) ?>">
</label>
<label class="setup-field">
<span>Besitzer</span>
<input type="text" name="owner" value="<?= e((string)($metadata['owner'] ?? '')) ?>">
</label>
<label class="setup-field">
<span>Standort</span>
<input type="text" name="location" value="<?= e((string)($metadata['location'] ?? '')) ?>">
</label>
<label class="setup-field">
<span>Gerätetyp</span>
<input type="text" name="device_type" value="<?= e((string)($metadata['device_type'] ?? '')) ?>">
</label>
<label class="setup-field">
<span>Gruppe</span>
<select name="group_name" data-kea-group-select>
<option value="">Bitte waehlen</option>
<?php foreach ($groups as $group): ?>
<option value="<?= e($group) ?>" <?= $selectedGroup === $group ? 'selected' : '' ?>><?= e($group) ?></option>
<?php endforeach; ?>
</select>
<?php if ($groups === []): ?>
<small class="muted">Noch keine Gruppen definiert. Oeffne zuerst Gruppen verwalten.</small>
<?php endif; ?>
</label>
<label class="setup-field">
<span>Feste IP</span>
<select name="desired_ip" data-kea-ip-select data-selected-ip="<?= e($selectedIp) ?>">
<option value="">Erst Gruppe waehlen</option>
</select>
<small class="muted">Es werden nur freie IPs aus dem IP-Bereich der gewaehlten Gruppe angeboten.</small>
</label>
<label class="setup-field kea-edit-form__wide">
<span>Notizen</span>
<textarea name="notes" rows="4"><?= e((string)($metadata['notes'] ?? '')) ?></textarea>
</label>
<div class="setup-actions kea-edit-form__wide">
<button class="cta-button" type="submit">Speichern</button>
<a class="nav-link" href="/module/kea">Abbrechen</a>
</div>
</form>
</section>
<section class="module-box kea-panel">
<div class="module-box-head kea-panel__head">
<div>
<span class="pill">Pruefungen</span>
<h2 class="module-box-title">Gerätechecks</h2>
<p class="muted">Pruefergebnisse werden in der Nexus-DHCP-Datenbank gespeichert und koennen spaeter fuer Reports genutzt werden.</p>
</div>
</div>
<div class="kea-check-grid">
<div class="kea-check-card">
<h4>DNS / Hostname</h4>
<?php $dnsCheck = $checks['dns'] ?? null; ?>
<?php if ($dnsCheck): ?>
<?php $dnsResult = json_decode((string)($dnsCheck['result_json'] ?? '{}'), true) ?: []; ?>
<p class="muted">Status: <?= e((string)($dnsCheck['status'] ?? '')) ?> · <?= e((string)($dnsCheck['checked_at'] ?? '')) ?></p>
<p class="mono">Reverse: <?= e((string)($dnsResult['reverse'] ?? '-')) ?></p>
<p class="mono">Forward: <?= e((string)($dnsResult['forward'] ?? '-')) ?></p>
<?php else: ?>
<p class="muted">Noch keine DNS-Pruefung gespeichert.</p>
<?php endif; ?>
<form method="post" class="setup-actions">
<input type="hidden" name="action" value="dns_lookup">
<input type="hidden" name="source" value="<?= e($source) ?>">
<input type="hidden" name="id" value="<?= e((string)$id) ?>">
<button class="nav-link" type="submit">DNS jetzt pruefen</button>
</form>
</div>
<div class="kea-check-card">
<h4>Login-Erkennung</h4>
<p class="muted">Vorbereitet fuer spaetere HTTP/Port-Erkennung. Noch nicht automatisch aktiv, damit keine ungewollten Scans laufen.</p>
</div>
</div>
</section>
<script>
(() => {
const ipsByGroup = <?= json_encode($availableIpsByGroup, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
const groupSelect = document.querySelector('[data-kea-group-select]');
const ipSelect = document.querySelector('[data-kea-ip-select]');
if (!groupSelect || !ipSelect) {
return;
}
const selectedIp = ipSelect.dataset.selectedIp || '';
const renderIps = () => {
const ips = ipsByGroup[groupSelect.value] || [];
ipSelect.innerHTML = '';
const empty = document.createElement('option');
empty.value = '';
empty.textContent = groupSelect.value ? 'Keine feste IP' : 'Erst Gruppe waehlen';
ipSelect.appendChild(empty);
if (selectedIp && !ips.includes(selectedIp)) {
ips.unshift(selectedIp);
}
for (const ip of ips) {
const option = document.createElement('option');
option.value = ip;
option.textContent = ip;
option.selected = ip === selectedIp;
ipSelect.appendChild(option);
}
};
groupSelect.addEventListener('change', renderIps);
renderIps();
})();
</script>
<?php endif; ?>
</div>
<?= module_shell_footer() ?>

View File

@@ -0,0 +1,227 @@
<?php
use App\Database;
use App\Repository\KeaHostMetadataRepository;
use App\Repository\KeaHostRepository;
$module = modules()->get('kea');
$settings = modules()->settings('kea');
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
$metadataConfig = is_array($settings['metadata_db'] ?? null)
? array_replace($metadataFallback, $settings['metadata_db'])
: $metadataFallback;
$fallback = $module['db_defaults'] ?? [];
$error = null;
$notice = null;
$groups = [];
$availableIpsByGroup = [];
$usedIps = [];
try {
if (empty($metadataConfig['driver']) || empty($metadataConfig['dbname'])) {
throw new RuntimeException('Nexus DHCP Zusatzdatenbank ist nicht konfiguriert.');
}
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
$metadataRepo->ensureSchema();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = (string)($_POST['action'] ?? '');
if ($action === 'save_group') {
$metadataRepo->saveGroup(
(string)($_POST['name'] ?? ''),
(string)($_POST['description'] ?? ''),
(string)($_POST['parent_name'] ?? '')
);
$notice = 'Gruppe gespeichert.';
} elseif ($action === 'add_range') {
$metadataRepo->addRange(
(string)($_POST['group_name'] ?? ''),
(string)($_POST['start_ip'] ?? ''),
(string)($_POST['end_ip'] ?? '')
);
$notice = 'IP-Bereich gespeichert.';
}
}
$groups = $metadataRepo->listGroupsWithRanges();
$keaRepo = new KeaHostRepository(modules()->modulePdo('kea', $fallback), $metadataRepo);
$usedIps = array_merge($keaRepo->usedIpAddresses(), $metadataRepo->desiredIps());
$availableIpsByGroup = $metadataRepo->availableIpsByGroup($usedIps, 4096);
} catch (Throwable $e) {
$error = $e->getMessage();
}
$usedIpLookup = array_flip(array_filter(array_map('strval', $usedIps)));
$matrixForGroup = static function (array $group) use ($usedIpLookup): array {
$dots = [];
foreach (($group['ranges'] ?? []) as $range) {
$start = ip2long((string)($range['start_ip'] ?? ''));
$end = ip2long((string)($range['end_ip'] ?? ''));
if ($start === false || $end === false) {
continue;
}
for ($ip = $start; $ip <= $end && count($dots) < 512; $ip++) {
$address = long2ip($ip);
if ($address === false) {
continue;
}
$dots[] = [
'ip' => $address,
'used' => isset($usedIpLookup[$address]),
];
}
}
return $dots;
};
?>
<?= module_shell_header('kea', [
'title' => 'KEA Gruppen',
]) ?>
<div class="module-flow kea-page">
<section class="module-box">
<div class="module-box-head">
<div>
<h2 class="module-box-title">KEA Gruppen</h2>
<p>Gruppen und IP-Bereiche fuer DHCP-Reservierungen.</p>
</div>
<a class="module-button module-button--secondary" href="/module/kea">Zurueck</a>
</div>
</section>
<?php if ($error): ?>
<div class="kea-message kea-message--error" role="alert">
<strong>Fehler</strong>
<p><?= e($error) ?></p>
</div>
<?php elseif ($notice): ?>
<div class="kea-message kea-message--success"><?= e($notice) ?></div>
<?php endif; ?>
<section class="module-box kea-panel">
<div class="module-box-head kea-panel__head">
<div>
<span class="pill">Gruppe</span>
<h2 class="module-box-title">Gruppe anlegen</h2>
</div>
</div>
<form method="post" class="kea-edit-form">
<input type="hidden" name="action" value="save_group">
<label class="setup-field">
<span>Name</span>
<input type="text" name="name" required>
</label>
<label class="setup-field">
<span>Beschreibung</span>
<input type="text" name="description">
</label>
<label class="setup-field">
<span>Uebergeordnete Gruppe</span>
<select name="parent_name">
<option value="">Keine</option>
<?php foreach ($groups as $group): ?>
<option value="<?= e((string)$group['name']) ?>"><?= e((string)$group['name']) ?></option>
<?php endforeach; ?>
</select>
</label>
<div class="setup-actions kea-edit-form__wide">
<button class="cta-button" type="submit">Gruppe speichern</button>
</div>
</form>
</section>
<section class="module-box kea-panel">
<div class="module-box-head kea-panel__head">
<div>
<span class="pill">IP-Bereich</span>
<h2 class="module-box-title">Bereich zuweisen</h2>
</div>
</div>
<form method="post" class="kea-edit-form">
<input type="hidden" name="action" value="add_range">
<label class="setup-field">
<span>Gruppe</span>
<select name="group_name" required>
<option value="">Bitte waehlen</option>
<?php foreach ($groups as $group): ?>
<option value="<?= e((string)$group['name']) ?>"><?= e((string)$group['name']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="setup-field">
<span>Start-IP</span>
<input type="text" name="start_ip" placeholder="192.168.178.50" required>
</label>
<label class="setup-field">
<span>End-IP</span>
<input type="text" name="end_ip" placeholder="192.168.178.99" required>
</label>
<div class="setup-actions kea-edit-form__wide">
<button class="cta-button" type="submit">Bereich speichern</button>
</div>
</form>
</section>
<section class="module-box-table kea-panel">
<div class="module-box-head kea-panel__head">
<div>
<span class="pill">Uebersicht</span>
<h2 class="module-box-title">Gruppen und freie IPs</h2>
</div>
</div>
<div class="kea-table-wrap">
<table class="kea-table">
<thead>
<tr>
<th>Gruppe</th>
<th>Untergruppe von</th>
<th>Beschreibung</th>
<th>Bereiche</th>
<th>Freie IPs</th>
<th>Matrix</th>
</tr>
</thead>
<tbody>
<?php if ($groups === []): ?>
<tr><td colspan="6" class="kea-empty">Noch keine Gruppen definiert.</td></tr>
<?php else: ?>
<?php foreach ($groups as $group): ?>
<?php $available = $availableIpsByGroup[(string)$group['name']] ?? []; ?>
<tr>
<td><?= e((string)$group['name']) ?></td>
<td><?= e((string)($group['parent_name'] ?: '-')) ?></td>
<td><?= e((string)($group['description'] ?? '-')) ?></td>
<td>
<?php if (($group['ranges'] ?? []) === []): ?>
<span class="muted">Kein Bereich</span>
<?php else: ?>
<?php foreach ($group['ranges'] as $range): ?>
<div class="mono"><?= e((string)$range['start_ip']) ?> - <?= e((string)$range['end_ip']) ?></div>
<?php endforeach; ?>
<?php endif; ?>
</td>
<td><?= e((string)count($available)) ?></td>
<td>
<?php $matrix = $matrixForGroup($group); ?>
<?php if ($matrix === []): ?>
<span class="muted">Kein Bereich</span>
<?php else: ?>
<div class="ip-matrix" aria-label="IP Matrix fuer <?= e((string)$group['name']) ?>">
<?php foreach ($matrix as $dot): ?>
<span
class="ip-dot <?= $dot['used'] ? 'is-used' : 'is-free' ?>"
title="<?= e((string)$dot['ip']) ?> · <?= $dot['used'] ? 'belegt' : 'frei' ?>"
></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</div>
<?= module_shell_footer() ?>

View File

@@ -1,18 +1,62 @@
<?php
use App\Database;
use App\Repository\KeaHostRepository;
use App\Repository\KeaHostMetadataRepository;
$module = modules()->get('kea');
$fallback = $module['db_defaults'] ?? [];
$pdo = modules()->modulePdo('kea', $fallback);
$settings = modules()->settings('kea');
$metadataFallback = is_array($module['metadata_db_defaults'] ?? null) ? $module['metadata_db_defaults'] : [];
$metadataConfig = is_array($settings['metadata_db'] ?? null)
? array_replace($metadataFallback, $settings['metadata_db'])
: $metadataFallback;
$metadataRepo = null;
$hosts = [];
$error = null;
$warnings = [];
$stats = [
'total' => 0,
'reservations' => 0,
'leases' => 0,
'groups' => [],
'free_ips' => [],
];
try {
$repo = new KeaHostRepository($pdo);
$hosts = $repo->findAll(50);
$pdo = modules()->modulePdo('kea', $fallback);
if (!empty($metadataConfig['driver']) && !empty($metadataConfig['dbname'])) {
try {
$metadataRepo = new KeaHostMetadataRepository(Database::createFromArray($metadataConfig));
$metadataRepo->ensureSchema();
} catch (\Throwable $e) {
$warnings[] = 'Nexus DHCP Zusatzdatenbank nicht verfuegbar: ' . $e->getMessage();
$metadataRepo = null;
}
}
$repo = new KeaHostRepository($pdo, $metadataRepo);
$hosts = $repo->findAll(200);
$stats['reservations'] = $repo->countReservations();
$stats['leases'] = $repo->countLeases();
$stats['total'] = $stats['reservations'] + $stats['leases'];
foreach ($hosts as $host) {
$group = trim((string)($host['metadata']['group_name'] ?? ''));
if ($group !== '') {
$stats['groups'][$group] = ($stats['groups'][$group] ?? 0) + 1;
}
}
if ($metadataRepo !== null) {
$stats['free_ips'] = array_map(
static fn(array $ips): int => count($ips),
$metadataRepo->availableIpsByGroup(
array_merge($repo->usedIpAddresses(), $metadataRepo->desiredIps()),
4096
)
);
}
} catch (\Exception $e) {
$error = "Datenbankfehler: " . $e->getMessage();
}
module_tpl('kea', 'dashboard', compact('hosts', 'error'));
module_tpl('kea', 'dashboard', compact('hosts', 'error', 'warnings', 'stats'));

View File

@@ -2,67 +2,128 @@
/**
* @var array $hosts Die Liste der KEA-Hosts.
* @var string|null $error Eine Fehlermeldung, falls vorhanden.
* @var array $warnings Hinweise, falls Zusatzdaten nicht geladen werden konnten.
* @var array $stats Kennzahlen fuer die Uebersicht.
*/
?>
<div class="px-4 py-6 sm:px-0">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-semibold text-white">KEA DHCP Hosts</h1>
<button class="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded shadow transition-colors">
+ Neuer Host
</button>
</div>
<?= module_shell_header('kea', [
'title' => 'KEA DHCP Hosts',
'description' => 'Reservierungen und aktuelle Leases aus der KEA-Datenbank.',
]) ?>
<div class="module-flow kea-page">
<section class="module-box">
<div class="module-box-head">
<div>
<h2 class="module-box-title">KEA DHCP Hosts</h2>
<p>Reservierungen und aktuelle Leases aus der KEA-Datenbank.</p>
<p class="muted kea-refresh-state" data-kea-refresh-state>
Automatische Aktualisierung alle 5 Sekunden.
</p>
</div>
</div>
</section>
<?php if ($error): ?>
<div class="bg-red-900 border-l-4 border-red-500 text-red-100 p-4 mb-6" role="alert">
<p class="font-bold">Fehler</p>
<div class="kea-message kea-message--error" role="alert">
<strong>Fehler</strong>
<p><?= e($error) ?></p>
</div>
<?php endif; ?>
<div class="bg-gray-800 shadow overflow-hidden sm:rounded-lg border border-gray-700">
<div class="px-4 py-5 sm:px-6 border-b border-gray-700">
<h3 class="text-lg leading-6 font-medium text-gray-200">
Registrierte Geräte
</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-400">
Übersicht der statischen Reservierungen und bekannten Clients.
</p>
<?php foreach (($warnings ?? []) as $warning): ?>
<div class="kea-message kea-message--warning" role="alert">
<p><?= e((string)$warning) ?></p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-700">
<thead class="bg-gray-900">
<?php endforeach; ?>
<div class="module-box-grid module-box-grid--stats stats">
<section class="module-box-soft stat-card">
<span class="stat-label">Einträge</span>
<span class="stat-value" data-kea-stat="total"><?= e((string)($stats['total'] ?? 0)) ?></span>
</section>
<section class="module-box-soft stat-card">
<span class="stat-label">Reservierungen</span>
<span class="stat-value" data-kea-stat="reservations"><?= e((string)($stats['reservations'] ?? 0)) ?></span>
</section>
<section class="module-box-soft stat-card">
<span class="stat-label">Leases</span>
<span class="stat-value" data-kea-stat="leases"><?= e((string)($stats['leases'] ?? 0)) ?></span>
</section>
<section class="module-box-soft stat-card">
<span class="stat-label">Gruppen</span>
<span class="stat-value" data-kea-stat="groups"><?= e((string)count($stats['groups'] ?? [])) ?></span>
</section>
<section class="module-box-soft stat-card">
<span class="stat-label">Freie Gruppen-IPs</span>
<span class="stat-value" data-kea-stat="free_ips"><?= e((string)array_sum($stats['free_ips'] ?? [])) ?></span>
</section>
</div>
<section class="module-box-table kea-panel">
<div class="module-box-head kea-panel__head">
<div>
<span class="pill">Inventar</span>
<h2 class="module-box-title">Registrierte Geräte</h2>
<p class="muted">Zusatzdaten werden in der separaten Nexus-DHCP-Datenbank gespeichert.</p>
</div>
</div>
<div class="kea-table-wrap">
<table class="kea-table">
<thead>
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Hostname</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">IP Adresse</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">MAC Adresse</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">Kontext</th>
<th scope="col" class="relative px-6 py-3">
<span class="sr-only">Edit</span>
</th>
<th>Quelle</th>
<th>Hostname</th>
<th>IP Adresse</th>
<th>MAC Adresse</th>
<th>Zuletzt gesehen</th>
<th>Lease bis</th>
<th>Echter Name</th>
<th>Standort</th>
<th>Gruppe</th>
<th>Aktion</th>
</tr>
</thead>
<tbody class="bg-gray-800 divide-y divide-gray-700">
<tbody data-kea-host-rows>
<?php if (empty($hosts)): ?>
<tr>
<td colspan="5" class="px-6 py-4 text-center text-sm text-gray-500">Keine Hosts gefunden.</td>
<td colspan="10" class="kea-empty">
Keine Reservierungen oder aktiven Leases gefunden.
</td>
</tr>
<?php else: ?>
<?php foreach ($hosts as $host): ?>
<tr class="hover:bg-gray-750 transition-colors">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-white">
<?= e($host['hostname'] ?: 'Unbekannt') ?>
<tr>
<td>
<span class="pill"><?= ($host['source'] ?? '') === 'lease' ? 'Lease' : 'Reservierung' ?></span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300 font-mono">
<td>
<?= e((string)($host['metadata']['device_name'] ?? $host['metadata']['real_name'] ?? $host['display_name'] ?? $host['hostname'] ?? 'Unbekannt')) ?>
</td>
<td class="mono">
<?= e($host['ipv4_address']) ?>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400 font-mono">
<td class="mono">
<?= e($host['dhcp_identifier']) ?>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-400">
<?= e($host['user_context'] ?? '-') ?>
<td>
<?= e((string)($host['last_seen_at'] ?? '-')) ?>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="#" class="text-indigo-400 hover:text-indigo-300">Bearbeiten</a>
<td>
<?= e((string)($host['lease_expires_at'] ?? '-')) ?>
</td>
<td>
<?= e((string)($host['metadata']['real_name'] ?? '-')) ?>
</td>
<td>
<?= e((string)($host['metadata']['location'] ?? '-')) ?>
</td>
<td>
<?= e((string)($host['metadata']['group_name'] ?? '-')) ?>
</td>
<td>
<a class="nav-link" href="/module/kea/edit?source=<?= e((string)($host['source'] ?? 'reservation')) ?>&id=<?= e((string)($host['host_id'] ?? '0')) ?>">
Bearbeiten
</a>
</td>
</tr>
<?php endforeach; ?>
@@ -70,5 +131,101 @@
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
<script>
(() => {
const rowsTarget = document.querySelector('[data-kea-host-rows]');
const stateTarget = document.querySelector('[data-kea-refresh-state]');
if (!rowsTarget) {
return;
}
const setText = (selector, value) => {
const target = document.querySelector(selector);
if (target) {
target.textContent = String(value ?? '0');
}
};
const cell = (value, className = '') => {
const td = document.createElement('td');
if (className) {
td.className = className;
}
td.textContent = value || '-';
return td;
};
const renderRows = (rows) => {
rowsTarget.textContent = '';
if (!Array.isArray(rows) || rows.length === 0) {
const tr = document.createElement('tr');
const td = cell('Keine Reservierungen oder aktiven Leases gefunden.', 'kea-empty');
td.colSpan = 10;
tr.appendChild(td);
rowsTarget.appendChild(tr);
return;
}
for (const row of rows) {
const tr = document.createElement('tr');
const source = document.createElement('td');
const pill = document.createElement('span');
pill.className = 'pill';
pill.textContent = row.source === 'lease' ? 'Lease' : 'Reservierung';
source.appendChild(pill);
tr.appendChild(source);
tr.appendChild(cell(row.display_name));
tr.appendChild(cell(row.ipv4_address, 'mono'));
tr.appendChild(cell(row.dhcp_identifier, 'mono'));
tr.appendChild(cell(row.last_seen_at));
tr.appendChild(cell(row.lease_expires_at));
tr.appendChild(cell(row.real_name));
tr.appendChild(cell(row.location));
tr.appendChild(cell(row.group_name));
const action = document.createElement('td');
const link = document.createElement('a');
link.className = 'nav-link';
link.href = `/module/kea/edit?source=${encodeURIComponent(row.source || 'reservation')}&id=${encodeURIComponent(row.host_id || '0')}`;
link.textContent = 'Bearbeiten';
action.appendChild(link);
tr.appendChild(action);
rowsTarget.appendChild(tr);
}
};
const refresh = async () => {
try {
const response = await fetch('/module/kea/data', {
headers: {Accept: 'application/json'},
cache: 'no-store',
});
const payload = await response.json();
if (!response.ok || !payload.ok) {
throw new Error(payload.error || 'Aktualisierung fehlgeschlagen.');
}
setText('[data-kea-stat="total"]', payload.stats?.total);
setText('[data-kea-stat="reservations"]', payload.stats?.reservations);
setText('[data-kea-stat="leases"]', payload.stats?.leases);
setText('[data-kea-stat="groups"]', payload.stats?.groups);
setText('[data-kea-stat="free_ips"]', payload.stats?.free_ips);
renderRows(payload.rows);
if (stateTarget) {
stateTarget.textContent = `Automatische Aktualisierung aktiv. Zuletzt aktualisiert: ${payload.updated_at || '-'}`;
}
} catch (error) {
if (stateTarget) {
stateTarget.textContent = `Automatische Aktualisierung fehlgeschlagen: ${error.message}`;
}
}
};
window.setInterval(refresh, 5000);
})();
</script>
<?= module_shell_footer() ?>

View 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'] ?? '');

View File

@@ -0,0 +1,772 @@
#mining-checker-app {
--mc-surface: var(--surface);
--mc-surface-strong: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(248,252,252,0.92));
--mc-line: var(--line);
--mc-line-strong: color-mix(in srgb, var(--brand-accent) 28%, transparent);
--mc-text: var(--text);
--mc-text-muted: var(--muted);
--mc-accent: var(--brand-accent);
--mc-accent-strong: var(--brand-accent-3);
--mc-danger: #d92d20;
--mc-success: #14804a;
--mc-warning: #b54708;
min-height: 0;
background: none;
color: var(--mc-text);
font-family: inherit;
max-width: 100%;
overflow-x: clip;
}
#mining-checker-app,
#mining-checker-app * {
box-sizing: border-box;
}
#mining-checker-app .mc-grid-bg {
background: none;
max-width: 100%;
overflow-x: clip;
}
#mining-checker-app .mc-shell {
width: 100%;
max-width: 100%;
margin: 0;
padding: 0;
}
#mining-checker-app .mc-stack {
display: grid;
gap: 16px;
}
#mining-checker-app .mc-panel,
#mining-checker-app .mc-stat-card,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-target-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty,
#mining-checker-app .mc-table-shell,
#mining-checker-app .mc-display-field {
border: 1px solid var(--mc-line);
border-radius: 22px;
background: var(--mc-surface);
/* box-shadow: 0 12px 30px rgba(1, 22, 32, 0.08); */
backdrop-filter: blur(8px);
}
#mining-checker-app .mc-panel,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty,
#mining-checker-app .mc-table-shell {
padding: 18px 20px;
}
#mining-checker-app .mc-hero-top,
#mining-checker-app .mc-inline-row,
#mining-checker-app .mc-flex-split,
#mining-checker-app .mc-section-head {
display: flex;
gap: 16px;
}
#mining-checker-app .mc-hero-top,
#mining-checker-app .mc-flex-split,
#mining-checker-app .mc-section-head {
justify-content: space-between;
}
#mining-checker-app .mc-hero-copy,
#mining-checker-app .mc-hero-controls,
#mining-checker-app .mc-panel-body,
#mining-checker-app .mc-form,
#mining-checker-app .mc-field,
#mining-checker-app .mc-filter-grid,
#mining-checker-app .mc-chart,
#mining-checker-app .mc-target-grid {
display: grid;
gap: 14px;
}
#mining-checker-app .mc-filter-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
#mining-checker-app .mc-text,
#mining-checker-app p,
#mining-checker-app td,
#mining-checker-app th,
#mining-checker-app label,
#mining-checker-app summary,
#mining-checker-app pre {
color: var(--mc-text-muted);
}
#mining-checker-app h1,
#mining-checker-app h2,
#mining-checker-app h3 {
margin: 0;
color: var(--mc-text);
}
#mining-checker-app .mc-stats-grid,
#mining-checker-app .mc-target-grid,
#mining-checker-app .mc-overview-grid,
#mining-checker-app .mc-two-col,
#mining-checker-app .mc-main-grid {
display: grid;
gap: 16px;
}
#mining-checker-app .mc-stats-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
#mining-checker-app .mc-overview-grid {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
#mining-checker-app .mc-target-grid {
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
#mining-checker-app .mc-two-col {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
#mining-checker-app .mc-asset-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 14px;
}
#mining-checker-app .mc-asset-card {
gap: 8px;
}
#mining-checker-app .mc-asset-balance {
font-size: 1.15rem;
font-weight: 700;
color: var(--mc-text);
}
#mining-checker-app .mc-asset-list {
display: grid;
gap: 10px;
min-width: 240px;
}
#mining-checker-app .mc-asset-row {
display: grid;
gap: 2px;
}
#mining-checker-app .mc-asset-row strong {
color: var(--mc-text);
}
#mining-checker-app .mc-main-grid {
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
}
#mining-checker-app .mc-stat-card,
#mining-checker-app .mc-target-card {
padding: 20px;
}
#mining-checker-app .mc-stat-card {
background: var(--mc-surface-strong);
}
#mining-checker-app .mc-kicker {
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--mc-accent-strong);
}
#mining-checker-app .mc-stat-value {
font-size: 2rem;
font-weight: 700;
line-height: 1.1;
color: var(--mc-text);
}
#mining-checker-app .mc-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.18em;
border: 1px solid var(--mc-line-strong);
background: color-mix(in srgb, var(--mc-accent) 16%, transparent);
color: var(--mc-accent-strong);
}
#mining-checker-app .mc-badge--warn {
background: color-mix(in srgb, var(--mc-warning) 12%, transparent);
color: var(--mc-warning);
}
#mining-checker-app .mc-badge--info {
background: color-mix(in srgb, var(--mc-accent) 16%, transparent);
color: var(--mc-accent-strong);
}
#mining-checker-app .mc-badge--danger {
background: color-mix(in srgb, var(--mc-danger) 12%, transparent);
color: var(--mc-danger);
}
#mining-checker-app .mc-badge--success {
background: color-mix(in srgb, var(--mc-success) 12%, transparent);
color: var(--mc-success);
}
#mining-checker-app .mc-button,
#mining-checker-app button,
#mining-checker-app input,
#mining-checker-app select,
#mining-checker-app textarea {
font: inherit;
}
#mining-checker-app .mc-button {
border: 1px solid transparent;
border-radius: 16px;
padding: 12px 16px;
cursor: pointer;
text-decoration: none;
transition: 160ms ease;
}
#mining-checker-app .mc-button:hover {
transform: translateY(-1px);
}
#mining-checker-app .mc-button:disabled {
opacity: 0.6;
cursor: default;
transform: none;
}
#mining-checker-app .mc-button--primary {
background: linear-gradient(135deg, var(--mc-accent), var(--mc-accent-strong));
color: #05121f;
font-weight: 700;
}
#mining-checker-app .mc-button--secondary {
background: rgba(255, 255, 255, 0.92);
color: var(--mc-text);
font-weight: 700;
}
#mining-checker-app .mc-button--danger {
background: linear-gradient(135deg, rgba(251, 113, 133, 0.92), rgba(239, 68, 68, 0.92));
color: #fff7f7;
font-weight: 700;
}
#mining-checker-app .mc-button--ghost {
background: color-mix(in srgb, var(--mc-accent) 14%, transparent);
border-color: color-mix(in srgb, var(--mc-accent) 34%, transparent);
color: var(--mc-accent-strong);
}
#mining-checker-app .mc-debug-tools {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: flex-start;
}
#mining-checker-app .mc-debug-console {
border-color: rgba(125, 211, 252, 0.28);
}
#mining-checker-app .mc-debug-view-switch {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
#mining-checker-app .mc-debug-log {
display: grid;
gap: 12px;
max-height: 520px;
overflow: auto;
}
#mining-checker-app .mc-debug-text-console {
max-height: 520px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
#mining-checker-app .mc-debug-entry {
border: 1px solid var(--mc-line);
border-radius: 18px;
padding: 14px 16px;
background: color-mix(in srgb, var(--brand-accent-2) 8%, var(--mc-surface));
}
#mining-checker-app .mc-field {
gap: 8px;
}
#mining-checker-app .mc-field-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: var(--mc-text-muted);
}
#mining-checker-app .mc-input,
#mining-checker-app .mc-select,
#mining-checker-app .mc-textarea,
#mining-checker-app .mc-file {
width: 100%;
border: 1px solid var(--mc-line);
border-radius: 16px;
padding: 13px 15px;
color: var(--mc-text);
background: rgba(255, 255, 255, 0.72);
outline: none;
}
#mining-checker-app .mc-select option {
color: #09111f;
background: #f8fafc;
}
#mining-checker-app .mc-textarea {
min-height: 120px;
resize: vertical;
}
#mining-checker-app .mc-input::placeholder,
#mining-checker-app .mc-textarea::placeholder {
color: #6d7c90;
}
#mining-checker-app .mc-input:focus,
#mining-checker-app .mc-select:focus,
#mining-checker-app .mc-textarea:focus,
#mining-checker-app .mc-file:focus {
border-color: rgba(125, 211, 252, 0.5);
box-shadow: 0 0 0 3px rgba(125, 211, 252, 0.12);
}
#mining-checker-app .mc-checkbox {
display: flex;
align-items: center;
gap: 12px;
border: 1px solid var(--mc-line);
border-radius: 16px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.05);
}
#mining-checker-app .mc-inline-fields {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: end;
}
#mining-checker-app .mc-inline-fields > .mc-field {
flex: 1 1 280px;
}
#mining-checker-app .mc-alert--error {
border-color: rgba(251, 113, 133, 0.28);
background: rgba(127, 29, 29, 0.35);
color: #ffe4e6;
}
#mining-checker-app .mc-alert--warning {
border-color: rgba(245, 158, 11, 0.28);
background: rgba(120, 53, 15, 0.34);
color: #fef3c7;
}
#mining-checker-app .mc-alert--success {
border-color: rgba(52, 211, 153, 0.28);
background: rgba(6, 78, 59, 0.34);
color: #d1fae5;
}
#mining-checker-app .mc-table-shell {
overflow: auto;
padding: 0;
}
#mining-checker-app .mc-table {
width: 100%;
border-collapse: collapse;
}
#mining-checker-app .mc-table th,
#mining-checker-app .mc-table td {
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
text-align: left;
vertical-align: top;
}
#mining-checker-app .mc-table thead {
background: rgba(255, 255, 255, 0.04);
}
#mining-checker-app .mc-empty {
border-style: dashed;
}
#mining-checker-app details {
border: 1px solid var(--mc-line);
border-radius: 18px;
padding: 16px;
background: rgba(255, 255, 255, 0.04);
}
#mining-checker-app pre {
white-space: pre-wrap;
margin: 12px 0 0;
line-height: 1.65;
}
#mining-checker-app .mc-mini-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
#mining-checker-app .mc-mini-card,
#mining-checker-app .mc-display-field {
padding: 12px 14px;
background: rgba(255, 255, 255, 0.04);
}
#mining-checker-app .mc-code-block {
margin: 0;
padding: 12px 14px;
border: 1px solid var(--mc-line);
border-radius: 16px;
background: rgba(255, 255, 255, 0.03);
white-space: pre-wrap;
word-break: break-word;
font-family: "JetBrains Mono", "SFMono-Regular", monospace;
font-size: 0.92rem;
line-height: 1.6;
}
#mining-checker-app .mc-chart svg {
width: 100%;
height: 220px;
}
#mining-checker-app .mc-modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(2, 6, 23, 0.72);
}
#mining-checker-app .mc-modal {
width: min(720px, 100%);
max-height: min(80vh, 900px);
overflow: auto;
padding: 24px;
border: 1px solid var(--mc-line);
border-radius: 28px;
background: rgba(9, 17, 31, 0.96);
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.34);
backdrop-filter: blur(14px);
}
#mining-checker-app .mc-chart path,
#mining-checker-app .mc-chart polyline,
#mining-checker-app .mc-chart line,
#mining-checker-app .mc-chart rect {
vector-effect: non-scaling-stroke;
}
@media (max-width: 960px) {
#mining-checker-app .mc-hero-top,
#mining-checker-app .mc-inline-row,
#mining-checker-app .mc-flex-split,
#mining-checker-app .mc-section-head {
flex-direction: column;
align-items: stretch;
}
#mining-checker-app .mc-main-grid {
grid-template-columns: 1fr;
}
#mining-checker-app .mc-shell {
width: min(100% - 10px, 1360px);
padding: 8px 0 20px;
}
#mining-checker-app .mc-stack {
gap: 14px;
}
#mining-checker-app .mc-hero,
#mining-checker-app .mc-panel,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty,
#mining-checker-app .mc-table-shell {
padding: 14px;
border-radius: 20px;
}
#mining-checker-app .mc-title {
font-size: clamp(1.45rem, 8vw, 2.15rem);
}
#mining-checker-app .mc-hero-copy {
gap: 8px;
}
#mining-checker-app .mc-hero-copy p {
margin: 0;
}
#mining-checker-app .mc-hero-controls {
grid-template-columns: 1fr;
}
#mining-checker-app .mc-home-link {
justify-self: stretch;
justify-content: center;
}
#mining-checker-app .mc-tabs {
flex-wrap: nowrap;
gap: 8px;
margin: 12px -4px 0;
padding: 0 4px 4px;
overflow-x: auto;
scrollbar-width: thin;
}
#mining-checker-app .mc-button {
padding: 10px 12px;
border-radius: 14px;
}
#mining-checker-app .mc-button--tab {
flex: 0 0 auto;
white-space: nowrap;
}
#mining-checker-app .mc-table th,
#mining-checker-app .mc-table td {
padding: 10px 12px;
}
#mining-checker-app .mc-modal {
max-height: min(92vh, 900px);
padding: 16px;
border-radius: 20px;
}
}
@media (max-width: 600px) {
#mining-checker-app,
#mining-checker-app * {
max-width: 100%;
}
#mining-checker-app {
min-height: 100vh;
overflow-x: hidden;
}
#mining-checker-app .mc-grid-bg {
overflow-x: hidden;
background-size: 18px 18px;
}
#mining-checker-app .mc-shell {
width: 100%;
padding: 0 0 16px;
}
#mining-checker-app .mc-stack {
gap: 12px;
}
#mining-checker-app .mc-hero,
#mining-checker-app .mc-panel,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty {
width: 100%;
border-radius: 18px;
padding: 14px;
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.16);
}
#mining-checker-app .mc-hero {
border-radius: 0 0 18px 18px;
}
#mining-checker-app .mc-panel,
#mining-checker-app .mc-dashboard-card,
#mining-checker-app .mc-alert,
#mining-checker-app .mc-empty,
#mining-checker-app .mc-table-shell {
border-left: 0;
border-right: 0;
}
#mining-checker-app .mc-title,
#mining-checker-app .mc-hero-copy p {
display: none;
}
#mining-checker-app .mc-hero-top {
gap: 10px;
}
#mining-checker-app .mc-kicker {
font-size: 0.68rem;
letter-spacing: 0.14em;
}
#mining-checker-app .mc-tabs {
margin: 10px -6px 0;
padding: 0 6px 6px;
gap: 6px;
}
#mining-checker-app .mc-button {
min-height: 38px;
padding: 8px 10px;
border-radius: 12px;
font-size: 0.92rem;
}
#mining-checker-app .mc-button:hover {
transform: none;
}
#mining-checker-app .mc-stats-grid,
#mining-checker-app .mc-target-grid,
#mining-checker-app .mc-overview-grid,
#mining-checker-app .mc-two-col,
#mining-checker-app .mc-main-grid,
#mining-checker-app .mc-filter-grid,
#mining-checker-app .mc-mini-grid {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
#mining-checker-app .mc-stat-card,
#mining-checker-app .mc-target-card,
#mining-checker-app .mc-mini-card,
#mining-checker-app .mc-display-field {
padding: 12px;
border-radius: 16px;
}
#mining-checker-app .mc-stat-value {
font-size: clamp(1.45rem, 8vw, 1.85rem);
overflow-wrap: anywhere;
}
#mining-checker-app h2 {
font-size: 1.25rem;
}
#mining-checker-app h3 {
font-size: 1.05rem;
}
#mining-checker-app .mc-text,
#mining-checker-app p,
#mining-checker-app td,
#mining-checker-app th,
#mining-checker-app label,
#mining-checker-app summary,
#mining-checker-app pre {
font-size: 0.92rem;
}
#mining-checker-app .mc-input,
#mining-checker-app .mc-select,
#mining-checker-app .mc-textarea,
#mining-checker-app .mc-file {
min-width: 0;
padding: 10px 12px;
border-radius: 12px;
font-size: 16px;
}
#mining-checker-app .mc-inline-fields,
#mining-checker-app .mc-debug-tools,
#mining-checker-app .mc-debug-view-switch {
gap: 8px;
}
#mining-checker-app .mc-inline-fields > .mc-field {
flex-basis: 100%;
min-width: 0;
}
#mining-checker-app .mc-badge {
padding: 7px 10px;
font-size: 0.78rem;
}
#mining-checker-app .mc-table-shell {
width: 100%;
max-width: 100%;
border-radius: 16px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
#mining-checker-app .mc-table {
max-width: none;
min-width: 640px;
}
#mining-checker-app .mc-table th,
#mining-checker-app .mc-table td {
padding: 9px 10px;
font-size: 0.86rem;
}
#mining-checker-app .mc-chart svg {
height: 180px;
}
#mining-checker-app .mc-modal-backdrop {
align-items: stretch;
padding: 8px;
}
#mining-checker-app .mc-modal {
width: 100%;
max-height: calc(100vh - 16px);
padding: 14px;
border-radius: 18px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
use Modules\MiningChecker\Infrastructure\ConnectionFactory;
use Modules\MiningChecker\Infrastructure\MiningRepository;
use Modules\MiningChecker\Infrastructure\ModuleConfig;
use Modules\MiningChecker\Infrastructure\SchemaManager;
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;
}
});
$mm = isset($modules) && $modules instanceof App\ModuleManager ? $modules : modules();
$moduleName = 'mining-checker';
$mm->registerFunction($moduleName, 'runtime_settings', static function (): array {
$moduleBasePath = __DIR__;
$config = ModuleConfig::load($moduleBasePath);
$projectKey = $config->defaultProjectKey();
$user = auth_user() ?? [];
$ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local';
$pdo = ConnectionFactory::make($config);
$repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub);
$settings = $repository->getSettings($projectKey) ?? [];
$displayTimezone = trim((string) ($settings['display_timezone'] ?? nexus_display_timezone_name()));
if ($displayTimezone === '') {
$displayTimezone = nexus_display_timezone_name();
}
$baselineMeasuredAt = trim((string) ($settings['baseline_measured_at'] ?? ''));
if ($baselineMeasuredAt !== '') {
try {
$baselineMeasuredAt = (new DateTimeImmutable($baselineMeasuredAt, new DateTimeZone('UTC')))
->setTimezone(new DateTimeZone($displayTimezone))
->format('Y-m-d\TH:i');
} catch (\Throwable) {
$baselineMeasuredAt = '';
}
}
return [
'baseline_measured_at' => $baselineMeasuredAt,
'baseline_coins_total' => isset($settings['baseline_coins_total']) ? (string) $settings['baseline_coins_total'] : '',
'report_currency' => strtoupper(trim((string) ($settings['report_currency'] ?? 'EUR'))) ?: 'EUR',
];
});
$mm->registerFunction($moduleName, 'save_runtime_settings', static function (array $payload): array {
$moduleBasePath = __DIR__;
$config = ModuleConfig::load($moduleBasePath);
$projectKey = $config->defaultProjectKey();
$user = auth_user() ?? [];
$ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local';
$pdo = ConnectionFactory::make($config);
$schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath);
$schema->ensureSchema();
$repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub);
$repository->ensureProject($projectKey, strtoupper(str_replace('-', ' ', $projectKey)));
$existing = $repository->getSettings($projectKey) ?? [];
$displayTimezone = trim((string) ($existing['display_timezone'] ?? nexus_display_timezone_name()));
if ($displayTimezone === '') {
$displayTimezone = nexus_display_timezone_name();
}
try {
$displayTz = new DateTimeZone($displayTimezone);
} catch (\Throwable) {
throw new RuntimeException('Ungueltige Anzeige-Zeitzone.');
}
$baselineInput = trim((string) ($payload['baseline_measured_at'] ?? ($existing['baseline_measured_at'] ?? '')));
if ($baselineInput === '') {
throw new RuntimeException('Baseline Zeitpunkt fehlt.');
}
try {
$baselineUtc = (new DateTimeImmutable($baselineInput, $displayTz))
->setTimezone(new DateTimeZone('UTC'))
->format('Y-m-d H:i:s');
} catch (\Throwable) {
throw new RuntimeException('Baseline Zeitpunkt ist ungueltig.');
}
$baselineCoins = trim((string) ($payload['baseline_coins_total'] ?? ($existing['baseline_coins_total'] ?? '')));
if ($baselineCoins === '' || !is_numeric($baselineCoins)) {
throw new RuntimeException('Baseline Coins muessen numerisch sein.');
}
$preferredCurrencies = $existing['preferred_currencies'] ?? ['DOGE', 'USD', 'EUR'];
if (is_string($preferredCurrencies)) {
$preferredCurrencies = preg_split('/[\s,;]+/', $preferredCurrencies) ?: [];
}
$preferredCurrencies = array_values(array_unique(array_filter(array_map(
static fn (mixed $value): string => strtoupper(trim((string) $value)),
is_array($preferredCurrencies) ? $preferredCurrencies : []
), static fn (string $value): bool => $value !== '')));
$settings = [
'baseline_measured_at' => $baselineUtc,
'baseline_coins_total' => (float) $baselineCoins,
'daily_cost_amount' => isset($existing['daily_cost_amount']) && is_numeric((string) $existing['daily_cost_amount'])
? (float) $existing['daily_cost_amount']
: 0.0,
'daily_cost_currency' => strtoupper(trim((string) ($existing['daily_cost_currency'] ?? 'EUR'))) ?: 'EUR',
'report_currency' => strtoupper(trim((string) ($payload['report_currency'] ?? ($existing['report_currency'] ?? 'EUR')))) ?: 'EUR',
'crypto_currency' => strtoupper(trim((string) ($existing['crypto_currency'] ?? 'DOGE'))) ?: 'DOGE',
'display_timezone' => $displayTimezone,
'fx_max_age_hours' => isset($existing['fx_max_age_hours']) && is_numeric((string) $existing['fx_max_age_hours'])
? (int) $existing['fx_max_age_hours']
: 3,
'module_theme_mode' => in_array((string) ($existing['module_theme_mode'] ?? 'inherit'), ['inherit', 'custom'], true)
? (string) $existing['module_theme_mode']
: 'inherit',
'module_theme_accent' => in_array((string) ($existing['module_theme_accent'] ?? 'teal'), ['teal', 'logo', 'pink', 'cyan', 'orange', 'green'], true)
? (string) $existing['module_theme_accent']
: 'teal',
'preferred_currencies' => $preferredCurrencies,
];
$repository->saveSettings($projectKey, $settings);
return module_fn('mining-checker', 'runtime_settings');
});
$mm->registerFunction($moduleName, 'setup_actions', static function (): array {
return [
[
'name' => 'initialize_schema',
'label' => 'Tabellen importieren',
'section' => 'database',
'help' => 'Legt die Mining-Checker Tabellen an, wenn sie noch nicht vorhanden sind.',
],
[
'name' => 'upgrade_schema',
'label' => 'Tabellen updaten',
'section' => 'database',
'help' => 'Fuehrt fehlende Tabellen- und Spalten-Upgrades fuer den Mining-Checker aus.',
],
[
'name' => 'seed_import',
'label' => 'Seed-Daten importieren',
'section' => 'database',
'help' => 'Importiert die vordefinierten Startdaten fuer das Standardprojekt.',
],
];
});
$mm->registerFunction($moduleName, 'setup_status', static function (): array {
$moduleBasePath = __DIR__;
$config = ModuleConfig::load($moduleBasePath);
$pdo = ConnectionFactory::make($config);
$schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath);
$status = $schema->schemaStatus();
return [
'title' => 'Tabellenstatus',
'type' => !empty($status['all_present']) && empty($status['pending_upgrades']) ? 'success' : 'hint',
'text' => !empty($status['all_present'])
? (empty($status['pending_upgrades'])
? 'Alle Mining-Checker Tabellen sind vorhanden.'
: 'Alle Grundtabellen sind vorhanden, aber es gibt noch ausstehende Upgrades.')
: 'Das Mining-Checker Schema ist noch unvollstaendig.',
'stats' => [
['label' => 'Vorhandene Tabellen', 'value' => (string) ((int) ($status['present_count'] ?? 0) . '/' . count((array) ($status['required_tables'] ?? [])))],
['label' => 'Fehlende Tabellen', 'value' => !empty($status['missing_tables']) ? implode(', ', (array) $status['missing_tables']) : 'keine'],
['label' => 'Ausstehende Upgrades', 'value' => !empty($status['pending_upgrades']) ? implode(', ', (array) $status['pending_upgrades']) : 'keine'],
],
];
});
$mm->registerFunction($moduleName, 'run_setup_action', static function (string $action): array {
$moduleBasePath = __DIR__;
$config = ModuleConfig::load($moduleBasePath);
$projectKey = $config->defaultProjectKey();
$user = auth_user() ?? [];
$ownerSub = trim((string) ($user['sub'] ?? '')) !== '' ? trim((string) ($user['sub'] ?? '')) : 'local';
$pdo = ConnectionFactory::make($config);
$schema = new SchemaManager($pdo, $config->tablePrefix(), $moduleBasePath);
$repository = new MiningRepository($pdo, $config->tablePrefix(), null, $ownerSub);
return match ($action) {
'initialize_schema' => (static function () use ($schema): array {
$result = $schema->initializeSchema(false);
$after = is_array($result['after'] ?? null) ? $result['after'] : [];
return [
'message' => ($result['message'] ?? 'Schema initialisiert.')
. ' Vorhanden: ' . (int) ($after['present_count'] ?? 0)
. '/' . count((array) ($after['required_tables'] ?? [])) . '.',
];
})(),
'upgrade_schema' => (static function () use ($schema): array {
$result = $schema->upgradeSchemaDirect();
$applied = array_values(array_filter(array_map('strval', (array) ($result['upgraded'] ?? []))));
return [
'message' => ($result['message'] ?? 'Schema-Upgrade ausgefuehrt.')
. ($applied !== [] ? ' Upgrades: ' . implode(', ', $applied) . '.' : ''),
];
})(),
'seed_import' => (static function () use ($schema, $repository, $projectKey): array {
$schema->ensureSchema();
$repository->ensureProject($projectKey, strtoupper(str_replace('-', ' ', $projectKey)));
$result = (new \Modules\MiningChecker\Domain\SeedImporter($repository))->import($projectKey);
return [
'message' => 'Seed-Daten importiert. Eingefuegt: ' . (int) ($result['inserted'] ?? 0) . '.',
];
})(),
default => throw new RuntimeException('Unbekannte Setup-Aktion.'),
};
});
$mm->registerFunction('mining-checker', 'sql_import_target', static function (): array {
$moduleBasePath = __DIR__;
$config = ModuleConfig::load($moduleBasePath);
return [
'pdo' => ConnectionFactory::make($config),
'label' => 'Mining-Checker Projekt-Datenbank',
];
});

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'MINING_CHECKER_DEFAULT_PROJECT_KEY' => 'doge-main',
'MINING_CHECKER_OCR_PROVIDERS' => 'ocrspace,tesseract',
'MINING_CHECKER_OCR_SPACE_URL' => 'https://api.ocr.space/parse/image',
'MINING_CHECKER_OCR_SPACE_API_KEY' => 'K83150278888957',
'MINING_CHECKER_OCR_SPACE_LANGUAGE' => 'eng',
'MINING_CHECKER_OCR_SPACE_ENGINE' => '2',
'MINING_CHECKER_OCR_SPACE_SCALE' => 'true',
'MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION' => 'true',
'MINING_CHECKER_OCR_SPACE_IS_TABLE' => 'false',
'MINING_CHECKER_OCR_SPACE_TIMEOUT' => '25',
'MINING_CHECKER_TESSERACT_BIN' => '/usr/bin/tesseract',
'MINING_CHECKER_TESSERACT_LANG' => 'eng',
'MINING_CHECKER_FX_PROVIDER' => 'currencyapi',
'MINING_CHECKER_FX_URL' => 'https://currencyapi.net',
'MINING_CHECKER_FX_CURRENCIES_URL' => 'https://currencyapi.net',
'MINING_CHECKER_FX_API_KEY' => 'eb18ce459ffb0461c59229b478f2e00388d1',
'MINING_CHECKER_FX_TIMEOUT' => '10',
'MINING_CHECKER_FX_CACHE_TTL' => '21600',
'MINING_CHECKER_FX_AUTO_FETCH_ON_MISS' => 'false',
];

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
return [
'default_project_key' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main',
'use_project_database' => true,
'table_prefix' => 'miningcheck_',
'uploads_dir' => dirname(__DIR__, 3) . '/data/mining-checker/uploads',
'uploads_public_prefix' => '/data/mining-checker/uploads',
'ocr' => [
'providers' => array_values(array_filter(array_map(
static fn (string $provider): string => trim(strtolower($provider)),
explode(',', getenv('MINING_CHECKER_OCR_PROVIDERS') ?: 'ocrspace,tesseract')
))),
'ocrspace' => [
'url' => getenv('MINING_CHECKER_OCR_SPACE_URL') ?: 'https://api.ocr.space/parse/image',
'api_key' => getenv('MINING_CHECKER_OCR_SPACE_API_KEY') ?: 'K83150278888957',
'language' => getenv('MINING_CHECKER_OCR_SPACE_LANGUAGE') ?: 'eng',
'engine' => (int) (getenv('MINING_CHECKER_OCR_SPACE_ENGINE') ?: 2),
'scale' => getenv('MINING_CHECKER_OCR_SPACE_SCALE') ?: 'true',
'detect_orientation' => getenv('MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION') ?: 'true',
'is_table' => getenv('MINING_CHECKER_OCR_SPACE_IS_TABLE') ?: 'false',
'timeout' => (int) (getenv('MINING_CHECKER_OCR_SPACE_TIMEOUT') ?: 25),
],
'tesseract' => [
'binary' => getenv('MINING_CHECKER_TESSERACT_BIN') ?: 'tesseract',
'language' => getenv('MINING_CHECKER_TESSERACT_LANG') ?: 'eng',
],
],
'fx' => [
'provider' => getenv('MINING_CHECKER_FX_PROVIDER') ?: 'currencyapi',
'url' => getenv('MINING_CHECKER_FX_URL') ?: 'https://currencyapi.net',
'currencies_url' => getenv('MINING_CHECKER_FX_CURRENCIES_URL') ?: 'https://currencyapi.net',
'api_key' => getenv('MINING_CHECKER_FX_API_KEY') ?: 'eb18ce459ffb0461c59229b478f2e00388d1',
'timeout' => (int) (getenv('MINING_CHECKER_FX_TIMEOUT') ?: 10),
'cache_ttl' => (int) (getenv('MINING_CHECKER_FX_CACHE_TTL') ?: 21600),
'auto_fetch_on_miss' => filter_var(
getenv('MINING_CHECKER_FX_AUTO_FETCH_ON_MISS') ?: 'false',
FILTER_VALIDATE_BOOL
),
],
'debug' => [
'enabled' => filter_var(getenv('MINING_CHECKER_DEBUG') ?: 'false', FILTER_VALIDATE_BOOL),
'dir' => getenv('MINING_CHECKER_DEBUG_DIR') ?: dirname(__DIR__, 3) . '/data/mining-checker/debug',
],
];

View File

@@ -0,0 +1,17 @@
{
"eyebrow": "Modul",
"title": "Mining-Checker",
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
"actions": [
{ "label": "Nexus Übersicht", "href": "/modules" },
{ "label": "Setup", "href": "/modules/setup/mining-checker", "variant": "secondary" }
],
"sections": [
{ "key": "overview", "label": "Übersicht" },
{ "key": "upload", "label": "Upload" },
{ "key": "measurements", "label": "Mining-History" },
{ "key": "wallet", "label": "Wallet" },
{ "key": "mining", "label": "Miner-Daten" },
{ "key": "dashboards", "label": "Dashboards" }
]
}

View File

@@ -0,0 +1,114 @@
# Mining-Checker Modul
## Zweck
Das Modul erfasst DOGE-Mining-Messpunkte, analysiert OCR-Vorschlaege aus Screenshots, speichert Messreihen projektbezogen und berechnet Performance-, Kurs- und Zielmetriken.
## Ordnerstruktur
```text
modules/mining-checker/
|-- api/
|-- assets/
| |-- css/
| `-- js/
|-- config/
|-- docs/
|-- pages/
|-- partials/
|-- sql/
| `-- migrations/
|-- src/
| |-- Api/
| |-- Domain/
| |-- Infrastructure/
| `-- Support/
|-- storage/uploads/
|-- bootstrap.php
`-- module.json
```
## API-Endpunkte
- `GET /api/mining-checker/v1/health`
- `GET /api/mining-checker/v1/projects/{projectKey}/bootstrap`
- `GET /api/mining-checker/v1/projects/{projectKey}/measurements`
- `POST /api/mining-checker/v1/projects/{projectKey}/measurements`
- `POST /api/mining-checker/v1/projects/{projectKey}/ocr-preview`
- `GET /api/mining-checker/v1/projects/{projectKey}/settings`
- `PUT /api/mining-checker/v1/projects/{projectKey}/settings`
- `GET /api/mining-checker/v1/projects/{projectKey}/targets`
- `POST /api/mining-checker/v1/projects/{projectKey}/targets`
- `PATCH /api/mining-checker/v1/projects/{projectKey}/targets/{targetId}`
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboards`
- `POST /api/mining-checker/v1/projects/{projectKey}/dashboards`
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboard-data`
- `POST /api/mining-checker/v1/projects/{projectKey}/seed-import`
- `GET /api/mining-checker/v1/projects/{projectKey}/schema-status`
- `POST /api/mining-checker/v1/projects/{projectKey}/initialize`
- `POST /api/mining-checker/v1/projects/{projectKey}/upgrade`
- `GET /api/mining-checker/v1/projects/{projectKey}/connection-test`
- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history`
- `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate`
## Integration
1. SQL aus dem passenden Dialekt-Schema ausfuehren:
- MySQL/MariaDB: `sql/schema.mysql.sql`
- PostgreSQL: `sql/schema.pgsql.sql`
- `sql/schema.sql` bleibt der Rueckfall fuer bestehende Setups
2. Das Modul nutzt bewusst dieselbe Projekt-Datenbank wie die Anwendung und legt seine Tabellen mit dem Praefix `miningcheck_` an.
3. Modulroute ueber `/module/mining-checker` aufrufen.
4. REST-API wird ueber `/api/mining-checker/...` vom Hauptprojekt geroutet.
Hinweis:
Wenn beim ersten API-Zugriff noch keine `miningcheck_*` Tabellen vorhanden sind, importiert das Modul automatisch das zum aktiven PDO-Treiber passende Schema.
Seed-Daten werden dabei nicht automatisch eingespielt.
Fuer eine manuelle Initialisierung, ein inkrementelles Upgrade oder einen Reset gibt es zusaetzlich `schema-status`, `upgrade` und `initialize`. Mit `{ "drop_existing": true }` werden vorhandene `miningcheck_*` Tabellen inklusive Daten geloescht und das Schema neu angelegt.
## OCR-Hinweis
Das Modul unterstuetzt einen OCR-Provider-Stack. Standardmaessig wird zuerst `ocr.space` verwendet und danach optional auf lokales `tesseract` zurueckgefallen.
Empfohlene Umgebungsvariablen:
- `MINING_CHECKER_OCR_PROVIDERS=ocrspace,tesseract`
- `MINING_CHECKER_OCR_SPACE_URL=https://api.ocr.space/parse/image`
- `MINING_CHECKER_OCR_SPACE_API_KEY=...`
- `MINING_CHECKER_OCR_SPACE_LANGUAGE=eng`
- `MINING_CHECKER_OCR_SPACE_ENGINE=2`
- `MINING_CHECKER_OCR_SPACE_SCALE=true`
- `MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION=true`
- `MINING_CHECKER_OCR_SPACE_IS_TABLE=false`
- `MINING_CHECKER_OCR_SPACE_TIMEOUT=25`
- `MINING_CHECKER_TESSERACT_BIN=/usr/bin/tesseract`
- `MINING_CHECKER_TESSERACT_LANG=eng`
Laut OCR.space-Doku wird `POST https://api.ocr.space/parse/image` mit `file`, Header-`apikey`, optional `language`, `scale`, `detectOrientation`, `isTable` und `OCREngine` verwendet. Der Modulparser wertet die OCR.space-Felder `ParsedResults`, `ParsedText`, `IsErroredOnProcessing`, `ErrorMessage` und `OCRExitCode` aus. Quellen: https://ocr.space/ocrapi
## Wechselkurse und Waehrungen
Der Mining-Checker speichert keine eigenen FX-Snapshots mehr, sondern referenziert die `fetch_id` aus `fx-rates`.
Auch der Waehrungskatalog und die bevorzugten Waehrungen kommen ausschliesslich aus `fx-rates`. Der Mining-Checker fuehrt keine eigene Waehrungstabelle und keine eigene Alias-Verwaltung mehr im Laufzeitpfad.
- `MINING_CHECKER_FX_AUTO_FETCH_ON_MISS=false`
Optionaler JSON-Body:
- `base`: Standard `EUR`
- `symbols`: wird aktuell ignoriert; der Mining-Checker speichert immer den kompletten Waehrungssatz des Fetches
Beispiel:
```json
{
"base": "EUR"
}
```
`currencyapi.net` wird ueber das Modul `fx-rates` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die eigentlichen Fetches und Raten liegen im Modul `fx-rates`.
Pro Abruf entsteht genau ein Datensatz in `fx-rates` mit Basiswaehrung, Provider und Stichtag. Neue Mining-Messpunkte pruefen beim Speichern, ob ein neuer FX-Fetch noetig ist; falls nicht, wird die letzte passende `fetch_id` wiederverwendet.
Falls noch historische Mining-Checker-Fetches in `miningcheck_fx_fetches` und `miningcheck_fx_rates` liegen, kann `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` diese nach `fx-rates` ueberfuehren. Danach werden bestehende Messpunkte soweit moeglich auf die passende `fx_fetch_id` aktualisiert.
Fuer Auswertungen, Berichte und Listen speichert der Mining-Checker pro Messpunkt die damals passende `fx_fetch_id`. Historische Umrechnungen laufen damit gegen genau den zugeordneten `fx-rates`-Snapshot.

View File

@@ -0,0 +1,30 @@
{
"name": "Mining-Checker",
"title": "Mining-Checker",
"version": "0.3.0",
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
"enabled_by_default": true,
"setup": {
"sections": {
"database": true
},
"fields": [
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql oder mysql" },
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
{ "name": "baseline_measured_at", "label": "Baseline Zeitpunkt", "type": "datetime-local", "required": false, "help": "Referenzzeitpunkt fuer die erste Baseline-Messung." },
{ "name": "baseline_coins_total", "label": "Baseline Coins", "type": "number", "required": false, "help": "Coin-Bestand zum Baseline-Zeitpunkt." },
{ "name": "report_currency", "label": "Standard-Berichtswaehrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Kennzahlen und Berichte, z.B. EUR." }
]
},
"auth": {
"required": true,
"users": [],
"groups": []
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$moduleConfig = require dirname(__DIR__) . '/config/module.php';
$design = module_design('mining-checker');
$defaultProjectKey = (string) ($moduleConfig['default_project_key'] ?? 'doge-main');
$sections = is_array($design['sections'] ?? null) ? $design['sections'] : [];
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
$allowedViews = [];
$tabs = [];
foreach ($sections as $section) {
if (!is_array($section)) {
continue;
}
$key = trim((string) ($section['key'] ?? ''));
$label = trim((string) ($section['label'] ?? ''));
if ($key === '' || $label === '') {
continue;
}
$allowedViews[] = $key;
$tabs[] = [
'label' => $label,
'href' => '/module/mining-checker?view=' . rawurlencode($key),
'active' => $activeView === $key,
];
}
if ($allowedViews === []) {
$allowedViews = ['overview'];
}
if (!in_array($activeView, $allowedViews, true)) {
$activeView = $allowedViews[0];
foreach ($tabs as &$tab) {
$tab['active'] = str_ends_with((string) ($tab['href'] ?? ''), 'view=' . $activeView);
}
unset($tab);
}
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$moduleCss = file_get_contents(dirname(__DIR__) . '/assets/css/app.css') ?: '';
$moduleJs = file_get_contents(dirname(__DIR__) . '/assets/js/app.js') ?: '';
$moduleJs = str_replace('</script>', '<\/script>', $moduleJs);
?>
<?= module_shell_header('mining-checker', [
'title' => 'DOGE Mining-Checker',
'tabs' => $tabs,
]) ?>
<div class="module-flow">
<div id="mining-checker-app"
data-default-project-key="<?= e($defaultProjectKey) ?>"
data-api-base="/api/mining-checker/v1"
data-active-view="<?= e($activeView) ?>"
data-sections-json="<?= e(is_string($sectionsJson) ? $sectionsJson : '[]') ?>"></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>
<?= module_shell_footer() ?>

View File

View File

@@ -0,0 +1,124 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
baseline_measured_at DATETIME NOT NULL,
baseline_coins_total DECIMAL(20,6) NOT NULL,
daily_cost_amount DECIMAL(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10) NULL,
crypto_currency VARCHAR(10) NULL,
display_timezone VARCHAR(64) NULL,
fx_max_age_hours DECIMAL(10,2) NULL,
module_theme_mode VARCHAR(16) NULL,
module_theme_accent VARCHAR(16) NULL,
preferred_currencies JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at DATETIME NOT NULL,
runtime_months INT NOT NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
base_price_amount DECIMAL(20,8) NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
measured_at DATETIME NOT NULL,
coins_total DECIMAL(20,6) NOT NULL,
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
price_per_coin DECIMAL(20,8) NULL,
price_currency VARCHAR(10) NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
);
CREATE INDEX idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, measured_at);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat DECIMAL(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
total_value_amount DECIMAL(20,8) NULL,
total_value_currency VARCHAR(10) NULL,
wallet_balance DECIMAL(28,10) NULL,
wallet_currency VARCHAR(10) NOT NULL,
balances_json JSON NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
);

View File

@@ -0,0 +1,75 @@
INSERT INTO miningcheck_projects (project_key, project_name)
VALUES ('doge-main', 'DOGE Mining Main')
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
INSERT INTO miningcheck_settings (
project_key,
baseline_measured_at,
baseline_coins_total,
daily_cost_amount,
daily_cost_currency,
preferred_currencies
)
VALUES (
'doge-main',
'2026-03-16 01:32:00',
27.617864,
0.3123287671,
'EUR',
'["DOGE","USD","EUR"]'
)
ON DUPLICATE KEY UPDATE
baseline_measured_at = VALUES(baseline_measured_at),
baseline_coins_total = VALUES(baseline_coins_total),
daily_cost_amount = VALUES(daily_cost_amount),
daily_cost_currency = VALUES(daily_cost_currency),
preferred_currencies = VALUES(preferred_currencies);
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
VALUES
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
ON DUPLICATE KEY UPDATE
target_amount_fiat = VALUES(target_amount_fiat),
currency = VALUES(currency),
is_active = VALUES(is_active),
sort_order = VALUES(sort_order);
INSERT INTO miningcheck_dashboard_definitions (
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
)
VALUES
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
('doge-main', 'Performance-Verlauf', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currency":"EUR"}', 1)
ON DUPLICATE KEY UPDATE
chart_type = VALUES(chart_type),
x_field = VALUES(x_field),
y_field = VALUES(y_field),
aggregation = VALUES(aggregation),
filters_json = VALUES(filters_json),
is_active = VALUES(is_active);
INSERT INTO miningcheck_measurements (
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
)
VALUES
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
ON DUPLICATE KEY UPDATE
price_per_coin = VALUES(price_per_coin),
price_currency = VALUES(price_currency),
note = VALUES(note),
source = VALUES(source);

View File

@@ -0,0 +1,34 @@
BEGIN;
ALTER TABLE miningcheck_settings
ADD COLUMN IF NOT EXISTS display_timezone VARCHAR(64);
UPDATE miningcheck_settings
SET display_timezone = 'Europe/Berlin'
WHERE display_timezone IS NULL OR BTRIM(display_timezone) = '';
UPDATE miningcheck_settings
SET baseline_measured_at = ((baseline_measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE baseline_measured_at IS NOT NULL;
UPDATE miningcheck_cost_plans
SET starts_at = ((starts_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE starts_at IS NOT NULL;
UPDATE miningcheck_measurements
SET measured_at = ((measured_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE measured_at IS NOT NULL;
UPDATE miningcheck_payouts
SET payout_at = ((payout_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE payout_at IS NOT NULL;
UPDATE miningcheck_purchased_miners
SET purchased_at = ((purchased_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE purchased_at IS NOT NULL;
UPDATE miningcheck_fx_fetches
SET fetched_at = ((fetched_at AT TIME ZONE 'Europe/Berlin') AT TIME ZONE 'UTC')
WHERE fetched_at IS NOT NULL;
COMMIT;

View File

@@ -0,0 +1,72 @@
BEGIN;
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans_legacy AS
SELECT *
FROM miningcheck_cost_plans
WHERE 1 = 0;
INSERT INTO miningcheck_cost_plans_legacy
SELECT cp.*
FROM miningcheck_cost_plans cp
LEFT JOIN miningcheck_cost_plans_legacy legacy
ON legacy.id = cp.id
WHERE legacy.id IS NULL;
INSERT INTO miningcheck_purchased_miners (
project_key,
miner_offer_id,
purchased_at,
label,
runtime_months,
mining_speed_value,
mining_speed_unit,
bonus_speed_value,
bonus_speed_unit,
total_cost_amount,
currency,
usd_reference_amount,
reference_price_amount,
reference_price_currency,
auto_renew,
note,
is_active
)
SELECT
cp.project_key,
NULL AS miner_offer_id,
cp.starts_at AS purchased_at,
cp.label,
cp.runtime_months,
cp.mining_speed_value,
cp.mining_speed_unit,
cp.bonus_speed_value,
cp.bonus_speed_unit,
cp.total_cost_amount,
cp.currency,
CASE
WHEN COALESCE(s.report_currency, 'EUR') = 'USD' THEN cp.base_price_amount
ELSE NULL
END AS usd_reference_amount,
cp.base_price_amount AS reference_price_amount,
COALESCE(s.report_currency, 'EUR') AS reference_price_currency,
cp.auto_renew,
CASE
WHEN cp.note IS NULL OR BTRIM(cp.note) = '' THEN 'Migriert aus miningcheck_cost_plans'
ELSE cp.note || ' | Migriert aus miningcheck_cost_plans'
END AS note,
cp.is_active
FROM miningcheck_cost_plans cp
LEFT JOIN miningcheck_settings s
ON s.project_key = cp.project_key
LEFT JOIN miningcheck_purchased_miners pm
ON pm.project_key = cp.project_key
AND pm.miner_offer_id IS NULL
AND pm.purchased_at = cp.starts_at
AND pm.label = cp.label
AND pm.total_cost_amount = cp.total_cost_amount
AND pm.currency = cp.currency
WHERE pm.id IS NULL;
DELETE FROM miningcheck_cost_plans;
COMMIT;

View File

@@ -0,0 +1,15 @@
BEGIN;
ALTER TABLE miningcheck_settings
ADD COLUMN IF NOT EXISTS module_theme_mode VARCHAR(16),
ADD COLUMN IF NOT EXISTS module_theme_accent VARCHAR(16);
UPDATE miningcheck_settings
SET module_theme_mode = 'inherit'
WHERE module_theme_mode IS NULL OR BTRIM(module_theme_mode) = '';
UPDATE miningcheck_settings
SET module_theme_accent = 'teal'
WHERE module_theme_accent IS NULL OR BTRIM(module_theme_accent) = '';
COMMIT;

View File

@@ -0,0 +1,182 @@
-- Bestehende benutzerspezifische Mining-Daten werden diesem Keycloak-Sub zugeordnet:
-- adea1766-5d1c-4c2e-98bd-5239861f745f
-- Die Keycloak-Sub ist stabiler als preferred_username und wird fuer alle benutzerspezifischen Mining-Daten genutzt.
BEGIN;
ALTER TABLE miningcheck_settings
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_cost_plans
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_measurements
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_measurement_rates
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_payouts
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_targets
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_dashboard_definitions
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
ALTER TABLE miningcheck_purchased_miners
ADD COLUMN IF NOT EXISTS owner_sub VARCHAR(128);
UPDATE miningcheck_settings
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_cost_plans
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_measurements
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_measurement_rates mr
SET owner_sub = m.owner_sub
FROM miningcheck_measurements m
WHERE mr.measurement_id = m.id
AND (mr.owner_sub IS NULL OR BTRIM(mr.owner_sub) = '');
UPDATE miningcheck_measurement_rates
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_payouts
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_targets
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_dashboard_definitions
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
UPDATE miningcheck_purchased_miners
SET owner_sub = 'adea1766-5d1c-4c2e-98bd-5239861f745f'
WHERE owner_sub IS NULL OR BTRIM(owner_sub) = '';
ALTER TABLE miningcheck_settings
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_cost_plans
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_measurements
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_measurement_rates
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_payouts
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_targets
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_dashboard_definitions
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_purchased_miners
ALTER COLUMN owner_sub SET NOT NULL;
ALTER TABLE miningcheck_settings
DROP CONSTRAINT IF EXISTS miningcheck_settings_project_key_key;
ALTER TABLE miningcheck_measurements
DROP CONSTRAINT IF EXISTS uq_mining_measurements_unique;
ALTER TABLE miningcheck_targets
DROP CONSTRAINT IF EXISTS uq_mining_targets_project_label;
ALTER TABLE miningcheck_dashboard_definitions
DROP CONSTRAINT IF EXISTS uq_mining_dashboards_project_name;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = 'miningcheck_settings'
AND constraint_name = 'uq_mining_settings_project_owner'
) THEN
ALTER TABLE miningcheck_settings
ADD CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = 'miningcheck_measurements'
AND constraint_name = 'uq_mining_measurements_unique'
) THEN
ALTER TABLE miningcheck_measurements
ADD CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = 'miningcheck_targets'
AND constraint_name = 'uq_mining_targets_project_label'
) THEN
ALTER TABLE miningcheck_targets
ADD CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label);
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.table_constraints
WHERE table_schema = current_schema()
AND table_name = 'miningcheck_dashboard_definitions'
AND constraint_name = 'uq_mining_dashboards_project_name'
) THEN
ALTER TABLE miningcheck_dashboard_definitions
ADD CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name);
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_owner_start
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_owner_measured_at
ON miningcheck_measurements(project_key, owner_sub, measured_at);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_owner_measurement
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_owner_payout_at
ON miningcheck_payouts(project_key, owner_sub, payout_at);
CREATE INDEX IF NOT EXISTS idx_miningcheck_targets_project_owner
ON miningcheck_targets(project_key, owner_sub, sort_order, id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_dashboards_project_owner
ON miningcheck_dashboard_definitions(project_key, owner_sub, id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_purchased_miners_project_owner_purchased_at
ON miningcheck_purchased_miners(project_key, owner_sub, purchased_at);
COMMIT;

View File

@@ -0,0 +1,223 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
baseline_measured_at DATETIME NOT NULL,
baseline_coins_total DECIMAL(20,6) NOT NULL,
daily_cost_amount DECIMAL(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10) NULL,
crypto_currency VARCHAR(10) NULL,
display_timezone VARCHAR(64) NULL,
fx_max_age_hours DECIMAL(10,2) NULL,
module_theme_mode VARCHAR(16) NULL,
module_theme_accent VARCHAR(16) NULL,
preferred_currencies JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at DATETIME NOT NULL,
runtime_months INT NOT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
base_price_amount DECIMAL(20,8) NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
measured_at DATETIME NOT NULL,
coins_total DECIMAL(20,6) NOT NULL,
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
price_per_coin DECIMAL(20,8) NULL,
price_currency VARCHAR(10) NULL,
fx_fetch_id BIGINT UNSIGNED NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
);
CREATE INDEX idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, measured_at);
CREATE INDEX idx_miningcheck_measurements_fx_fetch
ON miningcheck_measurements(fx_fetch_id);
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
measurement_id BIGINT UNSIGNED NOT NULL,
project_key VARCHAR(64) NOT NULL,
base_currency VARCHAR(10) NOT NULL,
quote_currency VARCHAR(10) NOT NULL,
rate DECIMAL(20,10) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
);
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
payout_at TIMESTAMP NOT NULL,
coins_amount DECIMAL(20,6) NOT NULL,
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
total_value_amount DECIMAL(20,8) NULL,
total_value_currency VARCHAR(10) NULL,
wallet_balance DECIMAL(28,10) NULL,
wallet_currency VARCHAR(10) NOT NULL,
balances_json JSON NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
base_price_amount DECIMAL(20,8) NOT NULL,
base_price_currency VARCHAR(10) NOT NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat DECIMAL(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
);
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
purchased_at TIMESTAMP NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
usd_reference_amount DECIMAL(20,8) NULL,
reference_price_amount DECIMAL(20,8) NULL,
reference_price_currency VARCHAR(10) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
fetch_id BIGINT UNSIGNED NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value DECIMAL(20,10) NOT NULL,
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
KEY idx_miningcheck_fx_rates_currency (currency_code),
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,243 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
baseline_measured_at TIMESTAMP NOT NULL,
baseline_coins_total NUMERIC(20,6) NOT NULL,
daily_cost_amount NUMERIC(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10),
crypto_currency VARCHAR(10),
display_timezone VARCHAR(64),
fx_max_age_hours NUMERIC(10,2),
module_theme_mode VARCHAR(16),
module_theme_accent VARCHAR(16),
preferred_currencies JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at TIMESTAMP NOT NULL,
runtime_months INTEGER NOT NULL,
mining_speed_value NUMERIC(20,4),
mining_speed_unit VARCHAR(8),
bonus_speed_value NUMERIC(20,4),
bonus_speed_unit VARCHAR(8),
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
base_price_amount NUMERIC(20,8),
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount NUMERIC(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
coins_total NUMERIC(20,6) NOT NULL,
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
price_per_coin NUMERIC(20,8),
price_currency VARCHAR(10),
fx_fetch_id BIGINT,
note TEXT,
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
image_path VARCHAR(255),
ocr_raw_text TEXT,
ocr_confidence NUMERIC(6,4),
ocr_flags JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total)
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, owner_sub, measured_at);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch
ON miningcheck_measurements(fx_fetch_id);
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
id BIGSERIAL PRIMARY KEY,
measurement_id BIGINT NOT NULL,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
base_currency VARCHAR(10) NOT NULL,
quote_currency VARCHAR(10) NOT NULL,
rate NUMERIC(20,10) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency)
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_measurement
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
payout_at TIMESTAMP NOT NULL,
coins_amount NUMERIC(20,6) NOT NULL,
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at
ON miningcheck_payouts(project_key, owner_sub, payout_at);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
total_value_amount NUMERIC(20,8),
total_value_currency VARCHAR(10),
wallet_balance NUMERIC(28,10),
wallet_currency VARCHAR(10) NOT NULL,
balances_json JSONB,
note TEXT,
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
image_path VARCHAR(255),
ocr_raw_text TEXT,
ocr_confidence NUMERIC(6,4),
ocr_flags JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at
ON miningcheck_wallet_snapshots(project_key, owner_sub, measured_at);
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INTEGER,
mining_speed_value NUMERIC(20,4),
mining_speed_unit VARCHAR(8),
bonus_speed_value NUMERIC(20,4),
bonus_speed_unit VARCHAR(8),
base_price_amount NUMERIC(20,8) NOT NULL,
base_price_currency VARCHAR(10) NOT NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat NUMERIC(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type VARCHAR(16) NOT NULL CHECK (chart_type IN ('line', 'bar', 'area', 'table')),
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name)
);
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
miner_offer_id BIGINT,
purchased_at TIMESTAMP NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INTEGER,
mining_speed_value NUMERIC(20,4),
mining_speed_unit VARCHAR(8),
bonus_speed_value NUMERIC(20,4),
bonus_speed_unit VARCHAR(8),
total_cost_amount NUMERIC(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
usd_reference_amount NUMERIC(20,8),
reference_price_amount NUMERIC(20,8),
reference_price_currency VARCHAR(10),
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
id BIGSERIAL PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_fetches_base_fetched
ON miningcheck_fx_fetches(base_currency, fetched_at);
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
id BIGSERIAL PRIMARY KEY,
fetch_id BIGINT NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value NUMERIC(20,10) NOT NULL,
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_fetch
ON miningcheck_fx_rates(fetch_id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_currency
ON miningcheck_fx_rates(currency_code);

View File

@@ -0,0 +1,223 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
baseline_measured_at DATETIME NOT NULL,
baseline_coins_total DECIMAL(20,6) NOT NULL,
daily_cost_amount DECIMAL(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10) NULL,
crypto_currency VARCHAR(10) NULL,
display_timezone VARCHAR(64) NULL,
fx_max_age_hours DECIMAL(10,2) NULL,
module_theme_mode VARCHAR(16) NULL,
module_theme_accent VARCHAR(16) NULL,
preferred_currencies JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at DATETIME NOT NULL,
runtime_months INT NOT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
base_price_amount DECIMAL(20,8) NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
measured_at DATETIME NOT NULL,
coins_total DECIMAL(20,6) NOT NULL,
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
price_per_coin DECIMAL(20,8) NULL,
price_currency VARCHAR(10) NULL,
fx_fetch_id BIGINT UNSIGNED NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
);
CREATE INDEX idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, measured_at);
CREATE INDEX idx_miningcheck_measurements_fx_fetch
ON miningcheck_measurements(fx_fetch_id);
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
measurement_id BIGINT UNSIGNED NOT NULL,
project_key VARCHAR(64) NOT NULL,
base_currency VARCHAR(10) NOT NULL,
quote_currency VARCHAR(10) NOT NULL,
rate DECIMAL(20,10) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
);
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
payout_at TIMESTAMP NOT NULL,
coins_amount DECIMAL(20,6) NOT NULL,
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
total_value_amount DECIMAL(20,8) NULL,
total_value_currency VARCHAR(10) NULL,
wallet_balance DECIMAL(28,10) NULL,
wallet_currency VARCHAR(10) NOT NULL,
balances_json JSON NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
base_price_amount DECIMAL(20,8) NOT NULL,
base_price_currency VARCHAR(10) NOT NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat DECIMAL(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
);
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
purchased_at TIMESTAMP NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
usd_reference_amount DECIMAL(20,8) NULL,
reference_price_amount DECIMAL(20,8) NULL,
reference_price_currency VARCHAR(10) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
fetch_id BIGINT UNSIGNED NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value DECIMAL(20,10) NOT NULL,
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
KEY idx_miningcheck_fx_rates_currency (currency_code),
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,75 @@
INSERT INTO miningcheck_projects (project_key, project_name)
VALUES ('doge-main', 'DOGE Mining Main')
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
INSERT INTO miningcheck_settings (
project_key,
baseline_measured_at,
baseline_coins_total,
daily_cost_amount,
daily_cost_currency,
preferred_currencies
)
VALUES (
'doge-main',
'2026-03-16 01:32:00',
27.617864,
0.3123287671,
'EUR',
'["DOGE","USD","EUR"]'
)
ON DUPLICATE KEY UPDATE
baseline_measured_at = VALUES(baseline_measured_at),
baseline_coins_total = VALUES(baseline_coins_total),
daily_cost_amount = VALUES(daily_cost_amount),
daily_cost_currency = VALUES(daily_cost_currency),
preferred_currencies = VALUES(preferred_currencies);
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
VALUES
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
ON DUPLICATE KEY UPDATE
target_amount_fiat = VALUES(target_amount_fiat),
currency = VALUES(currency),
is_active = VALUES(is_active),
sort_order = VALUES(sort_order);
INSERT INTO miningcheck_dashboard_definitions (
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
)
VALUES
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
('doge-main', 'DOGE pro Tag', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currencies":["EUR","USD"]}', 1)
ON DUPLICATE KEY UPDATE
chart_type = VALUES(chart_type),
x_field = VALUES(x_field),
y_field = VALUES(y_field),
aggregation = VALUES(aggregation),
filters_json = VALUES(filters_json),
is_active = VALUES(is_active);
INSERT INTO miningcheck_measurements (
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
)
VALUES
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
ON DUPLICATE KEY UPDATE
price_per_coin = VALUES(price_per_coin),
price_currency = VALUES(price_currency),
note = VALUES(note),
source = VALUES(source);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,950 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\MiningRepository;
use Modules\MiningChecker\Support\DebugTrace;
final class FxService
{
private ?MiningRepository $repository;
private string $provider;
private string $apiBaseUrl;
private string $currenciesApiBaseUrl;
private string $apiKey;
private int $timeout;
private int $cacheTtl;
private bool $autoFetchOnMiss;
private array $memoryCache = [];
private array $snapshotCache = [];
private ?DebugTrace $debug;
public function __construct(
?MiningRepository $repository = null,
string $apiBaseUrl = 'https://currencyapi.net',
string $currenciesApiBaseUrl = 'https://currencyapi.net',
int $timeout = 10,
int $cacheTtl = 21600,
bool $autoFetchOnMiss = false,
string $provider = 'currencyapi',
string $apiKey = '',
?DebugTrace $debug = null
)
{
$this->repository = $repository;
$this->provider = trim(strtolower($provider)) !== '' ? trim(strtolower($provider)) : 'currencyapi';
$this->apiBaseUrl = rtrim($apiBaseUrl, '/');
$this->currenciesApiBaseUrl = rtrim($currenciesApiBaseUrl, '/');
$this->apiKey = trim($apiKey);
$this->timeout = max(2, $timeout);
$this->cacheTtl = max(60, $cacheTtl);
$this->autoFetchOnMiss = $autoFetchOnMiss;
$this->debug = $debug;
}
public function convert(?float $amount, ?string $from, ?string $to): ?float
{
return $this->convertAt($amount, $from, $to, null, null, null);
}
public function convertAt(?float $amount, ?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
{
if ($amount === null || $from === null || $to === null) {
return null;
}
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
$shared = $this->sharedFxService();
if ($shared !== null && $normalizedFetchId !== null) {
$snapshot = $this->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]);
if (is_array($snapshot)) {
$resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to)));
if ($resolved !== null) {
return $amount * $resolved;
}
}
}
if ($shared !== null && method_exists($shared, 'convert')) {
$converted = $shared->convert($amount, $from, $to, $at, $windowMinutes);
return is_numeric($converted) ? (float) $converted : null;
}
$rate = $this->rateAt($from, $to, $at, $windowMinutes, $normalizedFetchId);
return $rate === null ? null : $amount * $rate;
}
public function rate(?string $from, ?string $to): ?float
{
return $this->rateAt($from, $to, null, null, null);
}
public function rateAt(?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
{
$base = strtoupper(trim((string) $from));
$target = strtoupper(trim((string) $to));
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
if ($base === '' || $target === '') {
return null;
}
if ($base === $target) {
return 1.0;
}
$shared = $this->sharedFxService();
if ($shared !== null && $normalizedFetchId !== null) {
$snapshot = $this->snapshotByFetchId($normalizedFetchId, $base, [$target]);
if (is_array($snapshot)) {
$resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target);
if ($resolved !== null) {
return $resolved;
}
}
}
if ($shared !== null && method_exists($shared, 'findRate')) {
$resolved = $shared->findRate($from, $to, $at, $windowMinutes);
return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null;
}
$cacheKey = implode(':', [$base, $target, $at ?? '', (string) ($windowMinutes ?? 0), (string) ($normalizedFetchId ?? 0)]);
if (array_key_exists($cacheKey, $this->memoryCache)) {
return $this->memoryCache[$cacheKey];
}
$stored = $this->storedRate($base, $target);
if ($stored !== null) {
$this->memoryCache[$cacheKey] = $stored;
return $stored;
}
$cached = $this->readFileCache($cacheKey);
if ($cached !== null) {
$this->memoryCache[$cacheKey] = $cached;
return $cached;
}
if (!$this->autoFetchOnMiss) {
return null;
}
$rate = $this->fetchAndPersistRate($base, $target);
$this->memoryCache[$cacheKey] = $rate;
if ($rate !== null) {
$this->writeFileCache($cacheKey, $rate);
}
return $rate;
}
public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array
{
if ($fetchId <= 0) {
return null;
}
$cacheKey = $this->snapshotCacheKey('fetch', [
$fetchId,
strtoupper(trim((string) ($baseCurrency ?? ''))),
$this->normalizeSymbolsForCache($symbols),
]);
if (array_key_exists($cacheKey, $this->snapshotCache)) {
return $this->snapshotCache[$cacheKey];
}
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) {
$snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols);
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
}
return $this->snapshotCache[$cacheKey] = null;
}
public function latestSnapshot(?string $baseCurrency = null, ?array $symbols = null): ?array
{
$cacheKey = $this->snapshotCacheKey('latest', [
strtoupper(trim((string) ($baseCurrency ?? ''))),
$this->normalizeSymbolsForCache($symbols),
]);
if (array_key_exists($cacheKey, $this->snapshotCache)) {
return $this->snapshotCache[$cacheKey];
}
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'snapshot')) {
$snapshot = $shared->snapshot($baseCurrency, null, $symbols, null);
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
}
return $this->snapshotCache[$cacheKey] = null;
}
public function nearestSnapshot(?string $baseCurrency, string $at, ?array $symbols = null, ?int $windowMinutes = null): ?array
{
$cacheKey = $this->snapshotCacheKey('nearest', [
strtoupper(trim((string) ($baseCurrency ?? ''))),
trim($at),
$windowMinutes ?? 0,
$this->normalizeSymbolsForCache($symbols),
]);
if (array_key_exists($cacheKey, $this->snapshotCache)) {
return $this->snapshotCache[$cacheKey];
}
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'nearestSnapshot')) {
$snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes);
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
}
return $this->snapshotCache[$cacheKey] = null;
}
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'refreshLatestRates')) {
return $shared->refreshLatestRates($currencies, $base);
}
$normalizedBase = strtoupper(trim($base));
$targets = $currencies === null
? null
: array_values(array_unique(array_filter(array_map(
static fn ($code): string => strtoupper(trim((string) $code)),
$currencies
), static fn (string $code): bool => $code !== '' && $code !== $normalizedBase)));
$payload = $this->fetchLatestPayload($normalizedBase, $targets);
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
$forwardRates = [];
foreach ($rates as $target => $rate) {
if (!is_numeric($rate)) {
continue;
}
$targetCode = strtoupper((string) $target);
if ($targetCode === '' || $targetCode === $normalizedBase) {
continue;
}
$forwardRates[$targetCode] = (float) $rate;
}
$updated = $this->persistRateSet($normalizedBase, $forwardRates, $rateDate);
return [
'base' => $normalizedBase,
'rate_date' => $rateDate,
'updated_count' => count($updated),
'rates' => $updated,
];
}
public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'ensureFreshLatestRates')) {
return $shared->ensureFreshLatestRates($maxAgeHours, $base, null);
}
$normalizedBase = strtoupper(trim($base));
$maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0;
if ($this->repository === null) {
return $this->refreshLatestRates(null, $normalizedBase);
}
$latestFetch = $this->repository->getLatestFxFetch($normalizedBase);
$latestFetchedAt = is_array($latestFetch) ? $this->parseStoredUtcTimestamp((string) ($latestFetch['fetched_at'] ?? '')) : null;
$ageSeconds = $latestFetchedAt !== null ? (time() - $latestFetchedAt) : null;
$maxAgeSeconds = (int) round($maxAgeHours * 3600);
if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) {
$this->debug?->add('fx.latest.reuse', [
'base' => $normalizedBase,
'fetched_at' => $latestFetch['fetched_at'] ?? null,
'age_seconds' => $ageSeconds,
'max_age_seconds' => $maxAgeSeconds,
]);
return [
'base' => $normalizedBase,
'rate_date' => $latestFetch['rate_date'] ?? null,
'updated_count' => 0,
'rates' => [],
'reused' => true,
'fetched_at' => $latestFetch['fetched_at'] ?? null,
];
}
$this->debug?->add('fx.latest.refresh_required', [
'base' => $normalizedBase,
'previous_fetched_at' => $latestFetch['fetched_at'] ?? null,
'age_seconds' => $ageSeconds,
'max_age_seconds' => $maxAgeSeconds,
]);
$result = $this->refreshLatestRates(null, $normalizedBase);
$result['reused'] = false;
return $result;
}
public function probeLatestRates(string $base = 'EUR'): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'probeLatestRates')) {
return $shared->probeLatestRates($base);
}
$normalizedBase = strtoupper(trim($base));
return $this->fetchLatestProbe($normalizedBase);
}
public function refreshCurrencyCatalog(): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'refreshCurrencyCatalog')) {
return $shared->refreshCurrencyCatalog();
}
$payload = $this->fetchCurrenciesPayload();
$items = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
if ($items === []) {
return [
'synced_count' => 0,
'currencies' => [],
];
}
$synced = [];
$sortOrder = 1000;
foreach ($items as $code => $name) {
$normalizedCode = strtoupper(trim((string) $code));
$normalizedName = trim((string) $name);
if ($normalizedCode === '' || $normalizedName === '') {
continue;
}
$currency = [
'code' => substr($normalizedCode, 0, 10),
'name' => function_exists('mb_substr') ? mb_substr($normalizedName, 0, 64) : substr($normalizedName, 0, 64),
'symbol' => substr($normalizedCode, 0, 8),
'is_active' => 1,
'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0,
'sort_order' => $this->catalogSortOrder($normalizedCode, $sortOrder),
];
$synced[] = $currency;
$sortOrder++;
}
usort($synced, static function (array $left, array $right): int {
return [$left['sort_order'], $left['code']] <=> [$right['sort_order'], $right['code']];
});
return [
'synced_count' => count($synced),
'currencies' => $synced,
];
}
public function probeCurrencyCatalog(): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'probeCurrencyCatalog')) {
return $shared->probeCurrencyCatalog();
}
return $this->fetchCurrenciesProbe();
}
private function fetchAndPersistRate(string $base, string $target): ?float
{
$payload = $this->fetchLatestPayload($base, [$target]);
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
$rate = $rates[$target] ?? null;
if (!is_numeric($rate)) {
return null;
}
$numericRate = (float) $rate;
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
$this->persistRateSet($base, [$target => $numericRate], $rateDate);
return $numericRate;
}
private function fetchLatestPayload(string $base, ?array $targets = null): array
{
if (!function_exists('curl_init')) {
return [];
}
$url = $this->buildLatestUrl($base, $targets);
if ($url === null) {
$this->debug?->add('fx.latest.skip', ['reason' => 'missing_url_or_key', 'base' => $base]);
return [];
}
$this->debug?->add('fx.latest.request', [
'base' => $base,
'url' => $this->maskUrl($url),
'targets' => $targets,
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$this->debug?->add('fx.latest.response', [
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_bytes' => is_string($response) ? strlen($response) : 0,
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
]);
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
return [];
}
$payload = json_decode((string) $response, true);
return $this->normalizePayload($payload, $base, $targets);
}
private function fetchLatestProbe(string $base): array
{
if (!function_exists('curl_init')) {
return ['ok' => false, 'message' => 'curl_init ist nicht verfuegbar.'];
}
$url = $this->buildLatestUrl($base, null);
if ($url === null) {
return ['ok' => false, 'message' => 'FX-URL oder API-Key fehlt.'];
}
$this->debug?->add('fx.latest.probe.request', [
'base' => $base,
'url' => $this->maskUrl($url),
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$curlError = curl_error($ch);
curl_close($ch);
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
$body = is_string($response) ? substr($response, $headerSize) : '';
$result = [
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
'url' => $this->maskUrl($url),
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_headers' => $rawHeaders,
'response_body' => substr($body, 0, 4000),
];
$this->debug?->add('fx.latest.probe.response', $result);
return $result;
}
private function fetchCurrenciesPayload(): array
{
if (!function_exists('curl_init') || $this->apiKey === '') {
return [];
}
$url = sprintf(
'%s/api/v2/currencies?output=json&key=%s',
$this->currenciesApiBaseUrl,
rawurlencode($this->apiKey)
);
$this->debug?->add('fx.currencies.request', [
'url' => $this->maskUrl($url),
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$this->debug?->add('fx.currencies.response', [
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_bytes' => is_string($response) ? strlen($response) : 0,
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
]);
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
return [];
}
$payload = json_decode((string) $response, true);
if (!is_array($payload)) {
throw new \RuntimeException('Waehrungskatalog konnte nicht gelesen werden.');
}
if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) {
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
}
return $payload;
}
private function fetchCurrenciesProbe(): array
{
if (!function_exists('curl_init') || $this->apiKey === '') {
return ['ok' => false, 'message' => 'curl_init oder API-Key fehlt.'];
}
$url = sprintf(
'%s/api/v2/currencies?output=json&key=%s',
$this->currenciesApiBaseUrl,
rawurlencode($this->apiKey)
);
$this->debug?->add('fx.currencies.probe.request', [
'url' => $this->maskUrl($url),
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$curlError = curl_error($ch);
curl_close($ch);
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
$body = is_string($response) ? substr($response, $headerSize) : '';
$result = [
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
'url' => $this->maskUrl($url),
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_headers' => $rawHeaders,
'response_body' => substr($body, 0, 4000),
];
$this->debug?->add('fx.currencies.probe.response', $result);
return $result;
}
private function storedRate(string $base, string $target): ?float
{
if ($this->repository === null) {
return null;
}
try {
$direct = $this->repository->getLatestFxRate($base, $target);
if (is_array($direct) && is_numeric($direct['rate'] ?? null)) {
return (float) $direct['rate'];
}
$inverse = $this->repository->getLatestFxRate($target, $base);
if (is_array($inverse) && is_numeric($inverse['rate'] ?? null) && (float) $inverse['rate'] > 0) {
return 1 / (float) $inverse['rate'];
}
$measurementRate = $this->repository->getLatestMeasurementRate($base, $target);
if (is_array($measurementRate) && is_numeric($measurementRate['rate'] ?? null)) {
return (float) $measurementRate['rate'];
}
$inverseMeasurementRate = $this->repository->getLatestMeasurementRate($target, $base);
if (
is_array($inverseMeasurementRate) &&
is_numeric($inverseMeasurementRate['rate'] ?? null) &&
(float) $inverseMeasurementRate['rate'] > 0
) {
return 1 / (float) $inverseMeasurementRate['rate'];
}
foreach (['USD', 'EUR'] as $viaBase) {
if ($base === $viaBase || $target === $viaBase) {
continue;
}
$fromVia = $this->repository->getLatestFxRate($viaBase, $base);
$toVia = $this->repository->getLatestFxRate($viaBase, $target);
if (
is_array($fromVia) && is_numeric($fromVia['rate'] ?? null) &&
is_array($toVia) && is_numeric($toVia['rate'] ?? null) &&
(float) $fromVia['rate'] > 0
) {
return (float) $toVia['rate'] / (float) $fromVia['rate'];
}
$fromViaInverse = $this->repository->getLatestFxRate($base, $viaBase);
$toViaInverse = $this->repository->getLatestFxRate($target, $viaBase);
if (
is_array($fromViaInverse) && is_numeric($fromViaInverse['rate'] ?? null) &&
is_array($toViaInverse) && is_numeric($toViaInverse['rate'] ?? null) &&
(float) $toViaInverse['rate'] > 0
) {
return (1 / (float) $fromViaInverse['rate']) / (1 / (float) $toViaInverse['rate']);
}
}
} catch (\Throwable) {
return null;
}
return null;
}
private function persistRateSet(string $base, array $rates, string $rateDate): array
{
$normalizedBase = strtoupper($base);
$normalizedRates = [];
foreach ($rates as $target => $rate) {
if (!is_numeric($rate)) {
continue;
}
$normalizedTarget = strtoupper((string) $target);
$normalizedRates[$normalizedTarget] = (float) $rate;
$this->memoryCache[$normalizedBase . ':' . $normalizedTarget] = (float) $rate;
$this->writeFileCache($normalizedBase . ':' . $normalizedTarget, (float) $rate);
}
if ($this->repository === null) {
$result = [];
foreach ($normalizedRates as $target => $rate) {
$result[] = [
'base_currency' => $normalizedBase,
'target_currency' => $target,
'rate' => $rate,
'rate_date' => $rateDate,
'provider' => $this->provider,
];
}
return $result;
}
try {
$saved = $this->repository->saveFxFetch($normalizedBase, $this->provider, $rateDate, $normalizedRates);
return is_array($saved['rates'] ?? null) ? $saved['rates'] : [];
} catch (\Throwable) {
$result = [];
foreach ($normalizedRates as $target => $rate) {
$result[] = [
'base_currency' => $normalizedBase,
'target_currency' => $target,
'rate' => $rate,
'rate_date' => $rateDate,
'provider' => $this->provider,
];
}
return $result;
}
}
private function buildLatestUrl(string $base, ?array $targets = null): ?string
{
if ($this->provider === 'currencyapi') {
if ($this->apiKey === '') {
return null;
}
return sprintf(
'%s/api/v2/rates?base=%s&output=json&key=%s',
$this->apiBaseUrl,
rawurlencode($base),
rawurlencode($this->apiKey)
);
}
$targets = $targets ?? $this->defaultCurrencies();
return sprintf(
'%s/latest?base=%s&symbols=%s',
$this->apiBaseUrl,
rawurlencode($base),
rawurlencode(implode(',', $targets))
);
}
private function normalizePayload(mixed $payload, string $base, ?array $targets = null): array
{
if (!is_array($payload)) {
return [];
}
if ($this->provider === 'currencyapi') {
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
}
$allRates = $payload['rates'];
$filteredRates = [];
if ($targets === null) {
foreach ($allRates as $target => $rate) {
$targetCode = strtoupper((string) $target);
if ($targetCode === $base || !is_numeric($rate)) {
continue;
}
$filteredRates[$targetCode] = (float) $rate;
}
} else {
foreach ($targets as $target) {
$targetCode = strtoupper((string) $target);
if ($targetCode === $base) {
continue;
}
$rate = $allRates[$targetCode] ?? null;
if (is_numeric($rate)) {
$filteredRates[$targetCode] = (float) $rate;
}
}
}
return [
'base' => strtoupper((string) ($payload['base'] ?? $base)),
'date' => $payload['updated'] ?? null,
'rates' => $filteredRates,
];
}
if (!is_array($payload['rates'] ?? null)) {
return [];
}
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
return [];
}
return $payload;
}
private function extractProviderError(array $payload, string $fallback): string
{
foreach (['error', 'message', 'msg'] as $field) {
$value = $payload[$field] ?? null;
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
$errors = $payload['errors'] ?? null;
if (is_array($errors)) {
$flat = [];
array_walk_recursive($errors, static function ($value) use (&$flat): void {
if (is_string($value) && trim($value) !== '') {
$flat[] = trim($value);
}
});
if ($flat !== []) {
return implode(' | ', array_values(array_unique($flat)));
}
}
return $fallback;
}
private function defaultCurrencies(): array
{
return ['EUR', 'USD'];
}
private function normalizeRateDate(mixed $value): string
{
if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit(trim($value)))) {
$timestamp = (int) $value;
if ($timestamp > 0) {
return date('Y-m-d', $timestamp);
}
}
if (is_string($value) && trim($value) !== '') {
$timestamp = strtotime($value);
if ($timestamp !== false) {
return date('Y-m-d', $timestamp);
}
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) {
return $matches[0];
}
}
return date('Y-m-d');
}
private function parseStoredUtcTimestamp(string $value): ?int
{
$normalized = trim($value);
if ($normalized === '') {
return null;
}
try {
if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $normalized) === 1) {
$date = new \DateTimeImmutable(str_replace(' ', 'T', $normalized), new \DateTimeZone('UTC'));
} else {
$date = new \DateTimeImmutable($normalized);
}
return $date->setTimezone(new \DateTimeZone('UTC'))->getTimestamp();
} catch (\Throwable) {
return null;
}
}
private function catalogSortOrder(string $code, int $fallback): int
{
return match (strtoupper($code)) {
'EUR' => 10,
'USD' => 20,
'DOGE' => 30,
'BTC' => 40,
'ETH' => 50,
'USDT' => 60,
'USDC' => 70,
default => $fallback,
};
}
private function isCryptoCode(string $code): bool
{
return in_array(strtoupper($code), [
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
'SOL', 'USDC', 'USDT', 'XRP',
], true);
}
private function cacheFile(string $cacheKey): string
{
return rtrim(sys_get_temp_dir(), '/') . '/mining-checker-fx-' . md5($cacheKey) . '.json';
}
private function readFileCache(string $cacheKey): ?float
{
$file = $this->cacheFile($cacheKey);
if (!is_file($file) || (time() - filemtime($file)) > $this->cacheTtl) {
return null;
}
$payload = json_decode((string) file_get_contents($file), true);
$rate = $payload['rate'] ?? null;
return is_numeric($rate) ? (float) $rate : null;
}
private function writeFileCache(string $cacheKey, float $rate): void
{
@file_put_contents($this->cacheFile($cacheKey), json_encode([
'rate' => $rate,
'cached_at' => time(),
], JSON_UNESCAPED_UNICODE));
}
private function maskUrl(string $url): string
{
return preg_replace_callback('/([?&]key=)([^&]+)/i', static function (array $matches): string {
$key = $matches[2] ?? '';
if (strlen($key) <= 8) {
return $matches[1] . $key;
}
return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4);
}, $url) ?: $url;
}
private function sharedFxService(): ?object
{
if (!function_exists('modules') || !modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'service')) {
return null;
}
try {
$service = module_fn('fx-rates', 'service');
return is_object($service) ? $service : null;
} catch (\Throwable) {
return null;
}
}
private function resolveRateFromSnapshot(array $snapshot, string $from, string $to): ?float
{
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
if ($base === '' || $from === '' || $to === '') {
return null;
}
if ($base === $from && is_numeric($rates[$to] ?? null)) {
return (float) $rates[$to];
}
if ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) {
return 1 / (float) $rates[$from];
}
if (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) {
return (float) $rates[$to] / (float) $rates[$from];
}
return null;
}
private function snapshotCacheKey(string $prefix, array $parts): string
{
return $prefix . ':' . implode(':', array_map(static fn (mixed $part): string => (string) $part, $parts));
}
private function normalizeSymbolsForCache(?array $symbols): string
{
if (!is_array($symbols) || $symbols === []) {
return '*';
}
$normalized = array_values(array_unique(array_filter(array_map(
static fn (mixed $symbol): string => strtoupper(trim((string) $symbol)),
$symbols
))));
sort($normalized);
return implode(',', $normalized);
}
}

View File

@@ -0,0 +1,648 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\ModuleConfig;
use Modules\MiningChecker\Support\ApiException;
final class OcrService
{
private ModuleConfig $config;
public function __construct(ModuleConfig $config)
{
$this->config = $config;
}
public function preview(array $file, array $input): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new ApiException('Screenshot-Upload fehlt oder ist fehlerhaft.', 422);
}
$mime = mime_content_type($file['tmp_name']) ?: '';
if (!in_array($mime, ['image/png', 'image/jpeg', 'image/webp'], true)) {
throw new ApiException('Nur PNG, JPEG und WEBP werden akzeptiert.', 422, ['mime' => $mime]);
}
$projectKey = (string) ($input['project_key'] ?? $this->config->defaultProjectKey());
$uploadDir = $this->resolveUploadDir($projectKey);
$extension = pathinfo((string) ($file['name'] ?? 'upload.png'), PATHINFO_EXTENSION) ?: 'png';
$filename = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . strtolower($extension);
$targetFile = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $targetFile)) {
throw new ApiException('Bild konnte nicht gespeichert werden.', 500);
}
$rawText = trim((string) ($input['ocr_hint_text'] ?? ''));
$flags = [];
if ($rawText === '') {
['text' => $rawText, 'flags' => $providerFlags] = $this->extractRawText($targetFile);
$flags = array_merge($flags, $providerFlags);
} else {
$flags[] = 'ocr_hint_text_used';
}
$parsed = $this->parseText(
$rawText,
(string) ($input['date_context'] ?? date('Y-m-d')),
strtoupper(trim((string) ($input['wallet_currency_hint'] ?? '')))
);
$parsed['image_path'] = $targetFile;
$parsed['raw_text'] = $rawText;
$parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags'])));
return $parsed;
}
private function resolveUploadDir(string $projectKey): string
{
$safeProjectKey = preg_replace('~[^a-zA-Z0-9_-]~', '-', $projectKey) ?: 'default';
$candidates = [
rtrim($this->config->uploadsDir(), '/') . '/' . $safeProjectKey,
rtrim(sys_get_temp_dir(), '/') . '/mining-checker/uploads/' . $safeProjectKey,
];
foreach ($candidates as $candidate) {
if ($this->ensureWritableDirectory($candidate)) {
return $candidate;
}
}
throw new ApiException('Upload-Verzeichnis konnte nicht erstellt werden.', 500, [
'candidates' => $candidates,
]);
}
private function ensureWritableDirectory(string $directory): bool
{
if (is_dir($directory)) {
return is_writable($directory);
}
return @mkdir($directory, 0775, true) || is_dir($directory);
}
private function extractRawText(string $imagePath): array
{
$ocrConfig = $this->config->ocr();
$providers = $ocrConfig['providers'] ?? ['tesseract'];
$flags = [];
if (!is_array($providers) || $providers === []) {
$providers = ['tesseract'];
}
foreach ($providers as $provider) {
$providerName = strtolower(trim((string) $provider));
if ($providerName === '') {
continue;
}
if ($providerName === 'ocrspace') {
$result = $this->runOcrSpace((array) ($ocrConfig['ocrspace'] ?? []), $imagePath);
} elseif ($providerName === 'tesseract') {
$result = $this->runTesseract((array) ($ocrConfig['tesseract'] ?? []), $imagePath);
} else {
$flags[] = 'ocr_provider_unsupported:' . $providerName;
continue;
}
$flags = array_merge($flags, $result['flags']);
if (($result['text'] ?? '') !== '') {
return [
'text' => (string) $result['text'],
'flags' => array_values(array_unique(array_merge(
$flags,
['ocr_provider:' . $providerName]
))),
];
}
}
return [
'text' => '',
'flags' => array_values(array_unique(array_merge($flags, ['ocr_engine_missing']))),
];
}
private function runOcrSpace(array $providerConfig, string $imagePath): array
{
if (!function_exists('curl_init') || !class_exists(\CURLFile::class)) {
return [
'text' => '',
'flags' => ['ocr_provider_missing:ocrspace', 'ocr_transport_missing:curl'],
];
}
$url = trim((string) ($providerConfig['url'] ?? ''));
if ($url === '') {
return [
'text' => '',
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_url_missing'],
];
}
$apiKey = trim((string) ($providerConfig['api_key'] ?? ''));
if ($apiKey === '') {
return [
'text' => '',
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_api_key_missing'],
];
}
$postFields = [
'file' => new \CURLFile($imagePath),
'language' => (string) ($providerConfig['language'] ?? 'eng'),
'OCREngine' => (string) ((int) ($providerConfig['engine'] ?? 2)),
'scale' => (string) ($providerConfig['scale'] ?? 'true'),
'detectOrientation' => (string) ($providerConfig['detect_orientation'] ?? 'true'),
'isTable' => (string) ($providerConfig['is_table'] ?? 'false'),
'isOverlayRequired' => 'false',
];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postFields,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => max(5, (int) ($providerConfig['timeout'] ?? 25)),
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'apikey: ' . $apiKey,
],
]);
$response = curl_exec($ch);
$curlError = curl_error($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($response === false || $curlError !== '') {
return [
'text' => '',
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_request_failed'],
];
}
$payload = json_decode((string) $response, true);
if (!is_array($payload)) {
return [
'text' => '',
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_invalid_response'],
];
}
$flags = [];
$rawText = '';
$parsedResults = $payload['ParsedResults'] ?? null;
if (is_array($parsedResults)) {
$texts = [];
foreach ($parsedResults as $result) {
if (!is_array($result)) {
continue;
}
$fileExitCode = (string) ($result['FileParseExitCode'] ?? '');
if ($fileExitCode !== '') {
$flags[] = 'ocrspace_file_exit_code:' . $fileExitCode;
}
$parsedText = trim((string) ($result['ParsedText'] ?? ''));
if ($parsedText !== '') {
$texts[] = $parsedText;
}
$resultError = trim((string) ($result['ErrorMessage'] ?? ''));
if ($resultError !== '') {
$flags[] = 'ocrspace_result_error';
}
}
$rawText = trim(implode("\n", $texts));
}
$ocrExitCode = (string) ($payload['OCRExitCode'] ?? '');
$isErroredOnProcessing = !empty($payload['IsErroredOnProcessing']);
$errorMessage = trim((string) ($payload['ErrorMessage'] ?? ''));
$errorDetails = trim((string) ($payload['ErrorDetails'] ?? ''));
if ($httpStatus >= 400) {
$flags[] = 'ocrspace_http_error';
}
if ($ocrExitCode !== '') {
$flags[] = 'ocrspace_exit_code:' . $ocrExitCode;
}
$flags[] = 'ocrspace_engine:' . (string) ((int) ($providerConfig['engine'] ?? 2));
if ($isErroredOnProcessing) {
$flags[] = 'ocrspace_processing_error';
}
if ($errorMessage !== '' || $errorDetails !== '') {
$flags[] = 'ocrspace_error';
}
return [
'text' => $rawText,
'flags' => $rawText === '' ? array_values(array_unique(array_merge($flags, ['ocr_provider_empty:ocrspace']))) : array_values(array_unique($flags)),
];
}
private function runTesseract(array $providerConfig, string $imagePath): array
{
$binary = (string) ($providerConfig['binary'] ?? 'tesseract');
if (!$this->binaryExists($binary)) {
return [
'text' => '',
'flags' => ['ocr_provider_missing:tesseract'],
];
}
$language = (string) ($providerConfig['language'] ?? 'eng');
$tmpBase = tempnam(sys_get_temp_dir(), 'mc-ocr-');
if ($tmpBase === false) {
return [
'text' => '',
'flags' => ['ocr_tempfile_failed:tesseract'],
];
}
@unlink($tmpBase);
$command = sprintf(
'%s %s %s -l %s 2>/dev/null',
escapeshellcmd($binary),
escapeshellarg($imagePath),
escapeshellarg($tmpBase),
escapeshellarg($language)
);
shell_exec($command);
$txtFile = $tmpBase . '.txt';
$text = is_file($txtFile) ? (string) file_get_contents($txtFile) : '';
@unlink($txtFile);
return [
'text' => trim($text),
'flags' => trim($text) === '' ? ['ocr_provider_empty:tesseract'] : [],
];
}
private function binaryExists(string $binary): bool
{
return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== '';
}
private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
{
$measurement = $this->parseMeasurementText($rawText, $dateContext);
$wallet = $this->parseWalletText($rawText, $dateContext, $walletCurrencyHint);
$isWallet = ($wallet['score'] ?? 0) > ($measurement['score'] ?? 0)
&& (
($wallet['suggested_wallet']['wallet_balance'] ?? null) !== null
|| ($wallet['suggested_wallet']['total_value_amount'] ?? null) !== null
);
return [
'kind' => $isWallet ? 'wallet' : 'measurement',
'suggested' => $measurement['suggested'],
'suggested_wallet' => $wallet['suggested_wallet'],
'confidence' => round((float) ($isWallet ? ($wallet['confidence'] ?? 0.0) : ($measurement['confidence'] ?? 0.0)), 4),
'flags' => $isWallet ? $wallet['flags'] : $measurement['flags'],
];
}
private function parseMeasurementText(string $rawText, string $dateContext): array
{
$flags = [];
$suggestedTime = null;
$coinsTotal = null;
$price = null;
$currency = null;
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
$lines = array_values(array_filter(array_map(
static fn (string $line): string => trim($line),
preg_split('/\R/u', $rawText) ?: []
), static fn (string $line): bool => $line !== ''));
if ($normalizedText === '') {
$flags[] = 'ocr_raw_text_empty';
}
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
}
preg_match_all('/\b\d+(?:[.,]\d+)?\b/', $normalizedText, $numberMatches);
$decimalCandidates = [];
foreach ($numberMatches[0] ?? [] as $candidate) {
$normalized = (float) str_replace(',', '.', $candidate);
if ($normalized <= 0) {
continue;
}
$decimalCandidates[] = [
'raw' => $candidate,
'value' => $normalized,
'precision' => str_contains($candidate, ',') || str_contains($candidate, '.')
? strlen((string) preg_replace('/^\d+[.,]/', '', $candidate))
: 0,
];
}
if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) {
$currency = strtoupper((string) $pairMatch[1]);
} elseif (preg_match('/\b(EUR|USD|USDT|USDC|BTC|ETH|LTC)\b/i', $normalizedText, $currencyMatch)) {
$currency = strtoupper((string) $currencyMatch[1]);
} elseif (str_contains($normalizedText, '$')) {
$currency = 'USD';
} else {
$flags[] = 'currency_missing';
}
if (preg_match('/DOGE\s*\/\s*(?:USD|EUR|USDT|USDC|BTC|ETH|LTC)[^\d]{0,20}(\d+[.,]\d{3,8})/i', $normalizedText, $priceMatch)) {
$price = round((float) str_replace(',', '.', $priceMatch[1]), 8);
}
if ($coinsTotal === null) {
foreach ($lines as $line) {
if (!preg_match('/MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE/i', $line)) {
continue;
}
if (preg_match('/(\d+[.,]\d{4,8})/', $line, $lineCoinsMatch)) {
$coinsTotal = round((float) str_replace(',', '.', $lineCoinsMatch[1]), 6);
$flags[] = 'coins_from_balance_line';
break;
}
}
}
if ($coinsTotal === null && preg_match('/(\d+[.,]\d{4,8})\s*(?:DOGE)?\s*(?:MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE)/i', $normalizedText, $coinsMatch)) {
$coinsTotal = round((float) str_replace(',', '.', $coinsMatch[1]), 6);
$flags[] = 'coins_from_balance_context';
}
if ($coinsTotal === null) {
$coinsCandidates = array_values(array_filter($decimalCandidates, static function (array $item) use ($price): bool {
if ($item['precision'] < 4) {
return false;
}
if ($item['value'] <= 0 || $item['value'] >= 1000000) {
return false;
}
if ($price !== null && abs($item['value'] - $price) < 0.0000005) {
return false;
}
return true;
}));
if ($coinsCandidates !== []) {
usort($coinsCandidates, static function (array $a, array $b): int {
return [$b['precision'], $b['value']] <=> [$a['precision'], $a['value']];
});
$coinsTotal = round((float) $coinsCandidates[0]['value'], 6);
if (count($coinsCandidates) > 1) {
$flags[] = 'coins_ambiguous';
}
} else {
$flags[] = 'coins_missing';
}
}
$priceCandidates = array_values(array_filter(
$decimalCandidates,
static fn (array $item): bool => $item['value'] > 0 && $item['value'] < 1
));
if ($price === null && $priceCandidates !== []) {
usort($priceCandidates, static function (array $a, array $b): int {
return [$b['precision'], $a['value']] <=> [$a['precision'], $b['value']];
});
$price = round((float) $priceCandidates[0]['value'], 8);
if (count($priceCandidates) > 1 && count(array_filter($priceCandidates, static fn (array $item): bool => $item['precision'] >= 4)) > 1) {
$flags[] = 'price_ambiguous';
}
}
if ($price === null && $coinsTotal !== null && preg_match('/~\s*(\d+[.,]\d+)\s*\$/', $normalizedText, $fiatMatch)) {
$fiatValue = (float) str_replace(',', '.', $fiatMatch[1]);
if ($fiatValue > 0) {
$price = round($fiatValue / $coinsTotal, 8);
$flags[] = 'price_derived_from_balance_value';
$currency = $currency ?? 'USD';
}
}
$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,
'score' => $matchedFields,
];
}
private function parseWalletText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
{
$flags = [];
$suggestedTime = null;
$totalValueAmount = null;
$totalValueCurrency = null;
$walletBalance = null;
$walletCurrency = $walletCurrencyHint !== '' ? $walletCurrencyHint : 'DOGE';
$balances = [];
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
$lines = array_values(array_filter(array_map(
static fn (string $line): string => trim($line),
preg_split('/\R/u', $rawText) ?: []
), static fn (string $line): bool => $line !== ''));
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
}
if (
preg_match('/GESAMTSALDO[^\d]{0,24}(\d+(?:[.,]\d+)?)\s*(USD|EUR|USDT|USDC|BTC|ETH|DOGE|CTC|HSH)/i', $normalizedText, $totalMatch)
|| preg_match('/(\d+(?:[.,]\d+)?)\s*(USD|EUR)\b.*GESAMTSALDO/i', $normalizedText, $totalMatch)
) {
$totalValueAmount = round((float) str_replace(',', '.', $totalMatch[1]), 8);
$totalValueCurrency = strtoupper((string) $totalMatch[2]);
} else {
$flags[] = 'wallet_total_missing';
}
$assetRows = [];
foreach ($lines as $lineIndex => $line) {
if (!preg_match('/(\d+(?:[.,]\d+)?)\s*([A-Z]{2,10})\b/u', $line, $match)) {
continue;
}
$amount = round((float) str_replace(',', '.', $match[1]), 10);
$currency = strtoupper((string) $match[2]);
if ($amount <= 0 || $currency === '' || in_array($currency, ['USD', 'EUR'], true)) {
continue;
}
$assetRows[] = [
'index' => $lineIndex,
'currency' => $currency,
'balance' => $amount,
];
}
foreach ($assetRows as $assetIndex => $assetRow) {
$currency = (string) $assetRow['currency'];
$balanceAmount = (float) $assetRow['balance'];
$startIndex = (int) $assetRow['index'];
$endIndex = isset($assetRows[$assetIndex + 1]['index'])
? (int) $assetRows[$assetIndex + 1]['index']
: count($lines);
$usdCandidates = [];
for ($i = $startIndex; $i < $endIndex; $i++) {
if (!isset($lines[$i])) {
continue;
}
if (preg_match_all('/(\d+(?:[.,]\d+)?)\s*USD\b/u', $lines[$i], $usdMatches, PREG_SET_ORDER)) {
foreach ($usdMatches as $usdMatch) {
$usdCandidates[] = round((float) str_replace(',', '.', $usdMatch[1]), 8);
}
}
}
$priceAmount = $this->pickWalletUnitPrice($balanceAmount, $usdCandidates);
$balances[$currency] = [
'balance' => $balanceAmount,
'price_amount' => $priceAmount,
'price_currency' => $priceAmount !== null ? 'USD' : null,
];
}
if ($walletCurrencyHint !== '' && array_key_exists($walletCurrencyHint, $balances)) {
$walletCurrency = $walletCurrencyHint;
$walletBalance = is_array($balances[$walletCurrencyHint])
? (float) ($balances[$walletCurrencyHint]['balance'] ?? 0.0)
: (float) $balances[$walletCurrencyHint];
} elseif ($balances !== []) {
foreach (['DOGE', 'BTC', 'ETH', 'CTC', 'HSH', 'LTC', 'USDT', 'USDC'] as $preferredCurrency) {
if (array_key_exists($preferredCurrency, $balances)) {
$walletCurrency = $preferredCurrency;
$walletBalance = is_array($balances[$preferredCurrency])
? (float) ($balances[$preferredCurrency]['balance'] ?? 0.0)
: (float) $balances[$preferredCurrency];
break;
}
}
if ($walletBalance === null) {
$firstCurrency = array_key_first($balances);
if (is_string($firstCurrency)) {
$walletCurrency = $firstCurrency;
$walletBalance = is_array($balances[$firstCurrency])
? (float) ($balances[$firstCurrency]['balance'] ?? 0.0)
: (float) $balances[$firstCurrency];
}
}
} else {
$flags[] = 'wallet_balance_missing';
}
$walletIndicators = 0;
$normalizedLower = strtolower($normalizedText);
foreach (['wallets', 'gesamtsaldo', 'alle münzen', 'alle munzen', 'letzte transaktion'] as $indicator) {
if (str_contains($normalizedLower, $indicator)) {
$walletIndicators++;
}
}
$matchedFields = 0;
foreach ([$totalValueAmount, $walletBalance, $walletCurrency] as $field) {
if ($field !== null && $field !== '') {
$matchedFields++;
}
}
$score = $matchedFields + ($walletIndicators * 2);
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $walletIndicators) * 0.12) - (count($flags) * 0.03)));
ksort($balances);
return [
'suggested_wallet' => [
'measured_at' => $suggestedTime,
'total_value_amount' => $totalValueAmount,
'total_value_currency' => $totalValueCurrency,
'wallet_balance' => $walletBalance,
'wallet_currency' => $walletCurrency,
'balances_json' => $balances,
'note' => null,
'source' => 'image_ocr',
],
'confidence' => round($confidence, 4),
'flags' => array_values(array_unique($flags)),
'score' => $score,
];
}
/**
* @param list<float|int> $candidates
*/
private function pickWalletUnitPrice(float $balance, array $candidates): ?float
{
$candidates = array_values(array_filter(array_map(
static fn (mixed $value): float => round((float) $value, 8),
$candidates
), static fn (float $value): bool => $value > 0));
if ($balance <= 0 || $candidates === []) {
return null;
}
if (count($candidates) === 1) {
return $candidates[0];
}
$bestPrice = null;
$bestError = null;
$candidateCount = count($candidates);
for ($i = 0; $i < $candidateCount; $i++) {
$priceCandidate = $candidates[$i];
for ($j = 0; $j < $candidateCount; $j++) {
if ($i === $j) {
continue;
}
$totalCandidate = $candidates[$j];
$estimatedTotal = $balance * $priceCandidate;
$denominator = max(abs($totalCandidate), 0.00000001);
$error = abs($estimatedTotal - $totalCandidate) / $denominator;
if ($bestError === null || $error < $bestError) {
$bestError = $error;
$bestPrice = $priceCandidate;
}
}
}
if ($bestPrice !== null && $bestError !== null && $bestError <= 0.2) {
return round($bestPrice, 8);
}
return round($candidates[count($candidates) - 1], 8);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
final class SeedData
{
public static function projectKey(): string
{
return 'doge-main';
}
public static function projectName(): string
{
return 'DOGE Mining Main';
}
public static function settings(): array
{
return [
'baseline_measured_at' => '2026-03-16 01:32:00',
'baseline_coins_total' => 27.617864,
'daily_cost_amount' => 0.3123287671,
'daily_cost_currency' => 'EUR',
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
];
}
public static function currencies(): array
{
return [
['code' => 'EUR', 'name' => 'Euro', 'symbol' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
['code' => 'USD', 'name' => 'US-Dollar', 'symbol' => 'USD', 'is_active' => 1, 'sort_order' => 20],
['code' => 'DOGE', 'name' => 'Dogecoin', 'symbol' => 'DOGE', 'is_active' => 1, 'sort_order' => 100],
['code' => 'BTC', 'name' => 'Bitcoin', 'symbol' => 'BTC', 'is_active' => 1, 'sort_order' => 110],
['code' => 'ETH', 'name' => 'Ethereum', 'symbol' => 'ETH', 'is_active' => 1, 'sort_order' => 120],
['code' => 'LTC', 'name' => 'Litecoin', 'symbol' => 'LTC', 'is_active' => 1, 'sort_order' => 130],
['code' => 'USDT', 'name' => 'Tether', 'symbol' => 'USDT', 'is_active' => 1, 'sort_order' => 140],
['code' => 'USDC', 'name' => 'USD Coin', 'symbol' => 'USDC', 'is_active' => 1, 'sort_order' => 150],
];
}
public static function measurements(): array
{
return [
['measured_at' => '2026-03-16 01:32:00', 'coins_total' => 27.617864, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Basiswert', 'source' => 'seed_import'],
['measured_at' => '2026-03-17 02:41:00', 'coins_total' => 33.751904, 'price_per_coin' => null, 'price_currency' => null, 'note' => 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'source' => 'seed_import'],
['measured_at' => '2026-03-17 07:15:00', 'coins_total' => 34.825695, 'price_per_coin' => 0.10037, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-17 13:21:00', 'coins_total' => 36.328140, 'price_per_coin' => 0.10002, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-17 18:53:00', 'coins_total' => 37.682757, 'price_per_coin' => 0.10062, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-18 00:08:00', 'coins_total' => 38.934351, 'price_per_coin' => 0.10097, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-18 07:40:00', 'coins_total' => 40.782006, 'price_per_coin' => 0.10040, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-18 13:32:00', 'coins_total' => 42.223449, 'price_per_coin' => 0.09607, 'price_currency' => 'EUR', 'note' => 'Originaleingabe im Chat: 18.6.2026', 'source' => 'seed_import'],
['measured_at' => '2026-03-18 21:15:00', 'coins_total' => 44.191018, 'price_per_coin' => 0.09446, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-19 00:09:00', 'coins_total' => 44.908500, 'price_per_coin' => 0.09507, 'price_currency' => 'EUR', 'note' => null, 'source' => 'seed_import'],
['measured_at' => '2026-03-19 02:33:00', 'coins_total' => 45.546924, 'price_per_coin' => 0.09499, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
['measured_at' => '2026-03-19 07:01:00', 'coins_total' => 46.694127, 'price_per_coin' => 0.09460, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
['measured_at' => '2026-03-19 12:24:00', 'coins_total' => 48.056494, 'price_per_coin' => 0.09419, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
['measured_at' => '2026-03-19 21:39:00', 'coins_total' => 50.427943, 'price_per_coin' => 0.09361, 'price_currency' => 'USD', 'note' => 'aus Screenshot extrahiert', 'source' => 'seed_import', 'ocr_flags' => ['source:screenshot']],
];
}
public static function targets(): array
{
return [
['label' => 'Ziel A', 'target_amount_fiat' => 10.82, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 10],
['label' => 'Ziel B', 'target_amount_fiat' => 19.50, 'currency' => 'EUR', 'is_active' => 1, 'sort_order' => 20],
];
}
public static function dashboards(): array
{
return [
['name' => 'Mining-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'coins_total', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
['name' => 'Performance-Verlauf', 'chart_type' => 'area', 'x_field' => 'measured_date', 'y_field' => 'doge_per_day_interval', 'aggregation' => 'avg', 'filters' => [], 'is_active' => 1],
['name' => 'Kurs-Verlauf', 'chart_type' => 'line', 'x_field' => 'measured_at', 'y_field' => 'price_per_coin', 'aggregation' => 'none', 'filters' => [], 'is_active' => 1],
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\MiningRepository;
final class SeedImporter
{
private MiningRepository $repository;
public function __construct(MiningRepository $repository)
{
$this->repository = $repository;
}
public function import(string $projectKey): array
{
$seedProjectKey = SeedData::projectKey();
if ($projectKey !== $seedProjectKey) {
return ['inserted' => 0, 'project_key' => $projectKey, 'warning' => 'Seed-Daten sind nur fuer doge-main definiert.'];
}
$this->repository->ensureProject($projectKey, SeedData::projectName());
$this->repository->saveSettings($projectKey, SeedData::settings());
$insertedMeasurements = 0;
foreach (SeedData::measurements() as $measurement) {
try {
$this->repository->createMeasurement($projectKey, array_merge([
'image_path' => null,
'ocr_raw_text' => null,
'ocr_confidence' => null,
'ocr_flags' => null,
], $measurement));
$insertedMeasurements++;
} catch (\Throwable $exception) {
// Duplicate seeds are expected on repeated imports.
}
}
$targetCount = 0;
foreach (SeedData::targets() as $target) {
$this->repository->saveTarget($projectKey, $target);
$targetCount++;
}
$dashboardCount = 0;
foreach (SeedData::dashboards() as $dashboard) {
$this->repository->saveDashboard($projectKey, $dashboard);
$dashboardCount++;
}
return [
'project_key' => $projectKey,
'imported_measurements' => $insertedMeasurements,
'historical_rows_total' => count(SeedData::measurements()),
'targets_synced' => $targetCount,
'dashboards_synced' => $dashboardCount,
];
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Infrastructure;
use App\Database as AppDatabase;
use Modules\MiningChecker\Support\ApiException;
use PDO;
final class ConnectionFactory
{
public static function make(ModuleConfig $config): PDO
{
$moduleSettings = modules()->settings('mining-checker');
$useSeparateDb = self::usesSeparateDatabase($moduleSettings);
if ($useSeparateDb) {
$dbConfig = is_array($moduleSettings['db'] ?? null) ? $moduleSettings['db'] : [];
if ($dbConfig === []) {
throw new ApiException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.', 500);
}
self::assertSupportedDriver($dbConfig);
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
return AppDatabase::connectFromConfig($dbConfig);
}
return AppDatabase::createFromArray($dbConfig);
}
$dbConfig = app()->config()->dbConfig;
if ($dbConfig === []) {
throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500);
}
self::assertSupportedDriver($dbConfig);
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
return AppDatabase::connectFromConfig($dbConfig);
}
return AppDatabase::createFromArray($dbConfig);
}
private static function usesSeparateDatabase(array $moduleSettings): bool
{
$raw = $moduleSettings['use_separate_db'] ?? false;
if (is_bool($raw)) {
return $raw;
}
$normalized = strtolower(trim((string) $raw));
return in_array($normalized, ['1', 'true', 'yes', 'on', 'custom'], true);
}
private static function assertSupportedDriver(array $dbConfig): void
{
$driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? '')));
if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:')) {
throw new ApiException(
'Mining-Checker unterstuetzt aktuell MySQL/MariaDB und PostgreSQL. Stelle den Driver auf mysql oder pgsql.',
500,
['driver' => $dbConfig['driver'] ?? 'unknown']
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Infrastructure;
final class ModuleConfig
{
private array $config;
public function __construct(array $config)
{
$this->config = $config;
}
public static function load(string $moduleBasePath): self
{
$config = require $moduleBasePath . '/config/module.php';
return new self(is_array($config) ? $config : []);
}
public function defaultProjectKey(): string
{
return (string) ($this->config['default_project_key'] ?? 'doge-main');
}
public function useProjectDatabase(): bool
{
return (bool) ($this->config['use_project_database'] ?? true);
}
public function tablePrefix(): string
{
return (string) ($this->config['table_prefix'] ?? 'miningcheck_');
}
public function uploadsDir(): string
{
return (string) ($this->config['uploads_dir'] ?? sys_get_temp_dir());
}
public function uploadsPublicPrefix(): string
{
return rtrim((string) ($this->config['uploads_public_prefix'] ?? '/uploads'), '/');
}
public function ocr(): array
{
return (array) ($this->config['ocr'] ?? []);
}
public function fx(): array
{
return (array) ($this->config['fx'] ?? []);
}
public function debug(): array
{
return (array) ($this->config['debug'] ?? []);
}
public function debugDir(): string
{
$debug = $this->debug();
return (string) ($debug['dir'] ?? (dirname($this->uploadsDir()) . '/debug'));
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class DebugTrace
{
private bool $enabled;
private array $entries = [];
private ?string $filePath;
public function __construct(bool $enabled = false, ?string $filePath = null)
{
$this->enabled = $enabled;
$this->filePath = $enabled ? $filePath : null;
DebugState::replace([]);
if ($this->enabled && $this->filePath !== null) {
$this->persist();
}
}
public function enabled(): bool
{
return $this->enabled;
}
public function add(string $event, array $context = []): void
{
if (!$this->enabled) {
return;
}
$this->entries[] = [
'time' => date('c'),
'event' => $event,
'context' => $context,
];
DebugState::replace($this->entries);
$this->persist();
}
public function export(): array
{
return $this->enabled ? $this->entries : [];
}
private function persist(): void
{
if (!$this->enabled || $this->filePath === null) {
return;
}
$directory = dirname($this->filePath);
if (!is_dir($directory)) {
@mkdir($directory, 0775, true);
}
@file_put_contents($this->filePath, json_encode($this->entries, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT));
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class Http
{
public static function json(array $payload, int $statusCode = 200): never
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
public static function input(): array
{
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '[]', true);
return is_array($data) ? $data : [];
}
return $_POST;
}
}

View File

@@ -269,32 +269,8 @@
padding: 0 6px;
}
.modal {
position: fixed;
inset: 0;
background: rgba(10, 14, 24, 0.55);
display: none;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 40;
}
.modal.is-open { display: flex; }
.modal-card {
width: min(1100px, 96vw);
max-height: 90vh;
overflow: auto;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 16px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modal-actions {
display: inline-flex;

View File

@@ -0,0 +1,14 @@
{
"eyebrow": "Modul",
"title": "Pi Control",
"description": "Verwaltung und Steuerung von Raspberry Pis per SSH, Presets und Konsole.",
"actions": [
{ "label": "Setup", "href": "/modules/setup/pi_control", "variant": "secondary" }
],
"tabs": [
{ "label": "Ueberblick", "href": "/module/pi_control", "match_prefixes": ["/module/pi_control"] },
{ "label": "Hosts", "href": "/module/pi_control/hosts", "match_prefixes": ["/module/pi_control/hosts"] },
{ "label": "Befehle", "href": "/module/pi_control/commands", "match_prefixes": ["/module/pi_control/commands"] },
{ "label": "Konsole", "href": "/module/pi_control/console", "match_prefixes": ["/module/pi_control/console"] }
]
}

View File

@@ -2,28 +2,6 @@
"title": "Pi Control",
"version": "0.1.0",
"description": "Verwaltung und Steuerung von Raspberry Pis (SSH/Commands/Presets).",
"menu": [
{ "label": "Übersicht", "href": "/module/pi_control" },
{ "label": "Konsole", "href": "/module/pi_control/console" },
{
"label": "Settings",
"children": [
{ "label": "Hosts", "href": "/module/pi_control/hosts" },
{ "label": "Befehle", "href": "/module/pi_control/commands" }
]
}
],
"sidebar": {
"enabled": true,
"collapsible": true,
"default": "collapsed",
"items": [
{ "label": "Übersicht", "href": "/module/pi_control" },
{ "label": "Konsole", "href": "/module/pi_control/console" },
{ "label": "Hosts", "href": "/module/pi_control/hosts" },
{ "label": "Befehle", "href": "/module/pi_control/commands" }
]
},
"setup": {
"fields": [
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Base-DB genutzt." },
@@ -34,7 +12,7 @@
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
{ "name": "ttyd_url", "label": "ttyd URL", "type": "text", "required": false, "help": "z.B. https://staging.nexus.int.kusche.berlin/ttyd" },
{ "name": "ttyd_url", "label": "ttyd URL", "type": "text", "required": false, "help": "z.B. https://staging.nexus.kusche.berlin/ttyd" },
{ "name": "terminal_token_ttl", "label": "Token TTL (Minuten)", "type": "number", "required": false, "help": "Gültigkeit der Konsole-Token, z.B. 10" },
{ "name": "terminal_shared_secret", "label": "Terminal Shared Secret", "type": "password", "required": false, "help": "Zusätzliche Absicherung für terminal_info (Header X-Terminal-Secret)" },
{ "name": "terminal_tmux_session", "label": "tmux Session-Name", "type": "text", "required": false, "help": "Session-Name für bestehende Konsole (Standard: nexus)" },

View File

@@ -79,26 +79,30 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY COALESCE(sort_order, id) ASC, id ASC')->fetchAll(PDO::FETCH_ASSOC);
?>
<div class="card">
<div class="pill">Pi Control</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-top:.75rem;">
<h1 style="margin:0;">Befehle</h1>
<button class="cta-button" type="button" data-command-new>+ Neuer Befehl</button>
</div>
<p class="muted">Verwalte vordefinierte SSH-Befehle.</p>
<?php if ($error): ?>
<div class="card notice-card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114;">
<?= e($error) ?>
<?= module_shell_header('pi_control', [
'title' => 'Befehle',
]) ?>
<div class="module-flow">
<section class="module-box">
<div class="module-box-head">
<div>
<h2 class="module-box-title">Befehle</h2>
<p>Verwalte vordefinierte SSH-Befehle.</p>
</div>
<button class="module-button module-button--primary" type="button" data-command-new>+ Neuer Befehl</button>
</div>
<?php elseif ($notice): ?>
<div class="card notice-card" style="margin-top:1rem; border-color:var(--accent-2);">
<?= e($notice) ?>
</div>
<?php endif; ?>
<div class="grid" style="margin-top:1rem;">
<div class="card" style="background:var(--panel-2);">
<?php if ($error): ?>
<div class="setup-db-message setup-db-message--error" style="margin-top:16px;">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="setup-db-message setup-db-message--success" style="margin-top:16px;">
<?= e($notice) ?>
</div>
<?php endif; ?>
<div class="module-box" style="margin-top:16px;">
<strong>Vorhandene Befehle</strong>
<?php if (!$commands): ?>
<div class="muted" style="margin-top:.75rem;">Keine Befehle vorhanden.</div>
@@ -142,7 +146,7 @@ $commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY COALE
<p class="muted" style="margin-top:.5rem;">Reihenfolge per Drag & Drop ändern.</p>
<?php endif; ?>
</div>
</div>
</section>
</div>
<div class="modal" data-command-modal aria-hidden="true">
@@ -174,3 +178,4 @@ $commands = $pdo->query('SELECT * FROM ' . $table('commands') . ' ORDER BY COALE
</form>
</div>
</div>
<?= module_shell_footer() ?>

View File

@@ -418,26 +418,32 @@ function sendToActiveConsole(array $host, string $command, bool $strictHostKey):
return [false, $msg !== '' ? $msg : 'Befehl konnte nicht gesendet werden.'];
}
?>
<div class="card">
<div class="pill">Pi Control</div>
<h1 style="margin-top:.75rem;">Konsole</h1>
<p class="muted">Wähle einen Host und führe einen Befehl aus.</p>
<?php if ($error): ?>
<div class="card notice-card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114;">
<?= e($error) ?>
<?= module_shell_header('pi_control', [
'title' => 'Konsole',
]) ?>
<div class="module-flow">
<section class="module-box">
<div class="module-box-head">
<div>
<h2 class="module-box-title">Konsole</h2>
<p>Wähle einen Host und führe einen Befehl aus.</p>
</div>
</div>
<?php elseif ($notice): ?>
<div class="card notice-card" style="margin-top:1rem; border-color:var(--accent-2);">
<?= e($notice) ?>
</div>
<?php endif; ?>
<div class="grid" style="margin-top:1rem;">
<div class="card form-card" style="background:var(--panel-2);">
<?php if ($error): ?>
<div class="setup-db-message setup-db-message--error" style="margin-top:16px;">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="setup-db-message setup-db-message--success" style="margin-top:16px;">
<?= e($notice) ?>
</div>
<?php endif; ?>
<div class="module-box" style="margin-top:16px;">
<strong>Live-Konsole</strong>
<div class="card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114; display:none;" data-console-error></div>
<div class="card" style="margin-top:1rem; border-color:var(--accent-2); display:none;" data-console-notice></div>
<div class="setup-db-message setup-db-message--error" style="margin-top:1rem; display:none;" data-console-error></div>
<div class="setup-db-message setup-db-message--success" style="margin-top:1rem; display:none;" data-console-notice></div>
<form method="post" class="form-grid" style="margin-top:.75rem;" data-console-form>
<label class="form-field">
<span class="muted">Host</span>
@@ -516,5 +522,6 @@ function sendToActiveConsole(array $host, string $command, bool $strictHostKey):
</div>
</div>
</div>
</div>
</section>
</div>
<?= module_shell_footer() ?>

View File

@@ -333,29 +333,34 @@ function hostAuthOk(array $host, bool $strictHostKey): bool
return $exitCode === 0;
}
?>
<div class="card">
<div class="pill">Pi Control</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-top:.75rem;">
<h1 style="margin:0;">Hosts</h1>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="nav-link" type="button" data-host-check-all>Alle Hosts prüfen</button>
<button class="cta-button" type="button" data-host-new>+ Neuer Host</button>
<?= module_shell_header('pi_control', [
'title' => 'Hosts',
]) ?>
<div class="module-flow">
<section class="module-box">
<div class="module-box-head">
<div>
<h2 class="module-box-title">Hosts</h2>
<p>Verwalte die Raspberry Pis, die du steuern möchtest.</p>
</div>
<div style="display:flex; gap:10px; flex-wrap:wrap;">
<button class="module-button module-button--secondary module-button--small" type="button" data-host-check-all>Alle Hosts prüfen</button>
<button class="module-button module-button--primary" type="button" data-host-new>+ Neuer Host</button>
</div>
</div>
</div>
<p class="muted">Verwalte die Raspberry Pis, die du steuern möchtest.</p>
<?php if ($error): ?>
<div class="card notice-card" style="margin-top:1rem; border-color:#ffb4a8; background:#fff5f3; color:#7a2114;">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="card notice-card" style="margin-top:1rem; border-color:var(--accent-2);">
<?= e($notice) ?>
</div>
<?php endif; ?>
<?php if ($error): ?>
<div class="setup-db-message setup-db-message--error" style="margin-top:16px;">
<?= e($error) ?>
</div>
<?php elseif ($notice): ?>
<div class="setup-db-message setup-db-message--success" style="margin-top:16px;">
<?= e($notice) ?>
</div>
<?php endif; ?>
<div class="grid" style="margin-top:1rem;">
<div class="card form-card" style="background:var(--panel-2);">
<div class="module-box-grid module-box-grid--panels" style="margin-top:16px;">
<div class="module-box form-card">
<strong>Registrierte Hosts</strong>
<?php if (!$hosts): ?>
<div class="muted" style="margin-top:.75rem;">Keine Hosts vorhanden.</div>
@@ -416,7 +421,9 @@ function hostAuthOk(array $host, bool $strictHostKey): bool
</div>
<?php endif; ?>
</div>
</div>
</div>
</div>
</section>
</div>
<div class="modal" data-host-modal aria-hidden="true">
@@ -469,3 +476,4 @@ function hostAuthOk(array $host, bool $strictHostKey): bool
</form>
</div>
</div>
<?= module_shell_footer() ?>

Some files were not shown because too many files have changed in this diff Show More