Compare commits
612 Commits
mytischten
...
multifunct
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
542fae089c | ||
|
|
111b37b287 | ||
|
|
8ef1f49118 | ||
|
|
8d1bce2ff9 | ||
|
|
5423f24969 | ||
|
|
2fa7f9b537 | ||
|
|
16465fafc8 | ||
|
|
f0142d5682 | ||
|
|
5194d4582f | ||
|
|
5727404f88 | ||
|
|
0ff67dae80 | ||
|
|
359527eb5b | ||
|
|
9d9481ac76 | ||
|
|
25f3802d66 | ||
|
|
88d852719d | ||
|
|
d6e51cd8d2 | ||
|
|
7ba25b2572 | ||
|
|
390d1a8897 | ||
|
|
7942e6108a | ||
|
|
211420444e | ||
|
|
4cf0ee2be8 | ||
|
|
e57cdc6ad8 | ||
|
|
2e7cf0c28d | ||
|
|
75a17d42b5 | ||
|
|
6adf6b73e8 | ||
|
|
84c63bc7d2 | ||
|
|
0f946e9514 | ||
|
|
3814d9f178 | ||
|
|
6aa544a1de | ||
|
|
1c5457ae8c | ||
|
|
37de4e0cb5 | ||
|
|
ecfd3bf851 | ||
|
|
b9bbd45ae9 | ||
|
|
197f06989f | ||
|
|
f9ab3d9932 | ||
|
|
5dfdcb63bc | ||
|
|
040e758044 | ||
|
|
697e67d46e | ||
|
|
6c7ae6860b | ||
|
|
f8f1c797e7 | ||
|
|
40bd5e0745 | ||
|
|
d955577d9a | ||
|
|
3d0c298af7 | ||
|
|
669f3f0365 | ||
|
|
eb54b4f7cf | ||
|
|
320010b94e | ||
|
|
2f15827658 | ||
|
|
bf3e1af084 | ||
|
|
83294406a4 | ||
|
|
95a3e9438a | ||
|
|
56ebffce69 | ||
|
|
e0196a6617 | ||
|
|
2f3f4fb275 | ||
|
|
3d1dfe9a4c | ||
|
|
6ef1d79a5f | ||
|
|
7981371136 | ||
|
|
004801b1a6 | ||
|
|
9be5f50ede | ||
|
|
ea46a6d4f9 | ||
|
|
61b1f27e5e | ||
|
|
54d9b9fc86 | ||
|
|
57468f1efb | ||
|
|
bea5facb7d | ||
|
|
1e23171370 | ||
|
|
48f71b9df1 | ||
|
|
27f8af559b | ||
|
|
be9caf92a4 | ||
|
|
808493c06e | ||
|
|
2b16cdff53 | ||
|
|
9622e9bdb7 | ||
|
|
d1fb6d4e74 | ||
|
|
810ad07b96 | ||
|
|
912bf88c3f | ||
|
|
93796cecd6 | ||
|
|
940f77e29b | ||
|
|
25c3b90972 | ||
|
|
95bfbf86a4 | ||
|
|
4bef76d6dd | ||
|
|
01aaca5928 | ||
|
|
92aebaadb1 | ||
|
|
d84438f52c | ||
|
|
715980d49c | ||
|
|
cb7a027462 | ||
|
|
9d58f24201 | ||
|
|
a7f967a730 | ||
|
|
87addd0c65 | ||
|
|
5ef7447200 | ||
|
|
725ede8dbf | ||
|
|
2339e12410 | ||
|
|
be9d26e51e | ||
|
|
5f07a3e3d6 | ||
|
|
630c202fd2 | ||
|
|
3462a5497c | ||
|
|
6ea92bef49 | ||
|
|
be1108511f | ||
|
|
37c3ffa899 | ||
|
|
1f477a4458 | ||
|
|
1d67b68b44 | ||
|
|
41bbf81958 | ||
|
|
c8dedb10cc | ||
|
|
894c84b94a | ||
|
|
9c738e8063 | ||
|
|
69418d8f9a | ||
|
|
a4f6b9b8b3 | ||
|
|
1dd7bb24ea | ||
|
|
5fa34637ba | ||
|
|
4cfc82c7aa | ||
|
|
3ce1702367 | ||
|
|
58012e0a44 | ||
|
|
c62e91d997 | ||
|
|
7d483ebf02 | ||
|
|
2dff5221e3 | ||
|
|
45c701b149 | ||
|
|
951842c824 | ||
|
|
4f8e2fee89 | ||
|
|
757507f212 | ||
|
|
003b8fd3bc | ||
|
|
bbd9f08e97 | ||
|
|
30994adee8 | ||
|
|
27f8186d91 | ||
|
|
c1b8b2c665 | ||
|
|
43dbd5442a | ||
|
|
4a53801a54 | ||
|
|
50fa07d0b7 | ||
|
|
a94ad55a2d | ||
|
|
cf57ade3f0 | ||
|
|
8503c9a79d | ||
|
|
68b8455340 | ||
|
|
9454761e34 | ||
|
|
fd02655be4 | ||
|
|
c76b5f32e2 | ||
|
|
2d43967c81 | ||
|
|
59034ff397 | ||
|
|
8b9a4b7bca | ||
|
|
b62b61505c | ||
|
|
49df0cc381 | ||
|
|
5eff1d63aa | ||
|
|
cb7830571b | ||
|
|
0df8674353 | ||
|
|
2043942e02 | ||
|
|
e4be66b469 | ||
|
|
0554a68eb7 | ||
|
|
92d29dc64e | ||
|
|
2c11f6b975 | ||
|
|
68f14eb5d6 | ||
|
|
7fdbe85d3c | ||
|
|
adefb120c0 | ||
|
|
9d023b534d | ||
|
|
a7d3e5b094 | ||
|
|
ddb3025b84 | ||
|
|
85cf0d0ddc | ||
|
|
37f9ba83aa | ||
|
|
32ba433008 | ||
|
|
c7d51efb5d | ||
|
|
c2a31d3b24 | ||
|
|
64090d9ff0 | ||
|
|
02f1bed452 | ||
|
|
e55ee0f88a | ||
|
|
128b13c679 | ||
|
|
65d464eab9 | ||
|
|
5a9e5913a6 | ||
|
|
80f8934bc8 | ||
|
|
36690980b7 | ||
|
|
cc6d1f6ebe | ||
|
|
cbc5054f1f | ||
|
|
542d741428 | ||
|
|
9d01ab6ce1 | ||
|
|
269f648ad7 | ||
|
|
76cc8d9c30 | ||
|
|
b13d33c72c | ||
|
|
dc15b48b80 | ||
|
|
c441d4a049 | ||
|
|
f94914703a | ||
|
|
0bb636b91d | ||
|
|
bf40927efb | ||
|
|
9340ee3509 | ||
|
|
eea3372057 | ||
|
|
563a7e8dde | ||
|
|
79adad9564 | ||
|
|
b7b40f5a9b | ||
|
|
414c5ccee5 | ||
|
|
6320c5ca72 | ||
|
|
483d5d2bc7 | ||
|
|
46812a0c14 | ||
|
|
1c1f05400f | ||
|
|
c325a5a4d6 | ||
|
|
b16743d27d | ||
|
|
afe51f399c | ||
|
|
f1cfd1147d | ||
|
|
44b2b9fdbf | ||
|
|
d0b6e6f0ac | ||
|
|
bf770291f6 | ||
|
|
2347dccafe | ||
|
|
43f96b2491 | ||
|
|
e1dccb6ff0 | ||
|
|
adc8857e29 | ||
|
|
a030e07b46 | ||
|
|
ad09a45b17 | ||
|
|
595e2eb141 | ||
|
|
4251dd6989 | ||
|
|
dba290c1d4 | ||
|
|
8776c01e47 | ||
|
|
fb39aa0e8b | ||
|
|
f49250e988 | ||
|
|
555e36ea39 | ||
|
|
2f82886ad6 | ||
|
|
36ed320893 | ||
|
|
9d13a2e211 | ||
|
|
7cb6b66971 | ||
|
|
312a1d9d8a | ||
|
|
bfd6068c5c | ||
|
|
ab466adde7 | ||
|
|
a77926838b | ||
|
|
c0efd56c9c | ||
|
|
79cec02c1a | ||
|
|
c1cf903196 | ||
|
|
6a18d4ce0f | ||
|
|
cf376a8f68 | ||
|
|
5c9901209c | ||
|
|
8750ac6d65 | ||
|
|
2919ee3764 | ||
|
|
7196fae28e | ||
|
|
2ddb63b932 | ||
|
|
8fc754c235 | ||
|
|
139d169fcc | ||
|
|
de1382b57e | ||
|
|
08095ce22e | ||
|
|
9c30cd181c | ||
|
|
c1f45b2b98 | ||
|
|
e5e1ccba82 | ||
|
|
3ea9cdd611 | ||
|
|
1398e8911a | ||
|
|
c40ee04e9e | ||
|
|
dee96a9445 | ||
|
|
4484f122d2 | ||
|
|
df95753f4d | ||
|
|
79ce79db8c | ||
|
|
59d7c3559c | ||
|
|
71ac054d48 | ||
|
|
c472bb1fdc | ||
|
|
0e4d1707fd | ||
|
|
4f3a1829ca | ||
|
|
bacc6b994d | ||
|
|
c5a88324c3 | ||
|
|
cab06f9ad6 | ||
|
|
c87cebba36 | ||
|
|
a3148a3781 | ||
|
|
c13f426b3d | ||
|
|
ea6acd8c6c | ||
|
|
c324da3938 | ||
|
|
f35e0510e7 | ||
|
|
13379d6b24 | ||
|
|
055dbf115c | ||
|
|
78f1196f0a | ||
|
|
ee2b12f6d0 | ||
|
|
436973e47e | ||
|
|
05ab872f77 | ||
|
|
e4e7f521e2 | ||
|
|
dd93755e6b | ||
|
|
27665a45df | ||
|
|
637bacf70f | ||
|
|
d5fe531664 | ||
|
|
3df8f6fd81 | ||
|
|
e26bc22e19 | ||
|
|
985c9074bd | ||
|
|
d33e9a94cf | ||
|
|
6ab6319256 | ||
|
|
e1e8b5f4a4 | ||
|
|
cf8cf17dc7 | ||
|
|
12bba26ff1 | ||
|
|
4e81a1c4a7 | ||
|
|
b2017b7365 | ||
|
|
b3bbca3887 | ||
|
|
0ee9e486b5 | ||
|
|
00e058a665 | ||
|
|
e5a0dfdddc | ||
|
|
83f4e1c45e | ||
|
|
f0477b1023 | ||
|
|
07370bfcef | ||
|
|
f031485bd4 | ||
|
|
e22e3257ef | ||
|
|
76f1b1a12f | ||
|
|
6007e70b9d | ||
|
|
d7935cc1e2 | ||
|
|
b470e728ed | ||
|
|
d09de49018 | ||
|
|
8892392bf2 | ||
|
|
26acb588e1 | ||
|
|
566361e46a | ||
|
|
1191636d92 | ||
|
|
526eca8b97 | ||
|
|
af6048b289 | ||
|
|
5605cd6189 | ||
|
|
84bbcb0f87 | ||
|
|
f9a63a13ce | ||
|
|
2a7694617b | ||
|
|
6ff672c5f1 | ||
|
|
a2e9e5e510 | ||
|
|
5b0a3baa21 | ||
|
|
9cb9ff511c | ||
|
|
e079fe4827 | ||
|
|
2c8cad52a7 | ||
|
|
12184c2f72 | ||
|
|
1f94c273ae | ||
|
|
e333a54025 | ||
|
|
a86c05eb66 | ||
|
|
c2dbf0a12d | ||
|
|
a8470145a0 | ||
|
|
2871b79b04 | ||
|
|
503ff90dfa | ||
|
|
673a3afbb5 | ||
|
|
10e6d74d93 | ||
|
|
3fc1760b2c | ||
|
|
d12b9daf87 | ||
|
|
75cc2df06b | ||
|
|
7454a274a1 | ||
|
|
380709c29c | ||
|
|
c6f8b4dd74 | ||
|
|
02c947b0e3 | ||
|
|
c3366313d6 | ||
|
|
b1e184c4c2 | ||
|
|
3e05bdab51 | ||
|
|
fde6ba55d2 | ||
|
|
19410a0ee2 | ||
|
|
28db204aba | ||
|
|
47a815dd71 | ||
|
|
14dc654145 | ||
|
|
025ad68cf3 | ||
|
|
89f30f76f5 | ||
|
|
85c26bc80d | ||
|
|
6cdcbfe0db | ||
|
|
7e1b09fa97 | ||
|
|
18a191f686 | ||
|
|
e21b50fc38 | ||
|
|
23caeddf9e | ||
|
|
663125670e | ||
|
|
515e04d1e3 | ||
|
|
bf082ea995 | ||
|
|
67fc5d45e1 | ||
|
|
30e3f4f321 | ||
|
|
c4e237cfca | ||
|
|
fea84e210a | ||
|
|
e94a12cd20 | ||
| 438029a3a4 | |||
| c58491c97a | |||
| 1d9b9dbc45 | |||
| dc791dc33d | |||
| 57fbbff353 | |||
| b00a35af30 | |||
|
|
dd0f29124c | ||
|
|
dc084806ab | ||
|
|
4b4c48a50f | ||
|
|
65acc9e0d5 | ||
|
|
13cd55c051 | ||
|
|
9bf37399d5 | ||
|
|
047b1801b3 | ||
|
|
945ec0d48c | ||
|
|
e83bc250a8 | ||
|
|
0c28b12978 | ||
|
|
5aa11151cf | ||
|
|
a651113dee | ||
|
|
bf0d5b0935 | ||
|
|
6acdcfa5c3 | ||
|
|
dc2c60cefe | ||
|
|
bdbbb88be9 | ||
|
|
e6146b8f5a | ||
|
|
f7a799ea7f | ||
|
|
b74cb30cf6 | ||
|
|
0d2dfd9a07 | ||
|
|
61e5efadb8 | ||
|
|
88d050392f | ||
|
|
08b0be78ad | ||
|
|
b0e610f3ab | ||
|
|
0285c05fa6 | ||
|
|
5d4f2ebd4b | ||
|
|
bfa908ac9a | ||
|
|
9592459348 | ||
|
|
47f53ee3fd | ||
|
|
c22f4016cc | ||
|
|
2458ba2d37 | ||
|
|
6eb42812fd | ||
|
|
938ce4d991 | ||
|
|
cb6e84945b | ||
|
|
8c6be234c6 | ||
|
|
fe160420c1 | ||
|
|
167e3ba3ec | ||
|
|
9455b5d65a | ||
|
|
e6627a897e | ||
|
|
71fc85427b | ||
|
|
76597a4360 | ||
|
|
4f9761efb0 | ||
|
|
51e47cf9f9 | ||
|
|
0525f7908d | ||
|
|
a4d89374b7 | ||
|
|
de907df092 | ||
|
|
b906ac64b3 | ||
|
|
b7bbb92f86 | ||
|
|
6896484e9e | ||
|
|
9cc9db3a5a | ||
|
|
1c99fb30a1 | ||
|
|
2782661206 | ||
|
|
d10b663dc1 | ||
|
|
9baa6bae01 | ||
|
|
945fd85e39 | ||
|
|
5b04ed7904 | ||
|
|
de36a8ce2b | ||
|
|
903b036a63 | ||
|
|
5f3b6200ec | ||
|
|
eff211856f | ||
|
|
a81c3453b5 | ||
|
|
56c708d3a0 | ||
|
|
062bddcf52 | ||
|
|
4f98c782f3 | ||
|
|
3ea2907d08 | ||
|
|
ba5d6b14a8 | ||
|
|
004a94404a | ||
|
|
5ddf998672 | ||
|
|
baf5bda6f2 | ||
|
|
572de5f7d4 | ||
|
|
37893474b1 | ||
|
|
f437747664 | ||
|
|
22e9750e5d | ||
|
|
bd95f77131 | ||
|
|
bbdc923950 | ||
|
|
3e5ddd8a05 | ||
|
|
f4e5cf2edb | ||
|
|
44dba70aac | ||
|
|
7698d87ba0 | ||
|
|
201d5e9214 | ||
|
|
c21544d9b6 | ||
|
|
6167116630 | ||
|
|
1bb5f61b57 | ||
|
|
1535c8795b | ||
|
|
cb2d7d3936 | ||
|
|
5b4a5ba501 | ||
|
|
90b5f8d63d | ||
|
|
1ff3d9d1a6 | ||
|
|
df6fb23132 | ||
|
|
1e86b821e8 | ||
|
|
5923ef8bba | ||
|
|
cd8f40aa9d | ||
|
|
d392ccddd5 | ||
|
|
4a83e5c159 | ||
|
|
911c07e522 | ||
|
|
cd89c68a69 | ||
|
|
f1321b18bb | ||
|
|
54ce09e9a9 | ||
|
|
7a9e856961 | ||
|
|
fd4b47327f | ||
|
|
3a26f10110 | ||
|
|
ce2bda37ac | ||
|
|
5dda346fd7 | ||
|
|
28c92b66af | ||
|
|
d08835e206 | ||
|
|
3334d76688 | ||
|
|
d48cc4385f | ||
|
|
9b8dcd8561 | ||
|
|
2b06a8dd10 | ||
|
|
58e773e51e | ||
|
|
8d17cad299 | ||
|
|
156f4d6921 | ||
|
|
e27a4d960d | ||
|
|
c589c11607 | ||
|
|
0caa31e3eb | ||
|
|
fff5d404f5 | ||
|
|
7aff827711 | ||
|
|
cf3bd3cd6d | ||
|
|
4c1a919d17 | ||
|
|
eb9748dd89 | ||
|
|
e295657621 | ||
|
|
da351b40b2 | ||
|
|
3c64e2e92d | ||
|
|
45381707ea | ||
|
|
b166f7c7d5 | ||
|
|
1f20737721 | ||
|
|
8ef4e1dc9d | ||
|
|
98c50bc03a | ||
|
|
7b28eb04ac | ||
|
|
ed15137003 | ||
|
|
2bf5c0137b | ||
|
|
22e6913005 | ||
|
|
f7eff0bcb7 | ||
|
|
45c90280f8 | ||
|
|
a4890f241b | ||
|
|
684409491f | ||
|
|
15b88f8177 | ||
|
|
20f204e70b | ||
|
|
b8191e41ee | ||
|
|
b906a218a5 | ||
|
|
6c3b46c037 | ||
|
|
3f1018ef93 | ||
|
|
620b065ac8 | ||
|
|
4cfa03834e | ||
|
|
d94238f6df | ||
|
|
eb37532de2 | ||
|
|
d79e71d6d7 | ||
|
|
f0e3c6a717 | ||
|
|
b2d47c7a37 | ||
|
|
498742e6ae | ||
|
|
94aab93f7d | ||
|
|
eba8ba30aa | ||
|
|
9d24c6ae7b | ||
|
|
02b8ba3d0a | ||
|
|
fc7b70b307 | ||
|
|
9cdbd60a23 | ||
|
|
5a4553a8a0 | ||
|
|
98637eec00 | ||
|
|
106c63890e | ||
|
|
f6b8388819 | ||
|
|
f1a29e4111 | ||
|
|
c9d82827ff | ||
|
|
75242f63fc | ||
|
|
2f161d1eb5 | ||
|
|
cad76edaad | ||
|
|
d0a8ef5ff2 | ||
|
|
a0d12a895e | ||
|
|
ad99787f75 | ||
|
|
c05cfbbe38 | ||
|
|
1f47a11091 | ||
|
|
5bba9522b3 | ||
|
|
5bdcd946cf | ||
|
|
6500493314 | ||
|
|
d0e3ae3610 | ||
|
|
8db827adeb | ||
|
|
d40eea5e46 | ||
|
|
a3ed130211 | ||
|
|
e8766b919a | ||
|
|
76ee9ee742 | ||
|
|
84ff4e126e | ||
|
|
23708b99b5 | ||
|
|
acf2cf00bd | ||
|
|
bb3f0f3a03 | ||
|
|
f4411a4ee5 | ||
|
|
e32871a005 | ||
|
|
a8318c74cf | ||
|
|
7e85926aa1 | ||
|
|
91fc3e9d13 | ||
|
|
6a333f198d | ||
|
|
7ea719c178 | ||
|
|
e23d9fbc44 | ||
|
|
3f2b92d886 | ||
|
|
89329607dc | ||
|
|
c2b8656783 | ||
|
|
0b1e745f03 | ||
|
|
7a35a0a1d3 | ||
|
|
bb2164f666 | ||
|
|
d16f250f80 | ||
|
|
c18b70c6f6 | ||
|
|
67f4f728fe | ||
|
|
b69684ad03 | ||
|
|
4ff021a85c | ||
|
|
f1b37d131f | ||
|
|
48bbc8015b | ||
|
|
56f0ce2f27 | ||
|
|
2dd5e28cbc | ||
|
|
c74217f6d8 | ||
|
|
01bbb85485 | ||
|
|
24aaa9c150 | ||
|
|
ea3cca563b | ||
|
|
e0d56ddadd | ||
|
|
32f06d7399 | ||
|
|
36bf99c013 | ||
|
|
7549fb5730 | ||
|
|
1517d83f6c | ||
|
|
993e12d4a5 | ||
|
|
806cb527d4 | ||
|
|
7e9d2d2c4f | ||
|
|
ec9b92000e | ||
|
|
d110900e85 | ||
|
|
cd3c3502f6 | ||
|
|
ccce9bffac | ||
|
|
f1ba25f9f5 | ||
|
|
548f51ac54 | ||
|
|
946e4fce1e | ||
|
|
40dcd0e54c | ||
|
|
bd338b86df | ||
|
|
1d4aa43b02 | ||
|
|
cc08f4ba43 | ||
|
|
d0ccaa9e54 | ||
|
|
dc0eff4e4c | ||
|
|
db9e404372 | ||
|
|
60ac89636e | ||
|
|
2b1365339e | ||
|
|
0cf2351c79 | ||
|
|
5c32fad34e | ||
|
|
7f0b681e88 | ||
|
|
e823af064e | ||
|
|
3bc6a465a2 | ||
|
|
e8b6578bd4 | ||
|
|
280c1432b7 | ||
|
|
fd82efdcee | ||
|
|
e354d82969 | ||
|
|
049ee56571 | ||
|
|
c6bb534a0d | ||
|
|
a0fdf256e7 | ||
|
|
d23a9f086c | ||
|
|
ac727c6c5b | ||
|
|
4b1a046149 | ||
|
|
cc964da9cf | ||
|
|
dbede48d4f | ||
|
|
6cd3c3a020 | ||
|
|
7ecbef806d | ||
|
|
1c70ca97bb | ||
|
|
a6493990d3 | ||
|
|
f8f4d23c4e | ||
|
|
1ef1711eea | ||
|
|
85981a880d | ||
|
|
84503b6404 | ||
|
|
bcc3ce036d | ||
|
|
0fe0514660 | ||
|
|
431ec861ba | ||
|
|
648b608036 |
53
.gitea/workflows/deploy.yml
Normal file
53
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Deploy tt-tagebuch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
SSH_HOST: ${{ vars.PROD_HOST }}
|
||||
SSH_PORT: ${{ vars.PROD_PORT }}
|
||||
SSH_USER: ${{ vars.PROD_USER }}
|
||||
|
||||
steps:
|
||||
- name: Show resolved non-secret config
|
||||
run: |
|
||||
echo "SSH_HOST=$SSH_HOST"
|
||||
echo "SSH_PORT=$SSH_PORT"
|
||||
echo "SSH_USER=$SSH_USER"
|
||||
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s' "${{ secrets.PROD_SSH_KEY_B64 }}" | base64 -d > ~/.ssh/id_deploy
|
||||
chmod 600 ~/.ssh/id_deploy
|
||||
ssh-keygen -l -f ~/.ssh/id_deploy
|
||||
ssh-keyscan -p "$SSH_PORT" "$SSH_HOST" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Test SSH connection
|
||||
run: |
|
||||
set -e
|
||||
ssh -i ~/.ssh/id_deploy \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=10 \
|
||||
-p "$SSH_PORT" \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"echo SSH OK"
|
||||
|
||||
- name: Run deployment script
|
||||
run: |
|
||||
set -e
|
||||
ssh -i ~/.ssh/id_deploy \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=10 \
|
||||
-p "$SSH_PORT" \
|
||||
"$SSH_USER@$SSH_HOST" \
|
||||
"/usr/local/bin/actualize-tagebuch.sh"
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -6,3 +6,15 @@ frontend/.env
|
||||
backend/.env
|
||||
|
||||
backend/images/*
|
||||
backend/backend-debug.log
|
||||
backend/*.log
|
||||
backend/.env.local
|
||||
|
||||
mobile-app/.gradle/
|
||||
mobile-app/.idea/
|
||||
mobile-app/.kotlin/
|
||||
mobile-app/build/
|
||||
mobile-app/composeApp/build/
|
||||
mobile-app/shared/build/
|
||||
mobile-app/local.properties
|
||||
mobile-app/signing.properties
|
||||
|
||||
86
CHECK_SERVER.md
Normal file
86
CHECK_SERVER.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Server-Prüfung: i18n-Fixes
|
||||
|
||||
## Lokale Prüfung (bereits durchgeführt)
|
||||
|
||||
✅ Alle Dateien sind lokal korrekt:
|
||||
- `TeamManagementView.vue` - Alle `$t()` durch `t()` ersetzt, `t` im return Statement
|
||||
- `PermissionsView.vue` - Alle `$t()` durch `t()` ersetzt, `t` im return Statement
|
||||
- `LogsView.vue` - Alle `$t()` durch `t()` ersetzt, `t` im return Statement
|
||||
- `SeasonSelector.vue` - Bereits korrekt
|
||||
|
||||
## Server-Prüfung
|
||||
|
||||
### 1. Prüfskript auf den Server kopieren
|
||||
|
||||
```bash
|
||||
# Vom lokalen Rechner aus:
|
||||
scp check-i18n-fixes.sh rv2756:/var/www/tt-tagebuch.de/
|
||||
```
|
||||
|
||||
### 2. Auf dem Server ausführen
|
||||
|
||||
```bash
|
||||
# Auf dem Server:
|
||||
cd /var/www/tt-tagebuch.de
|
||||
chmod +x check-i18n-fixes.sh
|
||||
./check-i18n-fixes.sh
|
||||
```
|
||||
|
||||
### 3. Falls Dateien nicht aktualisiert sind
|
||||
|
||||
```bash
|
||||
# Auf dem Server:
|
||||
cd /var/www/tt-tagebuch.de
|
||||
git pull origin main
|
||||
cd backend
|
||||
npm install # Erstellt automatisch den Frontend-Build (via postinstall script)
|
||||
```
|
||||
|
||||
### 4. Backend neu starten (falls nötig)
|
||||
|
||||
```bash
|
||||
# Falls als systemd-Service:
|
||||
sudo systemctl restart tt-tagebuch
|
||||
|
||||
# Oder falls als PM2-Prozess:
|
||||
pm2 restart tt-tagebuch-backend
|
||||
```
|
||||
|
||||
## Erwartete Ergebnisse
|
||||
|
||||
Das Prüfskript sollte folgende Ausgabe zeigen:
|
||||
|
||||
```
|
||||
1. TeamManagementView.vue:
|
||||
✓ Enthält 'const t = (key, params) => i18n.global.t'
|
||||
✓ Enthält keine $t() Aufrufe mehr
|
||||
✓ 't' ist im return Statement enthalten
|
||||
|
||||
2. PermissionsView.vue:
|
||||
✓ Enthält 'const t = (key, params) => i18n.global.t'
|
||||
✓ Enthält keine $t() Aufrufe mehr
|
||||
✓ 't' ist im return Statement enthalten
|
||||
|
||||
3. LogsView.vue:
|
||||
✓ Enthält 'const t = (key, params) => i18n.global.t'
|
||||
✓ Enthält keine $t() Aufrufe mehr
|
||||
✓ 't' ist im return Statement enthalten
|
||||
|
||||
4. SeasonSelector.vue:
|
||||
✓ Enthält 'const t = (key, params) => i18n.global.t'
|
||||
✓ Enthält keine $t() Aufrufe mehr
|
||||
```
|
||||
|
||||
## Commits, die auf den Server müssen
|
||||
|
||||
Die folgenden Commits müssen auf dem Server sein:
|
||||
|
||||
- `b0e610f` - Fix: Replace all $t() calls with t() in PermissionsView and LogsView templates
|
||||
- `0285c05` - Fix: Replace all $t() calls with t() in TeamManagementView template
|
||||
- `5d4f2eb` - Update localization handling in TeamManagementView
|
||||
|
||||
Prüfe mit:
|
||||
```bash
|
||||
git log --oneline -5
|
||||
```
|
||||
|
||||
0
CODEx_MIGRATION_RULES.md
Normal file
0
CODEx_MIGRATION_RULES.md
Normal file
191
DEPLOYMENT_SOCKET_IO.md
Normal file
191
DEPLOYMENT_SOCKET_IO.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Deployment-Anleitung: Socket.IO mit SSL
|
||||
|
||||
Socket.IO läuft jetzt direkt auf HTTPS-Port 3051 (nicht über Apache-Proxy).
|
||||
|
||||
## Schritte nach dem Deployment
|
||||
|
||||
### 1. Firewall-Port öffnen
|
||||
|
||||
```bash
|
||||
# UFW (Ubuntu Firewall)
|
||||
sudo ufw allow 3051/tcp
|
||||
```
|
||||
|
||||
### 2. Apache-Konfiguration aktualisieren
|
||||
|
||||
```bash
|
||||
sudo cp /var/www/tt-tagebuch.de/apache.conf.example /etc/apache2/sites-available/tt-tagebuch.de-le-ssl.conf
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
### 3. systemd-Service konfigurieren (als www-data)
|
||||
|
||||
**WICHTIG:** Der Service sollte als `www-data` laufen, nicht als `nobody`!
|
||||
|
||||
```bash
|
||||
# Service-Datei installieren
|
||||
sudo cp /var/www/tt-tagebuch.de/tt-tagebuch.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
Die Service-Datei konfiguriert:
|
||||
- User: `www-data` (Standard-Webserver-Benutzer)
|
||||
- Group: `www-data`
|
||||
- Port: 3050 (HTTP) und 3051 (HTTPS)
|
||||
|
||||
### 4. SSL-Zertifikat-Berechtigungen setzen
|
||||
|
||||
**WICHTIG:** Der Node.js-Prozess muss Zugriff auf die SSL-Zertifikate haben!
|
||||
|
||||
```bash
|
||||
cd /var/www/tt-tagebuch.de/backend
|
||||
chmod +x scripts/fixCertPermissions.sh
|
||||
sudo ./scripts/fixCertPermissions.sh
|
||||
```
|
||||
|
||||
Dieses Skript:
|
||||
- Erstellt die Gruppe `ssl-cert` (falls nicht vorhanden)
|
||||
- Fügt den Service-Benutzer (`www-data`) zur Gruppe hinzu
|
||||
- Setzt die Berechtigungen für die Zertifikate
|
||||
|
||||
### 5. Backend neu starten
|
||||
|
||||
**WICHTIG:** Der Backend-Server muss neu gestartet werden, damit der HTTPS-Server auf Port 3051 läuft!
|
||||
|
||||
```bash
|
||||
# Falls als systemd-Service:
|
||||
sudo systemctl restart tt-tagebuch
|
||||
|
||||
# Oder falls als PM2-Prozess:
|
||||
pm2 restart tt-tagebuch-backend
|
||||
```
|
||||
|
||||
### 6. Prüfen, ob HTTPS-Server läuft
|
||||
|
||||
```bash
|
||||
# Prüfe, ob Port 3051 geöffnet ist
|
||||
sudo netstat -tlnp | grep 3051
|
||||
# Oder:
|
||||
sudo ss -tlnp | grep 3051
|
||||
|
||||
# Prüfe Backend-Logs
|
||||
sudo journalctl -u tt-tagebuch -f
|
||||
# Oder bei PM2:
|
||||
pm2 logs tt-tagebuch-backend
|
||||
```
|
||||
|
||||
Du solltest folgende Meldung sehen:
|
||||
```
|
||||
🚀 HTTPS-Server für Socket.IO läuft auf Port 3051
|
||||
```
|
||||
|
||||
### 7. Diagnose-Skript ausführen
|
||||
|
||||
```bash
|
||||
cd /var/www/tt-tagebuch.de/backend
|
||||
node scripts/checkSocketIOServer.js
|
||||
```
|
||||
|
||||
Dieses Skript prüft:
|
||||
- Ob SSL-Zertifikate existieren
|
||||
- Ob Port 3051 geöffnet ist
|
||||
- Ob der Server erreichbar ist
|
||||
|
||||
### 8. Testen
|
||||
|
||||
Im Browser sollte Socket.IO jetzt direkt zu `wss://tt-tagebuch.de:3051` verbinden.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Port 3051 ist nicht erreichbar
|
||||
|
||||
1. **Prüfe Firewall:**
|
||||
```bash
|
||||
sudo ufw status
|
||||
sudo ufw allow 3051/tcp
|
||||
```
|
||||
|
||||
2. **Prüfe, ob der Server läuft:**
|
||||
```bash
|
||||
sudo netstat -tlnp | grep 3051
|
||||
sudo ss -tlnp | grep 3051
|
||||
```
|
||||
|
||||
3. **Prüfe Backend-Logs auf Fehler:**
|
||||
```bash
|
||||
sudo journalctl -u tt-tagebuch -n 50
|
||||
# Oder:
|
||||
pm2 logs tt-tagebuch-backend --lines 50
|
||||
```
|
||||
|
||||
4. **Prüfe, ob HTTPS-Server gestartet wurde:**
|
||||
- Suche in den Logs nach: `🚀 HTTPS-Server für Socket.IO läuft auf Port 3051`
|
||||
- Falls nicht vorhanden, prüfe auf Fehler: `⚠️ HTTPS-Server konnte nicht gestartet werden`
|
||||
|
||||
### SSL-Zertifikat-Fehler / Berechtigungsfehler
|
||||
|
||||
**Fehler:** `EACCES: permission denied, open '/etc/letsencrypt/live/tt-tagebuch.de/privkey.pem'`
|
||||
|
||||
**Lösung:**
|
||||
```bash
|
||||
cd /var/www/tt-tagebuch.de/backend
|
||||
chmod +x scripts/fixCertPermissions.sh
|
||||
sudo ./scripts/fixCertPermissions.sh
|
||||
sudo systemctl restart tt-tagebuch
|
||||
```
|
||||
|
||||
Stelle sicher, dass die Zertifikate existieren:
|
||||
```bash
|
||||
ls -la /etc/letsencrypt/live/tt-tagebuch.de/
|
||||
```
|
||||
|
||||
Falls die Zertifikate nicht existieren:
|
||||
```bash
|
||||
sudo certbot certonly --standalone -d tt-tagebuch.de
|
||||
```
|
||||
|
||||
### Service läuft als "nobody"
|
||||
|
||||
**Problem:** Der Service läuft als `nobody`, was zu eingeschränkt ist.
|
||||
|
||||
**Lösung:**
|
||||
1. Installiere die Service-Datei (siehe Schritt 3)
|
||||
2. Führe das Berechtigungs-Skript aus (siehe Schritt 4)
|
||||
3. Starte den Service neu
|
||||
|
||||
```bash
|
||||
# Prüfe aktuellen Service-User
|
||||
sudo systemctl show -p User tt-tagebuch.service
|
||||
|
||||
# Installiere Service-Datei
|
||||
sudo cp /var/www/tt-tagebuch.de/tt-tagebuch.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart tt-tagebuch
|
||||
|
||||
# Prüfe, ob jetzt als www-data läuft
|
||||
sudo systemctl show -p User tt-tagebuch.service
|
||||
```
|
||||
|
||||
### Frontend verbindet nicht
|
||||
|
||||
1. **Prüfe Browser-Konsole auf Fehler**
|
||||
2. **Prüfe, ob `import.meta.env.PROD` korrekt gesetzt ist:**
|
||||
- In Produktion sollte die Socket.IO-URL `https://tt-tagebuch.de:3051` sein
|
||||
- In Entwicklung sollte sie `http://localhost:3005` sein
|
||||
|
||||
3. **Prüfe, ob die Socket.IO-URL korrekt ist:**
|
||||
- Öffne Browser-Entwicklertools → Network
|
||||
- Suche nach WebSocket-Verbindungen
|
||||
- Die URL sollte `wss://tt-tagebuch.de:3051/socket.io/...` sein
|
||||
|
||||
### Server lauscht nur auf localhost
|
||||
|
||||
Der Server sollte auf `0.0.0.0` lauschen (nicht nur auf `localhost`).
|
||||
Dies ist bereits in der Konfiguration eingestellt:
|
||||
```javascript
|
||||
httpsServer.listen(httpsPort, '0.0.0.0', () => {
|
||||
console.log(`🚀 HTTPS-Server für Socket.IO läuft auf Port ${httpsPort}`);
|
||||
});
|
||||
```
|
||||
|
||||
Falls der Server trotzdem nicht erreichbar ist, prüfe die Backend-Logs.
|
||||
342
DSGVO_CHECKLIST.md
Normal file
342
DSGVO_CHECKLIST.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# DSGVO-Konformitäts-Checkliste für Trainingstagebuch
|
||||
|
||||
## Status: ⚠️ PRÜFUNG ERFORDERLICH
|
||||
|
||||
Diese Checkliste dokumentiert den aktuellen Stand der DSGVO-Konformität der Anwendung.
|
||||
|
||||
---
|
||||
|
||||
## 1. Datenschutzerklärung ✅ / ⚠️
|
||||
|
||||
### Status: ⚠️ Teilweise vorhanden, muss aktualisiert werden
|
||||
|
||||
**Vorhanden:**
|
||||
- ✅ Datenschutzerklärung vorhanden (`/datenschutz`)
|
||||
- ✅ Impressum vorhanden (`/impressum`)
|
||||
- ✅ Verlinkung im Footer
|
||||
|
||||
**Fehlend/Verbesserungsbedarf:**
|
||||
- ⚠️ MyTischtennis-Integration nicht erwähnt (Drittlandübermittlung?)
|
||||
- ⚠️ Logging von API-Requests nicht erwähnt
|
||||
- ⚠️ Verschlüsselung von Mitgliederdaten nicht erwähnt
|
||||
- ⚠️ Speicherdauer für Logs nicht konkretisiert
|
||||
- ⚠️ Keine Informationen zu automatischer Löschung
|
||||
|
||||
---
|
||||
|
||||
## 2. Einwilligungen ⚠️
|
||||
|
||||
### Status: ⚠️ Teilweise vorhanden
|
||||
|
||||
**Vorhanden:**
|
||||
- ✅ `picsInInternetAllowed` bei Mitgliedern (Einwilligung für Fotos im Internet)
|
||||
- ✅ MyTischtennis: `savePassword` und `autoUpdateRatings` (Einwilligungen)
|
||||
|
||||
**Fehlend/Verbesserungsbedarf:**
|
||||
- ⚠️ Keine explizite Einwilligung bei Registrierung zur Datenschutzerklärung
|
||||
- ⚠️ Keine Einwilligung für Logging von API-Requests
|
||||
- ⚠️ Keine Einwilligung für Datenübertragung an MyTischtennis.de
|
||||
- ⚠️ Keine Möglichkeit, Einwilligungen zu widerrufen (außer manuell)
|
||||
|
||||
---
|
||||
|
||||
## 3. Löschrechte (Art. 17 DSGVO) ⚠️
|
||||
|
||||
### Status: ⚠️ Teilweise implementiert
|
||||
|
||||
**Vorhanden:**
|
||||
- ✅ DELETE-Endpunkte für viele Ressourcen (Member, Tournament, etc.)
|
||||
- ✅ MyTischtennis-Account kann gelöscht werden
|
||||
|
||||
**Fehlend/Verbesserungsbedarf:**
|
||||
- ❌ **KRITISCH:** Kein Endpunkt zum vollständigen Löschen eines User-Accounts
|
||||
- ❌ **KRITISCH:** Keine automatische Löschung aller zugehörigen Daten (Cascade-Delete)
|
||||
- ❌ Keine Löschung von Logs nach Ablauf der Speicherdauer
|
||||
- ⚠️ Keine Anonymisierung statt Löschung (falls gesetzliche Aufbewahrungspflichten bestehen)
|
||||
- ⚠️ Keine Bestätigung vor Löschung kritischer Daten
|
||||
|
||||
**Empfehlung:**
|
||||
- Implementiere `/api/user/delete` Endpunkt
|
||||
- Implementiere automatische Löschung aller zugehörigen Daten:
|
||||
- UserClub-Einträge
|
||||
- MyTischtennis-Account
|
||||
- Alle Logs (nach Anonymisierung)
|
||||
- Alle Mitglieder, die nur diesem User zugeordnet sind
|
||||
- Implementiere automatische Löschung von Logs nach 90 Tagen
|
||||
|
||||
---
|
||||
|
||||
## 4. Auskunftsrechte (Art. 15 DSGVO) ❌
|
||||
|
||||
### Status: ❌ Nicht implementiert
|
||||
|
||||
**Fehlend:**
|
||||
- ❌ **KRITISCH:** Kein Endpunkt zur Auskunft über gespeicherte Daten
|
||||
- ❌ Keine Übersicht über alle personenbezogenen Daten eines Users
|
||||
- ❌ Keine Übersicht über alle Mitgliederdaten
|
||||
- ❌ Keine Übersicht über Logs, die einen User betreffen
|
||||
|
||||
**Empfehlung:**
|
||||
- Implementiere `/api/user/data-export` Endpunkt
|
||||
- Exportiere alle Daten in strukturiertem Format (JSON)
|
||||
- Inkludiere:
|
||||
- User-Daten
|
||||
- Vereinszugehörigkeiten
|
||||
- Mitgliederdaten (falls User Zugriff hat)
|
||||
- Logs
|
||||
- MyTischtennis-Daten
|
||||
|
||||
---
|
||||
|
||||
## 5. Datenportabilität (Art. 20 DSGVO) ❌
|
||||
|
||||
### Status: ❌ Nicht implementiert
|
||||
|
||||
**Fehlend:**
|
||||
- ❌ **KRITISCH:** Kein Export in maschinenlesbarem Format
|
||||
- ❌ Keine JSON/XML-Export-Funktion
|
||||
- ⚠️ PDF-Export für Trainingstage vorhanden, aber nicht für alle Daten
|
||||
|
||||
**Empfehlung:**
|
||||
- Implementiere `/api/user/data-export` mit JSON-Format
|
||||
- Implementiere Export für:
|
||||
- Alle eigenen Daten
|
||||
- Alle Mitgliederdaten (falls berechtigt)
|
||||
- Alle Trainingsdaten
|
||||
- Alle Turnierdaten
|
||||
|
||||
---
|
||||
|
||||
## 6. Verschlüsselung ✅ / ⚠️
|
||||
|
||||
### Status: ✅ Gut implementiert
|
||||
|
||||
**Vorhanden:**
|
||||
- ✅ AES-256-CBC Verschlüsselung für Mitgliederdaten:
|
||||
- firstName, lastName
|
||||
- birthDate
|
||||
- phone, street, city, postalCode
|
||||
- email
|
||||
- notes (Participant)
|
||||
- ✅ Passwörter werden mit bcrypt gehasht
|
||||
- ✅ HTTPS für alle Verbindungen
|
||||
|
||||
**Verbesserungsbedarf:**
|
||||
- ⚠️ Verschlüsselungsschlüssel sollte in separater, sicherer Konfiguration sein
|
||||
- ✅ **BEHOBEN:** MyTischtennis-Daten werden jetzt vollständig verschlüsselt (E-Mail, Zugriffstoken, Refresh-Token, Cookie, Benutzerdaten, Vereinsinformationen)
|
||||
- ⚠️ Keine Verschlüsselung für Logs (können personenbezogene Daten enthalten)
|
||||
|
||||
---
|
||||
|
||||
## 7. Logging ⚠️
|
||||
|
||||
### Status: ⚠️ Verbesserungsbedarf
|
||||
|
||||
**Vorhanden:**
|
||||
- ✅ Aktivitäts-Logging (`log` Tabelle) - protokolliert wichtige Aktionen
|
||||
- ✅ Server-Logs - Standard-Server-Logs für Fehlerbehebung
|
||||
- ✅ **ENTFERNT:** API-Logging für MyTischtennis-Requests wurde deaktiviert
|
||||
|
||||
**Probleme:**
|
||||
- ✅ **BEHOBEN:** API-Logging für MyTischtennis-Requests wurde komplett entfernt (keine personenbezogenen Daten mehr in API-Logs)
|
||||
- ⚠️ Keine automatische Löschung von Aktivitätslogs (noch zu implementieren)
|
||||
- ✅ **BEHOBEN:** In Datenschutzerklärung dokumentiert, was geloggt wird
|
||||
|
||||
**Empfehlung:**
|
||||
- ⚠️ Implementiere automatische Löschung von Aktivitätslogs nach angemessener Frist (noch ausstehend)
|
||||
|
||||
---
|
||||
|
||||
## 8. MyTischtennis-Integration ⚠️
|
||||
|
||||
### Status: ⚠️ Verbesserungsbedarf
|
||||
|
||||
**Vorhanden:**
|
||||
- ✅ Verschlüsselung von Passwörtern
|
||||
- ✅ Einwilligungen (`savePassword`, `autoUpdateRatings`)
|
||||
- ✅ DELETE-Endpunkt für Account
|
||||
|
||||
**Probleme:**
|
||||
- ✅ **BEHOBEN:** Drittlandübermittlung in Datenschutzerklärung erwähnt
|
||||
- ⚠️ Keine explizite Einwilligung für Datenübertragung an MyTischtennis.de
|
||||
- ✅ **BEHOBEN:** Informationen über Datenschutz bei MyTischtennis.de in Datenschutzerklärung
|
||||
- ✅ **BEHOBEN:** Alle MyTischtennis-Daten werden jetzt verschlüsselt gespeichert
|
||||
|
||||
**Empfehlung:**
|
||||
- Aktualisiere Datenschutzerklärung:
|
||||
- Erwähne MyTischtennis-Integration
|
||||
- Erkläre, welche Daten übertragen werden
|
||||
- Verweise auf Datenschutzerklärung von MyTischtennis.de
|
||||
- Erkläre Rechtsgrundlage (Einwilligung)
|
||||
- Implementiere explizite Einwilligung bei Einrichtung der Integration
|
||||
- Verschlüssele auch Zugriffstoken
|
||||
|
||||
---
|
||||
|
||||
## 9. Cookies & Local Storage ✅
|
||||
|
||||
### Status: ✅ Konform
|
||||
|
||||
**Vorhanden:**
|
||||
- ✅ Nur technisch notwendige Cookies/Storage:
|
||||
- Session-Token (Session Storage)
|
||||
- Username, Clubs, Permissions (Local Storage)
|
||||
- ✅ Keine Tracking-Cookies
|
||||
- ✅ Keine Werbe-Cookies
|
||||
- ✅ Dokumentiert in Datenschutzerklärung
|
||||
|
||||
**Hinweis:**
|
||||
- Local Storage wird für persistente Daten verwendet (Clubs, Permissions)
|
||||
- Dies ist technisch notwendig und DSGVO-konform
|
||||
|
||||
---
|
||||
|
||||
## 10. Berechtigungssystem ✅
|
||||
|
||||
### Status: ✅ Gut implementiert
|
||||
|
||||
**Vorhanden:**
|
||||
- ✅ Rollenbasierte Zugriffe (Admin, Trainer, Mannschaftsführer, Mitglied)
|
||||
- ✅ Individuelle Berechtigungen pro Ressource
|
||||
- ✅ Transparente Zugriffskontrolle
|
||||
- ✅ Logging von Aktivitäten
|
||||
|
||||
**Hinweis:**
|
||||
- Berechtigungssystem ist DSGVO-konform
|
||||
- Ermöglicht Datenminimierung (Zugriff nur auf notwendige Daten)
|
||||
|
||||
---
|
||||
|
||||
## 11. Datenminimierung ⚠️
|
||||
|
||||
### Status: ⚠️ Teilweise konform
|
||||
|
||||
**Vorhanden:**
|
||||
- ✅ Nur notwendige Daten werden gespeichert
|
||||
- ✅ Berechtigungssystem ermöglicht minimale Datenzugriffe
|
||||
|
||||
**Verbesserungsbedarf:**
|
||||
- ⚠️ Logs enthalten möglicherweise zu viele Daten (Request/Response-Bodies)
|
||||
- ⚠️ Keine automatische Löschung alter Daten
|
||||
- ⚠️ Keine Option, Daten zu anonymisieren statt zu löschen
|
||||
|
||||
---
|
||||
|
||||
## 12. Technische und organisatorische Maßnahmen (TOM) ✅ / ⚠️
|
||||
|
||||
### Status: ✅ Gut, aber verbesserungsbedürftig
|
||||
|
||||
**Vorhanden:**
|
||||
- ✅ Verschlüsselung sensibler Daten
|
||||
- ✅ HTTPS für alle Verbindungen
|
||||
- ✅ Passwort-Hashing (bcrypt)
|
||||
- ✅ Authentifizierung und Autorisierung
|
||||
- ✅ Berechtigungssystem
|
||||
|
||||
**Verbesserungsbedarf:**
|
||||
- ⚠️ Keine Dokumentation der TOM
|
||||
- ⚠️ Keine regelmäßigen Sicherheitsupdates dokumentiert
|
||||
- ⚠️ Keine Backup-Strategie dokumentiert
|
||||
- ⚠️ Keine Notfallpläne dokumentiert
|
||||
|
||||
---
|
||||
|
||||
## 13. Auftragsverarbeitung ⚠️
|
||||
|
||||
### Status: ⚠️ Nicht dokumentiert
|
||||
|
||||
**Fehlend:**
|
||||
- ⚠️ Keine Informationen über Hosting-Provider
|
||||
- ⚠️ Keine Informationen über Auftragsverarbeitungsverträge (AVV)
|
||||
- ⚠️ Keine Informationen über Subunternehmer
|
||||
|
||||
**Empfehlung:**
|
||||
- Dokumentiere alle Auftragsverarbeiter (Hosting, etc.)
|
||||
- Erwähne in Datenschutzerklärung, dass AVV abgeschlossen wurden
|
||||
|
||||
---
|
||||
|
||||
## 14. Betroffenenrechte - Umsetzung ❌
|
||||
|
||||
### Status: ❌ Nicht vollständig implementiert
|
||||
|
||||
**Fehlend:**
|
||||
- ❌ **KRITISCH:** Kein Endpunkt für Auskunft (Art. 15)
|
||||
- ❌ **KRITISCH:** Kein Endpunkt für Löschung (Art. 17)
|
||||
- ❌ **KRITISCH:** Kein Endpunkt für Datenexport (Art. 20)
|
||||
- ❌ Kein Endpunkt für Berichtigung (Art. 16) - teilweise vorhanden über normale Edit-Endpunkte
|
||||
- ❌ Kein Endpunkt für Einschränkung (Art. 18)
|
||||
- ❌ Kein Endpunkt für Widerspruch (Art. 21)
|
||||
|
||||
**Empfehlung:**
|
||||
- Implementiere zentrale Endpunkte für alle Betroffenenrechte:
|
||||
- `GET /api/user/rights/information` - Auskunft
|
||||
- `DELETE /api/user/rights/deletion` - Löschung
|
||||
- `GET /api/user/rights/export` - Datenexport
|
||||
- `PUT /api/user/rights/restriction` - Einschränkung
|
||||
- `POST /api/user/rights/objection` - Widerspruch
|
||||
|
||||
---
|
||||
|
||||
## 15. Kontakt für Datenschutz ✅
|
||||
|
||||
### Status: ✅ Vorhanden
|
||||
|
||||
**Vorhanden:**
|
||||
- ✅ E-Mail-Adresse in Datenschutzerklärung: tsschulz@tsschulz.de
|
||||
- ✅ Vollständige Anschrift im Impressum
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
### ✅ Gut implementiert:
|
||||
1. Verschlüsselung sensibler Daten
|
||||
2. HTTPS
|
||||
3. Berechtigungssystem
|
||||
4. Cookies/Local Storage (nur technisch notwendig)
|
||||
5. Datenschutzerklärung vorhanden
|
||||
|
||||
### ⚠️ Verbesserungsbedarf:
|
||||
1. Datenschutzerklärung aktualisieren (MyTischtennis, Logging)
|
||||
2. Logging von personenbezogenen Daten reduzieren/anonymisieren
|
||||
3. Automatische Löschung von Logs implementieren
|
||||
4. MyTischtennis-Integration in Datenschutzerklärung erwähnen
|
||||
|
||||
### ❌ Kritisch - Muss implementiert werden:
|
||||
1. **Löschrechte-API** (Art. 17 DSGVO)
|
||||
2. **Auskunftsrechte-API** (Art. 15 DSGVO)
|
||||
3. **Datenexport-API** (Art. 20 DSGVO)
|
||||
4. **Automatische Löschung von Logs** nach Retention-Periode
|
||||
|
||||
---
|
||||
|
||||
## Prioritäten
|
||||
|
||||
### Sofort (vor Live-Betrieb):
|
||||
1. Datenschutzerklärung aktualisieren
|
||||
2. Löschrechte-API implementieren
|
||||
3. Auskunftsrechte-API implementieren
|
||||
4. Datenexport-API implementieren
|
||||
|
||||
### Kurzfristig (innerhalb 1 Monat):
|
||||
1. Automatische Löschung von Logs implementieren
|
||||
2. Logging von personenbezogenen Daten reduzieren/anonymisieren
|
||||
3. MyTischtennis-Integration in Datenschutzerklärung dokumentieren
|
||||
|
||||
### Mittelfristig (innerhalb 3 Monate):
|
||||
1. Einwilligungsmanagement implementieren
|
||||
2. TOM dokumentieren
|
||||
3. Auftragsverarbeitung dokumentieren
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. ✅ Diese Checkliste erstellen
|
||||
2. ⏳ Datenschutzerklärung aktualisieren
|
||||
3. ⏳ Löschrechte-API implementieren
|
||||
4. ⏳ Auskunftsrechte-API implementieren
|
||||
5. ⏳ Datenexport-API implementieren
|
||||
6. ⏳ Logging verbessern
|
||||
|
||||
210
PERMISSIONS_GUIDE.md
Normal file
210
PERMISSIONS_GUIDE.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Berechtigungssystem - Dokumentation
|
||||
|
||||
## Übersicht
|
||||
|
||||
Das Trainingstagebuch verfügt nun über ein vollständiges rollenbasiertes Berechtigungssystem (RBAC - Role-Based Access Control). Der Club-Ersteller hat automatisch Admin-Rechte und kann anderen Mitgliedern Rollen und spezifische Berechtigungen zuweisen.
|
||||
|
||||
## Rollen
|
||||
|
||||
### 1. Administrator (admin)
|
||||
- **Vollzugriff** auf alle Funktionen
|
||||
- Kann Berechtigungen anderer Benutzer verwalten
|
||||
- Der Club-Ersteller ist automatisch Administrator und kann nicht degradiert werden
|
||||
|
||||
### 2. Trainer (trainer)
|
||||
- Kann Trainingseinheiten planen und verwalten
|
||||
- Kann Mitglieder anlegen und bearbeiten
|
||||
- Kann Spielpläne einsehen und bearbeiten
|
||||
- Kann Turniere organisieren
|
||||
- **Kann nicht**: Einstellungen ändern, Berechtigungen verwalten
|
||||
|
||||
### 3. Mannschaftsführer (team_manager)
|
||||
- Kann Teams und Spielpläne verwalten
|
||||
- Kann Spieler für Matches einteilen
|
||||
- Kann Spielergebnisse eintragen
|
||||
- **Kann nicht**: Trainingseinheiten planen, Mitglieder verwalten
|
||||
|
||||
### 4. Mitglied (member)
|
||||
- Nur Lesezugriff auf alle Bereiche
|
||||
- Kann eigene Daten einsehen
|
||||
- **Kann nicht**: Daten ändern oder löschen
|
||||
|
||||
## Berechtigungsbereiche
|
||||
|
||||
- **diary**: Trainingstagebuch
|
||||
- **members**: Mitgliederverwaltung
|
||||
- **teams**: Team-Management
|
||||
- **schedule**: Spielpläne
|
||||
- **tournaments**: Turniere
|
||||
- **statistics**: Statistiken
|
||||
- **settings**: Einstellungen
|
||||
- **permissions**: Berechtigungsverwaltung
|
||||
- **mytischtennis**: MyTischtennis-Integration (für alle zugänglich)
|
||||
|
||||
## Backend-Integration
|
||||
|
||||
### Migration ausführen
|
||||
|
||||
```sql
|
||||
mysql -u username -p database_name < backend/migrations/add_permissions_to_user_club.sql
|
||||
```
|
||||
|
||||
### Authorization Middleware verwenden
|
||||
|
||||
```javascript
|
||||
import { authorize, requireAdmin, requireOwner } from '../middleware/authorizationMiddleware.js';
|
||||
|
||||
// Beispiel: Nur Lesezugriff erforderlich
|
||||
router.get('/diary/:clubId', authenticate, authorize('diary', 'read'), getDiary);
|
||||
|
||||
// Beispiel: Schreibzugriff erforderlich
|
||||
router.post('/diary/:clubId', authenticate, authorize('diary', 'write'), createDiary);
|
||||
|
||||
// Beispiel: Admin-Rechte erforderlich
|
||||
router.put('/settings/:clubId', authenticate, requireAdmin(), updateSettings);
|
||||
|
||||
// Beispiel: Nur Owner
|
||||
router.delete('/club/:clubId', authenticate, requireOwner(), deleteClub);
|
||||
```
|
||||
|
||||
### Permission Service verwenden
|
||||
|
||||
```javascript
|
||||
import permissionService from '../services/permissionService.js';
|
||||
|
||||
// Berechtigungen prüfen
|
||||
const hasPermission = await permissionService.hasPermission(userId, clubId, 'diary', 'write');
|
||||
|
||||
// Rolle setzen
|
||||
await permissionService.setUserRole(userId, clubId, 'trainer', adminUserId);
|
||||
|
||||
// Custom Permissions setzen
|
||||
await permissionService.setCustomPermissions(
|
||||
userId,
|
||||
clubId,
|
||||
{ diary: { write: false }, members: { write: true } },
|
||||
adminUserId
|
||||
);
|
||||
```
|
||||
|
||||
## Frontend-Integration
|
||||
|
||||
### Composable verwenden
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { usePermissions } from '@/composables/usePermissions.js';
|
||||
|
||||
const { can, canWrite, canDelete, isAdmin, isOwner, userRole } = usePermissions();
|
||||
|
||||
// Beispiel
|
||||
if (can('diary', 'write')) {
|
||||
// Zeige Bearbeitungsbutton
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Direktiven verwenden
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- Nur anzeigen, wenn Schreibrechte für diary vorhanden -->
|
||||
<button v-can:diary.write>Bearbeiten</button>
|
||||
|
||||
<!-- Nur anzeigen, wenn Löschrechte für members vorhanden -->
|
||||
<button v-can:members.delete>Löschen</button>
|
||||
|
||||
<!-- Alternative Syntax -->
|
||||
<div v-can="'diary.write'">Inhalt nur für Berechtigte</div>
|
||||
|
||||
<!-- Nur für Admins -->
|
||||
<div v-admin>Admin-Bereich</div>
|
||||
|
||||
<!-- Nur für Owner -->
|
||||
<div v-owner>Owner-Bereich</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Store verwenden
|
||||
|
||||
```javascript
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
// Berechtigungen abrufen
|
||||
const permissions = store.getters.currentPermissions;
|
||||
const hasPermission = store.getters.hasPermission('diary', 'write');
|
||||
const isOwner = store.getters.isClubOwner;
|
||||
const userRole = store.getters.userRole;
|
||||
|
||||
// Berechtigungen laden (wird automatisch beim Club-Wechsel gemacht)
|
||||
await store.dispatch('loadPermissions', clubId);
|
||||
```
|
||||
|
||||
## Admin-UI
|
||||
|
||||
Die Berechtigungsverwaltung ist unter `/permissions` verfügbar und nur für Administratoren sichtbar.
|
||||
|
||||
**Funktionen:**
|
||||
- Übersicht aller Clubmitglieder mit ihren Rollen
|
||||
- Rollen zuweisen/ändern
|
||||
- Custom Permissions für einzelne Benutzer definieren
|
||||
- Erklärung der verfügbaren Rollen
|
||||
|
||||
## MyTischtennis-Integration
|
||||
|
||||
Die MyTischtennis-Einstellungen und -Funktionen sind für **alle Club-Mitglieder** zugänglich, unabhängig von ihrer Rolle. Dies ermöglicht es jedem, die Anbindung einzurichten und Daten abzurufen.
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
1. **Der Club-Ersteller** (Owner) kann nicht degradiert oder gelöscht werden
|
||||
2. **Owner-Rechte** können nicht übertragen werden
|
||||
3. **Backend-Validierung** wird immer durchgeführt, auch wenn das Frontend Elemente ausblendet
|
||||
4. **Alle API-Routen** sind durch Middleware geschützt
|
||||
5. **Permissions werden gecacht** im localStorage für bessere Performance
|
||||
|
||||
## Beispiel-Szenarien
|
||||
|
||||
### Szenario 1: Trainer hinzufügen
|
||||
1. Admin öffnet `/permissions`
|
||||
2. Wählt Benutzer aus
|
||||
3. Ändert Rolle zu "Trainer"
|
||||
4. Benutzer kann jetzt Trainingseinheiten planen
|
||||
|
||||
### Szenario 2: Custom Permissions
|
||||
1. Admin öffnet `/permissions`
|
||||
2. Wählt Benutzer aus
|
||||
3. Klickt auf "Anpassen"
|
||||
4. Setzt individuelle Berechtigungen (z.B. nur Diary-Schreibrecht)
|
||||
5. Speichert
|
||||
|
||||
### Szenario 3: Neues Mitglied
|
||||
1. Mitglied registriert sich und fordert Zugang an
|
||||
2. Admin genehmigt Anfrage (Standardrolle: "member")
|
||||
3. Mitglied hat Lesezugriff
|
||||
4. Bei Bedarf kann Admin die Rolle später ändern
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Problem**: Berechtigungen werden nicht aktualisiert
|
||||
- **Lösung**: Seite neu laden oder Club neu auswählen
|
||||
|
||||
**Problem**: "Keine Berechtigung" trotz korrekter Rolle
|
||||
- **Lösung**: Prüfen, ob Custom Permissions die Rolle überschreiben
|
||||
|
||||
**Problem**: Owner kann keine Änderungen vornehmen
|
||||
- **Lösung**: Owner sollte automatisch alle Rechte haben. Prüfen Sie die `isOwner`-Flag in der Datenbank
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
```
|
||||
GET /api/permissions/:clubId - Eigene Berechtigungen abrufen
|
||||
GET /api/permissions/:clubId/members - Alle Mitglieder mit Berechtigungen (Admin)
|
||||
PUT /api/permissions/:clubId/user/:userId/role - Rolle ändern (Admin)
|
||||
PUT /api/permissions/:clubId/user/:userId/permissions - Custom Permissions setzen (Admin)
|
||||
GET /api/permissions/roles/available - Verfügbare Rollen abrufen
|
||||
GET /api/permissions/structure/all - Berechtigungsstruktur abrufen
|
||||
```
|
||||
|
||||
|
||||
235
PERMISSIONS_MIGRATION.md
Normal file
235
PERMISSIONS_MIGRATION.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# Berechtigungssystem - Migrations-Anleitung
|
||||
|
||||
## Übersicht
|
||||
|
||||
Diese Anleitung hilft Ihnen, das neue Berechtigungssystem für bestehende Clubs einzurichten.
|
||||
|
||||
## Schritt 1: Datenbank-Schema erweitern
|
||||
|
||||
Führen Sie zuerst die SQL-Migration aus, um die neuen Spalten hinzuzufügen:
|
||||
|
||||
```bash
|
||||
mysql -u username -p database_name < backend/migrations/add_permissions_to_user_club.sql
|
||||
```
|
||||
|
||||
Dies fügt folgende Spalten zur `user_club` Tabelle hinzu:
|
||||
- `role` (VARCHAR) - Benutzerrolle (admin, trainer, team_manager, member)
|
||||
- `permissions` (JSON) - Custom Permissions
|
||||
- `is_owner` (BOOLEAN) - Markiert den Club-Ersteller
|
||||
|
||||
## Schritt 2: Bestehende Daten migrieren
|
||||
|
||||
Sie haben zwei Optionen:
|
||||
|
||||
### Option A: Node.js Script (Empfohlen)
|
||||
|
||||
Das Script identifiziert automatisch den ersten Benutzer jedes Clubs (nach `createdAt`) und setzt ihn als Owner.
|
||||
|
||||
```bash
|
||||
cd /home/torsten/Programs/trainingstagebuch/backend
|
||||
node scripts/migratePermissions.js
|
||||
```
|
||||
|
||||
**Ausgabe:**
|
||||
```
|
||||
Starting permissions migration...
|
||||
|
||||
Found 3 club(s)
|
||||
|
||||
--- Club: TTC Beispiel (ID: 1) ---
|
||||
Members found: 5
|
||||
First member (will be owner): admin@example.com
|
||||
✓ Updated admin@example.com: role=admin, isOwner=true
|
||||
✓ Updated user1@example.com: role=member, isOwner=false
|
||||
✓ Updated user2@example.com: role=member, isOwner=false
|
||||
...
|
||||
|
||||
✅ Migration completed successfully!
|
||||
|
||||
Summary:
|
||||
Club Owners (3):
|
||||
- TTC Beispiel: admin@example.com
|
||||
- SV Teststadt: owner@test.de
|
||||
- TSC Demo: demo@example.com
|
||||
|
||||
Role Distribution:
|
||||
- Admins: 3
|
||||
- Members: 12
|
||||
```
|
||||
|
||||
### Option B: SQL Script
|
||||
|
||||
Wenn Sie lieber SQL verwenden möchten:
|
||||
|
||||
```bash
|
||||
mysql -u username -p database_name < backend/migrations/update_existing_user_club_permissions.sql
|
||||
```
|
||||
|
||||
Dieses Script:
|
||||
1. Setzt `role = 'member'` für alle genehmigten Benutzer ohne Rolle
|
||||
2. Markiert den Benutzer mit der niedrigsten `user_id` pro Club als Owner
|
||||
|
||||
## Schritt 3: Manuelle Anpassungen (Optional)
|
||||
|
||||
### Falscher Owner?
|
||||
|
||||
Falls das Script den falschen Benutzer als Owner markiert hat, können Sie dies manuell korrigieren:
|
||||
|
||||
```sql
|
||||
-- Alten Owner zurücksetzen
|
||||
UPDATE user_club
|
||||
SET is_owner = 0, role = 'member'
|
||||
WHERE club_id = 1 AND user_id = 123;
|
||||
|
||||
-- Neuen Owner setzen
|
||||
UPDATE user_club
|
||||
SET is_owner = 1, role = 'admin'
|
||||
WHERE club_id = 1 AND user_id = 456;
|
||||
```
|
||||
|
||||
### Weitere Admins ernennen
|
||||
|
||||
```sql
|
||||
UPDATE user_club
|
||||
SET role = 'admin'
|
||||
WHERE club_id = 1 AND user_id = 789;
|
||||
```
|
||||
|
||||
### Trainer ernennen
|
||||
|
||||
```sql
|
||||
UPDATE user_club
|
||||
SET role = 'trainer'
|
||||
WHERE club_id = 1 AND user_id = 101;
|
||||
```
|
||||
|
||||
## Schritt 4: Verifizierung
|
||||
|
||||
### Backend neu starten
|
||||
|
||||
```bash
|
||||
# Server neu starten (wenn er läuft)
|
||||
sudo systemctl restart tt-tagebuch
|
||||
```
|
||||
|
||||
### Im Browser testen
|
||||
|
||||
1. Loggen Sie sich ein
|
||||
2. Wählen Sie einen Club aus
|
||||
3. Navigieren Sie zu "Berechtigungen" (nur für Admins sichtbar)
|
||||
4. Überprüfen Sie, dass alle Mitglieder korrekt angezeigt werden
|
||||
|
||||
### SQL Verifizierung
|
||||
|
||||
```sql
|
||||
-- Alle Club-Mitglieder mit ihren Berechtigungen anzeigen
|
||||
SELECT
|
||||
c.name as club_name,
|
||||
u.email as user_email,
|
||||
uc.role,
|
||||
uc.is_owner,
|
||||
uc.approved
|
||||
FROM user_club uc
|
||||
JOIN club c ON c.id = uc.club_id
|
||||
JOIN user u ON u.id = uc.user_id
|
||||
WHERE uc.approved = 1
|
||||
ORDER BY c.name, uc.is_owner DESC, uc.role, u.email;
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Problem: "Keine Berechtigung" trotz Owner-Status
|
||||
|
||||
**Lösung:** Überprüfen Sie in der Datenbank:
|
||||
|
||||
```sql
|
||||
SELECT role, is_owner, approved
|
||||
FROM user_club
|
||||
WHERE user_id = YOUR_USER_ID AND club_id = YOUR_CLUB_ID;
|
||||
```
|
||||
|
||||
Sollte sein: `role='admin'`, `is_owner=1`, `approved=1`
|
||||
|
||||
### Problem: Owner kann nicht geändert werden
|
||||
|
||||
Das ist korrekt! Der Owner (Club-Ersteller) kann seine eigenen Rechte nicht verlieren. Dies ist eine Sicherheitsmaßnahme.
|
||||
|
||||
### Problem: Berechtigungen werden nicht geladen
|
||||
|
||||
**Lösung:**
|
||||
1. Browser-Cache leeren
|
||||
2. LocalStorage leeren: `localStorage.clear()` in der Browser-Console
|
||||
3. Neu einloggen
|
||||
|
||||
### Problem: "Lade Mitglieder..." bleibt hängen
|
||||
|
||||
**Mögliche Ursachen:**
|
||||
1. Migration noch nicht ausgeführt
|
||||
2. Backend nicht neu gestartet
|
||||
3. Frontend nicht neu gebaut
|
||||
|
||||
**Lösung:**
|
||||
```bash
|
||||
# Backend
|
||||
cd /home/torsten/Programs/trainingstagebuch/backend
|
||||
node scripts/migratePermissions.js
|
||||
|
||||
# Frontend
|
||||
cd /home/torsten/Programs/trainingstagebuch/frontend
|
||||
npm run build
|
||||
|
||||
# Server neu starten
|
||||
sudo systemctl restart tt-tagebuch
|
||||
```
|
||||
|
||||
## Nach der Migration
|
||||
|
||||
### Neue Clubs
|
||||
|
||||
Bei neuen Clubs wird der Ersteller automatisch als Owner mit Admin-Rechten eingerichtet. Keine manuelle Aktion erforderlich.
|
||||
|
||||
### Neue Mitglieder
|
||||
|
||||
Neue Mitglieder erhalten automatisch die Rolle "member" (Lesezugriff). Admins können die Rolle später ändern.
|
||||
|
||||
### Berechtigungen verwalten
|
||||
|
||||
Admins können über die Web-UI unter `/permissions` Berechtigungen verwalten:
|
||||
1. Rollen zuweisen (Admin, Trainer, Mannschaftsführer, Mitglied)
|
||||
2. Custom Permissions definieren (für spezielle Anwendungsfälle)
|
||||
|
||||
## Wichtige Hinweise
|
||||
|
||||
⚠️ **Sicherung erstellen:**
|
||||
```bash
|
||||
mysqldump -u username -p database_name > backup_before_permissions_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
⚠️ **Owner-Rechte:**
|
||||
- Der Owner (is_owner=1) kann nicht degradiert oder gelöscht werden
|
||||
- Jeder Club hat genau einen Owner
|
||||
- Owner-Rechte können nicht übertragen werden (nur durch direkte DB-Änderung)
|
||||
|
||||
⚠️ **MyTischtennis:**
|
||||
- MyTischtennis-Funktionen sind für ALLE Mitglieder zugänglich
|
||||
- Keine Berechtigungsprüfung für MyTischtennis-Endpunkte
|
||||
|
||||
## Rollback (falls nötig)
|
||||
|
||||
Falls Sie das Berechtigungssystem zurücknehmen müssen:
|
||||
|
||||
```sql
|
||||
-- Spalten entfernen (Achtung: Datenverlust!)
|
||||
ALTER TABLE user_club
|
||||
DROP COLUMN role,
|
||||
DROP COLUMN permissions,
|
||||
DROP COLUMN is_owner;
|
||||
|
||||
-- Indizes entfernen
|
||||
DROP INDEX idx_user_club_role ON user_club;
|
||||
DROP INDEX idx_user_club_owner ON user_club;
|
||||
```
|
||||
|
||||
Dann Backend-Code auf vorherige Version zurücksetzen.
|
||||
|
||||
|
||||
69
SERVER_NODE_UPGRADE.md
Normal file
69
SERVER_NODE_UPGRADE.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Server Node.js Upgrade-Anleitung
|
||||
|
||||
## Problem
|
||||
|
||||
Der Server verwendet Node.js 20.17.0, aber Vite 7.2.4 benötigt Node.js 20.19+ oder 22.12+.
|
||||
|
||||
## Lösung 1: Node.js auf dem Server upgraden (Empfohlen)
|
||||
|
||||
### Option A: Node.js 20.19+ installieren
|
||||
|
||||
```bash
|
||||
# Auf dem Server:
|
||||
# Mit nvm (falls installiert):
|
||||
nvm install 20.19.0
|
||||
nvm use 20.19.0
|
||||
nvm alias default 20.19.0
|
||||
|
||||
# Oder mit NodeSource Repository:
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs=20.19.0-1nodesource1
|
||||
|
||||
# Prüfe Version:
|
||||
node --version # Sollte 20.19.0 oder höher sein
|
||||
```
|
||||
|
||||
### Option B: Node.js 22.12+ installieren (LTS)
|
||||
|
||||
```bash
|
||||
# Auf dem Server:
|
||||
# Mit nvm:
|
||||
nvm install 22.12.0
|
||||
nvm use 22.12.0
|
||||
nvm alias default 22.12.0
|
||||
|
||||
# Oder mit NodeSource Repository:
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
|
||||
# Prüfe Version:
|
||||
node --version # Sollte 22.12.0 oder höher sein
|
||||
```
|
||||
|
||||
### Nach dem Upgrade
|
||||
|
||||
```bash
|
||||
cd /var/www/tt-tagebuch.de/backend
|
||||
npm install # Erstellt automatisch den Frontend-Build
|
||||
sudo systemctl restart tt-tagebuch
|
||||
```
|
||||
|
||||
## Lösung 2: Vite auf Version 6 downgraden (Temporär)
|
||||
|
||||
Falls Node.js nicht upgradet werden kann, wurde Vite bereits auf Version 6.0.0 downgraded.
|
||||
|
||||
```bash
|
||||
cd /var/www/tt-tagebuch.de/backend
|
||||
npm install # Erstellt automatisch den Frontend-Build
|
||||
sudo systemctl restart tt-tagebuch
|
||||
```
|
||||
|
||||
**Hinweis:** Vite 6 funktioniert mit Node.js 20.17.0, aber Vite 7 bietet bessere Performance und Features.
|
||||
|
||||
## Empfehlung
|
||||
|
||||
**Node.js upgraden** ist die bessere Lösung, da:
|
||||
- Vite 7 bessere Performance bietet
|
||||
- Zukünftige Updates einfacher sind
|
||||
- Node.js 20.19+ oder 22.12+ LTS-Versionen sind
|
||||
|
||||
109
SITEMAP_ANLEITUNG.md
Normal file
109
SITEMAP_ANLEITUNG.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Sitemap für Google Search Console einreichen
|
||||
|
||||
## Aktuelle Sitemap
|
||||
|
||||
Die Sitemap ist verfügbar unter: `https://tt-tagebuch.de/sitemap.xml`
|
||||
|
||||
Sie enthält folgende öffentliche Seiten:
|
||||
- `/` (Home) - Priorität: 1.0
|
||||
- `/register` (Registrierung) - Priorität: 0.8
|
||||
- `/login` (Anmeldung) - Priorität: 0.7
|
||||
- `/impressum` (Impressum) - Priorität: 0.3
|
||||
- `/datenschutz` (Datenschutz) - Priorität: 0.3
|
||||
|
||||
## Sitemap aktualisieren
|
||||
|
||||
### Automatisch (empfohlen)
|
||||
```bash
|
||||
./update-sitemap.sh
|
||||
```
|
||||
|
||||
Das Skript aktualisiert automatisch das `lastmod`-Datum auf das heutige Datum.
|
||||
|
||||
### Manuell
|
||||
Die Sitemap-Datei befindet sich in: `frontend/public/sitemap.xml`
|
||||
|
||||
Nach Änderungen:
|
||||
1. Frontend neu bauen: `cd frontend && npm run build`
|
||||
2. Backend neu starten (falls nötig)
|
||||
|
||||
## Sitemap in Google Search Console einreichen
|
||||
|
||||
### Schritt 1: Google Search Console öffnen
|
||||
1. Gehe zu: https://search.google.com/search-console
|
||||
2. Wähle die Property für `tt-tagebuch.de` aus
|
||||
|
||||
### Schritt 2: Sitemap hinzufügen
|
||||
1. Klicke im linken Menü auf **"Sitemaps"**
|
||||
2. Im Feld **"Neue Sitemap hinzufügen"** eingeben:
|
||||
```
|
||||
sitemap.xml
|
||||
```
|
||||
Oder die vollständige URL:
|
||||
```
|
||||
https://tt-tagebuch.de/sitemap.xml
|
||||
```
|
||||
3. Klicke auf **"Senden"**
|
||||
|
||||
### Schritt 3: Status prüfen
|
||||
- Google wird die Sitemap innerhalb weniger Minuten verarbeiten
|
||||
- Der Status wird angezeigt:
|
||||
- ✅ **Erfolgreich**: Sitemap wurde erfolgreich verarbeitet
|
||||
- ⚠️ **Warnung**: Sitemap wurde verarbeitet, aber es gibt Warnungen
|
||||
- ❌ **Fehler**: Sitemap konnte nicht verarbeitet werden
|
||||
|
||||
### Schritt 4: Indexierung anfordern
|
||||
Nach dem Einreichen der Sitemap kannst du auch einzelne URLs zur Indexierung anfordern:
|
||||
1. Gehe zu **"URL-Prüfung"**
|
||||
2. Gib die URL ein: `https://tt-tagebuch.de/`
|
||||
3. Klicke auf **"Indexierung anfordern"**
|
||||
|
||||
## Sitemap testen
|
||||
|
||||
### Online-Tools
|
||||
- Google Sitemap Tester: https://www.xml-sitemaps.com/validate-xml-sitemap.html
|
||||
- Sitemap Validator: https://validator.w3.org/
|
||||
|
||||
### Per Kommandozeile
|
||||
```bash
|
||||
# Sitemap abrufen
|
||||
curl https://tt-tagebuch.de/sitemap.xml
|
||||
|
||||
# XML-Validierung (falls xmllint installiert ist)
|
||||
curl -s https://tt-tagebuch.de/sitemap.xml | xmllint --noout -
|
||||
```
|
||||
|
||||
## Wichtige Hinweise
|
||||
|
||||
1. **robots.txt**: Die Sitemap ist bereits in der `robots.txt` referenziert:
|
||||
```
|
||||
Sitemap: https://tt-tagebuch.de/sitemap.xml
|
||||
```
|
||||
|
||||
2. **lastmod-Datum**: Wird automatisch beim Ausführen von `update-sitemap.sh` aktualisiert
|
||||
|
||||
3. **Nur öffentliche Seiten**: Die Sitemap enthält nur öffentlich zugängliche Seiten. Geschützte Seiten (die eine Anmeldung erfordern) sind nicht enthalten.
|
||||
|
||||
4. **Prioritäten**:
|
||||
- Homepage: 1.0 (höchste Priorität)
|
||||
- Registrierung/Login: 0.7-0.8 (wichtig für neue Nutzer)
|
||||
- Rechtliche Seiten: 0.3 (niedrige Priorität, ändern sich selten)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Sitemap wird nicht gefunden
|
||||
- Prüfe, ob die Sitemap unter `https://tt-tagebuch.de/sitemap.xml` erreichbar ist
|
||||
- Stelle sicher, dass das Frontend gebaut wurde: `cd frontend && npm run build`
|
||||
- Prüfe die Apache-Konfiguration (sollte statische Dateien aus `/var/www/tt-tagebuch.de` servieren)
|
||||
|
||||
### Sitemap wird nicht indexiert
|
||||
- Warte einige Stunden/Tage - Google braucht Zeit zum Crawlen
|
||||
- Prüfe in der Search Console, ob es Fehler gibt
|
||||
- Stelle sicher, dass die URLs in der Sitemap erreichbar sind
|
||||
- Prüfe, ob die `robots.txt` die Seiten nicht blockiert
|
||||
|
||||
### Sitemap enthält Fehler
|
||||
- Validiere die XML-Struktur mit einem XML-Validator
|
||||
- Prüfe, ob alle URLs korrekt sind (keine 404-Fehler)
|
||||
- Stelle sicher, dass alle URLs HTTPS verwenden (nicht HTTP)
|
||||
|
||||
22
apache-http.conf.example
Normal file
22
apache-http.conf.example
Normal file
@@ -0,0 +1,22 @@
|
||||
# Apache-Konfiguration für tt-tagebuch.de - HTTP (Port 80)
|
||||
#
|
||||
# Diese Datei kopieren nach: /etc/apache2/sites-available/tt-tagebuch.de.conf
|
||||
# Dann aktivieren mit: sudo a2ensite tt-tagebuch.de.conf
|
||||
# Und neu starten: sudo systemctl restart apache2
|
||||
#
|
||||
# WICHTIG: Folgende Module müssen aktiviert sein:
|
||||
# sudo a2enmod rewrite
|
||||
# sudo systemctl restart apache2
|
||||
|
||||
# HTTP: www.tt-tagebuch.de -> HTTPS: tt-tagebuch.de
|
||||
<VirtualHost *:80>
|
||||
ServerName www.tt-tagebuch.de
|
||||
Redirect permanent / https://tt-tagebuch.de/
|
||||
</VirtualHost>
|
||||
|
||||
# HTTP: tt-tagebuch.de -> HTTPS: tt-tagebuch.de
|
||||
<VirtualHost *:80>
|
||||
ServerName tt-tagebuch.de
|
||||
Redirect permanent / https://tt-tagebuch.de/
|
||||
</VirtualHost>
|
||||
|
||||
60
apache-https.conf.example
Normal file
60
apache-https.conf.example
Normal file
@@ -0,0 +1,60 @@
|
||||
# Apache-Konfiguration für tt-tagebuch.de - HTTPS (Port 443)
|
||||
#
|
||||
# Diese Datei kopieren nach: /etc/apache2/sites-available/tt-tagebuch.de-le-ssl.conf
|
||||
# Dann aktivieren mit: sudo a2ensite tt-tagebuch.de-le-ssl.conf
|
||||
# Und neu starten: sudo systemctl restart apache2
|
||||
#
|
||||
# WICHTIG: Folgende Module müssen aktiviert sein:
|
||||
# sudo a2enmod proxy
|
||||
# sudo a2enmod proxy_http
|
||||
# sudo a2enmod proxy_wstunnel
|
||||
# sudo a2enmod rewrite
|
||||
# sudo a2enmod headers
|
||||
# sudo systemctl restart apache2
|
||||
|
||||
# HTTPS: www.tt-tagebuch.de -> HTTPS: tt-tagebuch.de (301-Weiterleitung)
|
||||
<VirtualHost *:443>
|
||||
ServerName www.tt-tagebuch.de
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/tt-tagebuch.de/privkey.pem
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
Redirect permanent / https://tt-tagebuch.de/
|
||||
</VirtualHost>
|
||||
|
||||
# HTTPS: tt-tagebuch.de - Hauptkonfiguration (non-www)
|
||||
<VirtualHost *:443>
|
||||
ServerName tt-tagebuch.de
|
||||
|
||||
DocumentRoot /var/www/tt-tagebuch.de
|
||||
|
||||
<Directory /var/www/tt-tagebuch.de>
|
||||
Options Indexes FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/tt-tagebuch.de_error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/tt-tagebuch.de_access.log combined
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/tt-tagebuch.de/privkey.pem
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
ProxyRequests Off
|
||||
|
||||
# HINWEIS: Socket.IO läuft jetzt direkt auf HTTPS-Port 3051 (nicht über Apache-Proxy)
|
||||
# Siehe backend/SOCKET_IO_SSL_SETUP.md für Details
|
||||
|
||||
# API-Routen
|
||||
ProxyPass /api http://localhost:3050/api
|
||||
ProxyPassReverse /api http://localhost:3050/api
|
||||
|
||||
# Alle anderen Anfragen an den Backend-Server (für Frontend)
|
||||
ProxyPass / http://localhost:3050/
|
||||
ProxyPassReverse / http://localhost:3050/
|
||||
</VirtualHost>
|
||||
|
||||
89
apache.conf.example
Normal file
89
apache.conf.example
Normal file
@@ -0,0 +1,89 @@
|
||||
# Apache-Konfiguration für tt-tagebuch.de
|
||||
#
|
||||
# HINWEIS: Diese Datei ist eine kombinierte Referenz.
|
||||
# Für die tatsächliche Konfiguration werden zwei separate Dateien verwendet:
|
||||
#
|
||||
# 1. apache-http.conf.example -> /etc/apache2/sites-available/tt-tagebuch.de.conf
|
||||
# (HTTP, Port 80 - Weiterleitung zu HTTPS)
|
||||
#
|
||||
# 2. apache-https.conf.example -> /etc/apache2/sites-available/tt-tagebuch.de-le-ssl.conf
|
||||
# (HTTPS, Port 443 - Hauptkonfiguration)
|
||||
#
|
||||
# Oder verwende das Update-Skript: ./update-apache-config.sh
|
||||
#
|
||||
# WICHTIG: Folgende Module müssen aktiviert sein:
|
||||
# sudo a2enmod proxy
|
||||
# sudo a2enmod proxy_http
|
||||
# sudo a2enmod proxy_wstunnel
|
||||
# sudo a2enmod rewrite
|
||||
# sudo a2enmod headers
|
||||
# sudo systemctl restart apache2
|
||||
|
||||
# ============================================
|
||||
# HTTP (Port 80) - Weiterleitung zu HTTPS
|
||||
# ============================================
|
||||
|
||||
# HTTP: www.tt-tagebuch.de -> HTTPS: tt-tagebuch.de
|
||||
<VirtualHost *:80>
|
||||
ServerName www.tt-tagebuch.de
|
||||
Redirect permanent / https://tt-tagebuch.de/
|
||||
</VirtualHost>
|
||||
|
||||
# HTTP: tt-tagebuch.de -> HTTPS: tt-tagebuch.de
|
||||
<VirtualHost *:80>
|
||||
ServerName tt-tagebuch.de
|
||||
Redirect permanent / https://tt-tagebuch.de/
|
||||
</VirtualHost>
|
||||
|
||||
# ============================================
|
||||
# HTTPS (Port 443) - Weiterleitung www -> non-www
|
||||
# ============================================
|
||||
|
||||
# HTTPS: www.tt-tagebuch.de -> HTTPS: tt-tagebuch.de (301-Weiterleitung)
|
||||
<VirtualHost *:443>
|
||||
ServerName www.tt-tagebuch.de
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/tt-tagebuch.de/privkey.pem
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
Redirect permanent / https://tt-tagebuch.de/
|
||||
</VirtualHost>
|
||||
|
||||
# ============================================
|
||||
# HTTPS (Port 443) - Hauptkonfiguration (non-www)
|
||||
# ============================================
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName tt-tagebuch.de
|
||||
|
||||
DocumentRoot /var/www/tt-tagebuch.de
|
||||
|
||||
<Directory /var/www/tt-tagebuch.de>
|
||||
Options Indexes FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/tt-tagebuch.de_error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/tt-tagebuch.de_access.log combined
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/tt-tagebuch.de/privkey.pem
|
||||
Include /etc/letsencrypt/options-ssl-apache.conf
|
||||
|
||||
ProxyRequests Off
|
||||
|
||||
# HINWEIS: Socket.IO läuft jetzt direkt auf HTTPS-Port 3051 (nicht über Apache-Proxy)
|
||||
# Siehe backend/SOCKET_IO_SSL_SETUP.md für Details
|
||||
|
||||
# API-Routen
|
||||
ProxyPass /api http://localhost:3050/api
|
||||
ProxyPassReverse /api http://localhost:3050/api
|
||||
|
||||
# Alle anderen Anfragen an den Backend-Server (für Frontend)
|
||||
ProxyPass / http://localhost:3050/
|
||||
ProxyPassReverse / http://localhost:3050/
|
||||
</VirtualHost>
|
||||
122
backend/CLICKTT_HTTV_README.md
Normal file
122
backend/CLICKTT_HTTV_README.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# HTTV / click-TT HTTP-Seiten – Integration & Logging
|
||||
|
||||
Dieses Modul ermöglicht das Testen und Logging von HTTP-Aufrufen an die click-TT-Seiten verschiedener Tischtennis-Verbände (HTTV, RTTV, WTTV etc.).
|
||||
|
||||
## Zweck
|
||||
|
||||
- **Logging**: Jeder Aufruf wird in `http_page_fetch_log` protokolliert (URL, HTTP-Status, Response-Snippet, Fehler).
|
||||
- **Strukturanalyse**: Die Logs helfen zu verstehen, wie die Seiten je nach Verband und Saison aufgebaut sind.
|
||||
- **URL-Varianten**: Links können je nach Verein, Saison und Verband unterschiedlich sein.
|
||||
|
||||
## Verband → Domain
|
||||
|
||||
| Verband | Domain |
|
||||
|---------|--------|
|
||||
| HeTTV / HTTV | httv.click-tt.de |
|
||||
| RTTV | rttv.click-tt.de |
|
||||
| WTTV | wttv.click-tt.de |
|
||||
| TTVNw | ttvnw.click-tt.de |
|
||||
| BTTV | battv.click-tt.de |
|
||||
|
||||
## URL-Struktur (httv.click-tt.de)
|
||||
|
||||
### leaguePage – Ligenübersicht
|
||||
|
||||
```
|
||||
https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/leaguePage?championship=HTTV+25%2F26
|
||||
```
|
||||
|
||||
- `championship`: Saison/Championship, z.B.:
|
||||
- `HTTV 25/26` – Haupt-HTTV-Saison
|
||||
- `K43 25/26` – Bezirk Frankfurt
|
||||
- `K16 25/26` – Bezirk Werra-Meißner
|
||||
- `RL-OL West 25/26` – Regional-/Oberligen West
|
||||
|
||||
### regionMeetingFilter – Regionsspielplan
|
||||
|
||||
```
|
||||
https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/regionMeetingFilter?championship=HTTV+25%2F26
|
||||
```
|
||||
|
||||
### clubInfoDisplay – Vereinsinfo
|
||||
|
||||
```
|
||||
https://httv.click-tt.de/cgi-bin/WebObjects/nuLigaTTDE.woa/wa/clubInfoDisplay?club=1060
|
||||
```
|
||||
|
||||
- `club`: Vereins-ID in der click-TT-Datenbank
|
||||
|
||||
## UI-Seite
|
||||
|
||||
Unter **/clicktt** (nur für Admins) gibt es eine Vue-Seite, mit der du:
|
||||
|
||||
- Seitentyp wählen (Ligenübersicht, Vereinsinfo, Regionsspielplan oder direkte URL)
|
||||
- Verband, Championship/Saison und ggf. Vereins-ID eingeben
|
||||
- Die Seite im iframe laden und direkt bedienen (klicken, navigieren)
|
||||
|
||||
Alle Aufrufe werden in `http_page_fetch_log` protokolliert.
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
Die meisten Endpunkte erfordern Authentifizierung (Token). Der **Proxy** (`/api/clicktt/proxy`) ist ohne Auth nutzbar (für iframe-Einbettung).
|
||||
|
||||
### Ligenübersicht abrufen
|
||||
|
||||
```
|
||||
GET /api/clicktt/league-page?association=HeTTV&championship=HTTV+25%2F26
|
||||
```
|
||||
|
||||
### Vereinsinfo abrufen
|
||||
|
||||
```
|
||||
GET /api/clicktt/club-info?association=HeTTV&clubId=1060
|
||||
```
|
||||
|
||||
### Regionsspielplan abrufen
|
||||
|
||||
```
|
||||
GET /api/clicktt/region-meetings?association=HeTTV&championship=HTTV+25%2F26
|
||||
```
|
||||
|
||||
### Beliebige URL abrufen (nur click-tt.de / httv.de)
|
||||
|
||||
```
|
||||
GET /api/clicktt/fetch?url=https%3A%2F%2Fhttv.click-tt.de%2Fcgi-bin%2F...
|
||||
```
|
||||
|
||||
### Logs abrufen
|
||||
|
||||
```
|
||||
GET /api/clicktt/logs?limit=50&fetchType=leaguePage&association=HeTTV
|
||||
```
|
||||
|
||||
### URL-Info (Beispiele, Verband→Domain)
|
||||
|
||||
```
|
||||
GET /api/clicktt/url-info
|
||||
```
|
||||
|
||||
## Konfiguration (.env)
|
||||
|
||||
Für das Link-Rewriting (Folge-Klicks im iframe) wird die Backend-URL benötigt:
|
||||
|
||||
- **BACKEND_BASE_URL** – URL, unter der die API erreichbar ist (z.B. `https://tt-tagebuch.de`)
|
||||
- **BASE_URL** – Fallback, falls BACKEND_BASE_URL nicht gesetzt ist
|
||||
|
||||
In Produktion mit Reverse-Proxy (Apache) reicht meist `BASE_URL=https://tt-tagebuch.de`. In der Entwicklung kann `BACKEND_BASE_URL=http://localhost:3005` nötig sein, wenn die API auf einem anderen Port als das Frontend läuft.
|
||||
|
||||
## Datenbank-Migration
|
||||
|
||||
```bash
|
||||
mysql -u USER -p DATABASE < backend/migrations/create_http_page_fetch_log.sql
|
||||
```
|
||||
|
||||
## Hinweis: mytischtennis.de vs. click-TT
|
||||
|
||||
Die **leaguePage** auf httv.click-tt.de zeigt eine Übersicht mit Links. Die eigentlichen **Tabellen und Spielpläne** verweisen auf **mytischtennis.de**:
|
||||
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/Hessenliga_Gr._Süd-West/gruppe/496273/tabelle/gesamt
|
||||
```
|
||||
|
||||
Diese mytischtennis.de-URLs werden bereits über den bestehenden MyTischtennis-URL-Parser und Auto-Fetch unterstützt. Die httv.click-tt.de-Seiten dienen vor allem der Navigation und der Ermittlung von Gruppen-IDs für verschiedene Bezirke/Saisonen.
|
||||
217
backend/MYTISCHTENNIS_AUTO_FETCH_README.md
Normal file
217
backend/MYTISCHTENNIS_AUTO_FETCH_README.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# MyTischtennis Automatischer Datenabruf
|
||||
|
||||
## Übersicht
|
||||
|
||||
Dieses System ermöglicht den automatischen Abruf von Spielergebnissen und Statistiken von myTischtennis.de.
|
||||
|
||||
## Scheduler
|
||||
|
||||
### 6:00 Uhr - Rating Updates
|
||||
- **Service:** `autoUpdateRatingsService.js`
|
||||
- **Funktion:** Aktualisiert TTR/QTTR-Werte für Spieler
|
||||
- **Status:** ✅ Aktiv. Nutzt `memberService.updateRatingsFromMyTischtennisByUserId(...)` pro Verein ueber einen freigeschalteten Benutzer mit gespeichertem myTischtennis-Login.
|
||||
|
||||
### 6:30 Uhr - Spielergebnisse
|
||||
- **Service:** `autoFetchMatchResultsService.js`
|
||||
- **Funktion:** Ruft Team-Spielplaene, Liga-Spielplaene, Spielerbilanzen und Ligatabellen fuer konfigurierte Teams ab
|
||||
- **Status:** ✅ Aktiv. Importiert neue Spiele, aktualisiert Ergebnis- und Termin-Aenderungen und synchronisiert Ligatabellen.
|
||||
|
||||
## Benötigte Konfiguration
|
||||
|
||||
### 1. MyTischtennis-Account
|
||||
- Account muss in den MyTischtennis-Settings verknüpft sein
|
||||
- Checkbox "Automatische Updates" aktivieren
|
||||
- Passwort speichern (erforderlich für automatische Re-Authentifizierung)
|
||||
|
||||
### 2. League-Konfiguration
|
||||
|
||||
Für jede Liga müssen folgende Felder ausgefüllt werden:
|
||||
|
||||
```sql
|
||||
UPDATE league SET
|
||||
my_tischtennis_group_id = '504417', -- Group ID von myTischtennis
|
||||
association = 'HeTTV', -- Verband (z.B. HeTTV, DTTB)
|
||||
groupname = '1.Kreisklasse' -- Gruppenname für URL
|
||||
WHERE id = 1;
|
||||
```
|
||||
|
||||
**Beispiel-URL:**
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/...
|
||||
^^^^^ ^^^^^^^^^^^^^^ ^^^^^^
|
||||
association groupname group_id
|
||||
```
|
||||
|
||||
### 3. Team-Konfiguration
|
||||
|
||||
Für jedes Team muss die myTischtennis Team-ID gesetzt werden:
|
||||
|
||||
```sql
|
||||
UPDATE club_team SET
|
||||
my_tischtennis_team_id = '2995094' -- Team ID von myTischtennis
|
||||
WHERE id = 1;
|
||||
```
|
||||
|
||||
**Beispiel-URL:**
|
||||
```
|
||||
.../mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt
|
||||
^^^^^^^
|
||||
team_id
|
||||
```
|
||||
|
||||
### 4. Spieler-Zuordnung (Optional)
|
||||
|
||||
Spieler werden automatisch anhand des Namens zugeordnet. Für genauere Zuordnung kann die myTischtennis Player-ID gesetzt werden:
|
||||
|
||||
```sql
|
||||
UPDATE member SET
|
||||
my_tischtennis_player_id = 'NU2705037' -- Player ID von myTischtennis
|
||||
WHERE id = 1;
|
||||
```
|
||||
|
||||
## Migrationen
|
||||
|
||||
Folgende Migrationen müssen ausgeführt werden:
|
||||
|
||||
```bash
|
||||
# 1. MyTischtennis Auto-Update-Felder
|
||||
mysql -u root -p trainingstagebuch < backend/migrations/add_auto_update_ratings_to_my_tischtennis.sql
|
||||
|
||||
# 2. MyTischtennis Update-History-Tabelle
|
||||
mysql -u root -p trainingstagebuch < backend/migrations/create_my_tischtennis_update_history.sql
|
||||
|
||||
# 3. League MyTischtennis-Felder
|
||||
mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_fields_to_league.sql
|
||||
|
||||
# 4. Team MyTischtennis-ID
|
||||
mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_team_id_to_club_team.sql
|
||||
|
||||
# 5. Member MyTischtennis Player-ID
|
||||
mysql -u root -p trainingstagebuch < backend/migrations/add_mytischtennis_player_id_to_member.sql
|
||||
|
||||
# 6. Match Result-Felder
|
||||
mysql -u root -p trainingstagebuch < backend/migrations/add_match_result_fields.sql
|
||||
```
|
||||
|
||||
## Abgerufene Daten
|
||||
|
||||
Von der myTischtennis API werden folgende Daten abgerufen:
|
||||
|
||||
### Einzelstatistiken
|
||||
- Player ID, Vorname, Nachname
|
||||
- Gewonnene/Verlorene Punkte
|
||||
- Anzahl Spiele
|
||||
- Detaillierte Statistiken nach Gegner-Position
|
||||
|
||||
### Doppelstatistiken
|
||||
- Player IDs, Namen der beiden Spieler
|
||||
- Gewonnene/Verlorene Punkte
|
||||
- Anzahl Spiele
|
||||
- Zuordnung der beteiligten Mitglieder ueber Player-ID oder Namensabgleich
|
||||
|
||||
### Team-Informationen
|
||||
- Teamname, Liga, Saison
|
||||
- Gesamtpunkte (gewonnen/verloren)
|
||||
- Doppel- und Einzelpunkte
|
||||
|
||||
## Implementierungsdetails
|
||||
|
||||
### Datenfluss
|
||||
|
||||
1. **Scheduler** (6:30 Uhr):
|
||||
- `schedulerService.js` triggert `autoFetchMatchResultsService.executeAutomaticFetch()`
|
||||
|
||||
2. **Account-Verarbeitung**:
|
||||
- Lädt alle MyTischtennis-Accounts mit `autoUpdateRatings = true`
|
||||
- Prüft Session-Gültigkeit
|
||||
- Re-Authentifizierung bei abgelaufener Session
|
||||
|
||||
3. **Team-Abfrage**:
|
||||
- Lädt alle Teams mit konfigurierten myTischtennis-IDs
|
||||
- Baut API-URL dynamisch zusammen
|
||||
- Führt authentifizierten GET-Request durch
|
||||
|
||||
4. **Datenverarbeitung**:
|
||||
- Parst JSON-Response
|
||||
- Matched Spieler anhand von ID oder Name
|
||||
- Speichert myTischtennis Player-ID bei Mitgliedern
|
||||
- verarbeitet auch Doppelpartner-Zuordnungen
|
||||
- speichert/aktualisiert Spiele und Ligatabellen
|
||||
|
||||
### Player-Matching-Algorithmus
|
||||
|
||||
```javascript
|
||||
1. Suche nach myTischtennis Player-ID (exakte Übereinstimmung)
|
||||
2. Falls nicht gefunden: Suche nach Name (case-insensitive)
|
||||
3. Falls gefunden: Speichere myTischtennis Player-ID für zukünftige Abfragen
|
||||
```
|
||||
|
||||
**Hinweis:** Da Namen verschlüsselt gespeichert werden, müssen für den Namens-Abgleich alle Members geladen und entschlüsselt werden. Dies ist bei großen Datenbanken ineffizient.
|
||||
|
||||
## TODO / Offene Punkte
|
||||
|
||||
### Noch offen:
|
||||
|
||||
1. **Spielergebnis-Details**:
|
||||
- Einzelne Matches mit Satzständen speichern
|
||||
- Tabelle für Match-Historie erstellen
|
||||
|
||||
2. **History-Tabelle für Spielergebnis-Abrufe** (optional):
|
||||
- Ähnlich zu `my_tischtennis_update_history`
|
||||
- Speichert Erfolg/Fehler der Abrufe
|
||||
|
||||
3. **Benachrichtigungen** (optional):
|
||||
- Email/Push bei neuen Ergebnissen
|
||||
- Highlights für besondere Siege
|
||||
|
||||
4. **Performance-Optimierung**:
|
||||
- Caching für Player-Matches
|
||||
- Incremental Updates (nur neue Daten)
|
||||
|
||||
## Manueller Test
|
||||
|
||||
```javascript
|
||||
// Im Node-Backend-Code oder über API-Endpoint:
|
||||
import schedulerService from './services/schedulerService.js';
|
||||
|
||||
// Rating Updates manuell triggern
|
||||
await schedulerService.triggerRatingUpdates();
|
||||
|
||||
// Spielergebnisse manuell abrufen
|
||||
await schedulerService.triggerMatchResultsFetch();
|
||||
```
|
||||
|
||||
### Manuelle HTTP-Trigger
|
||||
|
||||
```text
|
||||
POST /api/scheduler/rating_updates
|
||||
POST /api/scheduler/match_results
|
||||
GET /api/scheduler/status
|
||||
```
|
||||
|
||||
## API-Dokumentation
|
||||
|
||||
### MyTischtennis Spielerbilanzen-Endpoint
|
||||
|
||||
**URL-Format:**
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/{association}/{season}/ligen/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/spielerbilanzen/gesamt?_data=routes%2Fclick-tt%2B%2F%24association%2B%2F%24season%2B%2F%24type%2B%2F%28%24groupname%29.gruppe.%24urlid_.mannschaft.%24teamid.%24teamname%2B%2Fspielerbilanzen.%24filter
|
||||
```
|
||||
|
||||
**Parameter:**
|
||||
- `{association}`: Verband (z.B. "HeTTV")
|
||||
- `{season}`: Saison im Format "25--26"
|
||||
- `{groupname}`: Gruppenname URL-encoded (z.B. "1.Kreisklasse")
|
||||
- `{groupId}`: Gruppen-ID (numerisch, z.B. "504417")
|
||||
- `{teamId}`: Team-ID (numerisch, z.B. "2995094")
|
||||
- `{teamname}`: Teamname URL-encoded mit Underscores (z.B. "Harheimer_TC_(J11)")
|
||||
|
||||
**Response:** JSON mit `data.balancesheet` Array
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- ✅ Automatische Session-Verwaltung
|
||||
- ✅ Re-Authentifizierung bei abgelaufenen Sessions
|
||||
- ✅ Passwörter verschlüsselt gespeichert
|
||||
- ✅ Fehlerbehandlung und Logging
|
||||
- ✅ Graceful Degradation (einzelne Team-Fehler stoppen nicht den gesamten Prozess)
|
||||
332
backend/MYTISCHTENNIS_URL_PARSER_README.md
Normal file
332
backend/MYTISCHTENNIS_URL_PARSER_README.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# MyTischtennis URL Parser
|
||||
|
||||
## Übersicht
|
||||
|
||||
Der URL-Parser ermöglicht es, myTischtennis-Team-URLs automatisch zu parsen und die Konfiguration für automatische Datenabrufe vorzunehmen.
|
||||
|
||||
## Verwendung
|
||||
|
||||
### 1. URL Parsen
|
||||
|
||||
**Endpoint:** `POST /api/mytischtennis/parse-url`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"association": "HeTTV",
|
||||
"season": "25/26",
|
||||
"type": "ligen",
|
||||
"groupname": "1.Kreisklasse",
|
||||
"groupId": "504417",
|
||||
"teamId": "2995094",
|
||||
"teamname": "Harheimer TC (J11)",
|
||||
"originalUrl": "https://www.mytischtennis.de/click-tt/...",
|
||||
"clubId": "43030",
|
||||
"clubName": "Harheimer TC",
|
||||
"teamName": "Jugend 11",
|
||||
"leagueName": "Jugend 13 1. Kreisklasse",
|
||||
"region": "Frankfurt",
|
||||
"tableRank": 8,
|
||||
"matchesWon": 0,
|
||||
"matchesLost": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Team Automatisch Konfigurieren
|
||||
|
||||
**Endpoint:** `POST /api/mytischtennis/configure-team`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt",
|
||||
"clubTeamId": 1,
|
||||
"createLeague": false,
|
||||
"createSeason": false
|
||||
}
|
||||
```
|
||||
|
||||
**Parameter:**
|
||||
- `url` (required): Die myTischtennis-URL
|
||||
- `clubTeamId` (required): Die ID des lokalen Club-Teams
|
||||
- `createLeague` (optional): Wenn `true`, wird eine neue League erstellt
|
||||
- `createSeason` (optional): Wenn `true`, wird eine neue Season erstellt
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Team configured successfully",
|
||||
"data": {
|
||||
"team": {
|
||||
"id": 1,
|
||||
"name": "Jugend 11",
|
||||
"myTischtennisTeamId": "2995094"
|
||||
},
|
||||
"league": {
|
||||
"id": 5,
|
||||
"name": "Jugend 13 1. Kreisklasse",
|
||||
"myTischtennisGroupId": "504417",
|
||||
"association": "HeTTV",
|
||||
"groupname": "1.Kreisklasse"
|
||||
},
|
||||
"season": {
|
||||
"id": 2,
|
||||
"name": "25/26"
|
||||
},
|
||||
"parsedData": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. URL für Team Abrufen
|
||||
|
||||
**Endpoint:** `GET /api/mytischtennis/team-url/:teamId`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"url": "https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer%20TC%20%28J11%29/spielerbilanzen/gesamt"
|
||||
}
|
||||
```
|
||||
|
||||
## URL-Format
|
||||
|
||||
### Unterstützte URL-Muster
|
||||
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/{association}/{season}/{type}/{groupname}/gruppe/{groupId}/mannschaft/{teamId}/{teamname}/...
|
||||
```
|
||||
|
||||
**Komponenten:**
|
||||
- `{association}`: Verband (z.B. "HeTTV", "DTTB", "WestD")
|
||||
- `{season}`: Saison im Format "YY--YY" (z.B. "25--26" für 2025/2026)
|
||||
- `{type}`: Typ (meist "ligen")
|
||||
- `{groupname}`: Gruppenname URL-encoded (z.B. "1.Kreisklasse", "Kreisliga")
|
||||
- `{groupId}`: Numerische Gruppen-ID (z.B. "504417")
|
||||
- `{teamId}`: Numerische Team-ID (z.B. "2995094")
|
||||
- `{teamname}`: Teamname URL-encoded mit Underscores (z.B. "Harheimer_TC_(J11)")
|
||||
|
||||
### Beispiel-URLs
|
||||
|
||||
**Spielerbilanzen:**
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielerbilanzen/gesamt
|
||||
```
|
||||
|
||||
**Spielplan:**
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/spielplan
|
||||
```
|
||||
|
||||
**Tabelle:**
|
||||
```
|
||||
https://www.mytischtennis.de/click-tt/HeTTV/25--26/ligen/1.Kreisklasse/gruppe/504417/mannschaft/2995094/Harheimer_TC_(J11)/tabelle
|
||||
```
|
||||
|
||||
## Datenfluss
|
||||
|
||||
### Ohne MyTischtennis-Login
|
||||
|
||||
1. URL wird geparst
|
||||
2. Nur URL-Komponenten werden extrahiert
|
||||
3. Zusätzliche Daten (clubName, leagueName, etc.) sind nicht verfügbar
|
||||
|
||||
### Mit MyTischtennis-Login
|
||||
|
||||
1. URL wird geparst
|
||||
2. API-Request an myTischtennis mit Authentication
|
||||
3. Vollständige Team-Daten werden abgerufen
|
||||
4. Alle Felder sind verfügbar
|
||||
|
||||
## Frontend-Integration
|
||||
|
||||
### Vue.js Beispiel
|
||||
|
||||
```javascript
|
||||
<template>
|
||||
<div>
|
||||
<input
|
||||
v-model="myTischtennisUrl"
|
||||
placeholder="MyTischtennis URL einfügen..."
|
||||
@blur="parseUrl"
|
||||
/>
|
||||
|
||||
<div v-if="parsedData">
|
||||
<h3>{{ parsedData.teamname }}</h3>
|
||||
<p>Liga: {{ parsedData.leagueName }}</p>
|
||||
<p>Verband: {{ parsedData.association }}</p>
|
||||
<p>Tabelle: Platz {{ parsedData.tableRank }}</p>
|
||||
|
||||
<button @click="configureTeam">Team konfigurieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
myTischtennisUrl: '',
|
||||
parsedData: null
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async parseUrl() {
|
||||
if (!this.myTischtennisUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/mytischtennis/parse-url', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'userid': this.userId,
|
||||
'authcode': this.authCode
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: this.myTischtennisUrl
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
this.parsedData = result.data;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Parsen:', error);
|
||||
// Hinweis: Im Frontend stattdessen InfoDialog/ConfirmDialog verwenden
|
||||
// alert('URL konnte nicht geparst werden');
|
||||
}
|
||||
},
|
||||
|
||||
async configureTeam() {
|
||||
try {
|
||||
const response = await fetch('/api/mytischtennis/configure-team', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'userid': this.userId,
|
||||
'authcode': this.authCode
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: this.myTischtennisUrl,
|
||||
clubTeamId: this.selectedTeamId,
|
||||
createLeague: false,
|
||||
createSeason: true
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// In der Anwendung bitte InfoDialog nutzen
|
||||
// alert('Team erfolgreich konfiguriert!');
|
||||
} else {
|
||||
// alert('Team konnte nicht konfiguriert werden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler bei Konfiguration:', error);
|
||||
// alert('Team konnte nicht konfiguriert werden');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### Empfohlener Workflow für Benutzer
|
||||
|
||||
1. **MyTischtennis-URL kopieren:**
|
||||
- Auf myTischtennis.de zum Team navigieren
|
||||
- URL aus Adresszeile kopieren
|
||||
|
||||
2. **URL in Trainingstagebuch einfügen:**
|
||||
- Zu Team-Verwaltung navigieren
|
||||
- URL einfügen
|
||||
- Automatisches Parsen
|
||||
|
||||
3. **Konfiguration überprüfen:**
|
||||
- Geparste Daten werden angezeigt
|
||||
- Benutzer kann Daten überprüfen und bei Bedarf anpassen
|
||||
|
||||
4. **Team konfigurieren:**
|
||||
- Auf "Konfigurieren" klicken
|
||||
- System speichert alle benötigten IDs
|
||||
- Automatischer Datenabruf ist ab sofort aktiv
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
### Häufige Fehler
|
||||
|
||||
**"Invalid myTischtennis URL format"**
|
||||
- URL entspricht nicht dem erwarteten Format
|
||||
- Lösung: Vollständige URL von der Spielerbilanzen-Seite kopieren
|
||||
|
||||
**"Season not found"**
|
||||
- Saison existiert noch nicht in der Datenbank
|
||||
- Lösung: `createSeason: true` setzen
|
||||
|
||||
**"Team has no league assigned"**
|
||||
- Team hat keine verknüpfte Liga
|
||||
- Lösung: `createLeague: true` setzen oder Liga manuell zuweisen
|
||||
|
||||
**"HTTP 401: Unauthorized"**
|
||||
- MyTischtennis-Login abgelaufen oder nicht vorhanden
|
||||
- Lösung: In MyTischtennis-Settings erneut anmelden
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- ✅ Alle Endpoints erfordern Authentifizierung
|
||||
- ✅ UserID wird aus Header-Parameter gelesen
|
||||
- ✅ MyTischtennis-Credentials werden sicher gespeichert
|
||||
- ✅ Keine sensiblen Daten in URLs
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Service: `myTischtennisUrlParserService`
|
||||
|
||||
**Methoden:**
|
||||
- `parseUrl(url)` - Parst URL und extrahiert Komponenten
|
||||
- `fetchTeamData(parsedUrl, cookie, accessToken)` - Ruft zusätzliche Daten ab
|
||||
- `getCompleteConfig(url, cookie, accessToken)` - Kombination aus Parsen + Abrufen
|
||||
- `isValidTeamUrl(url)` - Validiert URL-Format
|
||||
- `buildUrl(config)` - Baut URL aus Komponenten
|
||||
|
||||
### Controller: `myTischtennisUrlController`
|
||||
|
||||
**Endpoints:**
|
||||
- `POST /api/mytischtennis/parse-url` - URL parsen
|
||||
- `POST /api/mytischtennis/configure-team` - Team konfigurieren
|
||||
- `GET /api/mytischtennis/team-url/:teamId` - URL abrufen
|
||||
|
||||
## Zukünftige Erweiterungen
|
||||
|
||||
### Geplante Features
|
||||
|
||||
1. **Bulk-Import:**
|
||||
- Mehrere URLs gleichzeitig importieren
|
||||
- Alle Teams einer Liga auf einmal konfigurieren
|
||||
|
||||
2. **Auto-Discovery:**
|
||||
- Automatisches Finden aller Teams eines Vereins
|
||||
- Vorschläge für ähnliche Teams
|
||||
|
||||
3. **Validierung:**
|
||||
- Prüfung, ob Team bereits konfiguriert ist
|
||||
- Warnung bei Duplikaten
|
||||
|
||||
4. **History:**
|
||||
- Speichern der URL-Konfigurationen
|
||||
- Versionierung bei Änderungen
|
||||
|
||||
140
backend/SOCKET_IO_SSL_SETUP.md
Normal file
140
backend/SOCKET_IO_SSL_SETUP.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Socket.IO mit SSL direkt betreiben (Alternative zu Apache-Proxy)
|
||||
|
||||
Falls die Apache-WebSocket-Proxy-Konfiguration nicht funktioniert, kann Socket.IO direkt mit SSL betrieben werden.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
1. SSL-Zertifikat (z.B. von Let's Encrypt)
|
||||
2. Port in der Firewall öffnen (z.B. 3051)
|
||||
3. Socket.IO-Server auf HTTPS konfigurieren
|
||||
|
||||
## Backend-Konfiguration
|
||||
|
||||
### 1. Socket.IO auf HTTPS umstellen
|
||||
|
||||
Ändere `backend/server.js`:
|
||||
|
||||
```javascript
|
||||
import https from 'https';
|
||||
import fs from 'fs';
|
||||
|
||||
// SSL-Zertifikat laden
|
||||
const httpsOptions = {
|
||||
key: fs.readFileSync('/etc/letsencrypt/live/tt-tagebuch.de/privkey.pem'),
|
||||
cert: fs.readFileSync('/etc/letsencrypt/live/tt-tagebuch.de/fullchain.pem')
|
||||
};
|
||||
|
||||
// HTTPS-Server erstellen
|
||||
const httpsServer = https.createServer(httpsOptions, app);
|
||||
|
||||
// Socket.IO initialisieren
|
||||
initializeSocketIO(httpsServer);
|
||||
|
||||
// HTTPS-Server starten
|
||||
const httpsPort = process.env.HTTPS_PORT || 3051;
|
||||
httpsServer.listen(httpsPort, () => {
|
||||
console.log(`🚀 HTTPS-Server läuft auf Port ${httpsPort}`);
|
||||
});
|
||||
|
||||
// HTTP-Server für API (optional, falls API weiterhin über HTTP laufen soll)
|
||||
const httpServer = createServer(app);
|
||||
const httpPort = process.env.PORT || 3005;
|
||||
httpServer.listen(httpPort, () => {
|
||||
console.log(`🚀 HTTP-Server läuft auf Port ${httpPort}`);
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Frontend-Konfiguration
|
||||
|
||||
Ändere `frontend/src/services/socketService.js`:
|
||||
|
||||
```javascript
|
||||
import { io } from 'socket.io-client';
|
||||
import { backendBaseUrl } from '../apiClient.js';
|
||||
|
||||
let socket = null;
|
||||
|
||||
export const connectSocket = (clubId) => {
|
||||
// Verwende HTTPS-URL für Socket.IO
|
||||
const socketUrl = backendBaseUrl.replace('http://', 'https://').replace(':3005', ':3051');
|
||||
|
||||
if (socket && socket.connected) {
|
||||
// Wenn bereits verbunden, verlasse den alten Club-Raum und trete dem neuen bei
|
||||
if (socket.currentClubId) {
|
||||
socket.emit('leave-club', socket.currentClubId);
|
||||
}
|
||||
} else {
|
||||
// Neue Verbindung erstellen
|
||||
socket = io(socketUrl, {
|
||||
path: '/socket.io/',
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 5,
|
||||
timeout: 20000,
|
||||
upgrade: true,
|
||||
forceNew: false,
|
||||
secure: true // Wichtig für HTTPS
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Socket.IO verbunden');
|
||||
if (socket.currentClubId) {
|
||||
socket.emit('join-club', socket.currentClubId);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Socket.IO getrennt');
|
||||
});
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('Socket.IO Verbindungsfehler:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Club-Raum beitreten
|
||||
if (clubId) {
|
||||
socket.emit('join-club', clubId);
|
||||
socket.currentClubId = clubId;
|
||||
}
|
||||
|
||||
return socket;
|
||||
};
|
||||
|
||||
export const disconnectSocket = () => {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getSocket = () => socket;
|
||||
```
|
||||
|
||||
### 3. Firewall-Port öffnen
|
||||
|
||||
```bash
|
||||
# UFW (Ubuntu Firewall)
|
||||
sudo ufw allow 3051/tcp
|
||||
|
||||
# Oder iptables
|
||||
sudo iptables -A INPUT -p tcp --dport 3051 -j ACCEPT
|
||||
```
|
||||
|
||||
### 4. Apache-Konfiguration anpassen
|
||||
|
||||
Entferne die Socket.IO-Proxy-Konfiguration aus Apache, da Socket.IO jetzt direkt erreichbar ist.
|
||||
|
||||
## Vorteile
|
||||
|
||||
- Einfacher zu konfigurieren
|
||||
- Keine Apache-Proxy-Probleme
|
||||
- Direkte WebSocket-Verbindung
|
||||
|
||||
## Nachteile
|
||||
|
||||
- Separater Port muss geöffnet sein
|
||||
- Zwei Ports (HTTP für API, HTTPS für Socket.IO)
|
||||
- CORS-Konfiguration muss angepasst werden
|
||||
|
||||
@@ -9,15 +9,18 @@ const dbConfig = {
|
||||
database: process.env.DB_NAME || 'trainingsdiary'
|
||||
};
|
||||
|
||||
const report = [];
|
||||
|
||||
async function cleanupKeys() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
console.log('🔌 Verbinde mit der Datenbank...');
|
||||
report.push('🔌 Verbinde mit der Datenbank...');
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
|
||||
// 1. Status vor dem Cleanup
|
||||
console.log('\n📊 STATUS VOR DEM CLEANUP:');
|
||||
report.push('');
|
||||
report.push('📊 STATUS VOR DEM CLEANUP:');
|
||||
const [tablesBefore] = await connection.execute(`
|
||||
SELECT
|
||||
TABLE_NAME,
|
||||
@@ -29,57 +32,60 @@ async function cleanupKeys() {
|
||||
`, [dbConfig.database]);
|
||||
|
||||
tablesBefore.forEach(table => {
|
||||
console.log(` ${table.TABLE_NAME}: ${table.key_count} Keys`);
|
||||
report.push(` ${table.TABLE_NAME}: ${table.key_count} Keys`);
|
||||
});
|
||||
|
||||
// 2. Alle INDEX der Problem-Tabellen anzeigen
|
||||
const problemTables = ['member', 'diary_tags', 'season'];
|
||||
|
||||
for (const tableName of problemTables) {
|
||||
console.log(`\n🔍 INDEX für Tabelle '${tableName}':`);
|
||||
report.push('');
|
||||
report.push(`🔍 INDEX für Tabelle '${tableName}':`);
|
||||
|
||||
try {
|
||||
const [indexes] = await connection.execute(`SHOW INDEX FROM \`${tableName}\``);
|
||||
|
||||
if (indexes.length === 0) {
|
||||
console.log(` Keine INDEX gefunden für Tabelle '${tableName}'`);
|
||||
report.push(` Keine INDEX gefunden für Tabelle '${tableName}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
indexes.forEach(index => {
|
||||
console.log(` - ${index.Key_name} (${index.Column_name}) - ${index.Non_unique === 0 ? 'UNIQUE' : 'NON-UNIQUE'}`);
|
||||
report.push(` - ${index.Key_name} (${index.Column_name}) - ${index.Non_unique === 0 ? 'UNIQUE' : 'NON-UNIQUE'}`);
|
||||
});
|
||||
|
||||
// 3. Überflüssige INDEX entfernen (alle außer PRIMARY und UNIQUE)
|
||||
console.log(`\n🗑️ Entferne überflüssige INDEX aus '${tableName}':`);
|
||||
report.push('');
|
||||
report.push(`🗑️ Entferne überflüssige INDEX aus '${tableName}':`);
|
||||
|
||||
for (const index of indexes) {
|
||||
// Behalte PRIMARY KEY und UNIQUE constraints
|
||||
if (index.Key_name === 'PRIMARY' || index.Non_unique === 0) {
|
||||
console.log(` ✅ Behalte: ${index.Key_name} (${index.Column_name})`);
|
||||
report.push(` ✅ Behalte: ${index.Key_name} (${index.Column_name})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Entferne alle anderen INDEX
|
||||
try {
|
||||
await connection.execute(`DROP INDEX \`${index.Key_name}\` ON \`${tableName}\``);
|
||||
console.log(` ❌ Entfernt: ${index.Key_name} (${index.Column_name})`);
|
||||
report.push(` ❌ Entfernt: ${index.Key_name} (${index.Column_name})`);
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_CANT_DROP_FIELD_OR_KEY') {
|
||||
console.log(` ⚠️ Kann nicht entfernen: ${index.Key_name} (${index.Column_name}) - ${error.message}`);
|
||||
report.push(` ⚠️ Kann nicht entfernen: ${index.Key_name} (${index.Column_name}) - ${error.message}`);
|
||||
} else {
|
||||
console.log(` ❌ Fehler beim Entfernen von ${index.Key_name}: ${error.message}`);
|
||||
report.push(` ❌ Fehler beim Entfernen von ${index.Key_name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(` ⚠️ Fehler beim Zugriff auf Tabelle '${tableName}': ${error.message}`);
|
||||
report.push(` ⚠️ Fehler beim Zugriff auf Tabelle '${tableName}': ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Status nach dem Cleanup
|
||||
console.log('\n📊 STATUS NACH DEM CLEANUP:');
|
||||
report.push('');
|
||||
report.push('📊 STATUS NACH DEM CLEANUP:');
|
||||
const [tablesAfter] = await connection.execute(`
|
||||
SELECT
|
||||
TABLE_NAME,
|
||||
@@ -96,7 +102,7 @@ async function cleanupKeys() {
|
||||
const diff = beforeCount - table.key_count;
|
||||
const status = table.key_count <= 5 ? '✅' : table.key_count <= 10 ? '⚠️' : '❌';
|
||||
|
||||
console.log(` ${status} ${table.TABLE_NAME}: ${table.key_count} Keys (${diff > 0 ? `-${diff}` : `+${Math.abs(diff)}`})`);
|
||||
report.push(` ${status} ${table.TABLE_NAME}: ${table.key_count} Keys (${diff > 0 ? `-${diff}` : `+${Math.abs(diff)}`})`);
|
||||
});
|
||||
|
||||
// 5. Gesamtanzahl der Keys
|
||||
@@ -106,18 +112,20 @@ async function cleanupKeys() {
|
||||
WHERE TABLE_SCHEMA = ?
|
||||
`, [dbConfig.database]);
|
||||
|
||||
console.log(`\n📈 GESAMTANZAHL KEYS: ${totalKeys[0].total_keys}`);
|
||||
|
||||
report.push('');
|
||||
report.push(`📈 GESAMTANZAHL KEYS: ${totalKeys[0].total_keys}`);
|
||||
|
||||
// 6. Zusammenfassung
|
||||
console.log('\n🎯 ZUSAMMENFASSUNG:');
|
||||
report.push('');
|
||||
report.push('🎯 ZUSAMMENFASSUNG:');
|
||||
const problemTablesAfter = tablesAfter.filter(t => t.key_count > 10);
|
||||
|
||||
if (problemTablesAfter.length === 0) {
|
||||
console.log(' ✅ Alle Tabellen haben jetzt weniger als 10 Keys!');
|
||||
report.push(' ✅ Alle Tabellen haben jetzt weniger als 10 Keys!');
|
||||
} else {
|
||||
console.log(' ⚠️ Folgende Tabellen haben immer noch zu viele Keys:');
|
||||
report.push(' ⚠️ Folgende Tabellen haben immer noch zu viele Keys:');
|
||||
problemTablesAfter.forEach(table => {
|
||||
console.log(` - ${table.TABLE_NAME}: ${table.key_count} Keys`);
|
||||
report.push(` - ${table.TABLE_NAME}: ${table.key_count} Keys`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,15 +134,18 @@ async function cleanupKeys() {
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
console.log('\n🔌 Datenbankverbindung geschlossen.');
|
||||
report.push('');
|
||||
report.push('🔌 Datenbankverbindung geschlossen.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Script ausführen
|
||||
console.log('🚀 Starte intelligentes INDEX-Cleanup...\n');
|
||||
report.push('🚀 Starte intelligentes INDEX-Cleanup...');
|
||||
cleanupKeys().then(() => {
|
||||
console.log('\n✨ Cleanup abgeschlossen!');
|
||||
report.push('');
|
||||
report.push('✨ Cleanup abgeschlossen!');
|
||||
process.stdout.write(`${report.join('\n')}\n`);
|
||||
process.exit(0);
|
||||
}).catch(error => {
|
||||
console.error('\n💥 Fehler beim Cleanup:', error);
|
||||
|
||||
@@ -7,41 +7,23 @@ import { fileURLToPath } from 'url';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const report = [];
|
||||
|
||||
// Umgebungsvariablen aus dem Root-Verzeichnis laden
|
||||
//const envPath = path.join(__dirname, '..', '.env');
|
||||
//console.log('🔍 Lade .env-Datei von:', envPath);
|
||||
const envPath = path.join(__dirname, '..', '.env');
|
||||
dotenv.config();
|
||||
|
||||
// Debug: Zeige geladene Umgebungsvariablen
|
||||
console.log('🔍 Geladene Umgebungsvariablen:');
|
||||
console.log(' DB_HOST:', process.env.DB_HOST);
|
||||
console.log(' DB_USER:', process.env.DB_USER);
|
||||
console.log(' DB_NAME:', process.env.DB_NAME);
|
||||
console.log(' DB_PASSWORD:', process.env.DB_PASSWORD ? '***gesetzt***' : 'nicht gesetzt');
|
||||
|
||||
// Datenbankverbindung
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'trainingsdiary'
|
||||
};
|
||||
|
||||
console.log('🔍 Datenbankverbindung:');
|
||||
console.log(' Host:', dbConfig.host);
|
||||
console.log(' User:', dbConfig.user);
|
||||
console.log(' Database:', dbConfig.database);
|
||||
console.log(' Password:', dbConfig.password ? '***gesetzt***' : 'nicht gesetzt');
|
||||
report.push('Environment variables loaded');
|
||||
|
||||
async function cleanupKeys() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
console.log('🔌 Verbinde mit der Datenbank...');
|
||||
report.push('🔌 Verbinde mit der Datenbank...');
|
||||
connection = await mysql.createConnection(dbConfig);
|
||||
|
||||
// 1. Status vor dem Cleanup
|
||||
console.log('\n📊 STATUS VOR DEM CLEANUP:');
|
||||
report.push('');
|
||||
report.push('📊 STATUS VOR DEM CLEANUP:');
|
||||
const [tablesBefore] = await connection.execute(`
|
||||
SELECT
|
||||
TABLE_NAME,
|
||||
@@ -53,57 +35,60 @@ async function cleanupKeys() {
|
||||
`, [dbConfig.database]);
|
||||
|
||||
tablesBefore.forEach(table => {
|
||||
console.log(` ${table.TABLE_NAME}: ${table.key_count} Keys`);
|
||||
report.push(` ${table.TABLE_NAME}: ${table.key_count} Keys`);
|
||||
});
|
||||
|
||||
// 2. Alle INDEX der Problem-Tabellen anzeigen
|
||||
const problemTables = ['member', 'diary_tags', 'season'];
|
||||
|
||||
for (const tableName of problemTables) {
|
||||
console.log(`\n🔍 INDEX für Tabelle '${tableName}':`);
|
||||
report.push('');
|
||||
report.push(`🔍 INDEX für Tabelle '${tableName}':`);
|
||||
|
||||
try {
|
||||
const [indexes] = await connection.execute(`SHOW INDEX FROM \`${tableName}\``);
|
||||
|
||||
if (indexes.length === 0) {
|
||||
console.log(` Keine INDEX gefunden für Tabelle '${tableName}'`);
|
||||
report.push(` Keine INDEX gefunden für Tabelle '${tableName}'`);
|
||||
continue;
|
||||
}
|
||||
|
||||
indexes.forEach(index => {
|
||||
console.log(` - ${index.Key_name} (${index.Column_name}) - ${index.Non_unique === 0 ? 'UNIQUE' : 'NON-UNIQUE'}`);
|
||||
report.push(` - ${index.Key_name} (${index.Column_name}) - ${index.Non_unique === 0 ? 'UNIQUE' : 'NON-UNIQUE'}`);
|
||||
});
|
||||
|
||||
// 3. Überflüssige INDEX entfernen (alle außer PRIMARY und UNIQUE)
|
||||
console.log(`\n🗑️ Entferne überflüssige INDEX aus '${tableName}':`);
|
||||
report.push('');
|
||||
report.push(`🗑️ Entferne überflüssige INDEX aus '${tableName}':`);
|
||||
|
||||
for (const index of indexes) {
|
||||
// Behalte PRIMARY KEY und UNIQUE constraints
|
||||
if (index.Key_name === 'PRIMARY' || index.Non_unique === 0) {
|
||||
console.log(` ✅ Behalte: ${index.Key_name} (${index.Column_name})`);
|
||||
report.push(` ✅ Behalte: ${index.Key_name} (${index.Column_name})`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Entferne alle anderen INDEX
|
||||
try {
|
||||
await connection.execute(`DROP INDEX \`${index.Key_name}\` ON \`${tableName}\``);
|
||||
console.log(` ❌ Entfernt: ${index.Key_name} (${index.Column_name})`);
|
||||
report.push(` ❌ Entfernt: ${index.Key_name} (${index.Column_name})`);
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_CANT_DROP_FIELD_OR_KEY') {
|
||||
console.log(` ⚠️ Kann nicht entfernen: ${index.Key_name} (${index.Column_name}) - ${error.message}`);
|
||||
report.push(` ⚠️ Kann nicht entfernen: ${index.Key_name} (${index.Column_name}) - ${error.message}`);
|
||||
} else {
|
||||
console.log(` ❌ Fehler beim Entfernen von ${index.Key_name}: ${error.message}`);
|
||||
report.push(` ❌ Fehler beim Entfernen von ${index.Key_name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log(` ⚠️ Fehler beim Zugriff auf Tabelle '${tableName}': ${error.message}`);
|
||||
report.push(` ⚠️ Fehler beim Zugriff auf Tabelle '${tableName}': ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Status nach dem Cleanup
|
||||
console.log('\n📊 STATUS NACH DEM CLEANUP:');
|
||||
report.push('');
|
||||
report.push('📊 STATUS NACH DEM CLEANUP:');
|
||||
const [tablesAfter] = await connection.execute(`
|
||||
SELECT
|
||||
TABLE_NAME,
|
||||
@@ -120,7 +105,7 @@ async function cleanupKeys() {
|
||||
const diff = beforeCount - table.key_count;
|
||||
const status = table.key_count <= 5 ? '✅' : table.key_count <= 10 ? '⚠️' : '❌';
|
||||
|
||||
console.log(` ${status} ${table.TABLE_NAME}: ${table.key_count} Keys (${diff > 0 ? `-${diff}` : `+${Math.abs(diff)}`})`);
|
||||
report.push(` ${status} ${table.TABLE_NAME}: ${table.key_count} Keys (${diff > 0 ? `-${diff}` : `+${Math.abs(diff)}`})`);
|
||||
});
|
||||
|
||||
// 5. Gesamtanzahl der Keys
|
||||
@@ -130,18 +115,20 @@ async function cleanupKeys() {
|
||||
WHERE TABLE_SCHEMA = ?
|
||||
`, [dbConfig.database]);
|
||||
|
||||
console.log(`\n📈 GESAMTANZAHL KEYS: ${totalKeys[0].total_keys}`);
|
||||
report.push('');
|
||||
report.push(`📈 GESAMTANZAHL KEYS: ${totalKeys[0].total_keys}`);
|
||||
|
||||
// 6. Zusammenfassung
|
||||
console.log('\n🎯 ZUSAMMENFASSUNG:');
|
||||
report.push('');
|
||||
report.push('🎯 ZUSAMMENFASSUNG:');
|
||||
const problemTablesAfter = tablesAfter.filter(t => t.key_count > 10);
|
||||
|
||||
if (problemTablesAfter.length === 0) {
|
||||
console.log(' ✅ Alle Tabellen haben jetzt weniger als 10 Keys!');
|
||||
report.push(' ✅ Alle Tabellen haben jetzt weniger als 10 Keys!');
|
||||
} else {
|
||||
console.log(' ⚠️ Folgende Tabellen haben immer noch zu viele Keys:');
|
||||
report.push(' ⚠️ Folgende Tabellen haben immer noch zu viele Keys:');
|
||||
problemTablesAfter.forEach(table => {
|
||||
console.log(` - ${table.TABLE_NAME}: ${table.key_count} Keys`);
|
||||
report.push(` - ${table.TABLE_NAME}: ${table.key_count} Keys`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,15 +137,18 @@ async function cleanupKeys() {
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
console.log('\n🔌 Datenbankverbindung geschlossen.');
|
||||
report.push('');
|
||||
report.push('🔌 Datenbankverbindung geschlossen.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Script ausführen
|
||||
console.log('🚀 Starte intelligentes INDEX-Cleanup...\n');
|
||||
report.push('🚀 Starte intelligentes INDEX-Cleanup...');
|
||||
cleanupKeys().then(() => {
|
||||
console.log('\n✨ Cleanup abgeschlossen!');
|
||||
report.push('');
|
||||
report.push('✨ Cleanup abgeschlossen!');
|
||||
process.stdout.write(`${report.join('\n')}\n`);
|
||||
process.exit(0);
|
||||
}).catch(error => {
|
||||
console.error('\n💥 Fehler beim Cleanup:', error);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import axios from 'axios';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE_URL = 'https://www.mytischtennis.de';
|
||||
|
||||
@@ -17,19 +18,246 @@ class MyTischtennisClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get login page to extract XSRF token and CAPTCHA token
|
||||
* @returns {Promise<Object>} Object with xsrfToken, captchaToken, and captchaClicked flag
|
||||
*/
|
||||
async getLoginPage() {
|
||||
try {
|
||||
const response = await this.client.get('/login?next=%2F');
|
||||
const html = typeof response.data === 'string' ? response.data : String(response.data || '');
|
||||
|
||||
const extractFirst = (patterns) => {
|
||||
for (const pattern of patterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match && (match[1] || match[2] || match[3])) {
|
||||
return match[1] || match[2] || match[3];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Parse form action and input fields for frontend login-form endpoint
|
||||
const formMatch = html.match(/<form[^>]*action=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/form>/i);
|
||||
const loginAction = formMatch ? (formMatch[1] || formMatch[2] || '/login') : '/login';
|
||||
const formHtml = formMatch ? formMatch[3] : html;
|
||||
const fields = [];
|
||||
|
||||
const inputRegex = /<input\b([\s\S]*?)>/gi;
|
||||
let inputMatch = null;
|
||||
while ((inputMatch = inputRegex.exec(formHtml)) !== null) {
|
||||
const rawAttributes = inputMatch[1] || '';
|
||||
const attributes = {};
|
||||
|
||||
// Parses key="value", key='value', key=value and boolean attributes.
|
||||
const attributeRegex = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
||||
let attributeMatch = null;
|
||||
while ((attributeMatch = attributeRegex.exec(rawAttributes)) !== null) {
|
||||
const key = attributeMatch[1];
|
||||
const value = attributeMatch[2] ?? attributeMatch[3] ?? attributeMatch[4] ?? true;
|
||||
attributes[key] = value;
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: typeof attributes.name === 'string' ? attributes.name : null,
|
||||
id: typeof attributes.id === 'string' ? attributes.id : null,
|
||||
type: typeof attributes.type === 'string' ? attributes.type : 'text',
|
||||
placeholder: typeof attributes.placeholder === 'string' ? attributes.placeholder : null,
|
||||
autocomplete: typeof attributes.autocomplete === 'string' ? attributes.autocomplete : null,
|
||||
minlength: typeof attributes.minlength === 'string' ? attributes.minlength : null,
|
||||
required: attributes.required === true || attributes.required === 'required',
|
||||
value: typeof attributes.value === 'string' ? attributes.value : null
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: if page is JS-rendered and no input tags are server-rendered, provide usable defaults.
|
||||
const hasEmailField = fields.some((f) => f?.name === 'email' || f?.type === 'email');
|
||||
const hasPasswordField = fields.some((f) => f?.name === 'password' || f?.type === 'password');
|
||||
if (!hasEmailField) {
|
||||
fields.push({
|
||||
name: 'email',
|
||||
id: null,
|
||||
type: 'email',
|
||||
placeholder: null,
|
||||
autocomplete: 'email',
|
||||
minlength: null,
|
||||
required: true,
|
||||
value: null
|
||||
});
|
||||
}
|
||||
if (!hasPasswordField) {
|
||||
fields.push({
|
||||
name: 'password',
|
||||
id: null,
|
||||
type: 'password',
|
||||
placeholder: null,
|
||||
autocomplete: 'current-password',
|
||||
minlength: null,
|
||||
required: true,
|
||||
value: null
|
||||
});
|
||||
}
|
||||
|
||||
// Extract XSRF token from hidden input
|
||||
const xsrfToken = extractFirst([
|
||||
/<input[^>]*name=(?:"xsrf"|'xsrf')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
|
||||
/(?:^|[,{])\s*"xsrf"\s*:\s*"([^"]+)"/i
|
||||
]);
|
||||
|
||||
// Extract CAPTCHA token from hidden input (if present)
|
||||
const captchaToken = extractFirst([
|
||||
/<input[^>]*name=(?:"captcha"|'captcha')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
|
||||
/(?:^|[,{])\s*"captcha"\s*:\s*"([^"]+)"/i
|
||||
]);
|
||||
|
||||
// Check if captcha_clicked is true or false
|
||||
const captchaClickedRaw = extractFirst([
|
||||
/<input[^>]*name=(?:"captcha_clicked"|'captcha_clicked')[^>]*value=(?:"([^"]+)"|'([^']+)')/i,
|
||||
/(?:^|[,{])\s*"captcha_clicked"\s*:\s*"([^"]+)"/i
|
||||
]);
|
||||
const captchaClicked = String(captchaClickedRaw || '').toLowerCase() === 'true';
|
||||
|
||||
// Check if CAPTCHA is required (look for private-captcha element or captcha input)
|
||||
const requiresCaptcha = html.includes('private-captcha')
|
||||
|| html.includes('name="captcha"')
|
||||
|| html.includes("name='captcha'")
|
||||
|| /captcha/i.test(html);
|
||||
|
||||
// Extract CAPTCHA metadata used by frontend
|
||||
const captchaSiteKey = extractFirst([
|
||||
/data-sitekey=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i,
|
||||
/(?:^|[,{])\s*"sitekey"\s*:\s*"([^"]+)"/i,
|
||||
/(?:^|[,{])\s*"captchaSiteKey"\s*:\s*"([^"]+)"/i
|
||||
]);
|
||||
const captchaPuzzleEndpoint = extractFirst([
|
||||
/data-puzzle-endpoint=(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i,
|
||||
/(?:^|[,{])\s*"puzzle_endpoint"\s*:\s*"([^"]+)"/i,
|
||||
/(?:^|[,{])\s*"captchaPuzzleEndpoint"\s*:\s*"([^"]+)"/i
|
||||
]);
|
||||
|
||||
console.log('[myTischtennisClient.getLoginPage]', {
|
||||
hasXsrfToken: !!xsrfToken,
|
||||
hasCaptchaToken: !!captchaToken,
|
||||
captchaClicked,
|
||||
requiresCaptcha,
|
||||
fieldsCount: fields.length,
|
||||
hasCaptchaSiteKey: !!captchaSiteKey,
|
||||
hasCaptchaPuzzleEndpoint: !!captchaPuzzleEndpoint
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
loginAction,
|
||||
fields,
|
||||
xsrfToken,
|
||||
captchaToken,
|
||||
captchaClicked,
|
||||
requiresCaptcha,
|
||||
captchaSiteKey,
|
||||
captchaPuzzleEndpoint
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching login page:', error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login to myTischtennis API
|
||||
* @param {string} email - myTischtennis email (not username!)
|
||||
* @param {string} password - myTischtennis password
|
||||
* @param {string} captchaToken - Optional CAPTCHA token if required
|
||||
* @param {string} xsrfToken - Optional XSRF token (will be fetched if not provided)
|
||||
* @returns {Promise<Object>} Login response with token and session data
|
||||
*/
|
||||
async login(email, password) {
|
||||
async login(email, password, captchaToken = null, xsrfToken = null) {
|
||||
try {
|
||||
let loginPage = null;
|
||||
let captchaClicked = false;
|
||||
|
||||
// If XSRF token not provided, fetch login page to get it
|
||||
if (!xsrfToken) {
|
||||
loginPage = await this.getLoginPage();
|
||||
if (!loginPage.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Konnte Login-Seite nicht abrufen: ' + loginPage.error
|
||||
};
|
||||
}
|
||||
xsrfToken = loginPage.xsrfToken;
|
||||
|
||||
// If CAPTCHA token not provided but found in HTML, use it
|
||||
if (!captchaToken && loginPage.captchaToken) {
|
||||
captchaToken = loginPage.captchaToken;
|
||||
captchaClicked = loginPage.captchaClicked;
|
||||
console.log('[myTischtennisClient.login] CAPTCHA-Token aus HTML extrahiert, captcha_clicked:', captchaClicked);
|
||||
}
|
||||
|
||||
// If CAPTCHA is required but no token found yet, wait and try to get it again
|
||||
// Das CAPTCHA-System löst das Puzzle im Hintergrund via JavaScript, daher kann es einen Moment dauern
|
||||
// Wir müssen mehrmals versuchen, da das Token erst generiert wird, nachdem das JavaScript gelaufen ist
|
||||
if (loginPage.requiresCaptcha && !captchaToken) {
|
||||
console.log('[myTischtennisClient.login] CAPTCHA erforderlich, aber noch kein Token gefunden. Warte und versuche erneut...');
|
||||
|
||||
// Versuche bis zu 5 Mal, das CAPTCHA-Token zu erhalten
|
||||
let maxRetries = 5;
|
||||
let retryCount = 0;
|
||||
let foundToken = false;
|
||||
|
||||
while (retryCount < maxRetries && !foundToken) {
|
||||
// Warte 2-4 Sekunden zwischen den Versuchen
|
||||
const waitMs = Math.floor(Math.random() * 2000) + 2000; // 2000-4000ms
|
||||
console.log(`[myTischtennisClient.login] Versuch ${retryCount + 1}/${maxRetries}: Warte ${waitMs}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, waitMs));
|
||||
|
||||
// Versuche erneut, die Login-Seite abzurufen, um das gelöste CAPTCHA-Token zu erhalten
|
||||
const retryLoginPage = await this.getLoginPage();
|
||||
if (retryLoginPage.success && retryLoginPage.captchaToken) {
|
||||
captchaToken = retryLoginPage.captchaToken;
|
||||
captchaClicked = retryLoginPage.captchaClicked;
|
||||
xsrfToken = retryLoginPage.xsrfToken || xsrfToken; // Aktualisiere XSRF-Token falls nötig
|
||||
foundToken = true;
|
||||
console.log(`[myTischtennisClient.login] CAPTCHA-Token nach ${retryCount + 1} Versuchen gefunden, captcha_clicked:`, captchaClicked);
|
||||
} else {
|
||||
retryCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundToken) {
|
||||
// Wenn nach allen Versuchen kein Token gefunden wurde, Fehler zurückgeben
|
||||
console.log('[myTischtennisClient.login] CAPTCHA-Token konnte nach mehreren Versuchen nicht gefunden werden');
|
||||
return {
|
||||
success: false,
|
||||
error: 'CAPTCHA erforderlich. Bitte lösen Sie das CAPTCHA auf der MyTischtennis-Website.',
|
||||
requiresCaptcha: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Zufällige Verzögerung von 2-5 Sekunden zwischen Laden des Forms und Absenden
|
||||
// Simuliert menschliches Verhalten und gibt dem CAPTCHA-System Zeit
|
||||
const delayMs = Math.floor(Math.random() * 3000) + 2000; // 2000-5000ms
|
||||
console.log(`[myTischtennisClient] Warte ${delayMs}ms vor Login-Request (simuliert menschliches Verhalten)`);
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
// Create form data
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('email', email);
|
||||
formData.append('password', password);
|
||||
formData.append('intent', 'login');
|
||||
|
||||
if (xsrfToken) {
|
||||
formData.append('xsrf', xsrfToken);
|
||||
}
|
||||
|
||||
if (captchaToken) {
|
||||
formData.append('captcha', captchaToken);
|
||||
formData.append('captcha_clicked', captchaClicked ? 'true' : 'false');
|
||||
}
|
||||
|
||||
const response = await this.client.post(
|
||||
'/login?next=%2F&_data=routes%2F_auth%2B%2Flogin',
|
||||
@@ -86,15 +314,477 @@ class MyTischtennisClient {
|
||||
cookie: authCookie.split(';')[0] // Just the cookie value without attributes
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('MyTischtennis login error:', error.message);
|
||||
const statusCode = error.response?.status || 500;
|
||||
const responseData = error.response?.data;
|
||||
|
||||
// Check if response contains CAPTCHA error
|
||||
let errorMessage = error.response?.data?.message || error.message || 'Login fehlgeschlagen';
|
||||
let requiresCaptcha = false;
|
||||
|
||||
// Check for CAPTCHA-related errors in response
|
||||
if (typeof responseData === 'string') {
|
||||
if (responseData.includes('Captcha') || responseData.includes('CAPTCHA') ||
|
||||
responseData.includes('captcha') || responseData.includes('Captcha-Bestätigung')) {
|
||||
requiresCaptcha = true;
|
||||
errorMessage = 'CAPTCHA erforderlich. Bitte lösen Sie das CAPTCHA auf der MyTischtennis-Website.';
|
||||
}
|
||||
} else if (responseData && typeof responseData === 'object') {
|
||||
// Check for CAPTCHA errors in JSON response or HTML
|
||||
const dataString = JSON.stringify(responseData);
|
||||
if (dataString.includes('Captcha') || dataString.includes('CAPTCHA') ||
|
||||
dataString.includes('captcha') || dataString.includes('Captcha-Bestätigung')) {
|
||||
requiresCaptcha = true;
|
||||
errorMessage = 'CAPTCHA erforderlich. Bitte lösen Sie das CAPTCHA auf der MyTischtennis-Website.';
|
||||
}
|
||||
}
|
||||
|
||||
console.error('MyTischtennis login error:', errorMessage, `(Status: ${statusCode})`, requiresCaptcha ? '(CAPTCHA erforderlich)' : '');
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || 'Login fehlgeschlagen',
|
||||
status: error.response?.status || 500
|
||||
error: errorMessage,
|
||||
status: statusCode,
|
||||
requiresCaptcha
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-based fallback login for CAPTCHA flows.
|
||||
* @param {string} email
|
||||
* @param {string} password
|
||||
* @param {Object} [options]
|
||||
* @param {Object} [options.savedStorageState] - Playwright storage state from a previous session.
|
||||
* If provided and the stored auth cookie is still valid, returns immediately without a new login.
|
||||
* @returns {Promise<Object>} Login response with token, session data, and `storageState` for persistence.
|
||||
*/
|
||||
async loginWithBrowserAutomation(email, password, options = {}) {
|
||||
const { savedStorageState } = options;
|
||||
let browser = null;
|
||||
let context = null;
|
||||
|
||||
// --- Fast path: restore a saved Playwright session ---
|
||||
if (savedStorageState) {
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
||||
context = await browser.newContext({ storageState: savedStorageState });
|
||||
const cookies = await context.cookies('https://www.mytischtennis.de');
|
||||
const authCookie = cookies.find((c) => c.name === 'sb-10-auth-token' || /^sb-\d+-auth-token$/.test(c.name));
|
||||
if (authCookie?.value) {
|
||||
const tokenMatch = String(authCookie.value).match(/^base64-(.+)$/);
|
||||
if (tokenMatch) {
|
||||
const tokenData = JSON.parse(Buffer.from(tokenMatch[1], 'base64').toString('utf-8'));
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
// Accept if not expired (with 5-minute safety buffer)
|
||||
if (tokenData.expires_at && tokenData.expires_at > nowSec + 300) {
|
||||
console.log('[myTischtennisClient.playwright] Restored session from saved state (no CAPTCHA needed)');
|
||||
const storageState = await context.storageState();
|
||||
await context.close();
|
||||
await browser.close();
|
||||
browser = null; context = null;
|
||||
return {
|
||||
success: true,
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
expiresAt: tokenData.expires_at,
|
||||
expiresIn: tokenData.expires_in,
|
||||
user: tokenData.user,
|
||||
cookie: `sb-10-auth-token=${authCookie.value}`,
|
||||
storageState,
|
||||
restoredFromCache: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Cookie absent or expired → close and fall through to full login
|
||||
console.log('[myTischtennisClient.playwright] Saved session expired or invalid, starting full login');
|
||||
await context.close();
|
||||
await browser.close();
|
||||
browser = null; context = null;
|
||||
} catch (restoreErr) {
|
||||
console.warn('[myTischtennisClient.playwright] Session restore failed, starting full login:', restoreErr.message);
|
||||
try { if (context) await context.close(); } catch (_e) { /* ignore */ }
|
||||
try { if (browser) await browser.close(); } catch (_e) { /* ignore */ }
|
||||
browser = null; context = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[myTischtennisClient.playwright] Start browser login flow');
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage']
|
||||
});
|
||||
context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// Helper: click the CMP/consent "Akzeptieren" button if visible.
|
||||
// Tries multiple selectors to cover different CMP implementations.
|
||||
const acceptConsentDialog = async (waitMs = 0) => {
|
||||
if (waitMs > 0) await page.waitForTimeout(waitMs);
|
||||
const consentSelectors = [
|
||||
'#onetrust-accept-btn-handler',
|
||||
'button:has-text("Alle akzeptieren")',
|
||||
'button:has-text("Akzeptieren")',
|
||||
'button:has-text("Einverstanden")',
|
||||
'button:has-text("Zustimmen")',
|
||||
'[data-testid="accept-button"]',
|
||||
'.cmp-accept-all',
|
||||
'.accept-all-btn'
|
||||
];
|
||||
for (const selector of consentSelectors) {
|
||||
try {
|
||||
const button = page.locator(selector).first();
|
||||
if (await button.count()) {
|
||||
await button.click({ timeout: 2500 });
|
||||
console.log('[myTischtennisClient.playwright] Consent dialog accepted via:', selector);
|
||||
await page.waitForTimeout(800);
|
||||
return true;
|
||||
}
|
||||
} catch (_e) {
|
||||
// try next selector
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Visit the homepage first so the browser receives and stores the correct CMP
|
||||
// consent cookies (the TCF v2 format cannot be guessed and set manually).
|
||||
// After accepting consent here, the login page will not show the banner again.
|
||||
try {
|
||||
await page.goto(this.baseURL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
const acceptedOnHome = await acceptConsentDialog(0);
|
||||
if (!acceptedOnHome) await acceptConsentDialog(2500);
|
||||
console.log('[myTischtennisClient.playwright] Homepage visited, consent handled');
|
||||
} catch (_homeErr) {
|
||||
console.log('[myTischtennisClient.playwright] Homepage pre-visit failed (continuing):', _homeErr.message);
|
||||
}
|
||||
|
||||
await page.goto(`${this.baseURL}/login?next=%2F`, { waitUntil: 'domcontentloaded', timeout: 45000 });
|
||||
console.log('[myTischtennisClient.playwright] Login page loaded');
|
||||
|
||||
// Second consent attempt in case it re-appears on the login page.
|
||||
const consentOnLogin = await acceptConsentDialog(0);
|
||||
if (!consentOnLogin) await acceptConsentDialog(1500);
|
||||
|
||||
// Fill credentials
|
||||
await page.locator('input[name="email"]').first().fill(email, { timeout: 10000 });
|
||||
await page.locator('input[name="password"]').first().fill(password, { timeout: 10000 });
|
||||
console.log('[myTischtennisClient.playwright] Credentials filled');
|
||||
|
||||
// Try to interact with private-captcha if present (it may render with delay).
|
||||
try {
|
||||
await page.waitForSelector('private-captcha', { timeout: 8000 });
|
||||
} catch (_e) {
|
||||
// ignore: captcha host might not be present in all flows
|
||||
}
|
||||
const captchaHost = page.locator('private-captcha').first();
|
||||
const hasCaptchaHost = (await captchaHost.count()) > 0;
|
||||
let captchaReadyDetected = !hasCaptchaHost;
|
||||
if (hasCaptchaHost) {
|
||||
try {
|
||||
await page.waitForTimeout(1200);
|
||||
const captchaVisualStateBefore = await page.evaluate(() => {
|
||||
const host = document.querySelector('private-captcha');
|
||||
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox');
|
||||
return {
|
||||
hostClass: host?.className || null,
|
||||
hostDataState: host?.getAttribute?.('data-state') || null,
|
||||
checkboxClass: checkbox?.className || null,
|
||||
checkboxChecked: !!checkbox?.checked,
|
||||
checkboxAriaChecked: checkbox?.getAttribute?.('aria-checked') || null
|
||||
};
|
||||
});
|
||||
const interaction = await page.evaluate(() => {
|
||||
const host = document.querySelector('private-captcha');
|
||||
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox');
|
||||
if (!checkbox) {
|
||||
return { clicked: false, reason: 'checkbox-missing' };
|
||||
}
|
||||
checkbox.click();
|
||||
checkbox.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return {
|
||||
clicked: true,
|
||||
viaShadowRoot: true,
|
||||
className: checkbox.className || null,
|
||||
checked: !!checkbox.checked
|
||||
};
|
||||
});
|
||||
console.log('[myTischtennisClient.playwright] evaluate interaction result:', interaction);
|
||||
|
||||
// Wait until hidden captcha fields are populated by site scripts.
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const captchaField = document.querySelector('input[name="captcha"]');
|
||||
const clickedField = document.querySelector('input[name="captcha_clicked"]');
|
||||
const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : '');
|
||||
const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : '');
|
||||
return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1');
|
||||
}, { timeout: 20000 });
|
||||
const captchaState = await page.evaluate(() => {
|
||||
const captchaField = document.querySelector('input[name="captcha"]');
|
||||
const clickedField = document.querySelector('input[name="captcha_clicked"]');
|
||||
return {
|
||||
captchaLen: captchaField?.value?.length || 0,
|
||||
captchaClicked: clickedField?.value || null
|
||||
};
|
||||
});
|
||||
console.log('[myTischtennisClient.playwright] Captcha value ready:', captchaState);
|
||||
captchaReadyDetected = true;
|
||||
} catch (_waitErr) {
|
||||
// Keep going; some flows still succeed without explicit hidden field update.
|
||||
console.warn('[myTischtennisClient.playwright] Captcha value not ready in time');
|
||||
}
|
||||
|
||||
// Optional diagnostic only: visual state change should never block submit.
|
||||
try {
|
||||
await page.waitForFunction((beforeState) => {
|
||||
const host = document.querySelector('private-captcha');
|
||||
const checkbox = host?.shadowRoot?.querySelector('#pc-checkbox');
|
||||
if (!host || !checkbox) return false;
|
||||
|
||||
const current = {
|
||||
hostClass: host.className || '',
|
||||
hostDataState: host.getAttribute?.('data-state') || '',
|
||||
checkboxClass: checkbox.className || '',
|
||||
checkboxChecked: !!checkbox.checked,
|
||||
checkboxAriaChecked: checkbox.getAttribute?.('aria-checked') || ''
|
||||
};
|
||||
|
||||
const visualChanged =
|
||||
current.hostClass !== (beforeState?.hostClass || '')
|
||||
|| current.hostDataState !== (beforeState?.hostDataState || '')
|
||||
|| current.checkboxClass !== (beforeState?.checkboxClass || '')
|
||||
|| current.checkboxChecked !== !!beforeState?.checkboxChecked
|
||||
|| current.checkboxAriaChecked !== (beforeState?.checkboxAriaChecked || '');
|
||||
|
||||
return visualChanged;
|
||||
}, captchaVisualStateBefore, { timeout: 1500 });
|
||||
console.log('[myTischtennisClient.playwright] Captcha visual state changed');
|
||||
} catch (_visualWaitErr) {
|
||||
// no-op: widget often keeps "ready" class despite solved token
|
||||
}
|
||||
} catch (captchaError) {
|
||||
console.warn('[myTischtennisClient.playwright] Captcha interaction warning:', captchaError?.message || captchaError);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure captcha_clicked field is set if available.
|
||||
await page.evaluate(() => {
|
||||
const clickedField = document.querySelector('input[name="captcha_clicked"]');
|
||||
if (clickedField && !clickedField.value) {
|
||||
clickedField.value = 'true';
|
||||
}
|
||||
});
|
||||
|
||||
// Before submit, ensure CAPTCHA fields are actually ready if captcha widget exists.
|
||||
if (hasCaptchaHost) {
|
||||
const isCaptchaReadyNow = await page.evaluate(() => {
|
||||
const captchaField = document.querySelector('input[name="captcha"]');
|
||||
const clickedField = document.querySelector('input[name="captcha_clicked"]');
|
||||
const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : '');
|
||||
const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : '');
|
||||
return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1');
|
||||
});
|
||||
captchaReadyDetected = captchaReadyDetected || isCaptchaReadyNow;
|
||||
|
||||
if (!isCaptchaReadyNow) {
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const captchaField = document.querySelector('input[name="captcha"]');
|
||||
const clickedField = document.querySelector('input[name="captcha_clicked"]');
|
||||
const captchaValue = (captchaField && captchaField.value ? captchaField.value.trim() : '');
|
||||
const clickedValue = (clickedField && clickedField.value ? clickedField.value.toLowerCase() : '');
|
||||
return captchaValue.length > 80 && (clickedValue === 'true' || clickedValue === '1');
|
||||
}, { timeout: 12000 });
|
||||
captchaReadyDetected = true;
|
||||
} catch (_captchaNotReadyErr) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Playwright-Login fehlgeschlagen: CAPTCHA wurde im Browser nicht als gelöst erkannt'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Human-like pause only after captcha was actually solved (2-6s).
|
||||
if (captchaReadyDetected) {
|
||||
const postCaptchaDelayMs = 2000 + Math.floor(Math.random() * 4001);
|
||||
await page.waitForTimeout(postCaptchaDelayMs);
|
||||
console.log('[myTischtennisClient.playwright] Waited after solved captcha:', postCaptchaDelayMs);
|
||||
}
|
||||
|
||||
// Ensure login intent is present and click the explicit login submit button.
|
||||
await page.evaluate(() => {
|
||||
const form = document.querySelector('form[action*="/login"]');
|
||||
if (!form) return;
|
||||
let intentField = form.querySelector('input[name="intent"]');
|
||||
if (!intentField) {
|
||||
intentField = document.createElement('input');
|
||||
intentField.setAttribute('type', 'hidden');
|
||||
intentField.setAttribute('name', 'intent');
|
||||
form.appendChild(intentField);
|
||||
}
|
||||
intentField.setAttribute('value', 'login');
|
||||
});
|
||||
|
||||
// Submit form
|
||||
const loginSubmitButton = page.locator('button[type="submit"][name="intent"][value="login"]').first();
|
||||
const genericSubmitButton = page.locator('button[type="submit"], input[type="submit"]').first();
|
||||
if (await loginSubmitButton.count()) {
|
||||
await loginSubmitButton.click({ timeout: 15000, noWaitAfter: true });
|
||||
} else if (await genericSubmitButton.count()) {
|
||||
await genericSubmitButton.click({ timeout: 15000, noWaitAfter: true });
|
||||
} else {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
console.log('[myTischtennisClient.playwright] Submit clicked');
|
||||
|
||||
// Wait for auth cookie after submit (polling avoids timing races).
|
||||
let authCookieObj = null;
|
||||
let detectedSubmitError = null;
|
||||
const pollIntervalMs = 500;
|
||||
const maxAttempts = 40; // ~20s max wait after submit
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const cookies = await context.cookies();
|
||||
authCookieObj = cookies.find((c) => c.name === 'sb-10-auth-token')
|
||||
|| cookies.find((c) => /^sb-\d+-auth-token$/.test(c.name))
|
||||
|| cookies.find((c) => c.name.includes('auth-token'));
|
||||
if (authCookieObj?.value) {
|
||||
console.log('[myTischtennisClient.playwright] Auth cookie detected:', authCookieObj.name);
|
||||
break;
|
||||
}
|
||||
|
||||
// Periodically: dismiss consent banner (may reappear after submit redirect)
|
||||
// and probe page text to fail fast on known error strings.
|
||||
if (attempt % 4 === 0) {
|
||||
try { await acceptConsentDialog(0); } catch (_e) { /* ignore */ }
|
||||
try {
|
||||
const textContent = await page.locator('body').innerText({ timeout: 600 });
|
||||
if (textContent?.includes('Captcha-Bestätigung fehlgeschlagen')) {
|
||||
detectedSubmitError = 'Captcha-Bestätigung fehlgeschlagen';
|
||||
break;
|
||||
}
|
||||
if (textContent?.includes('Captcha-Bestätigung ist erforderlich')) {
|
||||
detectedSubmitError = 'Captcha-Bestätigung ist erforderlich';
|
||||
break;
|
||||
}
|
||||
if (textContent?.includes('Ungültige E-Mail oder Passwort')) {
|
||||
detectedSubmitError = 'Ungültige E-Mail oder Passwort';
|
||||
break;
|
||||
}
|
||||
} catch (_readBodyErr) {
|
||||
// ignore text read errors during polling
|
||||
}
|
||||
}
|
||||
|
||||
await page.waitForTimeout(pollIntervalMs);
|
||||
}
|
||||
if (!authCookieObj || !authCookieObj.value) {
|
||||
let errorText = null;
|
||||
let failureDiagnostics = null;
|
||||
try {
|
||||
const textContent = await page.locator('body').innerText({ timeout: 1000 });
|
||||
if (textContent?.includes('Captcha-Bestätigung fehlgeschlagen')) {
|
||||
errorText = 'Captcha-Bestätigung fehlgeschlagen';
|
||||
}
|
||||
if (!errorText && textContent?.includes('Passwort')) {
|
||||
errorText = 'Login vermutlich fehlgeschlagen (Passwort oder CAPTCHA)';
|
||||
}
|
||||
|
||||
const currentUrl = page.url();
|
||||
const allCookies = await context.cookies();
|
||||
const cookieNames = allCookies.map((c) => c.name);
|
||||
failureDiagnostics = {
|
||||
url: currentUrl,
|
||||
cookieNames,
|
||||
bodyPreview: String(textContent || '').slice(0, 320)
|
||||
};
|
||||
} catch (_e) {
|
||||
// ignore text read errors
|
||||
}
|
||||
if (!errorText && detectedSubmitError) {
|
||||
errorText = detectedSubmitError;
|
||||
}
|
||||
if (failureDiagnostics) {
|
||||
console.warn('[myTischtennisClient.playwright] Login failure diagnostics:', failureDiagnostics);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorText
|
||||
? `Playwright-Login fehlgeschlagen: ${errorText}`
|
||||
: 'Playwright-Login fehlgeschlagen: Kein sb-10-auth-token Cookie gefunden'
|
||||
};
|
||||
}
|
||||
|
||||
// Cookie value is expected as "base64-<tokenData>"
|
||||
const tokenMatch = String(authCookieObj.value).match(/^base64-(.+)$/);
|
||||
if (!tokenMatch) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Playwright-Login fehlgeschlagen: Token-Format ungültig'
|
||||
};
|
||||
}
|
||||
|
||||
let tokenData;
|
||||
try {
|
||||
tokenData = JSON.parse(Buffer.from(tokenMatch[1], 'base64').toString('utf-8'));
|
||||
} catch (decodeError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Playwright-Login fehlgeschlagen: Token konnte nicht dekodiert werden (${decodeError.message})`
|
||||
};
|
||||
}
|
||||
|
||||
const cookie = `sb-10-auth-token=${authCookieObj.value}`;
|
||||
|
||||
// Persist the full browser storage state so future calls can skip the CAPTCHA flow.
|
||||
let storageState = null;
|
||||
try { storageState = await context.storageState(); } catch (_e) { /* ignore */ }
|
||||
|
||||
console.log('[myTischtennisClient.playwright] Browser login successful');
|
||||
return {
|
||||
success: true,
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
expiresAt: tokenData.expires_at,
|
||||
expiresIn: tokenData.expires_in,
|
||||
user: tokenData.user,
|
||||
cookie,
|
||||
storageState
|
||||
};
|
||||
} catch (error) {
|
||||
const rawMessage = String(error?.message || error || 'Playwright-Login fehlgeschlagen');
|
||||
const isMissingBrowserExecutable = /Executable doesn't exist|download new browsers|playwright install/i.test(rawMessage);
|
||||
const normalizedError = isMissingBrowserExecutable
|
||||
? 'Playwright-Browser ist auf dem Server nicht installiert. Bitte "npx playwright install chromium" ausführen.'
|
||||
: rawMessage;
|
||||
console.error('[myTischtennisClient.playwright] Browser login failed:', normalizedError);
|
||||
return {
|
||||
success: false,
|
||||
error: normalizedError,
|
||||
requiresSetup: isMissingBrowserExecutable,
|
||||
status: isMissingBrowserExecutable ? 503 : 400
|
||||
};
|
||||
} finally {
|
||||
if (context) {
|
||||
try {
|
||||
await context.close();
|
||||
} catch (contextCloseError) {
|
||||
console.warn('[myTischtennisClient.playwright] Context close warning:', contextCloseError?.message || contextCloseError);
|
||||
}
|
||||
}
|
||||
if (browser) {
|
||||
try {
|
||||
await browser.close();
|
||||
} catch (browserCloseError) {
|
||||
console.warn('[myTischtennisClient.playwright] Browser close warning:', browserCloseError?.message || browserCloseError);
|
||||
}
|
||||
console.log('[myTischtennisClient.playwright] Browser closed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify login credentials
|
||||
* @param {string} email - myTischtennis email
|
||||
@@ -149,28 +839,13 @@ class MyTischtennisClient {
|
||||
* @returns {Promise<Object>} User profile with club info
|
||||
*/
|
||||
async getUserProfile(cookie) {
|
||||
console.log('[getUserProfile] - Calling /?_data=root with cookie:', cookie?.substring(0, 50) + '...');
|
||||
|
||||
const result = await this.authenticatedRequest('/?_data=root', cookie, {
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
console.log('[getUserProfile] - Result success:', result.success);
|
||||
|
||||
if (result.success) {
|
||||
console.log('[getUserProfile] - Response structure:', {
|
||||
hasUserProfile: !!result.data?.userProfile,
|
||||
hasClub: !!result.data?.userProfile?.club,
|
||||
hasOrganization: !!result.data?.userProfile?.organization,
|
||||
clubnr: result.data?.userProfile?.club?.clubnr,
|
||||
clubName: result.data?.userProfile?.club?.name,
|
||||
orgShort: result.data?.userProfile?.organization?.short,
|
||||
ttr: result.data?.userProfile?.ttr,
|
||||
qttr: result.data?.userProfile?.qttr
|
||||
});
|
||||
|
||||
console.log('[getUserProfile] - Full userProfile.club:', result.data?.userProfile?.club);
|
||||
console.log('[getUserProfile] - Full userProfile.organization:', result.data?.userProfile?.organization);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -194,18 +869,15 @@ class MyTischtennisClient {
|
||||
* @param {string} fedNickname - Federation nickname (e.g., "HeTTV")
|
||||
* @returns {Promise<Object>} Rankings with player entries (all pages)
|
||||
*/
|
||||
async getClubRankings(cookie, clubId, fedNickname) {
|
||||
async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes', options = {}) {
|
||||
const { includeHistoryPlayerIds = false } = options;
|
||||
const allEntries = [];
|
||||
let currentPage = 0;
|
||||
let hasMorePages = true;
|
||||
|
||||
console.log('[getClubRankings] - Starting to fetch rankings for club', clubId);
|
||||
|
||||
while (hasMorePages) {
|
||||
const endpoint = `/rankings/andro-rangliste?all-players=on&clubnr=${clubId}&fednickname=${fedNickname}&results-per-page=100&page=${currentPage}&_data=routes%2F%24`;
|
||||
|
||||
console.log(`[getClubRankings] - Fetching page ${currentPage}...`);
|
||||
|
||||
const endpoint = `/rankings/andro-rangliste?all-players=on&clubnr=${clubId}&fednickname=${fedNickname}¤t-ranking=${currentRanking}&results-per-page=100&page=${currentPage}&_data=routes%2F%24`;
|
||||
const result = await this.authenticatedRequest(endpoint, cookie, {
|
||||
method: 'GET'
|
||||
});
|
||||
@@ -244,30 +916,49 @@ class MyTischtennisClient {
|
||||
error: 'Keine entries in blockLoaderData gefunden'
|
||||
};
|
||||
}
|
||||
|
||||
let historyPlayerIdsByName = null;
|
||||
if (includeHistoryPlayerIds) {
|
||||
const htmlEndpoint = `/rankings/andro-rangliste?clubnr=${clubId}&fednickname=${fedNickname}&all-players=on&continent=all&country=all¤t-ranking=${currentRanking}&results-per-page=100&page=${currentPage + 1}`;
|
||||
const htmlResult = await this.authenticatedRequest(htmlEndpoint, cookie, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'text/html,application/xhtml+xml'
|
||||
}
|
||||
});
|
||||
historyPlayerIdsByName = htmlResult.success
|
||||
? this.extractHistoryPlayerIdsFromAndroRankingHtml(htmlResult.data)
|
||||
: new Map();
|
||||
}
|
||||
|
||||
const enrichedEntries = entries.map((entry) => {
|
||||
const nameKey = this._buildRankingNameKey(entry?.firstname, entry?.lastname);
|
||||
const historyPlayerId = historyPlayerIdsByName?.get(nameKey) || null;
|
||||
return {
|
||||
...entry,
|
||||
historyPlayerId,
|
||||
myTischtennisHistoryPlayerId: historyPlayerId
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`[getClubRankings] - Page ${currentPage}: Found ${entries.length} entries`);
|
||||
|
||||
// Füge Entries hinzu
|
||||
allEntries.push(...entries);
|
||||
allEntries.push(...enrichedEntries);
|
||||
|
||||
// Prüfe ob es weitere Seiten gibt
|
||||
// Wenn die aktuelle Seite weniger Einträge hat als das Limit, sind wir am Ende
|
||||
// Oder wenn wir alle erwarteten Einträge haben
|
||||
if (entries.length === 0) {
|
||||
if (enrichedEntries.length === 0) {
|
||||
hasMorePages = false;
|
||||
console.log('[getClubRankings] - No more entries, stopping');
|
||||
} else if (rankingData.numberOfPages && currentPage >= rankingData.numberOfPages - 1) {
|
||||
hasMorePages = false;
|
||||
console.log(`[getClubRankings] - Reached last page (${rankingData.numberOfPages})`);
|
||||
} else if (allEntries.length >= rankingData.resultLength) {
|
||||
hasMorePages = false;
|
||||
console.log(`[getClubRankings] - Got all entries (${allEntries.length}/${rankingData.resultLength})`);
|
||||
} else {
|
||||
currentPage++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[getClubRankings] - Total entries fetched: ${allEntries.length}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -278,7 +969,45 @@ class MyTischtennisClient {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
extractHistoryPlayerIdsFromAndroRankingHtml(html) {
|
||||
const result = new Map();
|
||||
const source = typeof html === 'string' ? html : String(html || '');
|
||||
const anchorPattern = /href="\/community\/external-profile\?player-id=(P[A-Z0-9]+)"[^>]*>([^<]+)<\/a>/gi;
|
||||
|
||||
let match = null;
|
||||
while ((match = anchorPattern.exec(source)) !== null) {
|
||||
const playerId = match[1];
|
||||
const fullName = this._decodeHtmlEntities(match[2] || '');
|
||||
const key = this._buildRankingFullNameKey(fullName);
|
||||
if (key && playerId && !result.has(key)) {
|
||||
result.set(key, playerId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
_buildRankingNameKey(firstname, lastname) {
|
||||
return this._buildRankingFullNameKey(`${firstname || ''} ${lastname || ''}`);
|
||||
}
|
||||
|
||||
_buildRankingFullNameKey(name) {
|
||||
return String(name || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
_decodeHtmlEntities(value) {
|
||||
return String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
|
||||
export default new MyTischtennisClient();
|
||||
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
dotenv.config();
|
||||
// Ensure .env is loaded from the backend folder (not dependent on process.cwd())
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||
|
||||
export const development = {
|
||||
const isTestEnv = process.env.NODE_ENV === 'test';
|
||||
|
||||
const baseConfig = {
|
||||
username: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || 'hitomisan',
|
||||
database: process.env.DB_NAME || 'trainingdiary',
|
||||
host: process.env.DB_HOST,
|
||||
dialect: process.env.DB_DIALECT,
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
dialect: process.env.DB_DIALECT || 'mysql',
|
||||
define: {
|
||||
freezeTableName: true,
|
||||
underscored: true,
|
||||
underscoredAll: true,
|
||||
},
|
||||
logging: false,
|
||||
storage: process.env.DB_STORAGE,
|
||||
};
|
||||
|
||||
if (isTestEnv) {
|
||||
baseConfig.username = 'sqlite';
|
||||
baseConfig.password = '';
|
||||
baseConfig.database = 'sqlite';
|
||||
baseConfig.host = 'localhost';
|
||||
baseConfig.dialect = 'sqlite';
|
||||
baseConfig.storage = process.env.DB_STORAGE || ':memory:';
|
||||
}
|
||||
|
||||
if (baseConfig.dialect === 'sqlite' && !baseConfig.storage) {
|
||||
baseConfig.storage = ':memory:';
|
||||
}
|
||||
|
||||
export const development = baseConfig;
|
||||
|
||||
125
backend/constants/ERROR_CODES_USAGE.md
Normal file
125
backend/constants/ERROR_CODES_USAGE.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Fehlercode-System - Verwendungsanleitung
|
||||
|
||||
## Übersicht
|
||||
|
||||
Das Fehlercode-System ersetzt hardcodierte deutsche Fehlermeldungen durch strukturierte Fehlercodes, die im Frontend übersetzt werden.
|
||||
|
||||
## Backend-Verwendung
|
||||
|
||||
### 1. Fehlercode verwenden
|
||||
|
||||
```javascript
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
import { ERROR_CODES, createError } from '../constants/errorCodes.js';
|
||||
|
||||
// Einfacher Fehlercode ohne Parameter
|
||||
throw new HttpError(createError(ERROR_CODES.USER_NOT_FOUND), 404);
|
||||
|
||||
// Fehlercode mit Parametern
|
||||
throw new HttpError(
|
||||
createError(ERROR_CODES.MEMBER_NOT_FOUND, { memberId: 123 }),
|
||||
404
|
||||
);
|
||||
|
||||
// Oder direkt:
|
||||
throw new HttpError(
|
||||
{ code: ERROR_CODES.MEMBER_NOT_FOUND, params: { memberId: 123 } },
|
||||
404
|
||||
);
|
||||
```
|
||||
|
||||
### 2. Legacy-Format (wird weiterhin unterstützt)
|
||||
|
||||
```javascript
|
||||
// Alte Variante funktioniert noch:
|
||||
throw new HttpError('Benutzer nicht gefunden', 404);
|
||||
```
|
||||
|
||||
## Frontend-Verwendung
|
||||
|
||||
### 1. Fehlermeldungen automatisch übersetzen
|
||||
|
||||
Die `getSafeErrorMessage`-Funktion erkennt automatisch Fehlercodes:
|
||||
|
||||
```javascript
|
||||
import { getSafeErrorMessage } from '../utils/errorMessages.js';
|
||||
|
||||
// In einer Vue-Komponente (Options API)
|
||||
try {
|
||||
await apiClient.post('/api/endpoint', data);
|
||||
} catch (error) {
|
||||
const message = getSafeErrorMessage(error, this.$t('errors.ERROR_UNKNOWN_ERROR'), this.$t);
|
||||
await this.showInfo(this.$t('messages.error'), message, '', 'error');
|
||||
}
|
||||
|
||||
// In einer Vue-Komponente (Composition API)
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { t } = useI18n();
|
||||
|
||||
try {
|
||||
await apiClient.post('/api/endpoint', data);
|
||||
} catch (error) {
|
||||
const message = getSafeErrorMessage(error, t('errors.ERROR_UNKNOWN_ERROR'), t);
|
||||
await showInfo(t('messages.error'), message, '', 'error');
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Dialog-Utils mit Übersetzung
|
||||
|
||||
```javascript
|
||||
import { buildInfoConfig, safeErrorMessage } from '../utils/dialogUtils.js';
|
||||
|
||||
// Mit Übersetzungsfunktion
|
||||
this.infoDialog = buildInfoConfig({
|
||||
title: this.$t('messages.error'),
|
||||
message: safeErrorMessage(error, this.$t('errors.ERROR_UNKNOWN_ERROR'), this.$t),
|
||||
type: 'error'
|
||||
}, this.$t);
|
||||
```
|
||||
|
||||
## API-Response-Format
|
||||
|
||||
### Neues Format (mit Fehlercode):
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"code": "ERROR_MEMBER_NOT_FOUND",
|
||||
"params": {
|
||||
"memberId": 123
|
||||
},
|
||||
"error": "ERROR_MEMBER_NOT_FOUND" // Für Rückwärtskompatibilität
|
||||
}
|
||||
```
|
||||
|
||||
### Legacy-Format (wird weiterhin unterstützt):
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Mitglied nicht gefunden",
|
||||
"error": "Mitglied nicht gefunden"
|
||||
}
|
||||
```
|
||||
|
||||
## Übersetzungen hinzufügen
|
||||
|
||||
1. **Backend**: Fehlercode in `backend/constants/errorCodes.js` definieren
|
||||
2. **Frontend**: Übersetzung in `frontend/src/i18n/locales/de.json` unter `errors` hinzufügen
|
||||
|
||||
Beispiel:
|
||||
```json
|
||||
{
|
||||
"errors": {
|
||||
"ERROR_MEMBER_NOT_FOUND": "Mitglied nicht gefunden.",
|
||||
"ERROR_MEMBER_NOT_FOUND_WITH_ID": "Mitglied mit ID {memberId} nicht gefunden."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migration bestehender Fehler
|
||||
|
||||
1. Hardcodierte Fehlermeldung identifizieren
|
||||
2. Passenden Fehlercode in `errorCodes.js` finden oder erstellen
|
||||
3. Backend-Code anpassen: `throw new HttpError(createError(ERROR_CODES.XXX), status)`
|
||||
4. Übersetzung in `de.json` hinzufügen
|
||||
5. Frontend-Code muss nicht geändert werden (automatische Erkennung)
|
||||
|
||||
121
backend/constants/errorCodes.js
Normal file
121
backend/constants/errorCodes.js
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Fehlercodes für die API
|
||||
* Diese Codes werden an das Frontend gesendet und dort übersetzt
|
||||
*
|
||||
* Format: { code: string, params?: object }
|
||||
*
|
||||
* Beispiel:
|
||||
* - { code: 'ERROR_USER_NOT_FOUND' }
|
||||
* - { code: 'ERROR_MEMBER_NOT_FOUND', params: { memberId: 123 } }
|
||||
* - { code: 'ERROR_VALIDATION_FAILED', params: { field: 'email', value: 'invalid' } }
|
||||
*/
|
||||
|
||||
export const ERROR_CODES = {
|
||||
// Allgemeine Fehler
|
||||
INTERNAL_SERVER_ERROR: 'ERROR_INTERNAL_SERVER_ERROR',
|
||||
UNKNOWN_ERROR: 'ERROR_UNKNOWN_ERROR',
|
||||
VALIDATION_FAILED: 'ERROR_VALIDATION_FAILED',
|
||||
NOT_FOUND: 'ERROR_NOT_FOUND',
|
||||
UNAUTHORIZED: 'ERROR_UNAUTHORIZED',
|
||||
FORBIDDEN: 'ERROR_FORBIDDEN',
|
||||
BAD_REQUEST: 'ERROR_BAD_REQUEST',
|
||||
|
||||
// Authentifizierung
|
||||
USER_NOT_FOUND: 'ERROR_USER_NOT_FOUND',
|
||||
INVALID_PASSWORD: 'ERROR_INVALID_PASSWORD',
|
||||
LOGIN_FAILED: 'ERROR_LOGIN_FAILED',
|
||||
SESSION_EXPIRED: 'ERROR_SESSION_EXPIRED',
|
||||
|
||||
// MyTischtennis
|
||||
MYTISCHTENNIS_USER_NOT_FOUND: 'ERROR_MYTISCHTENNIS_USER_NOT_FOUND',
|
||||
MYTISCHTENNIS_INVALID_PASSWORD: 'ERROR_MYTISCHTENNIS_INVALID_PASSWORD',
|
||||
MYTISCHTENNIS_LOGIN_FAILED: 'ERROR_MYTISCHTENNIS_LOGIN_FAILED',
|
||||
MYTISCHTENNIS_ACCOUNT_NOT_LINKED: 'ERROR_MYTISCHTENNIS_ACCOUNT_NOT_LINKED',
|
||||
MYTISCHTENNIS_PASSWORD_NOT_SAVED: 'ERROR_MYTISCHTENNIS_PASSWORD_NOT_SAVED',
|
||||
MYTISCHTENNIS_SESSION_EXPIRED: 'ERROR_MYTISCHTENNIS_SESSION_EXPIRED',
|
||||
MYTISCHTENNIS_NO_PASSWORD_SAVED: 'ERROR_MYTISCHTENNIS_NO_PASSWORD_SAVED',
|
||||
MYTISCHTENNIS_CAPTCHA_REQUIRED: 'ERROR_MYTISCHTENNIS_CAPTCHA_REQUIRED',
|
||||
|
||||
// Mitglieder
|
||||
MEMBER_NOT_FOUND: 'ERROR_MEMBER_NOT_FOUND',
|
||||
MEMBER_ALREADY_EXISTS: 'ERROR_MEMBER_ALREADY_EXISTS',
|
||||
MEMBER_FIRSTNAME_REQUIRED: 'ERROR_MEMBER_FIRSTNAME_REQUIRED',
|
||||
MEMBER_LASTNAME_REQUIRED: 'ERROR_MEMBER_LASTNAME_REQUIRED',
|
||||
|
||||
// Gruppen
|
||||
GROUP_NOT_FOUND: 'ERROR_GROUP_NOT_FOUND',
|
||||
GROUP_NAME_REQUIRED: 'ERROR_GROUP_NAME_REQUIRED',
|
||||
GROUP_ALREADY_EXISTS: 'ERROR_GROUP_ALREADY_EXISTS',
|
||||
GROUP_INVALID_PRESET_TYPE: 'ERROR_GROUP_INVALID_PRESET_TYPE',
|
||||
GROUP_CANNOT_RENAME_PRESET: 'ERROR_GROUP_CANNOT_RENAME_PRESET',
|
||||
|
||||
// Turniere
|
||||
TOURNAMENT_NOT_FOUND: 'ERROR_TOURNAMENT_NOT_FOUND',
|
||||
TOURNAMENT_NO_DATE: 'ERROR_TOURNAMENT_NO_DATE',
|
||||
TOURNAMENT_CLASS_NAME_REQUIRED: 'ERROR_TOURNAMENT_CLASS_NAME_REQUIRED',
|
||||
TOURNAMENT_NO_PARTICIPANTS: 'ERROR_TOURNAMENT_NO_PARTICIPANTS',
|
||||
TOURNAMENT_NO_VALID_PARTICIPANTS: 'ERROR_TOURNAMENT_NO_VALID_PARTICIPANTS',
|
||||
TOURNAMENT_NO_TRAINING_DAY: 'ERROR_TOURNAMENT_NO_TRAINING_DAY',
|
||||
TOURNAMENT_PDF_GENERATION_FAILED: 'ERROR_TOURNAMENT_PDF_GENERATION_FAILED',
|
||||
TOURNAMENT_SELECT_FIRST: 'ERROR_TOURNAMENT_SELECT_FIRST',
|
||||
|
||||
// Trainingstagebuch
|
||||
DIARY_DATE_NOT_FOUND: 'ERROR_DIARY_DATE_NOT_FOUND',
|
||||
DIARY_DATE_UPDATED: 'ERROR_DIARY_DATE_UPDATED',
|
||||
DIARY_NO_PARTICIPANTS: 'ERROR_DIARY_NO_PARTICIPANTS',
|
||||
DIARY_PDF_GENERATION_FAILED: 'ERROR_DIARY_PDF_GENERATION_FAILED',
|
||||
DIARY_IMAGE_LOAD_FAILED: 'ERROR_DIARY_IMAGE_LOAD_FAILED',
|
||||
DIARY_STATS_LOAD_FAILED: 'ERROR_DIARY_STATS_LOAD_FAILED',
|
||||
DIARY_NO_EXERCISE_DATA: 'ERROR_DIARY_NO_EXERCISE_DATA',
|
||||
DIARY_ACTIVITY_PARTICIPANTS_UPDATE_FAILED: 'ERROR_DIARY_ACTIVITY_PARTICIPANTS_UPDATE_FAILED',
|
||||
DIARY_GROUP_ASSIGNMENT_UPDATED: 'SUCCESS_DIARY_GROUP_ASSIGNMENT_UPDATED',
|
||||
DIARY_GROUP_ASSIGNMENT_UPDATE_FAILED: 'ERROR_DIARY_GROUP_ASSIGNMENT_UPDATE_FAILED',
|
||||
DIARY_ASSIGN_ALL_PARTICIPANTS_FAILED: 'ERROR_DIARY_ASSIGN_ALL_PARTICIPANTS_FAILED',
|
||||
DIARY_ASSIGN_GROUP_FAILED: 'ERROR_DIARY_ASSIGN_GROUP_FAILED',
|
||||
DIARY_PARTICIPANT_ASSIGN_FAILED: 'ERROR_DIARY_PARTICIPANT_ASSIGN_FAILED',
|
||||
DIARY_PARTICIPANT_GROUP_ASSIGNMENT_UPDATE_FAILED: 'ERROR_DIARY_PARTICIPANT_GROUP_ASSIGNMENT_UPDATE_FAILED',
|
||||
DIARY_MEMBER_CREATED: 'SUCCESS_DIARY_MEMBER_CREATED',
|
||||
DIARY_MEMBER_CREATE_FAILED: 'ERROR_DIARY_MEMBER_CREATE_FAILED',
|
||||
|
||||
// Team Management
|
||||
TEAM_NOT_LINKED_TO_LEAGUE: 'ERROR_TEAM_NOT_LINKED_TO_LEAGUE',
|
||||
TEAM_LINK_TO_LEAGUE_REQUIRED: 'ERROR_TEAM_LINK_TO_LEAGUE_REQUIRED',
|
||||
TEAM_PDF_LOAD_FAILED: 'ERROR_TEAM_PDF_LOAD_FAILED',
|
||||
TEAM_STATS_LOAD_FAILED: 'ERROR_TEAM_STATS_LOAD_FAILED',
|
||||
|
||||
// Aktivitäten
|
||||
ACTIVITY_IMAGE_DELETE_FAILED: 'ERROR_ACTIVITY_IMAGE_DELETE_FAILED',
|
||||
|
||||
// Offizielle Turniere
|
||||
OFFICIAL_TOURNAMENT_PDF_UPLOAD_SUCCESS: 'SUCCESS_OFFICIAL_TOURNAMENT_PDF_UPLOAD',
|
||||
OFFICIAL_TOURNAMENT_PDF_UPLOAD_FAILED: 'ERROR_OFFICIAL_TOURNAMENT_PDF_UPLOAD',
|
||||
|
||||
// Vereine
|
||||
CLUB_NOT_FOUND: 'ERROR_CLUB_NOT_FOUND',
|
||||
CLUB_ALREADY_EXISTS: 'ERROR_CLUB_ALREADY_EXISTS',
|
||||
CLUB_NAME_REQUIRED: 'ERROR_CLUB_NAME_REQUIRED',
|
||||
CLUB_NAME_TOO_SHORT: 'ERROR_CLUB_NAME_TOO_SHORT',
|
||||
|
||||
// Mitglieder-Übertragung
|
||||
MEMBER_TRANSFER_BULK_FAILED: 'ERROR_MEMBER_TRANSFER_BULK_FAILED',
|
||||
|
||||
// Training
|
||||
TRAINING_STATS_LOAD_FAILED: 'ERROR_TRAINING_STATS_LOAD_FAILED',
|
||||
|
||||
// Logs
|
||||
LOG_NOT_FOUND: 'ERROR_LOG_NOT_FOUND',
|
||||
};
|
||||
|
||||
/**
|
||||
* Erstellt ein Fehler-Objekt mit Code und optionalen Parametern
|
||||
* @param {string} code - Fehlercode aus ERROR_CODES
|
||||
* @param {object} params - Optionale Parameter für die Fehlermeldung
|
||||
* @returns {object} Fehler-Objekt mit code und params
|
||||
*/
|
||||
export function createError(code, params = null) {
|
||||
return {
|
||||
code,
|
||||
...(params && { params })
|
||||
};
|
||||
}
|
||||
|
||||
85
backend/controllers/apiLogController.js
Normal file
85
backend/controllers/apiLogController.js
Normal file
@@ -0,0 +1,85 @@
|
||||
import apiLogService from '../services/apiLogService.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
class ApiLogController {
|
||||
/**
|
||||
* GET /api/logs
|
||||
* Get API logs with optional filters
|
||||
*/
|
||||
async getLogs(req, res, next) {
|
||||
try {
|
||||
const {
|
||||
userId,
|
||||
logType,
|
||||
method,
|
||||
path,
|
||||
statusCode,
|
||||
startDate,
|
||||
endDate,
|
||||
limit = 100,
|
||||
offset = 0
|
||||
} = req.query;
|
||||
|
||||
const result = await apiLogService.getLogs({
|
||||
userId: userId ? parseInt(userId) : null,
|
||||
logType,
|
||||
method,
|
||||
path,
|
||||
statusCode: statusCode ? parseInt(statusCode) : null,
|
||||
startDate,
|
||||
endDate,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/logs/:id
|
||||
* Get a single log entry by ID
|
||||
*/
|
||||
async getLogById(req, res, next) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const log = await apiLogService.getLogById(parseInt(id));
|
||||
|
||||
if (!log) {
|
||||
throw new HttpError('Log entry not found', 404);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: log
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/logs/scheduler/last-executions
|
||||
* Get last execution info for scheduler jobs
|
||||
*/
|
||||
async getLastSchedulerExecutions(req, res, next) {
|
||||
try {
|
||||
const { clubId } = req.query;
|
||||
const results = await apiLogService.getLastSchedulerExecutions(clubId ? parseInt(clubId) : null);
|
||||
res.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ApiLogController();
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { register, activateUser, login, logout } from '../services/authService.js';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import UserToken from '../models/UserToken.js';
|
||||
import User from '../models/User.js'; // ggf. Pfad anpassen
|
||||
import { register, activateUser, login, logout, deleteOwnAccount, requestPasswordReset, resetPassword } from '../services/authService.js';
|
||||
|
||||
const registerUser = async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
const user = await register(email, password);
|
||||
res.status(201).json(user);
|
||||
console.log('registerUser', email, password);
|
||||
await register(email, password);
|
||||
console.log('registerUser done');
|
||||
// Aus Sicherheitsgründen KEINE Userdaten (Passwort-Hash, Aktivierungscode, ...) zurückgeben
|
||||
res.status(201).json({ success: true });
|
||||
console.log('registerUser response sent');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -16,8 +17,9 @@ const registerUser = async (req, res, next) => {
|
||||
const activate = async (req, res, next) => {
|
||||
try {
|
||||
const { activationCode } = req.params;
|
||||
const user = await activateUser(activationCode);
|
||||
res.status(200).json(user);
|
||||
await activateUser(activationCode);
|
||||
// Auch bei Aktivierung kein komplettes User-Objekt zurückgeben
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -25,8 +27,8 @@ const activate = async (req, res, next) => {
|
||||
|
||||
const loginUser = async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
const result = await login(email, password);
|
||||
const { email, password, rememberMe } = req.body;
|
||||
const result = await login(email, password, { rememberMe });
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -35,7 +37,7 @@ const loginUser = async (req, res, next) => {
|
||||
|
||||
const logoutUser = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.headers['authorization']?.split(' ')[1];
|
||||
const token = req.headers['authorization']?.split(' ')[1] || req.headers.authcode;
|
||||
const result = await logout(token);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
@@ -43,4 +45,34 @@ const logoutUser = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
export { registerUser, activate, loginUser, logoutUser };
|
||||
const deleteAccount = async (req, res, next) => {
|
||||
try {
|
||||
const { password } = req.body || {};
|
||||
const result = await deleteOwnAccount(req.user?.id, password);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const forgotPassword = async (req, res, next) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
const result = await requestPasswordReset(email);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserPassword = async (req, res, next) => {
|
||||
try {
|
||||
const { token, password } = req.body;
|
||||
const result = await resetPassword(token, password);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export { registerUser, activate, loginUser, logoutUser, deleteAccount, forgotPassword, resetUserPassword };
|
||||
|
||||
207
backend/controllers/billingController.js
Normal file
207
backend/controllers/billingController.js
Normal file
@@ -0,0 +1,207 @@
|
||||
import fs from 'fs';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import billingService from '../services/billingService.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const BILLING_TEMPLATE_UPLOAD_DIR = path.resolve(__dirname, '..', 'uploads', 'billing-templates');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const dir = BILLING_TEMPLATE_UPLOAD_DIR;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || '.pdf';
|
||||
cb(null, `billing-template-${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname || '').toLowerCase();
|
||||
const isPdf = ext === '.pdf' || (file.mimetype || '').includes('pdf');
|
||||
if (!isPdf) {
|
||||
return cb(new Error('Nur PDF-Dateien sind erlaubt.'));
|
||||
}
|
||||
cb(null, true);
|
||||
}
|
||||
});
|
||||
|
||||
export const uploadBillingTemplateMiddleware = upload.single('templatePdf');
|
||||
|
||||
export const listTemplates = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const result = await billingService.listTemplates(userToken, clubId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[listTemplates] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Vorlagen konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createTemplate = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const result = await billingService.createTemplate(userToken, clubId, req.body || {}, req.file);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[createTemplate] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Vorlage konnte nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTemplate = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { templateId } = req.params;
|
||||
const result = await billingService.deleteTemplate(userToken, templateId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[deleteTemplate] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Vorlage konnte nicht gelöscht werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTemplateFields = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { templateId } = req.params;
|
||||
const result = await billingService.saveTemplateFields(userToken, templateId, req.body?.fields || []);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[updateTemplateFields] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Felder konnten nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createRun = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const result = await billingService.createRun(userToken, clubId, req.body || {});
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[createRun] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Abrechnungslauf konnte nicht erstellt werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const listRuns = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const result = await billingService.listRuns(userToken, clubId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[listRuns] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Abrechnungsläufe konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getRunDetails = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { runId } = req.params;
|
||||
const result = await billingService.getRunDetails(userToken, runId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[getRunDetails] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Abrechnungslauf konnte nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRun = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { runId } = req.params;
|
||||
const result = await billingService.deleteRun(userToken, runId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[deleteRun] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Abrechnung konnte nicht gelöscht werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserSettings = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const result = await billingService.getUserSettings(userToken, clubId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[getUserSettings] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Einstellungen konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const calculateHoursPreview = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const { monthFrom, monthTo } = req.query;
|
||||
const result = await billingService.calculateHoursPreview(userToken, clubId, monthFrom, monthTo);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[calculateHoursPreview] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Stunden konnten nicht berechnet werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const generateRun = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { runId } = req.params;
|
||||
const result = await billingService.generateRun(userToken, runId, req.body || {});
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[generateRun] Error:', error);
|
||||
const message = String(error?.message || '');
|
||||
if (message.includes('Formularfelder') || message.includes('Feldnamen') || message.includes('Fehlend:')) {
|
||||
return res.status(400).json({ success: false, error: message });
|
||||
}
|
||||
res.status(500).json({ success: false, error: 'Abrechnung konnte nicht erzeugt werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadTemplatePdf = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { templateId } = req.params;
|
||||
const result = await billingService.downloadTemplatePdf(userToken, templateId);
|
||||
if (result.status !== 200) {
|
||||
return res.status(result.status).json(result.response);
|
||||
}
|
||||
res.setHeader('Content-Disposition', `inline; filename="${result.file.name}"`);
|
||||
res.setHeader('Content-Type', result.file.mimeType);
|
||||
return res.sendFile(result.file.path);
|
||||
} catch (error) {
|
||||
console.error('[downloadTemplatePdf] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'PDF konnte nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadRunPdf = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { runId } = req.params;
|
||||
const result = await billingService.downloadGeneratedRunPdf(userToken, runId);
|
||||
if (result.status !== 200) {
|
||||
return res.status(result.status).json(result.response);
|
||||
}
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${result.file.name}"`);
|
||||
res.setHeader('Content-Type', result.file.mimeType);
|
||||
return res.sendFile(result.file.path);
|
||||
} catch (error) {
|
||||
console.error('[downloadRunPdf] Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Abrechnungs-PDF konnte nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
20
backend/controllers/calendarController.js
Normal file
20
backend/controllers/calendarController.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import calendarHolidayService from '../services/calendarHolidayService.js';
|
||||
|
||||
export const getClubCalendarDays = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const { year } = req.query;
|
||||
const result = await calendarHolidayService.getClubCalendarDays(token, clubId, year);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
if (error.message === 'clubnotfound') {
|
||||
res.status(404).json({ error: 'clubnotfound' });
|
||||
} else if (error.message === 'noaccess') {
|
||||
res.status(403).json({ error: 'noaccess' });
|
||||
} else {
|
||||
console.error('[getClubCalendarDays] - error:', error);
|
||||
res.status(502).json({ error: 'calendarproviderfailed' });
|
||||
}
|
||||
}
|
||||
};
|
||||
42
backend/controllers/calendarEventController.js
Normal file
42
backend/controllers/calendarEventController.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import calendarEventService from '../services/calendarEventService.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorUtils.js';
|
||||
|
||||
export const listClubCalendarEvents = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const { year } = req.query;
|
||||
const events = await calendarEventService.listClubEvents(userToken, clubId, year);
|
||||
res.status(200).json(events);
|
||||
} catch (error) {
|
||||
console.error('[listClubCalendarEvents] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Kalender-Events');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubCalendarEvent = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const event = await calendarEventService.createClubEvent(userToken, clubId, req.body);
|
||||
res.status(201).json(event);
|
||||
} catch (error) {
|
||||
console.error('[createClubCalendarEvent] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Speichern des Kalender-Events');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubCalendarEvent = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, eventId } = req.params;
|
||||
const result = await calendarEventService.deleteClubEvent(userToken, clubId, eventId);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[deleteClubCalendarEvent] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen des Kalender-Events');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
72
backend/controllers/clickTtAccountController.js
Normal file
72
backend/controllers/clickTtAccountController.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import clickTtAccountService from '../services/clickTtAccountService.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
class ClickTtAccountController {
|
||||
async getAccount(req, res, next) {
|
||||
try {
|
||||
const account = await clickTtAccountService.getAccount(req.user.id);
|
||||
res.status(200).json({ account });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getStatus(req, res, next) {
|
||||
try {
|
||||
const status = await clickTtAccountService.checkAccountStatus(req.user.id);
|
||||
res.status(200).json(status);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async upsertAccount(req, res, next) {
|
||||
try {
|
||||
const { username, password, savePassword, userPassword } = req.body;
|
||||
if (!username) {
|
||||
throw new HttpError('Benutzername erforderlich', 400);
|
||||
}
|
||||
if (password && !userPassword) {
|
||||
throw new HttpError('App-Passwort erforderlich zum Setzen des HTTV-/click-TT-Passworts', 400);
|
||||
}
|
||||
|
||||
const account = await clickTtAccountService.upsertAccount(
|
||||
req.user.id,
|
||||
username,
|
||||
password,
|
||||
savePassword || false,
|
||||
userPassword
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
message: 'HTTV-/click-TT-Account erfolgreich gespeichert',
|
||||
account
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAccount(req, res, next) {
|
||||
try {
|
||||
const deleted = await clickTtAccountService.deleteAccount(req.user.id);
|
||||
if (!deleted) {
|
||||
throw new HttpError('Kein HTTV-/click-TT-Account gefunden', 404);
|
||||
}
|
||||
res.status(200).json({ message: 'HTTV-/click-TT-Account gelöscht' });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyLogin(req, res, next) {
|
||||
try {
|
||||
const result = await clickTtAccountService.verifyLogin(req.user.id, req.body.password);
|
||||
res.status(200).json({ success: true, message: 'Login erfolgreich', ...result });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClickTtAccountController();
|
||||
60
backend/controllers/clubAccountController.js
Normal file
60
backend/controllers/clubAccountController.js
Normal file
@@ -0,0 +1,60 @@
|
||||
import clubAccountService from '../services/clubAccountService.js';
|
||||
|
||||
class ClubAccountController {
|
||||
async listClubAccounts(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const accounts = await clubAccountService.listClubAccounts(Number(clubId));
|
||||
res.json({ accounts });
|
||||
} catch (error) {
|
||||
console.error('[listClubAccounts] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Konten konnten nicht geladen werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async createClubAccount(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const account = await clubAccountService.createClubAccount(Number(clubId), req.body || {});
|
||||
res.status(201).json({ account });
|
||||
} catch (error) {
|
||||
console.error('[createClubAccount] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateClubAccount(req, res) {
|
||||
try {
|
||||
const { clubId, accountId } = req.params;
|
||||
const account = await clubAccountService.updateClubAccount(Number(clubId), Number(accountId), req.body || {});
|
||||
res.json({ account });
|
||||
} catch (error) {
|
||||
console.error('[updateClubAccount] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateClubAccountStatus(req, res) {
|
||||
try {
|
||||
const { clubId, accountId } = req.params;
|
||||
const account = await clubAccountService.updateClubAccountStatus(Number(clubId), Number(accountId), String(req.body?.status || ''));
|
||||
res.json({ account });
|
||||
} catch (error) {
|
||||
console.error('[updateClubAccountStatus] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Kontostatus konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteClubAccount(req, res) {
|
||||
try {
|
||||
const { clubId, accountId } = req.params;
|
||||
await clubAccountService.deleteClubAccount(Number(clubId), Number(accountId));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteClubAccount] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Konto konnte nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubAccountController();
|
||||
19
backend/controllers/clubArchiveController.js
Normal file
19
backend/controllers/clubArchiveController.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import clubArchiveService from '../services/clubArchiveService.js';
|
||||
|
||||
class ClubArchiveController {
|
||||
async getClubArchive(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const archive = await clubArchiveService.getClubArchive(clubId);
|
||||
res.json(archive);
|
||||
} catch (error) {
|
||||
console.error('[getClubArchive] - Error:', error);
|
||||
if (error?.status) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Fehler beim Laden des Vereinsarchivs' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubArchiveController();
|
||||
557
backend/controllers/clubDashboardController.js
Normal file
557
backend/controllers/clubDashboardController.js
Normal file
@@ -0,0 +1,557 @@
|
||||
import { Op } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import {
|
||||
CalendarEvent,
|
||||
ClubPaymentClaim,
|
||||
ClubRequest,
|
||||
ClubSepaMandate,
|
||||
ClubTask,
|
||||
Match,
|
||||
Member,
|
||||
TrainingGroup,
|
||||
} from '../models/index.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorUtils.js';
|
||||
|
||||
function formatRequestWorkflowStage(stage) {
|
||||
return {
|
||||
contact_replied: 'Kontakt beantwortet',
|
||||
trial_training_scheduled: 'Probetraining terminiert',
|
||||
trial_training_feedback_recorded: 'Probetraining nachbereitet',
|
||||
membership_reviewed: 'Mitgliedsanfrage geprüft',
|
||||
admission_prepared: 'Aufnahme vorbereitet',
|
||||
member_record_created: 'Mitglied angelegt',
|
||||
sepa_pending: 'SEPA ausstehend',
|
||||
onboarding_completed: 'Onboarding abgeschlossen',
|
||||
sponsoring_contacted: 'Sponsoring kontaktiert',
|
||||
}[stage] || stage;
|
||||
}
|
||||
|
||||
function formatTaskType(taskType) {
|
||||
return {
|
||||
request_contact_reply: 'Kontaktanfrage beantworten',
|
||||
request_schedule_trial_training: 'Probetraining organisieren',
|
||||
request_trial_training_follow_up: 'Probetraining nachbereiten',
|
||||
request_membership_review: 'Mitgliedsanfrage prüfen',
|
||||
membership_prepare_admission: 'Aufnahme vorbereiten',
|
||||
membership_create_member_record: 'Mitglied anlegen',
|
||||
membership_collect_sepa_mandate: 'SEPA organisieren',
|
||||
membership_assign_fee: 'Beitrag zuordnen',
|
||||
request_sponsoring_reply: 'Sponsoring nachfassen',
|
||||
member_missing_email: 'E-Mail ergänzen',
|
||||
member_missing_birthdate: 'Geburtsdatum ergänzen',
|
||||
member_missing_sepa_mandate: 'SEPA-Mandat einholen',
|
||||
payment_claim_due_soon: 'Fällige Zahlung vorbereiten',
|
||||
payment_claim_overdue: 'Überfällige Zahlung nachfassen',
|
||||
payment_claim_reminder: 'Mahnstufe prüfen',
|
||||
calendar_event_prepare: 'Termin vorbereiten',
|
||||
calendar_event_deadline_check: 'Terminfrist prüfen',
|
||||
}[taskType] || taskType || 'Freie Aufgabe';
|
||||
}
|
||||
|
||||
function formatEventDateRange(event) {
|
||||
if (!event?.startDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' });
|
||||
const start = formatter.format(new Date(event.startDate));
|
||||
const end = event.endDate ? formatter.format(new Date(event.endDate)) : start;
|
||||
return start === end ? start : `${start} bis ${end}`;
|
||||
}
|
||||
|
||||
function formatDate(value, options = { dateStyle: 'medium' }) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('de-DE', options).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(value).slice(0, 5);
|
||||
}
|
||||
|
||||
function formatWeekday(weekday) {
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][Number(weekday)] || 'Unbekannt';
|
||||
}
|
||||
|
||||
function formatConfiguredTrainingLabel(entry) {
|
||||
const start = formatTime(entry.startTime);
|
||||
const end = formatTime(entry.endTime);
|
||||
const timeRange = start && end ? `${start} bis ${end} Uhr` : start ? `${start} Uhr` : null;
|
||||
|
||||
return [entry.groupName, formatWeekday(entry.weekday), timeRange].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function formatMatchLabel(match) {
|
||||
const date = formatDate(match.date);
|
||||
const time = formatTime(match.time);
|
||||
const homeTeam = match.homeTeam?.name || 'Heimteam';
|
||||
const guestTeam = match.guestTeam?.name || 'Gastteam';
|
||||
const league = match.leagueDetails?.name || null;
|
||||
|
||||
return [
|
||||
`${homeTeam} gegen ${guestTeam}`,
|
||||
date,
|
||||
time ? `${time} Uhr` : null,
|
||||
league,
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function createDashboardItem(label, to, extra = {}) {
|
||||
return { label, to, ...extra };
|
||||
}
|
||||
|
||||
function buildMemberRoute(memberId, scope = 'active', extraQuery = {}) {
|
||||
return {
|
||||
path: '/members',
|
||||
query: {
|
||||
scope,
|
||||
memberId: String(memberId),
|
||||
mode: 'edit',
|
||||
...extraQuery,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequestRoute(requestId, status = '') {
|
||||
const query = { requestId: String(requestId) };
|
||||
if (status) {
|
||||
query.status = status;
|
||||
}
|
||||
return {
|
||||
path: '/club-requests',
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskRoute(taskId, status = '') {
|
||||
const query = { taskId: String(taskId) };
|
||||
if (status) {
|
||||
query.status = status;
|
||||
}
|
||||
return {
|
||||
path: '/club-tasks',
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAvailableTables() {
|
||||
const tables = await sequelize.getQueryInterface().showAllTables();
|
||||
return new Set(
|
||||
tables
|
||||
.map((table) => (typeof table === 'string' ? table : Object.values(table || {})[0]))
|
||||
.filter(Boolean)
|
||||
.map((table) => String(table).toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
async function loadOptionalTableData(availableTables, tableName, loader, fallbackValue = []) {
|
||||
if (!availableTables.has(String(tableName).toLowerCase())) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return loader();
|
||||
}
|
||||
|
||||
function countMissingMemberFields(members) {
|
||||
const missing = {
|
||||
email: 0,
|
||||
birthDate: 0,
|
||||
};
|
||||
|
||||
for (const member of members) {
|
||||
if (!String(member.email || '').trim()) {
|
||||
missing.email += 1;
|
||||
}
|
||||
if (!String(member.birthDate || '').trim()) {
|
||||
missing.birthDate += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
function toNextOccurrenceDate(weekday, startTime) {
|
||||
const now = new Date();
|
||||
const result = new Date(now);
|
||||
const targetWeekday = Number(weekday);
|
||||
const daysUntilWeekday = (targetWeekday - result.getDay() + 7) % 7;
|
||||
result.setDate(result.getDate() + daysUntilWeekday);
|
||||
|
||||
const [hours = '0', minutes = '0'] = String(startTime || '00:00').split(':');
|
||||
result.setHours(Number(hours), Number(minutes), 0, 0);
|
||||
|
||||
if (result < now) {
|
||||
result.setDate(result.getDate() + 7);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildUpcomingTrainingSlots(groups, limit = 5) {
|
||||
return groups
|
||||
.flatMap((group) => (Array.isArray(group.trainingTimes) ? group.trainingTimes.map((time) => ({
|
||||
id: time.id,
|
||||
weekday: time.weekday,
|
||||
startTime: time.startTime,
|
||||
endTime: time.endTime,
|
||||
sortOrder: time.sortOrder,
|
||||
groupName: group.name,
|
||||
nextOccurrence: toNextOccurrenceDate(time.weekday, time.startTime),
|
||||
})) : []))
|
||||
.sort((left, right) => {
|
||||
const timeDiff = left.nextOccurrence.getTime() - right.nextOccurrence.getTime();
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return String(left.groupName || '').localeCompare(String(right.groupName || ''));
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
export const getClubDashboard = async (req, res) => {
|
||||
try {
|
||||
const clubId = Number(req.params.clubId);
|
||||
const currentUserId = Number(req.user?.id) || null;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todayIso = today.toISOString().slice(0, 10);
|
||||
const availableTables = await loadAvailableTables();
|
||||
|
||||
const [
|
||||
requests,
|
||||
tasks,
|
||||
members,
|
||||
mandates,
|
||||
paymentClaims,
|
||||
upcomingEvents,
|
||||
trainingGroups,
|
||||
upcomingMatches,
|
||||
] = await Promise.all([
|
||||
loadOptionalTableData(availableTables, 'club_requests', () => ClubRequest.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.notIn]: ['archived'] },
|
||||
},
|
||||
order: [['receivedAt', 'DESC']],
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'club_tasks', () => ClubTask.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.notIn]: ['archived'] },
|
||||
},
|
||||
order: [['dueAt', 'ASC'], ['updatedAt', 'DESC']],
|
||||
})),
|
||||
Member.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
active: true,
|
||||
},
|
||||
order: [['createdAt', 'DESC']],
|
||||
}),
|
||||
loadOptionalTableData(availableTables, 'club_sepa_mandates', () => ClubSepaMandate.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: 'active',
|
||||
revokedAt: null,
|
||||
memberId: { [Op.ne]: null },
|
||||
},
|
||||
attributes: ['memberId'],
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'club_payment_claims', () => ClubPaymentClaim.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
},
|
||||
order: [['dueOn', 'ASC']],
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'calendar_events', () => CalendarEvent.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
endDate: { [Op.gte]: todayIso },
|
||||
},
|
||||
order: [['startDate', 'ASC']],
|
||||
limit: 5,
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'training_group', () => TrainingGroup.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
association: 'trainingTimes',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [['isPreset', 'DESC'], ['sortOrder', 'ASC'], ['name', 'ASC']],
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'match', () => Match.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
date: { [Op.gte]: today },
|
||||
},
|
||||
include: [
|
||||
{ association: 'homeTeam', attributes: ['id', 'name'] },
|
||||
{ association: 'guestTeam', attributes: ['id', 'name'] },
|
||||
{ association: 'leagueDetails', attributes: ['id', 'name'] },
|
||||
],
|
||||
order: [['date', 'ASC'], ['time', 'ASC']],
|
||||
limit: 5,
|
||||
})),
|
||||
]);
|
||||
|
||||
const visibleDashboardTasks = tasks.filter((task) => !task.assignedUserId || Number(task.assignedUserId) === currentUserId);
|
||||
const membersById = new Map(members.map((member) => [Number(member.id), member]));
|
||||
const paymentClaimsById = new Map(paymentClaims.map((claim) => [Number(claim.id), claim]));
|
||||
const missingFields = countMissingMemberFields(members);
|
||||
const openTasks = visibleDashboardTasks.filter((task) => task.status === 'open');
|
||||
const inProgressTasks = visibleDashboardTasks.filter((task) => task.status === 'in_progress');
|
||||
const automatedTasks = visibleDashboardTasks.filter((task) => Boolean(task.automationKey));
|
||||
const automatedOpenTasks = automatedTasks.filter((task) => ['open', 'in_progress', 'waiting'].includes(task.status));
|
||||
const overdueTaskCount = visibleDashboardTasks.filter((task) => {
|
||||
if (!task.dueAt || ['done', 'cancelled', 'archived'].includes(task.status)) {
|
||||
return false;
|
||||
}
|
||||
return new Date(task.dueAt) < today;
|
||||
}).length;
|
||||
const memberIdsWithMandate = new Set(mandates.map((mandate) => Number(mandate.memberId)).filter(Boolean));
|
||||
const missingMandateCount = members.filter((member) => !memberIdsWithMandate.has(Number(member.id))).length;
|
||||
const openRequestCount = requests.filter((request) => request.status === 'open').length;
|
||||
const inProgressRequestCount = requests.filter((request) => request.status === 'in_progress').length;
|
||||
const trialTrainingCount = requests.filter((request) => request.requestType === 'trial_training' && request.status !== 'archived').length;
|
||||
const workflowStageCounts = requests.reduce((accumulator, request) => {
|
||||
if (!request.workflowStage) return accumulator;
|
||||
accumulator[request.workflowStage] = (accumulator[request.workflowStage] || 0) + 1;
|
||||
return accumulator;
|
||||
}, {});
|
||||
const onboardingCount =
|
||||
(workflowStageCounts.membership_reviewed || 0) +
|
||||
(workflowStageCounts.admission_prepared || 0) +
|
||||
(workflowStageCounts.member_record_created || 0) +
|
||||
(workflowStageCounts.sepa_pending || 0);
|
||||
const duePaymentCount = paymentClaims.filter((claim) => claim.status === 'open').length;
|
||||
const reminderCount = paymentClaims.filter((claim) => Number(claim.reminderLevel || 0) > 0).length;
|
||||
const recentMembers = members.slice(0, 4);
|
||||
const upcomingTrainings = buildUpcomingTrainingSlots(trainingGroups);
|
||||
const paidRatio = paymentClaims.length === 0
|
||||
? null
|
||||
: Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
((paymentClaims.length - duePaymentCount) / paymentClaims.length) * 100
|
||||
)
|
||||
);
|
||||
|
||||
function taskDetailTarget(task) {
|
||||
if (task.relatedEntityType === 'member' && task.relatedEntityId) {
|
||||
return buildMemberRoute(task.relatedEntityId, 'active');
|
||||
}
|
||||
|
||||
if (task.relatedEntityType === 'club_request' && task.relatedEntityId) {
|
||||
const request = requests.find((entry) => Number(entry.id) === Number(task.relatedEntityId));
|
||||
return buildRequestRoute(task.relatedEntityId, request?.status || '');
|
||||
}
|
||||
|
||||
if (task.relatedEntityType === 'club_payment_claim' && task.relatedEntityId) {
|
||||
const claim = paymentClaimsById.get(Number(task.relatedEntityId));
|
||||
if (claim?.memberId) {
|
||||
return buildMemberRoute(claim.memberId, 'active');
|
||||
}
|
||||
}
|
||||
|
||||
return buildTaskRoute(task.id, task.status || '');
|
||||
}
|
||||
|
||||
function taskDashboardItem(task, label) {
|
||||
return createDashboardItem(label, taskDetailTarget(task), {
|
||||
isAssignedToCurrentUser: Boolean(task.assignedUserId) && Number(task.assignedUserId) === currentUserId,
|
||||
});
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{
|
||||
id: 'action-needed',
|
||||
title: 'Handlungsbedarf',
|
||||
cards: [
|
||||
{
|
||||
title: 'Neue Anfragen',
|
||||
value: `${openRequestCount + inProgressRequestCount}`,
|
||||
meta: trialTrainingCount > 0 ? `${trialTrainingCount} Probetrainings` : null,
|
||||
to: '/club-requests',
|
||||
items: [
|
||||
createDashboardItem(`${openRequestCount} offen`, '/club-requests'),
|
||||
createDashboardItem(`${inProgressRequestCount} in Bearbeitung`, '/club-requests'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Anfrage-Workflows',
|
||||
value: `${onboardingCount}`,
|
||||
meta: onboardingCount > 0 ? 'im Aufnahme- und Onboardingprozess' : 'Keine aktiven Onboarding-Fälle',
|
||||
to: '/club-requests',
|
||||
items: [
|
||||
createDashboardItem(`${workflowStageCounts.trial_training_scheduled || 0} Probetrainings terminiert`, '/club-requests'),
|
||||
createDashboardItem(`${workflowStageCounts.membership_reviewed || 0} Mitgliedsanfragen geprüft`, '/club-requests'),
|
||||
createDashboardItem(`${workflowStageCounts.sepa_pending || 0} Fälle mit ausstehendem SEPA`, '/club-requests'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Offene Zahlungen',
|
||||
value: `${paymentClaims.length}`,
|
||||
meta: reminderCount > 0 ? `${reminderCount} mit Mahnstufe` : 'Keine Mahnungen aktiv',
|
||||
to: '/club-tasks',
|
||||
items: paymentClaims.slice(0, 3).map((claim) => {
|
||||
const amount = `${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'}`;
|
||||
return createDashboardItem(
|
||||
`${amount} fällig am ${claim.dueOn}`,
|
||||
claim.memberId ? buildMemberRoute(claim.memberId, 'active') : '/club-tasks'
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'Fehlende Daten',
|
||||
value: `${missingFields.email + missingFields.birthDate + missingMandateCount}`,
|
||||
to: '/members',
|
||||
items: [
|
||||
createDashboardItem(`${missingFields.email} Mitglieder ohne E-Mail`, { path: '/members', query: { scope: 'dataIncomplete' } }),
|
||||
createDashboardItem(`${missingFields.birthDate} Mitglieder ohne Geburtsdatum`, { path: '/members', query: { scope: 'dataIncomplete' } }),
|
||||
createDashboardItem(`${missingMandateCount} Mitglieder ohne SEPA-Mandat`, { path: '/members', query: { scope: 'dataIncomplete' } }),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Offene Aufgaben',
|
||||
value: `${openTasks.length + inProgressTasks.length}`,
|
||||
meta: overdueTaskCount > 0 ? `${overdueTaskCount} überfällig` : 'Keine überfälligen Aufgaben',
|
||||
to: '/club-tasks',
|
||||
items: [
|
||||
createDashboardItem(`${automatedOpenTasks.length} automatisch erzeugte Schritte`, '/club-tasks'),
|
||||
...visibleDashboardTasks.slice(0, 3).map((task) => taskDashboardItem(task, task.title)),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'appointments',
|
||||
title: 'Aktuelle Termine',
|
||||
cards: [
|
||||
{
|
||||
title: 'Nächste Trainings',
|
||||
value: `${upcomingTrainings.length}`,
|
||||
to: {
|
||||
path: '/club-settings',
|
||||
query: { tab: 'training-times' },
|
||||
},
|
||||
items: upcomingTrainings.map((training) => createDashboardItem(
|
||||
formatConfiguredTrainingLabel(training),
|
||||
{
|
||||
path: '/club-settings',
|
||||
query: { tab: 'training-times' },
|
||||
}
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: 'Nächste Spiele',
|
||||
value: `${upcomingMatches.length}`,
|
||||
to: '/schedule',
|
||||
items: upcomingMatches.map((match) => createDashboardItem(formatMatchLabel(match), '/schedule')),
|
||||
},
|
||||
{
|
||||
title: 'Kalendertermine',
|
||||
value: `${upcomingEvents.length}`,
|
||||
to: '/calendar',
|
||||
items: upcomingEvents.map((event) => createDashboardItem(`${event.title} · ${formatEventDateRange(event)}`, '/calendar')),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'club-status',
|
||||
title: 'Vereinsstatus',
|
||||
cards: [
|
||||
{
|
||||
title: 'Mitglieder',
|
||||
value: `${members.length} aktiv`,
|
||||
meta: members.length > 0 ? `${members.filter((member) => {
|
||||
const createdAt = new Date(member.createdAt);
|
||||
return createdAt.getFullYear() === today.getFullYear();
|
||||
}).length} dieses Jahr angelegt` : null,
|
||||
to: '/members',
|
||||
items: recentMembers.map((member) => {
|
||||
const name = [member.firstName, member.lastName].filter(Boolean).join(' ').trim() || member.email || `Mitglied ${member.id}`;
|
||||
return createDashboardItem(name, buildMemberRoute(member.id, 'active'));
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'Anfragen',
|
||||
value: `${requests.length}`,
|
||||
meta: `${openRequestCount} offen, ${inProgressRequestCount} in Bearbeitung`,
|
||||
to: '/club-requests',
|
||||
},
|
||||
{
|
||||
title: 'Workflow-Fortschritt',
|
||||
value: `${workflowStageCounts.onboarding_completed || 0}`,
|
||||
meta: 'Onboardings abgeschlossen',
|
||||
to: '/club-requests',
|
||||
items: [
|
||||
createDashboardItem(`${workflowStageCounts.admission_prepared || 0} Aufnahmen vorbereitet`, '/club-requests'),
|
||||
createDashboardItem(`${workflowStageCounts.member_record_created || 0} Mitglieder angelegt`, '/club-requests'),
|
||||
createDashboardItem(`${workflowStageCounts.sepa_pending || 0} warten auf SEPA`, '/club-requests'),
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Finanzen',
|
||||
value: paidRatio === null ? 'Keine Daten' : `${paidRatio} % erledigt`,
|
||||
meta: paymentClaims.length > 0 ? `${paymentClaims.length} offene oder teilweise offene Forderungen` : 'Noch keine Beitragsforderungen erfasst',
|
||||
to: '/club-tasks',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'recent-activity',
|
||||
title: 'Letzte Aktivitäten',
|
||||
cards: [
|
||||
{
|
||||
title: 'Zuletzt eingegangen',
|
||||
to: '/club-requests',
|
||||
items: requests.slice(0, 4).map((request) => {
|
||||
const name = [request.firstName, request.lastName].filter(Boolean).join(' ').trim() || request.email || 'Unbekannt';
|
||||
const workflow = request.workflowStage ? ` · ${formatRequestWorkflowStage(request.workflowStage)}` : '';
|
||||
return createDashboardItem(
|
||||
`${name} · ${request.subject || request.requestType}${workflow}`,
|
||||
buildRequestRoute(request.id, request.status || '')
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'Aktuelle Aufgaben',
|
||||
to: '/club-tasks',
|
||||
items: visibleDashboardTasks.slice(0, 4).map((task) => taskDashboardItem(task, `${formatTaskType(task.taskType)} · ${task.status}`)),
|
||||
},
|
||||
{
|
||||
title: 'Automatik zuletzt aktiv',
|
||||
to: '/club-tasks',
|
||||
items: automatedTasks.slice(0, 4).map((task) => {
|
||||
const sourceLabel = task.automationSource === 'club_requests'
|
||||
? 'Anfrage'
|
||||
: task.automationSource === 'club_payment_claims'
|
||||
? 'Zahlung'
|
||||
: task.automationSource === 'calendar_events'
|
||||
? 'Termin'
|
||||
: 'Workflow';
|
||||
return taskDashboardItem(task, `${formatTaskType(task.taskType)} · ${sourceLabel}`);
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
res.status(200).json({ sections });
|
||||
} catch (error) {
|
||||
console.error('[getClubDashboard] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Dashboard konnte nicht geladen werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
174
backend/controllers/clubRequestController.js
Normal file
174
backend/controllers/clubRequestController.js
Normal file
@@ -0,0 +1,174 @@
|
||||
import { ClubRequest, ClubRequestNote } from '../models/index.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorUtils.js';
|
||||
|
||||
const TERMINAL_REQUEST_STATUSES = new Set(['converted', 'rejected', 'archived']);
|
||||
|
||||
function isMissingRequestTableError(error) {
|
||||
return error?.original?.code === 'ER_NO_SUCH_TABLE'
|
||||
&& /club_requests|club_request_notes/.test(String(error?.original?.sqlMessage || ''));
|
||||
}
|
||||
|
||||
function normalizeRequestPayload(payload = {}) {
|
||||
return {
|
||||
requestType: payload.requestType || 'contact',
|
||||
subject: payload.subject?.trim() || null,
|
||||
firstName: payload.firstName?.trim() || null,
|
||||
lastName: payload.lastName?.trim() || null,
|
||||
email: payload.email?.trim() || null,
|
||||
phone: payload.phone?.trim() || null,
|
||||
message: payload.message?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRequestOrThrow(clubId, requestId) {
|
||||
const request = await ClubRequest.findOne({
|
||||
where: {
|
||||
id: requestId,
|
||||
clubId,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: ClubRequestNote,
|
||||
as: 'notes',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [[{ model: ClubRequestNote, as: 'notes' }, 'createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
if (!request) {
|
||||
const error = new Error('Anfrage wurde nicht gefunden.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
export const listClubRequests = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
let requests = [];
|
||||
try {
|
||||
requests = await ClubRequest.findAll({
|
||||
where: { clubId },
|
||||
include: [
|
||||
{
|
||||
model: ClubRequestNote,
|
||||
as: 'notes',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
order: [
|
||||
['receivedAt', 'DESC'],
|
||||
[{ model: ClubRequestNote, as: 'notes' }, 'createdAt', 'DESC'],
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isMissingRequestTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ requests });
|
||||
} catch (error) {
|
||||
console.error('[listClubRequests] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Anfragen konnten nicht geladen werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubRequest = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = normalizeRequestPayload(req.body);
|
||||
|
||||
if (!payload.subject && !payload.message) {
|
||||
return res.status(400).json({ error: 'Betreff oder Nachricht sind erforderlich.' });
|
||||
}
|
||||
|
||||
const request = await ClubRequest.create({
|
||||
clubId,
|
||||
...payload,
|
||||
receivedAt: new Date(),
|
||||
});
|
||||
|
||||
const created = await loadRequestOrThrow(clubId, request.id);
|
||||
res.status(201).json({ request: created });
|
||||
} catch (error) {
|
||||
console.error('[createClubRequest] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Anfrage konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubRequest = async (req, res) => {
|
||||
try {
|
||||
const { clubId, requestId } = req.params;
|
||||
const request = await loadRequestOrThrow(clubId, requestId);
|
||||
const payload = normalizeRequestPayload(req.body);
|
||||
|
||||
await request.update(payload);
|
||||
const updated = await loadRequestOrThrow(clubId, requestId);
|
||||
res.status(200).json({ request: updated });
|
||||
} catch (error) {
|
||||
console.error('[updateClubRequest] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Anfrage konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubRequestStatus = async (req, res) => {
|
||||
try {
|
||||
const { clubId, requestId } = req.params;
|
||||
const { status } = req.body || {};
|
||||
|
||||
if (!status) {
|
||||
return res.status(400).json({ error: 'Status fehlt.' });
|
||||
}
|
||||
|
||||
const request = await loadRequestOrThrow(clubId, requestId);
|
||||
await request.update({
|
||||
status,
|
||||
closedAt: TERMINAL_REQUEST_STATUSES.has(status) ? new Date() : null,
|
||||
});
|
||||
|
||||
const updated = await loadRequestOrThrow(clubId, requestId);
|
||||
res.status(200).json({ request: updated });
|
||||
} catch (error) {
|
||||
console.error('[updateClubRequestStatus] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Status konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const addClubRequestNote = async (req, res) => {
|
||||
try {
|
||||
const { clubId, requestId } = req.params;
|
||||
const body = String(req.body?.body || '').trim();
|
||||
|
||||
if (!body) {
|
||||
return res.status(400).json({ error: 'Notiztext fehlt.' });
|
||||
}
|
||||
|
||||
await loadRequestOrThrow(clubId, requestId);
|
||||
|
||||
await ClubRequestNote.create({
|
||||
clubRequestId: requestId,
|
||||
createdByUserId: req.user?.id || null,
|
||||
body,
|
||||
});
|
||||
|
||||
const updated = await loadRequestOrThrow(clubId, requestId);
|
||||
res.status(201).json({ request: updated });
|
||||
} catch (error) {
|
||||
console.error('[addClubRequestNote] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Notiz konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
19
backend/controllers/clubStatisticsController.js
Normal file
19
backend/controllers/clubStatisticsController.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import clubStatisticsService from '../services/clubStatisticsService.js';
|
||||
|
||||
class ClubStatisticsController {
|
||||
async getClubStatistics(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const statistics = await clubStatisticsService.getClubStatistics(clubId);
|
||||
res.json(statistics);
|
||||
} catch (error) {
|
||||
console.error('[getClubStatistics] - Error:', error);
|
||||
if (error?.status) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Fehler beim Laden der Vereinsstatistiken' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubStatisticsController();
|
||||
274
backend/controllers/clubTaskController.js
Normal file
274
backend/controllers/clubTaskController.js
Normal file
@@ -0,0 +1,274 @@
|
||||
import { ClubTask, User, UserClub } from '../models/index.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorUtils.js';
|
||||
import clubTaskAutomationService from '../services/clubTaskAutomationService.js';
|
||||
import clubWorkflowSourceService from '../services/clubWorkflowSourceService.js';
|
||||
|
||||
const TERMINAL_TASK_STATUSES = new Set(['done', 'cancelled', 'archived']);
|
||||
|
||||
function isMissingTaskTableError(error) {
|
||||
return error?.original?.code === 'ER_NO_SUCH_TABLE'
|
||||
&& /club_tasks/.test(String(error?.original?.sqlMessage || ''));
|
||||
}
|
||||
|
||||
function isMissingTaskSuppressionTableError(error) {
|
||||
return error?.original?.code === 'ER_NO_SUCH_TABLE'
|
||||
&& /club_task_suppressions/.test(String(error?.original?.sqlMessage || ''));
|
||||
}
|
||||
|
||||
function normalizeTaskPayload(payload = {}) {
|
||||
return {
|
||||
title: String(payload.title || '').trim(),
|
||||
taskType: payload.taskType?.trim() || null,
|
||||
description: payload.description?.trim() || null,
|
||||
status: payload.status || 'open',
|
||||
priority: payload.priority || 'normal',
|
||||
dueAt: payload.dueAt || null,
|
||||
remindAt: payload.remindAt || null,
|
||||
assignedUserId: payload.assignedUserId ? Number(payload.assignedUserId) : null,
|
||||
automationSource: payload.automationSource?.trim() || null,
|
||||
automationKey: payload.automationKey?.trim() || null,
|
||||
relatedEntityType: payload.relatedEntityType?.trim() || null,
|
||||
relatedEntityId: payload.relatedEntityId ? Number(payload.relatedEntityId) : null,
|
||||
sourceSnapshot: payload.sourceSnapshot || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadAssignableUsers(clubId) {
|
||||
const entries = await UserClub.findAll({
|
||||
where: { clubId },
|
||||
include: [{ model: User, as: 'user', attributes: ['id', 'email'] }],
|
||||
order: [[{ model: User, as: 'user' }, 'email', 'ASC']],
|
||||
});
|
||||
|
||||
return entries
|
||||
.filter((entry) => entry.user)
|
||||
.filter((entry) => entry.approved || entry.isOwner)
|
||||
.map((entry) => ({
|
||||
userId: entry.userId,
|
||||
email: entry.user.email,
|
||||
isOwner: Boolean(entry.isOwner),
|
||||
approved: Boolean(entry.approved),
|
||||
}));
|
||||
}
|
||||
|
||||
async function validateAssignedUser(clubId, assignedUserId) {
|
||||
if (!assignedUserId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userClub = await UserClub.findOne({
|
||||
where: {
|
||||
clubId,
|
||||
userId: assignedUserId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userClub || (!userClub.approved && !userClub.isOwner)) {
|
||||
const error = new Error('Der zugewiesene Benutzer gehört nicht zu diesem Verein.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return assignedUserId;
|
||||
}
|
||||
|
||||
async function loadTaskOrThrow(clubId, taskId) {
|
||||
const task = await ClubTask.findOne({
|
||||
where: {
|
||||
id: taskId,
|
||||
clubId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
const error = new Error('Aufgabe wurde nicht gefunden.');
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
export const listClubTasks = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
let tasks = [];
|
||||
let automationOverview = { definitions: [], suggestions: [] };
|
||||
|
||||
try {
|
||||
[tasks, automationOverview] = await Promise.all([
|
||||
ClubTask.findAll({
|
||||
where: { clubId },
|
||||
include: [{ model: User, as: 'assignedUser', attributes: ['id', 'email'], required: false }],
|
||||
order: [
|
||||
['status', 'ASC'],
|
||||
['dueAt', 'ASC'],
|
||||
['updatedAt', 'DESC'],
|
||||
],
|
||||
}),
|
||||
clubTaskAutomationService.buildAutomationOverview(clubId),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (!isMissingTaskTableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const assignableUsers = await loadAssignableUsers(clubId);
|
||||
|
||||
res.status(200).json({
|
||||
tasks,
|
||||
taskDefinitions: automationOverview.definitions,
|
||||
taskSuggestions: automationOverview.suggestions,
|
||||
assignableUsers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[listClubTasks] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Aufgaben konnten nicht geladen werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubTask = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = normalizeTaskPayload(req.body);
|
||||
|
||||
if (!payload.title) {
|
||||
return res.status(400).json({ error: 'Titel ist erforderlich.' });
|
||||
}
|
||||
|
||||
payload.assignedUserId = await validateAssignedUser(clubId, payload.assignedUserId);
|
||||
|
||||
const task = await ClubTask.create({
|
||||
clubId,
|
||||
...payload,
|
||||
createdByUserId: req.user?.id || null,
|
||||
});
|
||||
|
||||
res.status(201).json({ task });
|
||||
} catch (error) {
|
||||
console.error('[createClubTask] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubTask = async (req, res) => {
|
||||
try {
|
||||
const { clubId, taskId } = req.params;
|
||||
const task = await loadTaskOrThrow(clubId, taskId);
|
||||
const payload = normalizeTaskPayload(req.body);
|
||||
const wasDoneBefore = task.status === 'done';
|
||||
|
||||
if (!payload.title) {
|
||||
return res.status(400).json({ error: 'Titel ist erforderlich.' });
|
||||
}
|
||||
|
||||
payload.assignedUserId = await validateAssignedUser(clubId, payload.assignedUserId);
|
||||
|
||||
await task.update({
|
||||
...payload,
|
||||
completedAt: TERMINAL_TASK_STATUSES.has(payload.status) ? (task.completedAt || new Date()) : null,
|
||||
archivedAt: payload.status === 'archived' ? (task.archivedAt || new Date()) : null,
|
||||
});
|
||||
|
||||
const followUpTasks = !wasDoneBefore && payload.status === 'done'
|
||||
? await clubTaskAutomationService.materializeWorkflowFollowUps(task, req.user?.id || null)
|
||||
: [];
|
||||
const sourceUpdate = !wasDoneBefore && payload.status === 'done'
|
||||
? await clubWorkflowSourceService.syncSourceStateForCompletedTask(task)
|
||||
: null;
|
||||
|
||||
res.status(200).json({ task, followUpTasks, sourceUpdate });
|
||||
} catch (error) {
|
||||
console.error('[updateClubTask] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubTaskStatus = async (req, res) => {
|
||||
try {
|
||||
const { clubId, taskId } = req.params;
|
||||
const { status } = req.body || {};
|
||||
if (!status) {
|
||||
return res.status(400).json({ error: 'Status fehlt.' });
|
||||
}
|
||||
|
||||
const task = await loadTaskOrThrow(clubId, taskId);
|
||||
const wasDoneBefore = task.status === 'done';
|
||||
await task.update({
|
||||
status,
|
||||
completedAt: TERMINAL_TASK_STATUSES.has(status) ? (task.completedAt || new Date()) : null,
|
||||
archivedAt: status === 'archived' ? (task.archivedAt || new Date()) : null,
|
||||
});
|
||||
const followUpTasks = !wasDoneBefore && status === 'done'
|
||||
? await clubTaskAutomationService.materializeWorkflowFollowUps(task, req.user?.id || null)
|
||||
: [];
|
||||
const sourceUpdate = !wasDoneBefore && status === 'done'
|
||||
? await clubWorkflowSourceService.syncSourceStateForCompletedTask(task)
|
||||
: null;
|
||||
res.status(200).json({ task, followUpTasks, sourceUpdate });
|
||||
} catch (error) {
|
||||
console.error('[updateClubTaskStatus] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Status konnte nicht gespeichert werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const materializeAutomatedClubTasks = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const automationKeys = Array.isArray(req.body?.automationKeys) ? req.body.automationKeys : [];
|
||||
|
||||
if (automationKeys.length === 0) {
|
||||
return res.status(400).json({ error: 'Es wurden keine Automatik-Schlüssel übergeben.' });
|
||||
}
|
||||
|
||||
const tasks = await clubTaskAutomationService.materializeSuggestions(clubId, req.user?.id || null, automationKeys);
|
||||
res.status(201).json({ tasks });
|
||||
} catch (error) {
|
||||
console.error('[materializeAutomatedClubTasks] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Automatische Aufgaben konnten nicht erstellt werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const dismissAutomatedClubTaskSuggestion = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = req.body || {};
|
||||
const suppression = await clubTaskAutomationService.dismissSuggestion(clubId, req.user?.id || null, payload);
|
||||
res.status(200).json({ success: true, suppression });
|
||||
} catch (error) {
|
||||
console.error('[dismissAutomatedClubTaskSuggestion] - Error:', error);
|
||||
if (isMissingTaskSuppressionTableError(error)) {
|
||||
return res.status(500).json({
|
||||
error: 'Die Tabelle club_task_suppressions fehlt noch. Bitte die aktuelle SQL-Datei auf dem System ausführen.',
|
||||
});
|
||||
}
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Vorschlag konnte nicht ausgeblendet werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubTask = async (req, res) => {
|
||||
try {
|
||||
const { clubId, taskId } = req.params;
|
||||
const task = await loadTaskOrThrow(clubId, taskId);
|
||||
await task.destroy();
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteClubTask] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({
|
||||
error: getSafeErrorMessage(error, 'Aufgabe konnte nicht gelöscht werden.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
185
backend/controllers/clubTeamController.js
Normal file
185
backend/controllers/clubTeamController.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import ClubTeamService from '../services/clubTeamService.js';
|
||||
import { getUserByToken } from '../utils/userUtils.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
export const getClubTeams = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { seasonid: seasonId } = req.query;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
// Check if user has access to this club
|
||||
const clubTeams = await ClubTeamService.getAllClubTeamsByClub(clubId, seasonId);
|
||||
|
||||
res.status(200).json(clubTeams);
|
||||
} catch (error) {
|
||||
console.error('[getClubTeams] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getClubTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const clubTeam = await ClubTeamService.getClubTeamById(clubTeamId);
|
||||
if (!clubTeam) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json(clubTeam);
|
||||
} catch (error) {
|
||||
console.error('[getClubTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { name, leagueId, seasonId, teamGender, teamAgeGroup, plannedLeagueName } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: "missingname" });
|
||||
}
|
||||
|
||||
const planned = plannedLeagueName !== undefined && plannedLeagueName !== null
|
||||
? String(plannedLeagueName).trim() || null
|
||||
: undefined;
|
||||
const clubTeamData = {
|
||||
name,
|
||||
clubId: parseInt(clubId),
|
||||
leagueId: leagueId ? parseInt(leagueId) : null,
|
||||
seasonId: seasonId ? parseInt(seasonId) : null,
|
||||
teamGender: teamGender || 'open',
|
||||
teamAgeGroup: teamAgeGroup || 'adult',
|
||||
...(planned !== undefined ? { plannedLeagueName: planned } : {})
|
||||
};
|
||||
|
||||
const newClubTeam = await ClubTeamService.createClubTeam(clubTeamData);
|
||||
|
||||
res.status(201).json(newClubTeam);
|
||||
} catch (error) {
|
||||
console.error('[createClubTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
const { name, leagueId, seasonId, teamGender, teamAgeGroup, plannedLeagueName } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (leagueId !== undefined) updateData.leagueId = leagueId ? parseInt(leagueId) : null;
|
||||
if (seasonId !== undefined) updateData.seasonId = seasonId ? parseInt(seasonId) : null;
|
||||
if (teamGender !== undefined) updateData.teamGender = teamGender || 'open';
|
||||
if (teamAgeGroup !== undefined) updateData.teamAgeGroup = teamAgeGroup || 'adult';
|
||||
if (plannedLeagueName !== undefined) {
|
||||
updateData.plannedLeagueName = plannedLeagueName === null || plannedLeagueName === ''
|
||||
? null
|
||||
: String(plannedLeagueName).trim() || null;
|
||||
}
|
||||
|
||||
const success = await ClubTeamService.updateClubTeam(clubTeamId, updateData);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
const updatedClubTeam = await ClubTeamService.getClubTeamById(clubTeamId);
|
||||
res.status(200).json(updatedClubTeam);
|
||||
} catch (error) {
|
||||
console.error('[updateClubTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const success = await ClubTeamService.deleteClubTeam(clubTeamId);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: "Club team deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error('[deleteClubTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getLeagues = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { seasonid: seasonId } = req.query;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const leagues = await ClubTeamService.getLeaguesByClub(clubId, seasonId);
|
||||
|
||||
res.status(200).json(leagues);
|
||||
} catch (error) {
|
||||
console.error('[getLeagues] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getClubTeamLineup = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
const lineupHalf = req.query.half === 'second_half' ? 'second_half' : 'first_half';
|
||||
await getUserByToken(token);
|
||||
|
||||
const clubTeam = await ClubTeamService.getClubTeamById(clubTeamId);
|
||||
if (!clubTeam) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
const lineup = await ClubTeamService.getTeamLineup(clubTeamId, lineupHalf);
|
||||
res.status(200).json(lineup);
|
||||
} catch (error) {
|
||||
console.error('[getClubTeamLineup] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubTeamLineup = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
const { assignments, lineupHalf: requestLineupHalf } = req.body;
|
||||
const lineupHalf = requestLineupHalf === 'second_half' || req.query.half === 'second_half' ? 'second_half' : 'first_half';
|
||||
await getUserByToken(token);
|
||||
|
||||
const clubTeam = await ClubTeamService.getClubTeamById(clubTeamId);
|
||||
if (!clubTeam) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
const lineup = await ClubTeamService.replaceTeamLineup(clubTeamId, assignments, lineupHalf);
|
||||
res.status(200).json(lineup);
|
||||
} catch (error) {
|
||||
console.error('[updateClubTeamLineup] - Error:', error);
|
||||
if (error?.code === 'TEAM_LINEUP_TABLE_MISSING') {
|
||||
return res.status(500).json({ error: 'teamlineuptablemissing' });
|
||||
}
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
52
backend/controllers/clubVenueController.js
Normal file
52
backend/controllers/clubVenueController.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import clubVenueService from '../services/clubVenueService.js';
|
||||
|
||||
const handleError = (res, label, error) => {
|
||||
if (error.message === 'noaccess') return res.status(403).json({ error: 'noaccess' });
|
||||
if (error.statusCode || error.status) return res.status(error.statusCode || error.status).json({ error: error.message });
|
||||
console.error(`[${label}] - error:`, error);
|
||||
return res.status(500).json({ error: 'internalerror' });
|
||||
};
|
||||
|
||||
export const listClubVenues = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const venues = await clubVenueService.list(token, clubId);
|
||||
res.status(200).json(venues);
|
||||
} catch (error) {
|
||||
handleError(res, 'listClubVenues', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubVenue = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const venue = await clubVenueService.create(token, clubId, req.body);
|
||||
res.status(201).json(venue);
|
||||
} catch (error) {
|
||||
handleError(res, 'createClubVenue', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubVenue = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, venueId } = req.params;
|
||||
const venue = await clubVenueService.update(token, clubId, venueId, req.body);
|
||||
res.status(200).json(venue);
|
||||
} catch (error) {
|
||||
handleError(res, 'updateClubVenue', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubVenue = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, venueId } = req.params;
|
||||
const result = await clubVenueService.delete(token, clubId, venueId);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
handleError(res, 'deleteClubVenue', error);
|
||||
}
|
||||
};
|
||||
@@ -1,88 +1,102 @@
|
||||
import ClubService from '../services/clubService.js';
|
||||
import { getUserByToken } from '../utils/userUtils.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
export const getClubs = async (req, res) => {
|
||||
try {
|
||||
console.log('[getClubs] - get clubs');
|
||||
const clubs = await ClubService.getAllClubs();
|
||||
console.log('[getClubs] - prepare response');
|
||||
res.status(200).json(clubs);
|
||||
console.log('[getClubs] - done');
|
||||
} catch (error) {
|
||||
console.log('[getClubs] - error');
|
||||
console.log(error);
|
||||
console.error('[getClubs] - error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const addClub = async (req, res) => {
|
||||
console.log('[addClub] - Read out parameters');
|
||||
const { authcode: token } = req.headers;
|
||||
const { name: clubName } = req.body;
|
||||
|
||||
try {
|
||||
console.log('[addClub] - find club by name');
|
||||
const club = await ClubService.findClubByName(clubName);
|
||||
console.log('[addClub] - get user');
|
||||
const user = await getUserByToken(token);
|
||||
console.log('[addClub] - check if club already exists');
|
||||
if (club) {
|
||||
res.status(409).json({ error: "alreadyexists" });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[addClub] - create club');
|
||||
const newClub = await ClubService.createClub(clubName);
|
||||
console.log('[addClub] - add user to new club');
|
||||
await ClubService.addUserToClub(user.id, newClub.id);
|
||||
console.log('[addClub] - prepare response');
|
||||
await ClubService.addUserToClub(user.id, newClub.id, true); // true = isOwner
|
||||
res.status(200).json(newClub);
|
||||
console.log('[addClub] - done');
|
||||
} catch (error) {
|
||||
console.log('[addClub] - error');
|
||||
console.log(error);
|
||||
console.error('[addClub] - error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getClub = async (req, res) => {
|
||||
console.log('[getClub] - start');
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
console.log('[getClub] - get user');
|
||||
const user = await getUserByToken(token);
|
||||
console.log('[getClub] - get users club');
|
||||
const access = await ClubService.getUserClubAccess(user.id, clubId);
|
||||
console.log('[getClub] - check access');
|
||||
if (access.length === 0 || !access[0].approved) {
|
||||
res.status(403).json({ error: "noaccess", status: access.length === 0 ? "notrequested" : "requested" });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[getClub] - get club');
|
||||
const club = await ClubService.findClubById(clubId);
|
||||
console.log('[getClub] - check club exists');
|
||||
if (!club) {
|
||||
return res.status(404).json({ message: 'Club not found' });
|
||||
}
|
||||
|
||||
console.log('[getClub] - set response');
|
||||
res.status(200).json(club);
|
||||
console.log('[getClub] - done');
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.error('[getClub] - error:', error);
|
||||
res.status(500).json({ message: 'Server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubSettings = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid } = req.params;
|
||||
const {
|
||||
greetingText,
|
||||
associationMemberNumber,
|
||||
myTischtennisFedNickname,
|
||||
autoFetchRankings,
|
||||
countryCode,
|
||||
stateCode,
|
||||
memberDataQualityRequirements
|
||||
} = req.body;
|
||||
const updated = await ClubService.updateClubSettings(token, clubid, {
|
||||
greetingText,
|
||||
associationMemberNumber,
|
||||
myTischtennisFedNickname,
|
||||
autoFetchRankings,
|
||||
countryCode,
|
||||
stateCode,
|
||||
memberDataQualityRequirements
|
||||
});
|
||||
res.status(200).json(updated);
|
||||
} catch (error) {
|
||||
if (error.message === 'noaccess') {
|
||||
res.status(403).json({ error: 'noaccess' });
|
||||
} else if (error.message === 'clubnotfound') {
|
||||
res.status(404).json({ error: 'clubnotfound' });
|
||||
} else {
|
||||
console.error('[updateClubSettings] - error:', error);
|
||||
res.status(500).json({ error: 'internalerror' });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const requestClubAccess = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
|
||||
try {
|
||||
const user = await getUserByToken(token);
|
||||
console.log(user);
|
||||
|
||||
await ClubService.requestAccessToClub(user.id, clubId);
|
||||
res.status(200).json({});
|
||||
@@ -92,6 +106,7 @@ export const requestClubAccess = async (req, res) => {
|
||||
} else if (error.message === 'clubnotfound') {
|
||||
res.status(404).json({ err: "clubnotfound" });
|
||||
} else {
|
||||
console.error('[requestClubAccess] - error:', error);
|
||||
res.status(500).json({ err: "internalerror" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import diaryService from '../services/diaryService.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
import { emitDiaryDateUpdated, emitDiaryTagAdded, emitDiaryTagRemoved } from '../services/socketService.js';
|
||||
const getDatesForClub = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
@@ -17,14 +18,14 @@ const createDateForClub = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { date, trainingStart, trainingEnd } = req.body;
|
||||
const { date, trainingStart, trainingEnd, excludeFromBilling } = req.body;
|
||||
if (!date) {
|
||||
throw new HttpError('The date field is required', 400);
|
||||
}
|
||||
if (isNaN(new Date(date).getTime())) {
|
||||
throw new HttpError('Invalid date format', 400);
|
||||
}
|
||||
const newDate = await diaryService.createDateForClub(userToken, clubId, date, trainingStart, trainingEnd);
|
||||
const newDate = await diaryService.createDateForClub(userToken, clubId, date, trainingStart, trainingEnd, excludeFromBilling);
|
||||
res.status(201).json(newDate);
|
||||
} catch (error) {
|
||||
console.error('[createDateForClub] - Error:', error);
|
||||
@@ -36,12 +37,23 @@ const updateTrainingTimes = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { dateId, trainingStart, trainingEnd } = req.body;
|
||||
if (!dateId || !trainingStart) {
|
||||
console.log(dateId, trainingStart, trainingEnd);
|
||||
const { dateId, trainingStart, trainingEnd, excludeFromBilling } = req.body;
|
||||
if (!dateId) {
|
||||
devLog(dateId, trainingStart, trainingEnd, excludeFromBilling);
|
||||
throw new HttpError('notallfieldsfilled', 400);
|
||||
}
|
||||
const updatedDate = await diaryService.updateTrainingTimes(userToken, clubId, dateId, trainingStart, trainingEnd);
|
||||
const updatedDate = await diaryService.updateTrainingTimes(
|
||||
userToken,
|
||||
clubId,
|
||||
dateId,
|
||||
trainingStart,
|
||||
trainingEnd,
|
||||
excludeFromBilling,
|
||||
);
|
||||
|
||||
// Emit Socket-Event
|
||||
emitDiaryDateUpdated(clubId, dateId, { trainingStart, trainingEnd, excludeFromBilling });
|
||||
|
||||
res.status(200).json(updatedDate);
|
||||
} catch (error) {
|
||||
console.error('[updateTrainingTimes] - Error:', error);
|
||||
@@ -78,6 +90,14 @@ const addDiaryTag = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { diaryDateId, tagName } = req.body;
|
||||
const tags = await diaryService.addTagToDate(userToken, diaryDateId, tagName);
|
||||
|
||||
// Hole clubId für Event
|
||||
const { DiaryDate } = await import('../models/index.js');
|
||||
const diaryDate = await DiaryDate.findByPk(diaryDateId);
|
||||
if (diaryDate?.clubId && tags && tags.length > 0) {
|
||||
emitDiaryTagAdded(diaryDate.clubId, diaryDateId, tags[tags.length - 1]);
|
||||
}
|
||||
|
||||
res.status(201).json(tags);
|
||||
} catch (error) {
|
||||
console.error('[addDiaryTag] - Error:', error);
|
||||
@@ -94,6 +114,12 @@ const addTagToDiaryDate = async (req, res) => {
|
||||
return res.status(400).json({ message: 'diaryDateId and tagId are required.' });
|
||||
}
|
||||
const result = await diaryService.addTagToDiaryDate(userToken, clubId, diaryDateId, tagId);
|
||||
|
||||
// Emit Socket-Event
|
||||
if (result && result.tag) {
|
||||
emitDiaryTagAdded(clubId, diaryDateId, result.tag);
|
||||
}
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[addTagToDiaryDate] - Error:', error);
|
||||
@@ -105,8 +131,20 @@ const deleteTagFromDiaryDate = async (req, res) => {
|
||||
try {
|
||||
const { tagId } = req.query;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const { clubId } = req.params;
|
||||
|
||||
// Hole diaryDateId vor dem Löschen
|
||||
const { DiaryDateTag } = await import('../models/index.js');
|
||||
const diaryDateTag = await DiaryDateTag.findByPk(tagId);
|
||||
const diaryDateId = diaryDateTag?.diaryDateId;
|
||||
|
||||
await diaryService.removeTagFromDiaryDate(userToken, clubId, tagId);
|
||||
|
||||
// Emit Socket-Event
|
||||
if (diaryDateId) {
|
||||
emitDiaryTagRemoved(clubId, diaryDateId, tagId);
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'Tag deleted' });
|
||||
} catch (error) {
|
||||
console.error('[deleteTag] - Error:', error);
|
||||
|
||||
@@ -1,28 +1,49 @@
|
||||
import fs from 'fs';
|
||||
import diaryDateActivityService from '../services/diaryDateActivityService.js';
|
||||
import { emitActivityChanged } from '../services/socketService.js';
|
||||
import DiaryDate from '../models/DiaryDates.js';
|
||||
|
||||
import { devLog, errorLog } from '../utils/logger.js';
|
||||
export const createDiaryDateActivity = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
let userToken = req.headers['authcode'] || req.headers['auth-code'] || null;
|
||||
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
|
||||
if (!userToken && authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
||||
userToken = authHeader.split(' ')[1];
|
||||
}
|
||||
const { clubId } = req.params;
|
||||
const { diaryDateId, activity, duration, durationText, orderId, isTimeblock } = req.body;
|
||||
const { diaryDateId, activity, predefinedActivityId, duration, durationText, orderId, isTimeblock, groupId } = req.body;
|
||||
const activityItem = await diaryDateActivityService.createActivity(userToken, clubId, {
|
||||
diaryDateId,
|
||||
activity,
|
||||
predefinedActivityId,
|
||||
duration,
|
||||
durationText,
|
||||
orderId,
|
||||
isTimeblock,
|
||||
groupId,
|
||||
});
|
||||
|
||||
// Emit Socket-Event
|
||||
const diaryDate = await DiaryDate.findByPk(diaryDateId);
|
||||
if (diaryDate?.clubId) {
|
||||
emitActivityChanged(diaryDate.clubId, diaryDateId);
|
||||
}
|
||||
|
||||
res.status(201).json(activityItem);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
devLog(error);
|
||||
res.status(500).json({ error: 'Error creating activity' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateDiaryDateActivity = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
let userToken = req.headers['authcode'] || req.headers['auth-code'] || null;
|
||||
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
|
||||
if (!userToken && authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
||||
userToken = authHeader.split(' ')[1];
|
||||
}
|
||||
const { clubId, id } = req.params;
|
||||
const { predefinedActivityId, customActivityName, duration, durationText, orderId, groupId } = req.body; // Add groupId
|
||||
const updatedActivity = await diaryDateActivityService.updateActivity(userToken, clubId, id, {
|
||||
@@ -33,6 +54,15 @@ export const updateDiaryDateActivity = async (req, res) => {
|
||||
orderId,
|
||||
groupId, // Pass groupId to the service
|
||||
});
|
||||
|
||||
// Emit Socket-Event
|
||||
if (updatedActivity?.diaryDateId) {
|
||||
const diaryDate = await DiaryDate.findByPk(updatedActivity.diaryDateId);
|
||||
if (diaryDate?.clubId) {
|
||||
emitActivityChanged(diaryDate.clubId, updatedActivity.diaryDateId);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json(updatedActivity);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Error updating activity' });
|
||||
@@ -41,9 +71,28 @@ export const updateDiaryDateActivity = async (req, res) => {
|
||||
|
||||
export const deleteDiaryDateActivity = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
let userToken = req.headers['authcode'] || req.headers['auth-code'] || null;
|
||||
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
|
||||
if (!userToken && authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
||||
userToken = authHeader.split(' ')[1];
|
||||
}
|
||||
const { clubId, id } = req.params;
|
||||
|
||||
// Hole diaryDateId vor dem Löschen
|
||||
const DiaryDateActivity = (await import('../models/DiaryDateActivity.js')).default;
|
||||
const activity = await DiaryDateActivity.findByPk(id);
|
||||
const diaryDateId = activity?.diaryDateId;
|
||||
|
||||
await diaryDateActivityService.deleteActivity(userToken, clubId, id);
|
||||
|
||||
// Emit Socket-Event
|
||||
if (diaryDateId) {
|
||||
const diaryDate = await DiaryDate.findByPk(diaryDateId);
|
||||
if (diaryDate?.clubId) {
|
||||
emitActivityChanged(diaryDate.clubId, diaryDateId);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'Activity deleted' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Error deleting activity' });
|
||||
@@ -52,37 +101,154 @@ export const deleteDiaryDateActivity = async (req, res) => {
|
||||
|
||||
export const updateDiaryDateActivityOrder = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
let userToken = req.headers['authcode'] || req.headers['auth-code'] || null;
|
||||
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
|
||||
if (!userToken && authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
||||
userToken = authHeader.split(' ')[1];
|
||||
}
|
||||
const { clubId, id } = req.params;
|
||||
const { orderId } = req.body;
|
||||
const updatedActivity = await diaryDateActivityService.updateActivityOrder(userToken, clubId, id, orderId);
|
||||
|
||||
// Emit Socket-Event
|
||||
if (updatedActivity?.diaryDateId) {
|
||||
const diaryDate = await DiaryDate.findByPk(updatedActivity.diaryDateId);
|
||||
if (diaryDate?.clubId) {
|
||||
emitActivityChanged(diaryDate.clubId, updatedActivity.diaryDateId);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json(updatedActivity);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
devLog(error);
|
||||
res.status(500).json({ error: 'Error updating activity order' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getDiaryDateActivities = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
let userToken = req.headers['authcode'] || req.headers['auth-code'] || null;
|
||||
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
|
||||
if (!userToken && authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
||||
userToken = authHeader.split(' ')[1];
|
||||
}
|
||||
const { clubId, diaryDateId } = req.params;
|
||||
const activities = await diaryDateActivityService.getActivities(userToken, clubId, diaryDateId);
|
||||
res.status(200).json(activities);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// Fallback-Logging: schreibe Stacktrace in eine Datei, falls STDOUT/STDERR nicht sichtbar ist
|
||||
try {
|
||||
const msg = `${new Date().toISOString()} - getDiaryDateActivities error: ${error && error.stack ? error.stack : JSON.stringify(error)}\n`;
|
||||
fs.appendFileSync('/tmp/diary-activity-error.log', msg);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
devLog(error);
|
||||
errorLog(error);
|
||||
res.status(500).json({ error: 'Error getting activities' });
|
||||
}
|
||||
}
|
||||
|
||||
export const addGroupActivity = async(req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, diaryDateId, groupId, activity } = req.body;
|
||||
const activityItem = await diaryDateActivityService.addGroupActivity(userToken, clubId, diaryDateId, groupId, activity);
|
||||
let userToken = req.headers['authcode'] || req.headers['auth-code'] || null;
|
||||
const authHeader = req.headers['authorization'] || req.headers['Authorization'];
|
||||
if (!userToken && authHeader && typeof authHeader === 'string' && authHeader.startsWith('Bearer ')) {
|
||||
userToken = authHeader.split(' ')[1];
|
||||
}
|
||||
const { clubId, diaryDateId, groupId, activity, predefinedActivityId, timeblockId, duration, durationText } = req.body;
|
||||
const activityItem = await diaryDateActivityService.addGroupActivity(
|
||||
userToken,
|
||||
clubId,
|
||||
diaryDateId,
|
||||
groupId,
|
||||
activity,
|
||||
predefinedActivityId,
|
||||
timeblockId,
|
||||
duration,
|
||||
durationText
|
||||
);
|
||||
|
||||
// Emit Socket-Event
|
||||
const diaryDate = await DiaryDate.findByPk(diaryDateId);
|
||||
if (diaryDate?.clubId) {
|
||||
emitActivityChanged(diaryDate.clubId, diaryDateId);
|
||||
}
|
||||
|
||||
res.status(201).json(activityItem);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
devLog(error);
|
||||
res.status(500).json({ error: 'Error adding group activity' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const updateGroupActivity = async(req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, groupActivityId } = req.params;
|
||||
const { predefinedActivityId, duration, durationText, orderId, groupId } = req.body;
|
||||
const activityItem = await diaryDateActivityService.updateGroupActivity(
|
||||
userToken,
|
||||
clubId,
|
||||
groupActivityId,
|
||||
predefinedActivityId,
|
||||
duration,
|
||||
durationText,
|
||||
orderId,
|
||||
groupId
|
||||
);
|
||||
|
||||
// Emit Socket-Event
|
||||
const GroupActivity = (await import('../models/GroupActivity.js')).default;
|
||||
const DiaryDateActivity = (await import('../models/DiaryDateActivity.js')).default;
|
||||
const groupActivity = await GroupActivity.findByPk(groupActivityId);
|
||||
let diaryDateId = null;
|
||||
if (groupActivity?.diaryDateActivity) {
|
||||
const activity = await DiaryDateActivity.findByPk(groupActivity.diaryDateActivity);
|
||||
diaryDateId = activity?.diaryDateId;
|
||||
}
|
||||
if (diaryDateId) {
|
||||
const diaryDate = await DiaryDate.findByPk(diaryDateId);
|
||||
if (diaryDate?.clubId) {
|
||||
emitActivityChanged(diaryDate.clubId, diaryDateId);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json(activityItem);
|
||||
} catch (error) {
|
||||
devLog(error);
|
||||
res.status(500).json({ error: 'Error updating group activity' });
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteGroupActivity = async(req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, groupActivityId } = req.params;
|
||||
|
||||
// Hole diaryDateId vor dem Löschen
|
||||
const GroupActivity = (await import('../models/GroupActivity.js')).default;
|
||||
const DiaryDateActivity = (await import('../models/DiaryDateActivity.js')).default;
|
||||
const groupActivity = await GroupActivity.findByPk(groupActivityId);
|
||||
let diaryDateId = null;
|
||||
if (groupActivity?.diaryDateActivity) {
|
||||
const activity = await DiaryDateActivity.findByPk(groupActivity.diaryDateActivity);
|
||||
diaryDateId = activity?.diaryDateId;
|
||||
}
|
||||
|
||||
await diaryDateActivityService.deleteGroupActivity(userToken, clubId, groupActivityId);
|
||||
|
||||
// Emit Socket-Event
|
||||
if (diaryDateId) {
|
||||
const diaryDate = await DiaryDate.findByPk(diaryDateId);
|
||||
if (diaryDate?.clubId) {
|
||||
emitActivityChanged(diaryDate.clubId, diaryDateId);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'Group activity deleted' });
|
||||
} catch (error) {
|
||||
devLog(error);
|
||||
res.status(500).json({ error: 'Error deleting group activity' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import diaryDateTagService from "../services/diaryDateTagService.js"
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
export const getDiaryDateMemberTags = async (req, res) => {
|
||||
console.log("getDiaryDateMemberTags");
|
||||
devLog("getDiaryDateMemberTags");
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, memberId } = req.params;
|
||||
@@ -14,7 +15,7 @@ export const getDiaryDateMemberTags = async (req, res) => {
|
||||
}
|
||||
|
||||
export const addDiaryDateTag = async (req, res) => {
|
||||
console.log("addDiaryDateTag");
|
||||
devLog("addDiaryDateTag");
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import DiaryMemberActivity from '../models/DiaryMemberActivity.js';
|
||||
import DiaryDateActivity from '../models/DiaryDateActivity.js';
|
||||
import DiaryDates from '../models/DiaryDates.js';
|
||||
import Participant from '../models/Participant.js';
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import { emitActivityMemberAdded, emitActivityMemberRemoved } from '../services/socketService.js';
|
||||
|
||||
export const getMembersForActivity = async (req, res) => {
|
||||
try {
|
||||
@@ -19,20 +22,44 @@ export const addMembersToActivity = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, diaryDateActivityId } = req.params;
|
||||
const { participantIds } = req.body; // array of participant ids
|
||||
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
if (!participantIds || !Array.isArray(participantIds)) {
|
||||
console.error('[addMembersToActivity] Invalid participantIds:', participantIds);
|
||||
return res.status(400).json({ error: 'participantIds must be an array' });
|
||||
}
|
||||
|
||||
const validParticipants = await Participant.findAll({ where: { id: participantIds } });
|
||||
|
||||
const validIds = new Set(validParticipants.map(p => p.id));
|
||||
const created = [];
|
||||
|
||||
// Hole clubId und dateId für Events (falls nicht aus params verfügbar)
|
||||
const activity = await DiaryDateActivity.findByPk(diaryDateActivityId);
|
||||
const diaryDate = activity ? await DiaryDates.findByPk(activity.diaryDateId) : null;
|
||||
const eventClubId = diaryDate?.clubId || clubId;
|
||||
const dateId = diaryDate?.id || null;
|
||||
|
||||
for (const pid of participantIds) {
|
||||
if (!validIds.has(pid)) continue;
|
||||
if (!validIds.has(pid)) {
|
||||
continue;
|
||||
}
|
||||
const existing = await DiaryMemberActivity.findOne({ where: { diaryDateActivityId, participantId: pid } });
|
||||
if (!existing) {
|
||||
const rec = await DiaryMemberActivity.create({ diaryDateActivityId, participantId: pid });
|
||||
created.push(rec);
|
||||
|
||||
// Emit Socket-Event
|
||||
if (eventClubId && dateId) {
|
||||
emitActivityMemberAdded(eventClubId, diaryDateActivityId, pid, dateId);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
}
|
||||
res.status(201).json(created);
|
||||
} catch (e) {
|
||||
console.error('[addMembersToActivity] Error:', e);
|
||||
res.status(500).json({ error: 'Error adding members to activity' });
|
||||
}
|
||||
};
|
||||
@@ -42,7 +69,19 @@ export const removeMemberFromActivity = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, diaryDateActivityId, participantId } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
// Hole dateId für Event
|
||||
const activity = await DiaryDateActivity.findByPk(diaryDateActivityId);
|
||||
const diaryDate = activity ? await DiaryDates.findByPk(activity.diaryDateId) : null;
|
||||
const dateId = diaryDate?.id || null;
|
||||
|
||||
await DiaryMemberActivity.destroy({ where: { diaryDateActivityId, participantId } });
|
||||
|
||||
// Emit Socket-Event
|
||||
if (dateId) {
|
||||
emitActivityMemberRemoved(clubId, diaryDateActivityId, participantId, dateId);
|
||||
}
|
||||
|
||||
res.status(200).json({ ok: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Error removing member from activity' });
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import DiaryMemberService from '../services/diaryMemberService.js';
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
const getMemberTags = async (req, res) => {
|
||||
try {
|
||||
const { diaryDateId, memberId } = req.query;
|
||||
const { clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
console.log(diaryDateId, memberId, clubId);
|
||||
devLog(diaryDateId, memberId, clubId);
|
||||
const tags = await DiaryMemberService.getTagsForMemberAndDate(userToken, clubId, diaryDateId, memberId);
|
||||
res.status(200).json(tags);
|
||||
} catch (error) {
|
||||
@@ -19,7 +20,7 @@ const getMemberNotes = async (req, res) => {
|
||||
const { diaryDateId, memberId } = req.query;
|
||||
const { clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
console.log('---------->', userToken, clubId);
|
||||
devLog('---------->', userToken, clubId);
|
||||
const notes = await DiaryMemberService.getNotesForMember(userToken, clubId, diaryDateId, memberId);
|
||||
res.status(200).json(notes);
|
||||
} catch (error) {
|
||||
@@ -60,8 +61,12 @@ const removeMemberNote = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, noteId } = req.params;
|
||||
const { diaryDateId, memberId } = req.query;
|
||||
if (!diaryDateId || !memberId) {
|
||||
return res.status(400).json({ error: 'diaryDateId and memberId query parameters are required' });
|
||||
}
|
||||
await DiaryMemberService.removeNoteFromMember(userToken, clubId, noteId);
|
||||
const notes = await DiaryMemberService.getNotesForMember(userToken, req.params.clubId, diaryDateId, memberId);
|
||||
const notes = await DiaryMemberService.getNotesForMember(userToken, clubId, diaryDateId, memberId);
|
||||
res.status(200).json(notes);
|
||||
} catch (error) {
|
||||
console.error('[removeMemberNote] - Error: ', error.message);
|
||||
@@ -73,7 +78,7 @@ const removeMemberTag = async (req, res) => {
|
||||
try {
|
||||
const { diaryDateId, memberId, tagId } = req.body;
|
||||
const { authcode: userToken } = req.headers;
|
||||
await DiaryMemberService.removeTagFromMemberAndDate(userToken, req.params.clubId, diaryDateId, memberId, tagId);
|
||||
await DiaryMemberService.removeTagFromMemberAndDate(userToken, req.params.clubId, diaryDateId, memberId, { id: tagId });
|
||||
const tags = await DiaryMemberService.getTagsForMemberAndDate(userToken, req.params.clubId, diaryDateId, memberId);
|
||||
res.status(200).json(tags);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DiaryNote, DiaryTag } from '../models/index.js';
|
||||
import { DiaryNote, DiaryTag, DiaryDate } from '../models/index.js';
|
||||
import diaryService from '../services/diaryService.js';
|
||||
import { emitDiaryNoteAdded, emitDiaryNoteDeleted } from '../services/socketService.js';
|
||||
|
||||
export const getNotes = async (req, res) => {
|
||||
try {
|
||||
@@ -18,17 +19,41 @@ export const getNotes = async (req, res) => {
|
||||
|
||||
export const createNote = async (req, res) => {
|
||||
try {
|
||||
const { memberId, content, tags } = req.body;
|
||||
const newNote = await DiaryNote.create({ memberId, content });
|
||||
if (tags && tags.length > 0) {
|
||||
const { memberId, diaryDateId, content, tags } = req.body;
|
||||
|
||||
if (!memberId || !diaryDateId || !content) {
|
||||
return res.status(400).json({ error: 'memberId, diaryDateId und content sind erforderlich.' });
|
||||
}
|
||||
|
||||
const newNote = await DiaryNote.create({ memberId, diaryDateId, content });
|
||||
|
||||
// Hole DiaryDate für clubId
|
||||
const diaryDate = await DiaryDate.findByPk(diaryDateId);
|
||||
|
||||
if (Array.isArray(tags) && tags.length > 0 && typeof newNote.addTags === 'function') {
|
||||
const tagInstances = await DiaryTag.findAll({ where: { id: tags } });
|
||||
await newNote.addTags(tagInstances);
|
||||
|
||||
const noteWithTags = await DiaryNote.findByPk(newNote.id, {
|
||||
include: [{ model: DiaryTag, as: 'tags', required: false }],
|
||||
});
|
||||
|
||||
// Emit Socket-Event
|
||||
if (diaryDate?.clubId) {
|
||||
emitDiaryNoteAdded(diaryDate.clubId, diaryDateId, noteWithTags ?? newNote);
|
||||
}
|
||||
|
||||
return res.status(201).json(noteWithTags ?? newNote);
|
||||
}
|
||||
const noteWithTags = await DiaryNote.findByPk(newNote.id, {
|
||||
include: [{ model: DiaryTag, as: 'tags' }],
|
||||
});
|
||||
res.status(201).json(noteWithTags);
|
||||
|
||||
// Emit Socket-Event
|
||||
if (diaryDate?.clubId) {
|
||||
emitDiaryNoteAdded(diaryDate.clubId, diaryDateId, newNote);
|
||||
}
|
||||
|
||||
res.status(201).json(newNote);
|
||||
} catch (error) {
|
||||
console.error('[createNote] - Error:', error);
|
||||
res.status(500).json({ error: 'Error creating note' });
|
||||
}
|
||||
};
|
||||
@@ -36,7 +61,25 @@ export const createNote = async (req, res) => {
|
||||
export const deleteNote = async (req, res) => {
|
||||
try {
|
||||
const { noteId } = req.params;
|
||||
|
||||
// Hole Note für diaryDateId vor dem Löschen
|
||||
const note = await DiaryNote.findByPk(noteId);
|
||||
const diaryDateId = note?.diaryDateId;
|
||||
|
||||
// Hole DiaryDate für clubId
|
||||
let clubId = null;
|
||||
if (diaryDateId) {
|
||||
const diaryDate = await DiaryDate.findByPk(diaryDateId);
|
||||
clubId = diaryDate?.clubId;
|
||||
}
|
||||
|
||||
await DiaryNote.destroy({ where: { id: noteId } });
|
||||
|
||||
// Emit Socket-Event
|
||||
if (clubId && diaryDateId) {
|
||||
emitDiaryNoteDeleted(clubId, diaryDateId, noteId);
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'Note deleted' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Error deleting note' });
|
||||
|
||||
@@ -12,11 +12,13 @@ export const getTags = async (req, res) => {
|
||||
export const createTag = async (req, res) => {
|
||||
try {
|
||||
const { name } = req.body;
|
||||
console.log(name);
|
||||
const newTag = await DiaryTag.findOrCreate({ where: { name }, defaults: { name } });
|
||||
res.status(201).json(newTag);
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Der Name des Tags ist erforderlich.' });
|
||||
}
|
||||
|
||||
const [tag, created] = await DiaryTag.findOrCreate({ where: { name }, defaults: { name } });
|
||||
res.status(created ? 201 : 200).json(tag);
|
||||
} catch (error) {
|
||||
console.log('[createTag] - Error:', error);
|
||||
res.status(500).json({ error: 'Error creating tag' });
|
||||
}
|
||||
};
|
||||
@@ -24,9 +26,14 @@ export const createTag = async (req, res) => {
|
||||
export const deleteTag = async (req, res) => {
|
||||
try {
|
||||
const { tagId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
await diaryService.removeTagFromDiaryDate(userToken, clubId, tagId);
|
||||
|
||||
await DiaryDateTag.destroy({ where: { tagId } });
|
||||
const deleted = await DiaryTag.destroy({ where: { id: tagId } });
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Tag nicht gefunden' });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'Tag deleted' });
|
||||
} catch (error) {
|
||||
console.error('[deleteTag] - Error:', error);
|
||||
|
||||
70
backend/controllers/friendlyMatchController.js
Normal file
70
backend/controllers/friendlyMatchController.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import FriendlyMatchService from '../services/friendlyMatchService.js';
|
||||
import { emitScheduleMatchUpdated } from '../services/socketService.js';
|
||||
|
||||
function userTokenFrom(req) {
|
||||
return req.headers.authcode;
|
||||
}
|
||||
|
||||
export const listFriendlyMatches = async (req, res) => {
|
||||
try {
|
||||
const matches = await FriendlyMatchService.list(userTokenFrom(req), req.params.clubId);
|
||||
res.status(200).json(matches);
|
||||
} catch (error) {
|
||||
console.error('[listFriendlyMatches] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiele konnten nicht geladen werden' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createFriendlyMatch = async (req, res) => {
|
||||
try {
|
||||
const match = await FriendlyMatchService.create(userTokenFrom(req), req.params.clubId, req.body);
|
||||
emitScheduleMatchUpdated(req.params.clubId, match.id, match);
|
||||
res.status(201).json(match);
|
||||
} catch (error) {
|
||||
console.error('[createFriendlyMatch] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiel konnte nicht erstellt werden' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateFriendlyMatch = async (req, res) => {
|
||||
try {
|
||||
const match = await FriendlyMatchService.update(userTokenFrom(req), req.params.clubId, req.params.matchId, req.body);
|
||||
emitScheduleMatchUpdated(req.params.clubId, match.id, match);
|
||||
res.status(200).json(match);
|
||||
} catch (error) {
|
||||
console.error('[updateFriendlyMatch] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiel konnte nicht gespeichert werden' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteFriendlyMatch = async (req, res) => {
|
||||
try {
|
||||
const result = await FriendlyMatchService.remove(userTokenFrom(req), req.params.clubId, req.params.matchId);
|
||||
emitScheduleMatchUpdated(req.params.clubId, Number(req.params.matchId), null);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[deleteFriendlyMatch] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Freundschaftsspiel konnte nicht gelöscht werden' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateFriendlyMatchPlayers = async (req, res) => {
|
||||
try {
|
||||
const match = await FriendlyMatchService.updatePlayers(userTokenFrom(req), req.params.clubId, req.params.matchId, req.body);
|
||||
emitScheduleMatchUpdated(req.params.clubId, match.id, match);
|
||||
res.status(200).json({ message: 'Teilnehmer gespeichert', data: match });
|
||||
} catch (error) {
|
||||
console.error('[updateFriendlyMatchPlayers] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Teilnehmer konnten nicht gespeichert werden' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getFriendlyMatchMembers = async (req, res) => {
|
||||
try {
|
||||
const members = await FriendlyMatchService.members(userTokenFrom(req), req.params.clubId);
|
||||
res.status(200).json(members);
|
||||
} catch (error) {
|
||||
console.error('[getFriendlyMatchMembers] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Mitglieder konnten nicht geladen werden' });
|
||||
}
|
||||
};
|
||||
69
backend/controllers/friendlyMatchInvitationController.js
Normal file
69
backend/controllers/friendlyMatchInvitationController.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import friendlyMatchSharedService from '../services/friendlyMatchSharedService.js';
|
||||
import {
|
||||
emitFriendlyInvitationAccepted,
|
||||
emitFriendlyInvitationCreated,
|
||||
emitFriendlyInvitationDeclined,
|
||||
emitFriendlySharedMatchUpdated,
|
||||
} from '../services/socketService.js';
|
||||
|
||||
function userTokenFrom(req) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.slice(7);
|
||||
}
|
||||
return req.headers.authcode || authHeader;
|
||||
}
|
||||
|
||||
export const createFriendlyMatchInvitation = async (req, res) => {
|
||||
try {
|
||||
const invitation = await friendlyMatchSharedService.createInvitation(userTokenFrom(req), req.params.clubId, req.body);
|
||||
emitFriendlyInvitationCreated(invitation.fromClubId, invitation.toClubId, invitation);
|
||||
res.status(201).json(invitation);
|
||||
} catch (error) {
|
||||
console.error('[createFriendlyMatchInvitation] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Einladung konnte nicht erstellt werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const listIncomingFriendlyMatchInvitations = async (req, res) => {
|
||||
try {
|
||||
const items = await friendlyMatchSharedService.listIncomingInvitations(userTokenFrom(req), req.params.clubId);
|
||||
res.status(200).json(items);
|
||||
} catch (error) {
|
||||
console.error('[listIncomingFriendlyMatchInvitations] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Eingehende Einladungen konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const listOutgoingFriendlyMatchInvitations = async (req, res) => {
|
||||
try {
|
||||
const items = await friendlyMatchSharedService.listOutgoingInvitations(userTokenFrom(req), req.params.clubId);
|
||||
res.status(200).json(items);
|
||||
} catch (error) {
|
||||
console.error('[listOutgoingFriendlyMatchInvitations] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Ausgehende Einladungen konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const acceptFriendlyMatchInvitation = async (req, res) => {
|
||||
try {
|
||||
const result = await friendlyMatchSharedService.acceptInvitation(userTokenFrom(req), req.params.clubId, req.params.invitationId);
|
||||
emitFriendlyInvitationAccepted(result.invitation.fromClubId, result.invitation.toClubId, result.invitation);
|
||||
emitFriendlySharedMatchUpdated(result.sharedMatch.homeClubId, result.sharedMatch.guestClubId, result.sharedMatch);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[acceptFriendlyMatchInvitation] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Einladung konnte nicht angenommen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const declineFriendlyMatchInvitation = async (req, res) => {
|
||||
try {
|
||||
const invitation = await friendlyMatchSharedService.declineInvitation(userTokenFrom(req), req.params.clubId, req.params.invitationId);
|
||||
emitFriendlyInvitationDeclined(invitation.fromClubId, invitation.toClubId, invitation.id);
|
||||
res.status(200).json({ success: true, id: invitation.id });
|
||||
} catch (error) {
|
||||
console.error('[declineFriendlyMatchInvitation] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Einladung konnte nicht abgelehnt werden.' });
|
||||
}
|
||||
};
|
||||
101
backend/controllers/friendlyMatchSharedController.js
Normal file
101
backend/controllers/friendlyMatchSharedController.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import friendlyMatchSharedService from '../services/friendlyMatchSharedService.js';
|
||||
import {
|
||||
emitFriendlySharedMatchDeleted,
|
||||
emitFriendlySharedMatchUpdated,
|
||||
} from '../services/socketService.js';
|
||||
|
||||
function userTokenFrom(req) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
return authHeader.slice(7);
|
||||
}
|
||||
return req.headers.authcode || authHeader;
|
||||
}
|
||||
|
||||
export const findSharedFriendlyMatches = async (req, res) => {
|
||||
try {
|
||||
const { clubId, name, date, startTime } = req.query;
|
||||
const matches = await friendlyMatchSharedService.findByNameDateStartTime(userTokenFrom(req), clubId, {
|
||||
name,
|
||||
date,
|
||||
startTime,
|
||||
});
|
||||
res.status(200).json(matches);
|
||||
} catch (error) {
|
||||
console.error('[findSharedFriendlyMatches] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Suche nach Freundschaftsspielen fehlgeschlagen.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const listSharedFriendlyMatches = async (req, res) => {
|
||||
try {
|
||||
const data = await friendlyMatchSharedService.listShared(userTokenFrom(req), req.params.clubId);
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error('[listSharedFriendlyMatches] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Gemeinsame Freundschaftsspiele konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getSharedFriendlyMatchMembers = async (req, res) => {
|
||||
try {
|
||||
const members = await friendlyMatchSharedService.membersForSide(
|
||||
userTokenFrom(req),
|
||||
req.params.clubId,
|
||||
req.params.matchId,
|
||||
req.params.side,
|
||||
);
|
||||
res.status(200).json(members);
|
||||
} catch (error) {
|
||||
console.error('[getSharedFriendlyMatchMembers] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Mitglieder konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSharedFriendlyMatch = async (req, res) => {
|
||||
try {
|
||||
const match = await friendlyMatchSharedService.updateShared(
|
||||
userTokenFrom(req),
|
||||
req.params.clubId,
|
||||
req.params.matchId,
|
||||
req.body,
|
||||
);
|
||||
emitFriendlySharedMatchUpdated(match.homeClubId, match.guestClubId, match);
|
||||
res.status(200).json(match);
|
||||
} catch (error) {
|
||||
console.error('[updateSharedFriendlyMatch] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Gemeinsames Freundschaftsspiel konnte nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSharedFriendlyMatchPlayers = async (req, res) => {
|
||||
try {
|
||||
const match = await friendlyMatchSharedService.updateSharedPlayers(
|
||||
userTokenFrom(req),
|
||||
req.params.clubId,
|
||||
req.params.matchId,
|
||||
req.body,
|
||||
);
|
||||
emitFriendlySharedMatchUpdated(match.homeClubId, match.guestClubId, match);
|
||||
res.status(200).json({ message: 'Teilnehmer gespeichert', data: match });
|
||||
} catch (error) {
|
||||
console.error('[updateSharedFriendlyMatchPlayers] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Teilnehmer konnten nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSharedFriendlyMatch = async (req, res) => {
|
||||
try {
|
||||
const match = await friendlyMatchSharedService.getSharedById(
|
||||
userTokenFrom(req),
|
||||
req.params.clubId,
|
||||
req.params.matchId,
|
||||
);
|
||||
const result = await friendlyMatchSharedService.removeShared(userTokenFrom(req), req.params.clubId, req.params.matchId);
|
||||
emitFriendlySharedMatchDeleted(match.homeClubId, match.guestClubId, Number(req.params.matchId));
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[deleteSharedFriendlyMatch] Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message || 'Gemeinsames Freundschaftsspiel konnte nicht geloescht werden.' });
|
||||
}
|
||||
};
|
||||
@@ -1,15 +1,27 @@
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
import groupService from '../services/groupService.js';
|
||||
import { emitActivityChanged, emitGroupChanged } from '../services/socketService.js';
|
||||
import DiaryDate from '../models/DiaryDates.js';
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
const addGroup = async(req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubid: clubId, dateid: dateId, name, lead } = req.body;
|
||||
const result = await groupService.addGroup(userToken, clubId, dateId, name, lead);
|
||||
|
||||
// Emit Socket-Event für Gruppen-Änderungen
|
||||
if (dateId) {
|
||||
const diaryDate = await DiaryDate.findByPk(dateId);
|
||||
if (diaryDate?.clubId) {
|
||||
emitGroupChanged(diaryDate.clubId, dateId);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(result);
|
||||
} catch (error) {
|
||||
console.error('[addGroup] - Error:', error);
|
||||
console.log(req.params, req.headers, req.body)
|
||||
devLog(req.params, req.headers, req.body)
|
||||
res.status(error.statusCode || 500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
@@ -32,6 +44,15 @@ const changeGroup = async(req, res) => {
|
||||
const { groupId } = req.params;
|
||||
const { clubid: clubId, dateid: dateId, name, lead } = req.body;
|
||||
const result = await groupService.changeGroup(userToken, groupId, clubId, dateId, name, lead);
|
||||
|
||||
// Emit Socket-Event für Gruppen-Änderungen
|
||||
if (dateId) {
|
||||
const diaryDate = await DiaryDate.findByPk(dateId);
|
||||
if (diaryDate?.clubId) {
|
||||
emitGroupChanged(diaryDate.clubId, dateId);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[changeGroup] - Error:', error);
|
||||
@@ -39,4 +60,27 @@ const changeGroup = async(req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
export { addGroup, getGroups, changeGroup};
|
||||
const deleteGroup = async(req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { groupId } = req.params;
|
||||
const { clubid: clubId, dateid: dateId } = req.body;
|
||||
const result = await groupService.deleteGroup(userToken, groupId, clubId, dateId);
|
||||
|
||||
// Emit Socket-Events für Gruppen- und Aktivitäts-Änderungen (Gruppen werden in Aktivitäten verwendet)
|
||||
if (dateId) {
|
||||
const diaryDate = await DiaryDate.findByPk(dateId);
|
||||
if (diaryDate?.clubId) {
|
||||
emitGroupChanged(diaryDate.clubId, dateId);
|
||||
emitActivityChanged(diaryDate.clubId, dateId);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[deleteGroup] - Error:', error);
|
||||
res.status(error.statusCode || 500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export { addGroup, getGroups, changeGroup, deleteGroup};
|
||||
@@ -1,6 +1,7 @@
|
||||
import MatchService from '../services/matchService.js';
|
||||
import fs from 'fs';
|
||||
|
||||
import { emitScheduleMatchUpdated } from '../services/socketService.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
export const uploadCSV = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.body;
|
||||
@@ -21,10 +22,11 @@ export const uploadCSV = async (req, res) => {
|
||||
|
||||
export const getLeaguesForCurrentSeason = async (req, res) => {
|
||||
try {
|
||||
console.log(req.headers, req.params);
|
||||
devLog(req.headers, req.params);
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const leagues = await MatchService.getLeaguesForCurrentSeason(userToken, clubId);
|
||||
const { seasonid: seasonId } = req.query;
|
||||
const leagues = await MatchService.getLeaguesForCurrentSeason(userToken, clubId, seasonId);
|
||||
return res.status(200).json(leagues);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving leagues:', error);
|
||||
@@ -36,7 +38,8 @@ export const getMatchesForLeagues = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const matches = await MatchService.getMatchesForLeagues(userToken, clubId);
|
||||
const { seasonid: seasonId } = req.query;
|
||||
const matches = await MatchService.getMatchesForLeagues(userToken, clubId, seasonId);
|
||||
return res.status(200).json(matches);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving matches:', error);
|
||||
@@ -48,10 +51,120 @@ export const getMatchesForLeague = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, leagueId } = req.params;
|
||||
const matches = await MatchService.getMatchesForLeague(userToken, clubId, leagueId);
|
||||
const { scope = 'own' } = req.query;
|
||||
const matches = await MatchService.getMatchesForLeague(userToken, clubId, leagueId, scope);
|
||||
return res.status(200).json(matches);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving matches:', error);
|
||||
return res.status(500).json({ error: 'Failed to retrieve matches' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getLeagueTable = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, leagueId } = req.params;
|
||||
const table = await MatchService.getLeagueTable(userToken, clubId, leagueId);
|
||||
return res.status(200).json(table);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving league table:', error);
|
||||
return res.status(500).json({ error: 'Failed to retrieve league table' });
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchLeagueTableFromMyTischtennis = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, leagueId } = req.params;
|
||||
const { userid: userIdOrEmail } = req.headers;
|
||||
|
||||
// Convert email to userId if needed
|
||||
let userId = userIdOrEmail;
|
||||
if (isNaN(userIdOrEmail)) {
|
||||
const User = (await import('../models/User.js')).default;
|
||||
const user = await User.findOne({ where: { email: userIdOrEmail } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
userId = user.id;
|
||||
}
|
||||
|
||||
const autoFetchService = (await import('../services/autoFetchMatchResultsService.js')).default;
|
||||
await autoFetchService.fetchAndUpdateLeagueTable(userId, leagueId);
|
||||
|
||||
// Return updated table data
|
||||
const table = await MatchService.getLeagueTable(userToken, clubId, leagueId);
|
||||
return res.status(200).json({
|
||||
message: 'League table updated from MyTischtennis',
|
||||
data: table
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching league table from MyTischtennis:', error);
|
||||
return res.status(500).json({ error: 'Failed to fetch league table from MyTischtennis' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateMatchPlayers = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { matchId } = req.params;
|
||||
const { playersReady, playersPlanned, playersPlayed } = req.body;
|
||||
|
||||
const result = await MatchService.updateMatchPlayers(
|
||||
userToken,
|
||||
matchId,
|
||||
playersReady,
|
||||
playersPlanned,
|
||||
playersPlayed
|
||||
);
|
||||
|
||||
if (result.clubId) {
|
||||
emitScheduleMatchUpdated(result.clubId, result.id, result.match || null);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Match players updated successfully',
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating match players:', error);
|
||||
return res.status(error.statusCode || 500).json({
|
||||
error: error.message || 'Failed to update match players'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getPlayerMatchStats = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, leagueId } = req.params;
|
||||
const { seasonid: seasonId } = req.query;
|
||||
|
||||
const stats = await MatchService.getPlayerMatchStats(userToken, clubId, leagueId, seasonId);
|
||||
|
||||
return res.status(200).json(stats);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving player match stats:', error);
|
||||
return res.status(error.statusCode || 500).json({
|
||||
error: error.message || 'Failed to retrieve player match stats'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getMatchPlayers = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ error: 'Club-ID fehlt' });
|
||||
}
|
||||
const Member = (await import('../models/Member.js')).default;
|
||||
const members = await Member.findAll({
|
||||
where: { clubId: clubId, active: true },
|
||||
attributes: ['id', 'firstName', 'lastName', 'gender']
|
||||
});
|
||||
return res.status(200).json(members);
|
||||
} catch (error) {
|
||||
console.error('Error retrieving match players:', error);
|
||||
return res.status(500).json({ error: 'Failed to retrieve match players' });
|
||||
}
|
||||
};
|
||||
|
||||
531
backend/controllers/memberActivityController.js
Normal file
531
backend/controllers/memberActivityController.js
Normal file
@@ -0,0 +1,531 @@
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import DiaryMemberActivity from '../models/DiaryMemberActivity.js';
|
||||
import DiaryDateActivity from '../models/DiaryDateActivity.js';
|
||||
import DiaryDates from '../models/DiaryDates.js';
|
||||
import Participant from '../models/Participant.js';
|
||||
import PredefinedActivity from '../models/PredefinedActivity.js';
|
||||
import GroupActivity from '../models/GroupActivity.js';
|
||||
import { Op } from 'sequelize';
|
||||
|
||||
export const getMemberActivities = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, memberId } = req.params;
|
||||
const { period } = req.query; // 'month', '3months', '6months', 'year', 'all'
|
||||
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
// Calculate date range based on period
|
||||
const now = new Date();
|
||||
let startDate = null;
|
||||
|
||||
switch (period) {
|
||||
case 'month':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||
break;
|
||||
case '3months':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
||||
break;
|
||||
case '6months':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate());
|
||||
break;
|
||||
case 'year':
|
||||
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
break;
|
||||
case 'all':
|
||||
default:
|
||||
startDate = null;
|
||||
break;
|
||||
}
|
||||
|
||||
// Get participant ID for this member
|
||||
const participants = await Participant.findAll({
|
||||
where: { memberId: memberId }
|
||||
});
|
||||
|
||||
if (participants.length === 0) {
|
||||
return res.status(200).json([]);
|
||||
}
|
||||
|
||||
const participantIds = participants.map(p => p.id);
|
||||
|
||||
// Sammle alle Gruppen-IDs, zu denen der Member gehört
|
||||
const memberGroupIds = new Set();
|
||||
participants.forEach(p => {
|
||||
if (p.groupId !== null && p.groupId !== undefined) {
|
||||
memberGroupIds.add(p.groupId);
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Get all diary member activities explicitly assigned to this member
|
||||
const memberActivities = await DiaryMemberActivity.findAll({
|
||||
where: {
|
||||
participantId: participantIds
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Participant,
|
||||
as: 'participant',
|
||||
attributes: ['id', 'groupId', 'diaryDateId']
|
||||
},
|
||||
{
|
||||
model: DiaryDateActivity,
|
||||
as: 'activity',
|
||||
include: [
|
||||
{
|
||||
model: DiaryDates,
|
||||
as: 'diaryDate',
|
||||
where: startDate ? {
|
||||
date: {
|
||||
[Op.gte]: startDate
|
||||
}
|
||||
} : {}
|
||||
},
|
||||
{
|
||||
model: PredefinedActivity,
|
||||
as: 'predefinedActivity'
|
||||
},
|
||||
{
|
||||
model: GroupActivity,
|
||||
as: 'groupActivities',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [[{ model: DiaryDateActivity, as: 'activity' }, { model: DiaryDates, as: 'diaryDate' }, 'date', 'DESC']]
|
||||
});
|
||||
|
||||
// 2. Get all group activities for groups the member belongs to
|
||||
const groupActivities = [];
|
||||
if (memberGroupIds.size > 0) {
|
||||
// Suche direkt nach GroupActivity-Einträgen für die Gruppen des Members
|
||||
const groupActivitiesData = await GroupActivity.findAll({
|
||||
where: {
|
||||
groupId: {
|
||||
[Op.in]: Array.from(memberGroupIds)
|
||||
}
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: DiaryDateActivity,
|
||||
as: 'activityGroupActivity',
|
||||
include: [
|
||||
{
|
||||
model: DiaryDates,
|
||||
as: 'diaryDate',
|
||||
where: startDate ? {
|
||||
date: {
|
||||
[Op.gte]: startDate
|
||||
}
|
||||
} : {}
|
||||
},
|
||||
{
|
||||
model: PredefinedActivity,
|
||||
as: 'predefinedActivity',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: PredefinedActivity,
|
||||
as: 'groupPredefinedActivity',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Erstelle virtuelle DiaryMemberActivity-Objekte für Gruppen-Aktivitäten
|
||||
for (const groupActivity of groupActivitiesData) {
|
||||
if (!groupActivity.activityGroupActivity || !groupActivity.activityGroupActivity.diaryDate) {
|
||||
continue; // Überspringe, wenn keine DiaryDateActivity oder kein DiaryDate vorhanden
|
||||
}
|
||||
|
||||
const activity = groupActivity.activityGroupActivity;
|
||||
const diaryDateId = activity.diaryDateId;
|
||||
|
||||
// Finde alle relevanten Participants für dieses DiaryDate
|
||||
const relevantParticipants = participants.filter(p =>
|
||||
p.diaryDateId === diaryDateId &&
|
||||
p.groupId === groupActivity.groupId
|
||||
);
|
||||
|
||||
for (const participant of relevantParticipants) {
|
||||
// Verwende die PredefinedActivity aus GroupActivity, falls vorhanden
|
||||
// Sonst die aus DiaryDateActivity
|
||||
const predefinedActivity = groupActivity.groupPredefinedActivity || activity.predefinedActivity;
|
||||
|
||||
if (predefinedActivity) {
|
||||
// Erstelle ein modifiziertes Activity-Objekt
|
||||
const modifiedActivity = {
|
||||
...activity.toJSON(),
|
||||
predefinedActivity: predefinedActivity
|
||||
};
|
||||
groupActivities.push({
|
||||
activity: modifiedActivity,
|
||||
participant: participant,
|
||||
id: null // Virtuell, nicht in DB
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter: explizite Zuordnungen sollen nur dann zählen, wenn
|
||||
// - der Participant keine Gruppe hat UND die Aktivität KEINE Gruppenbindung hat, oder
|
||||
// - die Aktivität keine Gruppenbindung hat, oder
|
||||
// - es eine Gruppenbindung gibt, die zur Gruppe des Participants passt.
|
||||
const filteredMemberActivities = memberActivities.filter((ma) => {
|
||||
if (!ma?.participant || !ma?.activity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const participantGroupId = ma.participant.groupId;
|
||||
const groupActivitiesForActivity = ma.activity.groupActivities || [];
|
||||
|
||||
// Participant ohne Gruppe -> nur Aktivitäten ohne Gruppenbindung zählen
|
||||
if (participantGroupId === null || participantGroupId === undefined) {
|
||||
return !groupActivitiesForActivity.length;
|
||||
}
|
||||
|
||||
// Keine Gruppenbindung -> immer zählen
|
||||
if (!groupActivitiesForActivity.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Gruppenbindung vorhanden -> nur zählen, wenn die Gruppe passt
|
||||
return groupActivitiesForActivity.some((ga) => Number(ga.groupId) === Number(participantGroupId));
|
||||
});
|
||||
|
||||
// 3. Kombiniere beide Listen und entferne Duplikate
|
||||
// Ein Duplikat liegt vor, wenn dieselbe Aktivität für denselben Participant bereits explizit zugeordnet ist
|
||||
const explicitActivityKeys = new Set();
|
||||
filteredMemberActivities.forEach(ma => {
|
||||
if (ma.activity && ma.activity.id && ma.participant && ma.participant.id) {
|
||||
// Erstelle einen eindeutigen Schlüssel: activityId-participantId
|
||||
const key = `${ma.activity.id}-${ma.participant.id}`;
|
||||
explicitActivityKeys.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Filtere Gruppen-Aktivitäten, die bereits explizit zugeordnet sind
|
||||
const uniqueGroupActivities = groupActivities.filter(ga => {
|
||||
if (!ga.activity || !ga.activity.id || !ga.participant || !ga.participant.id) {
|
||||
return false;
|
||||
}
|
||||
const key = `${ga.activity.id}-${ga.participant.id}`;
|
||||
return !explicitActivityKeys.has(key);
|
||||
});
|
||||
|
||||
// Kombiniere beide Listen
|
||||
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities];
|
||||
|
||||
// Group activities by name and count occurrences
|
||||
// Verwende einen Set pro Aktivität, um eindeutige Datum-Aktivität-Kombinationen zu tracken
|
||||
const activityMap = new Map();
|
||||
|
||||
for (const ma of allActivities) {
|
||||
if (!ma.activity || !ma.activity.predefinedActivity || !ma.participant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const activity = ma.activity.predefinedActivity;
|
||||
const activityName = activity.name;
|
||||
const activityCode = activity.code || activity.name; // Verwende Code falls vorhanden, sonst Name
|
||||
const date = ma.activity.diaryDate?.date;
|
||||
|
||||
if (!date) {
|
||||
continue; // Überspringe Einträge ohne Datum
|
||||
}
|
||||
|
||||
// Verwende Code als Key, falls vorhanden, sonst Name
|
||||
const key = activityCode;
|
||||
|
||||
if (!activityMap.has(key)) {
|
||||
activityMap.set(key, {
|
||||
name: activityName, // Vollständiger Name für Tooltip
|
||||
code: activityCode, // Code/Kürzel für Anzeige
|
||||
uniqueDates: new Set(), // Set für eindeutige Daten
|
||||
dates: []
|
||||
});
|
||||
}
|
||||
|
||||
const activityData = activityMap.get(key);
|
||||
// Konvertiere Datum zu String für Set-Vergleich (nur Datum, keine Zeit)
|
||||
const dateString = date instanceof Date
|
||||
? date.toISOString().split('T')[0]
|
||||
: new Date(date).toISOString().split('T')[0];
|
||||
|
||||
// Füge Datum nur hinzu, wenn es noch nicht vorhanden ist
|
||||
if (!activityData.uniqueDates.has(dateString)) {
|
||||
activityData.uniqueDates.add(dateString);
|
||||
activityData.dates.push(date);
|
||||
}
|
||||
}
|
||||
|
||||
// Konvertiere Sets zu Arrays und setze count basierend auf eindeutigen Daten
|
||||
activityMap.forEach((activityData, key) => {
|
||||
activityData.count = activityData.uniqueDates.size;
|
||||
// Sortiere Daten (neueste zuerst)
|
||||
activityData.dates.sort((a, b) => {
|
||||
const dateA = new Date(a);
|
||||
const dateB = new Date(b);
|
||||
return dateB - dateA;
|
||||
});
|
||||
// Entferne uniqueDates, da es nicht an Frontend gesendet werden muss
|
||||
delete activityData.uniqueDates;
|
||||
});
|
||||
|
||||
// Convert map to array and sort by count
|
||||
const activities = Array.from(activityMap.values())
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
return res.status(200).json(activities);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching member activities:', error);
|
||||
return res.status(500).json({ error: 'Failed to fetch member activities' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getMemberLastParticipations = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, memberId } = req.params;
|
||||
const { limit = 3 } = req.query;
|
||||
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
// Get participant ID for this member
|
||||
const participants = await Participant.findAll({
|
||||
where: { memberId: memberId }
|
||||
});
|
||||
|
||||
if (participants.length === 0) {
|
||||
return res.status(200).json([]);
|
||||
}
|
||||
|
||||
const participantIds = participants.map(p => p.id);
|
||||
|
||||
// Sammle alle Gruppen-IDs, zu denen der Member gehört
|
||||
const memberGroupIds = new Set();
|
||||
participants.forEach(p => {
|
||||
if (p.groupId !== null && p.groupId !== undefined) {
|
||||
memberGroupIds.add(p.groupId);
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Get last participations explicitly assigned to this member
|
||||
const memberActivities = await DiaryMemberActivity.findAll({
|
||||
where: {
|
||||
participantId: participantIds
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Participant,
|
||||
as: 'participant',
|
||||
attributes: ['id', 'groupId', 'diaryDateId']
|
||||
},
|
||||
{
|
||||
model: DiaryDateActivity,
|
||||
as: 'activity',
|
||||
include: [
|
||||
{
|
||||
model: DiaryDates,
|
||||
as: 'diaryDate'
|
||||
},
|
||||
{
|
||||
model: PredefinedActivity,
|
||||
as: 'predefinedActivity'
|
||||
},
|
||||
{
|
||||
model: GroupActivity,
|
||||
as: 'groupActivities',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
order: [[{ model: DiaryDateActivity, as: 'activity' }, { model: DiaryDates, as: 'diaryDate' }, 'date', 'DESC']],
|
||||
limit: parseInt(limit) * 10 // Get more to filter by group
|
||||
});
|
||||
|
||||
// Siehe getMemberActivities(): nur zählen, wenn Gruppenbindung passt (oder keine existiert)
|
||||
const filteredMemberActivities = memberActivities.filter((ma) => {
|
||||
if (!ma?.participant || !ma?.activity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const participantGroupId = ma.participant.groupId;
|
||||
const groupActivitiesForActivity = ma.activity.groupActivities || [];
|
||||
|
||||
if (!groupActivitiesForActivity.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return groupActivitiesForActivity.some((ga) => Number(ga.groupId) === Number(participantGroupId));
|
||||
});
|
||||
|
||||
// 2. Get all group activities for groups the member belongs to
|
||||
const groupActivities = [];
|
||||
if (memberGroupIds.size > 0) {
|
||||
// Suche direkt nach GroupActivity-Einträgen für die Gruppen des Members
|
||||
const groupActivitiesData = await GroupActivity.findAll({
|
||||
where: {
|
||||
groupId: {
|
||||
[Op.in]: Array.from(memberGroupIds)
|
||||
}
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: DiaryDateActivity,
|
||||
as: 'activityGroupActivity',
|
||||
include: [
|
||||
{
|
||||
model: DiaryDates,
|
||||
as: 'diaryDate'
|
||||
},
|
||||
{
|
||||
model: PredefinedActivity,
|
||||
as: 'predefinedActivity',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
model: PredefinedActivity,
|
||||
as: 'groupPredefinedActivity',
|
||||
required: false
|
||||
}
|
||||
],
|
||||
order: [[{ model: DiaryDateActivity, as: 'activityGroupActivity' }, { model: DiaryDates, as: 'diaryDate' }, 'date', 'DESC']],
|
||||
limit: parseInt(limit) * 10 // Get more to filter
|
||||
});
|
||||
|
||||
// Erstelle virtuelle DiaryMemberActivity-Objekte für Gruppen-Aktivitäten
|
||||
for (const groupActivity of groupActivitiesData) {
|
||||
if (!groupActivity.activityGroupActivity || !groupActivity.activityGroupActivity.diaryDate) {
|
||||
continue; // Überspringe, wenn keine DiaryDateActivity oder kein DiaryDate vorhanden
|
||||
}
|
||||
|
||||
const activity = groupActivity.activityGroupActivity;
|
||||
const diaryDateId = activity.diaryDateId;
|
||||
|
||||
// Finde alle relevanten Participants für dieses DiaryDate
|
||||
const relevantParticipants = participants.filter(p =>
|
||||
p.diaryDateId === diaryDateId &&
|
||||
p.groupId === groupActivity.groupId
|
||||
);
|
||||
|
||||
for (const participant of relevantParticipants) {
|
||||
// Verwende die PredefinedActivity aus GroupActivity, falls vorhanden
|
||||
// Sonst die aus DiaryDateActivity
|
||||
const predefinedActivity = groupActivity.groupPredefinedActivity || activity.predefinedActivity;
|
||||
|
||||
if (predefinedActivity) {
|
||||
// Erstelle ein modifiziertes Activity-Objekt
|
||||
const modifiedActivity = {
|
||||
...activity.toJSON(),
|
||||
predefinedActivity: predefinedActivity
|
||||
};
|
||||
groupActivities.push({
|
||||
activity: modifiedActivity,
|
||||
participant: participant,
|
||||
id: null // Virtuell, nicht in DB
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Kombiniere beide Listen und entferne Duplikate
|
||||
// Ein Duplikat liegt vor, wenn dieselbe Aktivität für denselben Participant bereits explizit zugeordnet ist
|
||||
const explicitActivityKeys = new Set();
|
||||
filteredMemberActivities.forEach(ma => {
|
||||
if (ma.activity && ma.activity.id && ma.participant && ma.participant.id) {
|
||||
// Erstelle einen eindeutigen Schlüssel: activityId-participantId
|
||||
const key = `${ma.activity.id}-${ma.participant.id}`;
|
||||
explicitActivityKeys.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
// Filtere Gruppen-Aktivitäten, die bereits explizit zugeordnet sind
|
||||
const uniqueGroupActivities = groupActivities.filter(ga => {
|
||||
if (!ga.activity || !ga.activity.id || !ga.participant || !ga.participant.id) {
|
||||
return false;
|
||||
}
|
||||
const key = `${ga.activity.id}-${ga.participant.id}`;
|
||||
return !explicitActivityKeys.has(key);
|
||||
});
|
||||
|
||||
// Kombiniere beide Listen
|
||||
const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities];
|
||||
|
||||
// Gruppiere nach Datum
|
||||
const participationsByDate = new Map();
|
||||
|
||||
allActivities
|
||||
.filter(ma => {
|
||||
if (!ma.activity || !ma.activity.predefinedActivity || !ma.activity.diaryDate || !ma.participant) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.forEach(ma => {
|
||||
const date = ma.activity.diaryDate.date;
|
||||
const diaryDateId = ma.activity.diaryDate.id;
|
||||
const activity = ma.activity.predefinedActivity;
|
||||
const activityName = activity.name;
|
||||
const activityCode = activity.code || activity.name;
|
||||
|
||||
if (!participationsByDate.has(date)) {
|
||||
participationsByDate.set(date, {
|
||||
date: date,
|
||||
diaryDateId: diaryDateId,
|
||||
activities: []
|
||||
});
|
||||
}
|
||||
|
||||
const dateEntry = participationsByDate.get(date);
|
||||
// Füge Aktivität nur hinzu, wenn sie noch nicht vorhanden ist (vermeide Duplikate)
|
||||
// Speichere sowohl code als auch name
|
||||
const activityEntry = {
|
||||
code: activityCode,
|
||||
name: activityName
|
||||
};
|
||||
if (!dateEntry.activities.find(a => (a.code || a.name) === activityCode)) {
|
||||
dateEntry.activities.push(activityEntry);
|
||||
}
|
||||
});
|
||||
|
||||
// Sortiere nach Datum (neueste zuerst) und nehme die letzten N Daten
|
||||
const sortedDates = Array.from(participationsByDate.values())
|
||||
.sort((a, b) => {
|
||||
const dateA = new Date(a.date);
|
||||
const dateB = new Date(b.date);
|
||||
return dateB - dateA;
|
||||
})
|
||||
.slice(0, parseInt(limit));
|
||||
|
||||
// Formatiere für das Frontend: Flache Liste mit Datum und Aktivität
|
||||
const participations = [];
|
||||
sortedDates.forEach(dateEntry => {
|
||||
dateEntry.activities.forEach(activity => {
|
||||
participations.push({
|
||||
id: null, // Virtuell
|
||||
activityName: activity.code || activity.name, // Code für Anzeige
|
||||
activityFullName: activity.name, // Vollständiger Name für Tooltip
|
||||
date: dateEntry.date,
|
||||
diaryDateId: dateEntry.diaryDateId
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return res.status(200).json(participations);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching member last participations:', error);
|
||||
return res.status(500).json({ error: 'Failed to fetch member last participations' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,57 +1,126 @@
|
||||
import MemberService from "../services/memberService.js";
|
||||
import MemberTransferService from "../services/memberTransferService.js";
|
||||
import clickTtPlayerRegistrationService from "../services/clickTtPlayerRegistrationService.js";
|
||||
import { emitMemberChanged } from '../services/socketService.js';
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
const getClubMembers = async(req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { id: clubId, showAll } = req.params;
|
||||
if (showAll === null) {
|
||||
showAll = false;
|
||||
}
|
||||
const { id: clubId } = req.params;
|
||||
const showAll = req.params.showAll ?? 'false';
|
||||
res.status(200).json(await MemberService.getClubMembers(userToken, clubId, showAll));
|
||||
} catch(error) {
|
||||
console.log('[getClubMembers] - Error: ', error);
|
||||
res.status(500).json({ error: 'systemerror' });
|
||||
}
|
||||
}
|
||||
|
||||
const getWaitingApprovals = async(req, res) => {
|
||||
try {
|
||||
console.log('[getWaitingApprovals] - Start');
|
||||
const { id: clubId } = req.params;
|
||||
console.log('[getWaitingApprovals] - get token');
|
||||
const { authcode: userToken } = req.headers;
|
||||
console.log('[getWaitingApprovals] - load for waiting approvals');
|
||||
const waitingApprovals = await MemberService.getApprovalRequests(userToken, clubId);
|
||||
console.log('[getWaitingApprovals] - set response');
|
||||
res.status(200).json(waitingApprovals);
|
||||
console.log('[getWaitingApprovals] - done');
|
||||
} catch(error) {
|
||||
console.log('[getWaitingApprovals] - Error: ', error);
|
||||
res.status(403).json({ error: error });
|
||||
}
|
||||
}
|
||||
|
||||
const setClubMembers = async (req, res) => {
|
||||
try {
|
||||
const { id: memberId, firstname: firstName, lastname: lastName, street, city, birthdate, phone, email, active,
|
||||
testMembership, picsInInternetAllowed, gender, ttr, qttr } = req.body;
|
||||
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
|
||||
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts } = req.body;
|
||||
const { id: clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, birthdate,
|
||||
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr);
|
||||
const addResult = await MemberService.setClubMember(userToken, clubId, memberId, firstName, lastName, street, city, postalCode, birthdate,
|
||||
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contacts);
|
||||
|
||||
// Emit Socket-Event wenn Member erfolgreich erstellt/aktualisiert wurde
|
||||
if (addResult.status === 200) {
|
||||
emitMemberChanged(clubId);
|
||||
}
|
||||
|
||||
res.status(addResult.status || 500).json(addResult.response);
|
||||
} catch (error) {
|
||||
console.error('[setClubMembers] - Error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload image' });
|
||||
res.status(500).json({ error: 'Failed to save member' });
|
||||
}
|
||||
}
|
||||
|
||||
const getMemberSepaMandate = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.getMemberSepaMandate(userToken, Number(clubId), Number(memberId));
|
||||
res.status(result.status || 500).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[getMemberSepaMandate] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'SEPA-Mandat konnte nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const saveMemberSepaMandate = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.saveMemberSepaMandate(userToken, Number(clubId), Number(memberId), req.body || {});
|
||||
if (result.status === 200) {
|
||||
emitMemberChanged(clubId);
|
||||
}
|
||||
res.status(result.status || 500).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[saveMemberSepaMandate] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'SEPA-Mandat konnte nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const getMemberPlayInterests = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const { seasonId, lineupHalf } = req.query;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.getMemberPlayInterests(userToken, Number(clubId), Number(seasonId), String(lineupHalf || ''));
|
||||
res.status(result.status || 500).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[getMemberPlayInterests] - Error:', error);
|
||||
res.status(500).json({ error: 'Failed to load member play interests' });
|
||||
}
|
||||
};
|
||||
|
||||
const setMemberPlayInterest = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const { memberId, seasonId, lineupHalf, interested = true } = req.body;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const normalizedInterested = interested === true || interested === 'true' || interested === 1 || interested === '1';
|
||||
const result = await MemberService.setMemberPlayInterest(
|
||||
userToken,
|
||||
Number(clubId),
|
||||
Number(memberId),
|
||||
Number(seasonId),
|
||||
String(lineupHalf || ''),
|
||||
normalizedInterested
|
||||
);
|
||||
if (result.status === 200) {
|
||||
emitMemberChanged(clubId);
|
||||
}
|
||||
res.status(result.status || 500).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[setMemberPlayInterest] - Error:', error);
|
||||
res.status(500).json({ error: 'Failed to save member play interest' });
|
||||
}
|
||||
};
|
||||
|
||||
const uploadMemberImage = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.uploadMemberImage(userToken, clubId, memberId, req.file.buffer);
|
||||
res.status(result.status).json(result.message ? { message: result.message } : { error: result.error });
|
||||
const makePrimary =
|
||||
req.body?.makePrimary === true ||
|
||||
req.body?.makePrimary === 'true' ||
|
||||
req.query?.makePrimary === 'true';
|
||||
const result = await MemberService.uploadMemberImage(userToken, clubId, memberId, req.file.buffer, { makePrimary });
|
||||
res.status(result.status).json(result.response ?? { success: false, error: 'Unknown upload result' });
|
||||
} catch (error) {
|
||||
console.error('[uploadMemberImage] - Error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload image' });
|
||||
@@ -59,11 +128,12 @@ const uploadMemberImage = async (req, res) => {
|
||||
};
|
||||
|
||||
const getMemberImage = async (req, res) => {
|
||||
console.log('[getMemberImage]');
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { clubId, memberId, imageId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.getMemberImage(userToken, clubId, memberId);
|
||||
// Support "latest" as imageId to get the latest image
|
||||
const actualImageId = imageId === 'latest' ? null : (imageId || null);
|
||||
const result = await MemberService.getMemberImage(userToken, clubId, memberId, actualImageId);
|
||||
if (result.status === 200) {
|
||||
res.sendFile(result.imagePath);
|
||||
} else {
|
||||
@@ -76,7 +146,6 @@ const getMemberImage = async (req, res) => {
|
||||
};
|
||||
|
||||
const updateRatingsFromMyTischtennis = async (req, res) => {
|
||||
console.log('[updateRatingsFromMyTischtennis]');
|
||||
try {
|
||||
const { id: clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
@@ -88,4 +157,219 @@ const updateRatingsFromMyTischtennis = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export { getClubMembers, getWaitingApprovals, setClubMembers, uploadMemberImage, getMemberImage, updateRatingsFromMyTischtennis };
|
||||
const getMemberTtrHistory = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.getMemberTtrHistory(userToken, clubId, memberId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[getMemberTtrHistory] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'TTR-Historie konnte nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const refreshMemberTtrHistory = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.refreshMemberTtrHistory(userToken, clubId, memberId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[refreshMemberTtrHistory] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'TTR-Historie konnte nicht aktualisiert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const rotateMemberImage = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId, imageId } = req.params;
|
||||
const { direction } = req.body;
|
||||
const { authcode: userToken } = req.headers;
|
||||
|
||||
if (!direction || !['left', 'right'].includes(direction)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Ungültige Drehrichtung. Verwenden Sie "left" oder "right".'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await MemberService.rotateMemberImage(userToken, clubId, memberId, imageId, direction);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[rotateMemberImage] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to rotate image' });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMemberImage = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId, imageId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.deleteMemberImage(userToken, clubId, memberId, imageId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[deleteMemberImage] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to delete image' });
|
||||
}
|
||||
};
|
||||
|
||||
const generateMemberGallery = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const size = parseInt(req.query.size) || 200; // Default: 200x200
|
||||
const format = req.query.format || 'image'; // 'image' or 'json'
|
||||
|
||||
// Bei format=json wird kein Bild erstellt, nur die Mitgliederliste zurückgegeben
|
||||
const createImage = format !== 'json';
|
||||
const result = await MemberService.generateMemberGallery(userToken, clubId, size, createImage);
|
||||
|
||||
if (result.status === 200) {
|
||||
if (format === 'json') {
|
||||
// Return member information for interactive gallery (ohne Bild zu erstellen)
|
||||
return res.status(200).json({
|
||||
members: result.galleryEntries.map(entry => ({
|
||||
memberId: entry.memberId,
|
||||
firstName: entry.firstName,
|
||||
lastName: entry.lastName,
|
||||
fullName: entry.fullName
|
||||
}))
|
||||
});
|
||||
}
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.status(200).send(result.buffer);
|
||||
}
|
||||
return res.status(result.status).json({ error: result.error || 'Galerie konnte nicht erstellt werden' });
|
||||
} catch (error) {
|
||||
console.error('[generateMemberGallery] - Error:', error);
|
||||
res.status(500).json({ error: 'Failed to generate member gallery' });
|
||||
}
|
||||
};
|
||||
|
||||
const setPrimaryMemberImage = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId, imageId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.setPrimaryMemberImage(userToken, clubId, memberId, imageId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[setPrimaryMemberImage] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to update primary image' });
|
||||
}
|
||||
};
|
||||
|
||||
const quickUpdateTestMembership = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.quickUpdateTestMembership(userToken, clubId, memberId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[quickUpdateTestMembership] - Error:', error);
|
||||
res.status(500).json({ error: 'Failed to update test membership' });
|
||||
}
|
||||
};
|
||||
|
||||
const quickUpdateMemberFormHandedOver = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.quickUpdateMemberFormHandedOver(userToken, clubId, memberId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[quickUpdateMemberFormHandedOver] - Error:', error);
|
||||
res.status(500).json({ error: 'Failed to update member form status' });
|
||||
}
|
||||
};
|
||||
|
||||
const quickDeactivateMember = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await MemberService.quickDeactivateMember(userToken, clubId, memberId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[quickDeactivateMember] - Error:', error);
|
||||
res.status(500).json({ error: 'Failed to deactivate member' });
|
||||
}
|
||||
};
|
||||
|
||||
const requestClickTtPlayerRegistration = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const userId = req.user?.id;
|
||||
const result = await clickTtPlayerRegistrationService.submitExistingPlayerApplication({
|
||||
userToken,
|
||||
userId,
|
||||
clubId,
|
||||
memberId
|
||||
});
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[requestClickTtPlayerRegistration] - Error:', error);
|
||||
res.status(error.statusCode || error.status || 500).json({
|
||||
success: false,
|
||||
error: error.message || 'Click-TT-Antrag konnte nicht eingereicht werden',
|
||||
details: error.details || null
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const transferMembers = async (req, res) => {
|
||||
try {
|
||||
const { id: clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const config = req.body;
|
||||
|
||||
// Validierung
|
||||
if (!config.transferEndpoint) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Übertragungs-Endpoint ist erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
if (!config.transferTemplate) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Übertragungs-Template ist erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await MemberTransferService.transferMembers(userToken, clubId, config);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[transferMembers] - Error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler bei der Übertragung: ' + error.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
getClubMembers,
|
||||
getWaitingApprovals,
|
||||
setClubMembers,
|
||||
getMemberSepaMandate,
|
||||
saveMemberSepaMandate,
|
||||
getMemberPlayInterests,
|
||||
setMemberPlayInterest,
|
||||
uploadMemberImage,
|
||||
getMemberImage,
|
||||
updateRatingsFromMyTischtennis,
|
||||
getMemberTtrHistory,
|
||||
refreshMemberTtrHistory,
|
||||
rotateMemberImage,
|
||||
transferMembers,
|
||||
quickUpdateTestMembership,
|
||||
quickUpdateMemberFormHandedOver,
|
||||
quickDeactivateMember,
|
||||
requestClickTtPlayerRegistration,
|
||||
deleteMemberImage,
|
||||
setPrimaryMemberImage,
|
||||
generateMemberGallery
|
||||
};
|
||||
|
||||
65
backend/controllers/memberGroupPhotoController.js
Normal file
65
backend/controllers/memberGroupPhotoController.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import memberGroupPhotoService from '../services/memberGroupPhotoService.js';
|
||||
|
||||
export const listMemberGroupPhotos = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const photos = await memberGroupPhotoService.list(userToken, clubId);
|
||||
res.status(200).json({ success: true, photos });
|
||||
} catch (error) {
|
||||
console.error('[listMemberGroupPhotos] error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to list group photos' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createMemberGroupPhoto = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const result = await memberGroupPhotoService.create(userToken, clubId, req.file, req.body);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[createMemberGroupPhoto] error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to save group photo' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateMemberGroupPhoto = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, photoId } = req.params;
|
||||
const result = await memberGroupPhotoService.update(userToken, clubId, photoId, req.body);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[updateMemberGroupPhoto] error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to update group photo' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteMemberGroupPhoto = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, photoId } = req.params;
|
||||
const result = await memberGroupPhotoService.remove(userToken, clubId, photoId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[deleteMemberGroupPhoto] error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to delete group photo' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getMemberGroupPhotoImage = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, photoId } = req.params;
|
||||
const result = await memberGroupPhotoService.getImage(userToken, clubId, photoId);
|
||||
if (result.status === 200) {
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
return res.sendFile(result.imagePath);
|
||||
}
|
||||
return res.status(result.status).json({ success: false, error: result.error });
|
||||
} catch (error) {
|
||||
console.error('[getMemberGroupPhotoImage] error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to load group photo' });
|
||||
}
|
||||
};
|
||||
@@ -1,15 +1,15 @@
|
||||
import MemberNoteService from "../services/memberNoteService.js";
|
||||
import MemberNote from '../models/MemberNote.js';
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
const getMemberNotes = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { memberId } = req.params;
|
||||
const { clubId } = req.query;
|
||||
console.log('[getMemberNotes]', userToken, memberId, clubId);
|
||||
const notes = await MemberNoteService.getNotesForMember(userToken, clubId, memberId);
|
||||
res.status(200).json(notes);
|
||||
} catch (error) {
|
||||
console.log('[getMemberNotes] - Error: ', error);
|
||||
res.status(500).json({ error: 'systemerror' });
|
||||
}
|
||||
};
|
||||
@@ -18,12 +18,11 @@ const addMemberNote = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { memberId, content, clubId } = req.body;
|
||||
console.log('[addMemberNote]', userToken, memberId, content, clubId);
|
||||
await MemberNoteService.addNoteToMember(userToken, clubId, memberId, content);
|
||||
const notes = await MemberNoteService.getNotesForMember(userToken, clubId, memberId);
|
||||
res.status(201).json(notes);
|
||||
} catch (error) {
|
||||
console.log('[addMemberNote] - Error: ', error);
|
||||
console.error('[addMemberNote] - Error:', error);
|
||||
res.status(500).json({ error: 'systemerror' });
|
||||
}
|
||||
};
|
||||
@@ -33,13 +32,16 @@ const deleteMemberNote = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { noteId } = req.params;
|
||||
const { clubId } = req.body;
|
||||
console.log('[deleteMemberNote]', userToken, noteId, clubId);
|
||||
const memberId = await MemberNoteService.getMemberIdForNote(noteId); // Member ID ermitteln
|
||||
const note = await MemberNote.findByPk(noteId);
|
||||
if (!note) {
|
||||
return res.status(404).json({ error: 'notfound' });
|
||||
}
|
||||
const memberId = note.memberId;
|
||||
await MemberNoteService.deleteNoteForMember(userToken, clubId, noteId);
|
||||
const notes = await MemberNoteService.getNotesForMember(userToken, clubId, memberId);
|
||||
res.status(200).json(notes);
|
||||
} catch (error) {
|
||||
console.log('[deleteMemberNote] - Error: ', error);
|
||||
console.error('[deleteMemberNote] - Error:', error);
|
||||
res.status(500).json({ error: 'systemerror' });
|
||||
}
|
||||
};
|
||||
|
||||
55
backend/controllers/memberOrderController.js
Normal file
55
backend/controllers/memberOrderController.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import memberOrderService from '../services/memberOrderService.js';
|
||||
|
||||
const getMemberOrders = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await memberOrderService.getMemberOrders(userToken, clubId, memberId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[getMemberOrders] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Bestellungen konnten nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const createMemberOrder = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await memberOrderService.createMemberOrder(userToken, clubId, memberId, req.body || {});
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[createMemberOrder] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Bestellung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const updateMemberOrder = async (req, res) => {
|
||||
try {
|
||||
const { clubId, memberId, orderId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await memberOrderService.updateMemberOrder(userToken, clubId, memberId, orderId, req.body || {});
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[updateMemberOrder] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Bestellung konnte nicht aktualisiert werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
const getGlobalOrders = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const result = await memberOrderService.getGlobalOrders(userToken);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[getGlobalOrders] - Error:', error);
|
||||
res.status(500).json({ success: false, error: 'Bestellübersicht konnte nicht geladen werden.' });
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
getMemberOrders,
|
||||
createMemberOrder,
|
||||
updateMemberOrder,
|
||||
getGlobalOrders
|
||||
};
|
||||
51
backend/controllers/memberTransferConfigController.js
Normal file
51
backend/controllers/memberTransferConfigController.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import MemberTransferConfigService from '../services/memberTransferConfigService.js';
|
||||
|
||||
export const getConfig = async (req, res) => {
|
||||
try {
|
||||
const { id: clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
|
||||
const result = await MemberTransferConfigService.getConfig(userToken, clubId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[getConfig] Error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der Konfiguration'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const saveConfig = async (req, res) => {
|
||||
try {
|
||||
const { id: clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const configData = req.body;
|
||||
|
||||
const result = await MemberTransferConfigService.saveConfig(userToken, clubId, configData);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[saveConfig] Error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Speichern der Konfiguration'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteConfig = async (req, res) => {
|
||||
try {
|
||||
const { id: clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
|
||||
const result = await MemberTransferConfigService.deleteConfig(userToken, clubId);
|
||||
res.status(result.status).json(result.response);
|
||||
} catch (error) {
|
||||
console.error('[deleteConfig] Error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Löschen der Konfiguration'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
29
backend/controllers/mobileFeedbackController.js
Normal file
29
backend/controllers/mobileFeedbackController.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import User from '../models/User.js';
|
||||
import { sendMobileFeedbackEmail } from '../services/emailService.js';
|
||||
|
||||
const clean = (value, max = 4000) => String(value ?? '').trim().slice(0, max);
|
||||
|
||||
export const sendMobileFeedback = async (req, res) => {
|
||||
try {
|
||||
const message = clean(req.body?.message, 5000);
|
||||
if (!message) {
|
||||
return res.status(400).json({ error: 'message_required' });
|
||||
}
|
||||
|
||||
const user = req.user?.id ? await User.findByPk(req.user.id) : null;
|
||||
await sendMobileFeedbackEmail({
|
||||
message,
|
||||
screen: clean(req.body?.screen, 200),
|
||||
clubId: req.body?.clubId ?? null,
|
||||
appVersion: clean(req.body?.appVersion, 80),
|
||||
platform: clean(req.body?.platform, 80) || 'Android',
|
||||
backendBaseUrl: clean(req.body?.backendBaseUrl, 300),
|
||||
user: user ? { id: user.id, username: user.username, email: user.email } : { id: req.user?.id },
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[sendMobileFeedback] - error:', error);
|
||||
return res.status(500).json({ error: 'internalerror' });
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,9 @@
|
||||
import myTischtennisService from '../services/myTischtennisService.js';
|
||||
import myTischtennisSessionService from '../services/myTischtennisSessionService.js';
|
||||
import myTischtennisProxyService from '../services/myTischtennisProxyService.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
import axios from 'axios';
|
||||
import myTischtennisClient from '../clients/myTischtennisClient.js';
|
||||
|
||||
class MyTischtennisController {
|
||||
/**
|
||||
@@ -35,6 +39,49 @@ class MyTischtennisController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mytischtennis/login-form
|
||||
* Parsed login form data from mytischtennis.de
|
||||
*/
|
||||
async getLoginForm(req, res, next) {
|
||||
try {
|
||||
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
|
||||
const result = await myTischtennisClient.getLoginPage();
|
||||
|
||||
if (!result.success) {
|
||||
throw new HttpError('Login-Formular konnte nicht geladen werden', 502);
|
||||
}
|
||||
|
||||
const publicFields = (result.fields || [])
|
||||
.filter((field) => ['email', 'password'].includes(field.type) || field.name === 'email' || field.name === 'password')
|
||||
.map((field) => ({
|
||||
name: field.name,
|
||||
id: field.id,
|
||||
type: field.type,
|
||||
placeholder: field.placeholder || null,
|
||||
required: !!field.required,
|
||||
autocomplete: field.autocomplete || null,
|
||||
minlength: field.minlength ? Number(field.minlength) : null
|
||||
}));
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
form: {
|
||||
action: result.loginAction,
|
||||
fields: publicFields
|
||||
},
|
||||
captcha: {
|
||||
required: !!result.requiresCaptcha,
|
||||
siteKey: result.captchaSiteKey || null,
|
||||
puzzleEndpoint: result.captchaPuzzleEndpoint || null,
|
||||
solutionField: result.captchaSolutionField || 'captcha'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mytischtennis/account
|
||||
* Create or update myTischtennis account
|
||||
@@ -43,14 +90,16 @@ class MyTischtennisController {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { email, password, savePassword, userPassword } = req.body;
|
||||
const hasAutoUpdateRatings = Object.prototype.hasOwnProperty.call(req.body, 'autoUpdateRatings');
|
||||
const autoUpdateRatings = hasAutoUpdateRatings ? req.body.autoUpdateRatings : undefined;
|
||||
|
||||
if (!email) {
|
||||
throw new HttpError(400, 'E-Mail-Adresse erforderlich');
|
||||
throw new HttpError('E-Mail-Adresse erforderlich', 400);
|
||||
}
|
||||
|
||||
// Wenn ein Passwort gesetzt wird, muss das App-Passwort angegeben werden
|
||||
if (password && !userPassword) {
|
||||
throw new HttpError(400, 'App-Passwort erforderlich zum Setzen des myTischtennis-Passworts');
|
||||
throw new HttpError('App-Passwort erforderlich zum Setzen des myTischtennis-Passworts', 400);
|
||||
}
|
||||
|
||||
const account = await myTischtennisService.upsertAccount(
|
||||
@@ -58,6 +107,7 @@ class MyTischtennisController {
|
||||
email,
|
||||
password,
|
||||
savePassword || false,
|
||||
autoUpdateRatings,
|
||||
userPassword
|
||||
);
|
||||
|
||||
@@ -80,7 +130,7 @@ class MyTischtennisController {
|
||||
const deleted = await myTischtennisService.deleteAccount(userId);
|
||||
|
||||
if (!deleted) {
|
||||
throw new HttpError(404, 'Kein myTischtennis-Account gefunden');
|
||||
throw new HttpError('Kein myTischtennis-Account gefunden', 404);
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'myTischtennis-Account gelöscht' });
|
||||
@@ -127,6 +177,472 @@ class MyTischtennisController {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mytischtennis/update-history
|
||||
* Get update ratings history
|
||||
*/
|
||||
async getUpdateHistory(req, res, next) {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const history = await myTischtennisService.getUpdateHistory(userId);
|
||||
res.status(200).json({ history });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fetch logs for current user
|
||||
*/
|
||||
async getFetchLogs(req, res, next) {
|
||||
try {
|
||||
const { userid: userIdOrEmail } = req.headers;
|
||||
|
||||
// Convert email to userId if needed
|
||||
let userId = userIdOrEmail;
|
||||
if (isNaN(userIdOrEmail)) {
|
||||
const User = (await import('../models/User.js')).default;
|
||||
const user = await User.findOne({ where: { email: userIdOrEmail } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
userId = user.id;
|
||||
}
|
||||
|
||||
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
|
||||
const logs = await fetchLogService.getFetchLogs(userId, {
|
||||
limit: req.query.limit ? parseInt(req.query.limit) : 50,
|
||||
fetchType: req.query.type
|
||||
});
|
||||
|
||||
res.status(200).json({ logs });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get latest successful fetches for each type
|
||||
*/
|
||||
async getLatestFetches(req, res, next) {
|
||||
try {
|
||||
const { userid: userIdOrEmail } = req.headers;
|
||||
|
||||
// Convert email to userId if needed
|
||||
let userId = userIdOrEmail;
|
||||
if (isNaN(userIdOrEmail)) {
|
||||
const User = (await import('../models/User.js')).default;
|
||||
const user = await User.findOne({ where: { email: userIdOrEmail } });
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
userId = user.id;
|
||||
}
|
||||
|
||||
const fetchLogService = (await import('../services/myTischtennisFetchLogService.js')).default;
|
||||
const latestFetches = await fetchLogService.getLatestSuccessfulFetches(userId);
|
||||
|
||||
res.status(200).json({ latestFetches });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mytischtennis/login-page
|
||||
* Proxy für Login-Seite (für iframe)
|
||||
* Lädt die Login-Seite von mytischtennis.de und modifiziert sie, sodass Form-Submissions über unseren Proxy gehen
|
||||
* Authentifizierung ist optional - Token kann als Query-Parameter übergeben werden
|
||||
*/
|
||||
async getLoginPage(req, res, next) {
|
||||
try {
|
||||
// Versuche, userId aus Token zu bekommen (optional)
|
||||
let userId = null;
|
||||
const token = req.query.token || req.headers['authorization']?.split(' ')[1] || req.headers['authcode'];
|
||||
if (token) {
|
||||
try {
|
||||
const jwt = (await import('jsonwebtoken')).default;
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
userId = decoded.userId;
|
||||
} catch (err) {
|
||||
// Token ungültig - ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
// Speichere userId im Request für submitLogin
|
||||
req.userId = userId;
|
||||
|
||||
// Lade die Login-Seite von mytischtennis.de
|
||||
const response = await axios.get(`${myTischtennisProxyService.getOrigin()}/login?next=%2F`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
|
||||
},
|
||||
maxRedirects: 5,
|
||||
validateStatus: () => true // Akzeptiere alle Status-Codes
|
||||
});
|
||||
|
||||
// Setze Cookies aus der Response
|
||||
const setCookieHeaders = response.headers['set-cookie'];
|
||||
if (setCookieHeaders) {
|
||||
res.setHeader('Set-Cookie', setCookieHeaders);
|
||||
}
|
||||
|
||||
// Modifiziere HTML: Ändere Form-Action auf unseren Proxy
|
||||
let html = response.data;
|
||||
if (typeof html === 'string') {
|
||||
// Füge Token als Hidden-Input hinzu, damit submitLogin die userId bekommt
|
||||
const tokenInput = userId ? `<input type="hidden" name="__token" value="${token}" />` : '';
|
||||
|
||||
// Ersetze Form-Action URLs und füge Token-Input hinzu
|
||||
html = html.replace(
|
||||
/(<form[^>]*action="[^"]*\/login[^"]*"[^>]*>)/g,
|
||||
`$1${tokenInput}`
|
||||
);
|
||||
html = html.replace(
|
||||
/action="([^"]*\/login[^"]*)"/g,
|
||||
'action="/api/mytischtennis/login-submit"'
|
||||
);
|
||||
// Ersetze auch relative URLs
|
||||
html = html.replace(
|
||||
/action="\/login/g,
|
||||
'action="/api/mytischtennis/login-submit'
|
||||
);
|
||||
html = myTischtennisProxyService.rewriteContent(html);
|
||||
|
||||
// MyTischtennis bootet eine große React-App, die im Proxy-Kontext häufig mit
|
||||
// Runtime-Fehlern abstürzt ("Da ist etwas schiefgelaufen"). Für den iframe-Login
|
||||
// reicht die serverseitig gerenderte Form aus; deshalb Bootstrap-Skripte entfernen.
|
||||
html = html.replace(/<script\b[^>]*type=(?:"|')module(?:"|')[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
html = html.replace(/<script\b[^>]*src=(?:"|')[^"']*\/build\/[^"']*(?:"|')[^>]*>\s*<\/script>/gi, '');
|
||||
html = html.replace(/<link\b[^>]*rel=(?:"|')modulepreload(?:"|')[^>]*>/gi, '');
|
||||
}
|
||||
|
||||
// Setze Content-Type
|
||||
res.setHeader('Content-Type', response.headers['content-type'] || 'text/html; charset=utf-8');
|
||||
|
||||
// Sende den modifizierten HTML-Inhalt
|
||||
res.status(response.status).send(html);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Login-Seite:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mytischtennis/proxy/*
|
||||
* Same-Origin-Proxy für mytischtennis Build-/Font-/Captcha-Ressourcen
|
||||
*/
|
||||
async proxyRemote(req, res, next) {
|
||||
try {
|
||||
const proxyPath = req.params[0] || '';
|
||||
const queryString = new URLSearchParams(req.query || {}).toString();
|
||||
const targetUrl = `${myTischtennisProxyService.getOrigin()}/${proxyPath}${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const upstream = await axios.get(targetUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0',
|
||||
'Accept': req.headers.accept || '*/*',
|
||||
'Accept-Language': req.headers['accept-language'] || 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
...(req.headers.cookie ? { 'Cookie': req.headers.cookie } : {})
|
||||
},
|
||||
validateStatus: () => true
|
||||
});
|
||||
|
||||
// Wichtige Header durchreichen
|
||||
const passthroughHeaders = ['content-type', 'cache-control', 'etag', 'last-modified', 'expires'];
|
||||
for (const headerName of passthroughHeaders) {
|
||||
const value = upstream.headers[headerName];
|
||||
if (value) {
|
||||
res.setHeader(headerName, value);
|
||||
}
|
||||
}
|
||||
if (upstream.headers['set-cookie']) {
|
||||
res.setHeader('Set-Cookie', upstream.headers['set-cookie']);
|
||||
}
|
||||
|
||||
const contentType = String(upstream.headers['content-type'] || '').toLowerCase();
|
||||
const isTextLike = /(text\/|javascript|json|xml|svg)/.test(contentType);
|
||||
|
||||
if (isTextLike) {
|
||||
const asText = Buffer.from(upstream.data).toString('utf-8');
|
||||
const rewritten = myTischtennisProxyService.rewriteContent(asText);
|
||||
return res.status(upstream.status).send(rewritten);
|
||||
}
|
||||
|
||||
return res.status(upstream.status).send(upstream.data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Proxy von mytischtennis-Ressourcen:', error.message);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mytischtennis/login-submit
|
||||
* Proxy für Login-Form-Submission
|
||||
* Leitet den Login-Request durch, damit Cookies im Backend-Kontext bleiben
|
||||
* Authentifizierung ist optional - iframe kann keinen Token mitsenden
|
||||
*/
|
||||
async submitLogin(req, res, next) {
|
||||
try {
|
||||
// Versuche, userId aus Token zu bekommen (aus Query-Parameter oder Hidden-Input)
|
||||
let userId = null;
|
||||
const token = req.query.token || req.body.__token || req.headers['authorization']?.split(' ')[1] || req.headers['authcode'];
|
||||
if (token) {
|
||||
try {
|
||||
const jwt = (await import('jsonwebtoken')).default;
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
userId = decoded.userId;
|
||||
} catch (err) {
|
||||
// Token ungültig - ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
// Entferne __token aus req.body, damit es nicht an mytischtennis.de gesendet wird
|
||||
if (req.body.__token) {
|
||||
delete req.body.__token;
|
||||
}
|
||||
|
||||
// Hole Cookies aus dem Request (wird auch für CAPTCHA-Fallback benötigt)
|
||||
const cookies = req.headers.cookie || '';
|
||||
|
||||
// Normalisiere Payload
|
||||
const payload = { ...(req.body || {}) };
|
||||
const mask = (v) => (typeof v === 'string' && v.length > 12 ? `${v.slice(0, 12)}...(${v.length})` : v);
|
||||
|
||||
// Falls captcha im Browser-Kontext nicht gesetzt wurde, versuche serverseitigen Fallback
|
||||
if (!payload.captcha) {
|
||||
try {
|
||||
const loginPageResponse = await axios.get('https://www.mytischtennis.de/login?next=%2F', {
|
||||
headers: {
|
||||
'Cookie': cookies,
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
'Referer': 'https://www.mytischtennis.de/'
|
||||
},
|
||||
validateStatus: () => true
|
||||
});
|
||||
|
||||
const html = typeof loginPageResponse.data === 'string' ? loginPageResponse.data : '';
|
||||
const siteKeyMatch = html.match(/data-sitekey=(?:"([^"]+)"|'([^']+)')/i);
|
||||
const puzzleEndpointMatch = html.match(/data-puzzle-endpoint=(?:"([^"]+)"|'([^']+)')/i);
|
||||
const siteKey = siteKeyMatch ? (siteKeyMatch[1] || siteKeyMatch[2]) : null;
|
||||
const puzzleEndpoint = puzzleEndpointMatch ? (puzzleEndpointMatch[1] || puzzleEndpointMatch[2]) : null;
|
||||
|
||||
if (siteKey && puzzleEndpoint) {
|
||||
const puzzleResponse = await axios.get(`${puzzleEndpoint}?sitekey=${encodeURIComponent(siteKey)}`, {
|
||||
headers: {
|
||||
'Cookie': cookies,
|
||||
'Accept': '*/*',
|
||||
'Origin': 'https://www.mytischtennis.de',
|
||||
'Referer': 'https://www.mytischtennis.de/'
|
||||
},
|
||||
validateStatus: () => true
|
||||
});
|
||||
|
||||
if (puzzleResponse.status === 200 && typeof puzzleResponse.data === 'string' && puzzleResponse.data.trim()) {
|
||||
payload.captcha = puzzleResponse.data.trim();
|
||||
payload.captcha_clicked = 'true';
|
||||
}
|
||||
}
|
||||
} catch (captchaFallbackError) {
|
||||
console.warn('[submitLogin] CAPTCHA-Fallback fehlgeschlagen:', captchaFallbackError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn captcha vorhanden ist, als bestätigt markieren
|
||||
if (payload.captcha && !payload.captcha_clicked) {
|
||||
payload.captcha_clicked = 'true';
|
||||
}
|
||||
|
||||
console.log('[submitLogin] Incoming payload fields:', {
|
||||
keys: Object.keys(payload),
|
||||
hasEmail: !!payload.email,
|
||||
hasPassword: !!payload.password,
|
||||
xsrf: mask(payload.xsrf),
|
||||
captchaClicked: payload.captcha_clicked,
|
||||
captcha: mask(payload.captcha)
|
||||
});
|
||||
|
||||
// Form-Daten sauber als x-www-form-urlencoded serialisieren
|
||||
const formData = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
formData.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
// Leite den Login-Request an mytischtennis.de weiter
|
||||
const response = await axios.post(
|
||||
'https://www.mytischtennis.de/login?next=%2F&_data=routes%2F_auth%2B%2Flogin',
|
||||
formData.toString(),
|
||||
{
|
||||
headers: {
|
||||
'Cookie': cookies,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Accept': '*/*',
|
||||
'Referer': 'https://www.mytischtennis.de/login?next=%2F'
|
||||
},
|
||||
maxRedirects: 0,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[submitLogin] Upstream response:', {
|
||||
status: response.status,
|
||||
hasSetCookie: Array.isArray(response.headers['set-cookie']) && response.headers['set-cookie'].length > 0,
|
||||
bodyPreview: typeof response.data === 'string'
|
||||
? response.data.slice(0, 220)
|
||||
: JSON.stringify(response.data || {}).slice(0, 220)
|
||||
});
|
||||
|
||||
// Falls CAPTCHA-Bestätigung im Proxy-Flow fehlschlägt:
|
||||
// Fallback auf echten Browser-Login (Playwright), dann Session direkt speichern.
|
||||
const upstreamBody = typeof response.data === 'string' ? response.data : JSON.stringify(response.data || {});
|
||||
const isCaptchaFailure = response.status === 400
|
||||
&& (upstreamBody.includes('Captcha-Bestätigung fehlgeschlagen') || upstreamBody.includes('Captcha-Bestätigung ist erforderlich'));
|
||||
|
||||
if (isCaptchaFailure && userId && payload.email && payload.password) {
|
||||
console.log('[submitLogin] CAPTCHA-Fehler erkannt, starte Playwright-Fallback...');
|
||||
const browserLogin = await myTischtennisClient.loginWithBrowserAutomation(payload.email, payload.password);
|
||||
|
||||
if (browserLogin.success && browserLogin.cookie) {
|
||||
await myTischtennisSessionService.saveSessionFromCookie(userId, browserLogin.cookie);
|
||||
return res.status(200).send(
|
||||
'<!doctype html><html><body><p>Login erfolgreich. Fenster kann geschlossen werden.</p></body></html>'
|
||||
);
|
||||
}
|
||||
|
||||
console.warn('[submitLogin] Playwright-Fallback fehlgeschlagen:', browserLogin.error);
|
||||
}
|
||||
|
||||
// Setze Cookies aus der Response
|
||||
const setCookieHeaders = response.headers['set-cookie'];
|
||||
if (setCookieHeaders) {
|
||||
res.setHeader('Set-Cookie', setCookieHeaders);
|
||||
}
|
||||
|
||||
// Setze andere relevante Headers
|
||||
if (response.headers['content-type']) {
|
||||
res.setHeader('Content-Type', response.headers['content-type']);
|
||||
}
|
||||
if (response.headers['location']) {
|
||||
res.setHeader('Location', response.headers['location']);
|
||||
}
|
||||
|
||||
// Prüfe, ob Login erfolgreich war (durch Prüfung der Cookies)
|
||||
const authCookie = setCookieHeaders?.find(cookie => cookie.startsWith('sb-10-auth-token='));
|
||||
if (authCookie && userId) {
|
||||
// Login erfolgreich - speichere Session (nur wenn userId vorhanden)
|
||||
await myTischtennisSessionService.saveSessionFromCookie(userId, authCookie);
|
||||
}
|
||||
|
||||
// Sende Response weiter
|
||||
res.status(response.status).send(response.data);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Login-Submit:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mytischtennis/extract-session
|
||||
* Extrahiere Session nach Login im iframe
|
||||
* Versucht, die Session-Daten aus den Cookies zu extrahieren
|
||||
* Authentifizierung ist optional - iframe kann keinen Token mitsenden
|
||||
*/
|
||||
async extractSession(req, res, next) {
|
||||
try {
|
||||
// Versuche, userId aus Token zu bekommen (optional)
|
||||
let userId = req.user?.id;
|
||||
|
||||
// Falls kein Token vorhanden, versuche userId aus Account zu bekommen (falls E-Mail bekannt)
|
||||
if (!userId) {
|
||||
// Kann nicht ohne Authentifizierung arbeiten - Session kann nicht gespeichert werden
|
||||
return res.status(401).json({
|
||||
error: 'Authentifizierung erforderlich zum Speichern der Session'
|
||||
});
|
||||
}
|
||||
|
||||
// Hole die Cookies aus dem Request
|
||||
const cookies = req.headers.cookie || '';
|
||||
|
||||
// Versuche, die Session zu verifizieren, indem wir einen Request mit den Cookies machen
|
||||
const response = await axios.get('https://www.mytischtennis.de/?_data=root', {
|
||||
headers: {
|
||||
'Cookie': cookies,
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
validateStatus: () => true
|
||||
});
|
||||
|
||||
// Prüfe, ob wir eingeloggt sind (durch Prüfung der Response)
|
||||
if (response.status === 200 && response.data?.userProfile) {
|
||||
// Session erfolgreich - speichere die Daten
|
||||
const account = await myTischtennisService.getAccount(userId);
|
||||
if (!account) {
|
||||
throw new HttpError('Kein myTischtennis-Account verknüpft', 404);
|
||||
}
|
||||
|
||||
// Extrahiere Cookie-String
|
||||
const cookieString = cookies.split(';').find(c => c.trim().startsWith('sb-10-auth-token='));
|
||||
if (!cookieString) {
|
||||
throw new HttpError('Kein Auth-Token in Cookies gefunden', 400);
|
||||
}
|
||||
|
||||
// Parse Token aus Cookie
|
||||
const tokenMatch = cookieString.match(/sb-10-auth-token=base64-([^;]+)/);
|
||||
if (!tokenMatch) {
|
||||
throw new HttpError('Token-Format ungültig', 400);
|
||||
}
|
||||
|
||||
const base64Token = tokenMatch[1];
|
||||
const decodedToken = Buffer.from(base64Token, 'base64').toString('utf-8');
|
||||
const tokenData = JSON.parse(decodedToken);
|
||||
|
||||
// Aktualisiere Account mit Session-Daten
|
||||
const MyTischtennis = (await import('../models/MyTischtennis.js')).default;
|
||||
const myTischtennisAccount = await MyTischtennis.findOne({ where: { userId } });
|
||||
|
||||
if (myTischtennisAccount) {
|
||||
myTischtennisAccount.accessToken = tokenData.access_token;
|
||||
myTischtennisAccount.refreshToken = tokenData.refresh_token;
|
||||
myTischtennisAccount.expiresAt = tokenData.expires_at;
|
||||
myTischtennisAccount.cookie = cookieString.trim();
|
||||
myTischtennisAccount.userData = tokenData.user;
|
||||
myTischtennisAccount.lastLoginSuccess = new Date();
|
||||
myTischtennisAccount.lastLoginAttempt = new Date();
|
||||
|
||||
// Hole Club-Informationen
|
||||
const myTischtennisClient = (await import('../clients/myTischtennisClient.js')).default;
|
||||
const profileResult = await myTischtennisClient.getUserProfile(cookieString.trim());
|
||||
if (profileResult.success) {
|
||||
myTischtennisAccount.clubId = profileResult.clubId;
|
||||
myTischtennisAccount.clubName = profileResult.clubName;
|
||||
myTischtennisAccount.fedNickname = profileResult.fedNickname;
|
||||
}
|
||||
|
||||
await myTischtennisAccount.save();
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: 'Session erfolgreich extrahiert und gespeichert'
|
||||
});
|
||||
} else {
|
||||
throw new HttpError('Nicht eingeloggt oder Session ungültig', 401);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Extrahieren der Session:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MyTischtennisController();
|
||||
|
||||
719
backend/controllers/myTischtennisUrlController.js
Normal file
719
backend/controllers/myTischtennisUrlController.js
Normal file
@@ -0,0 +1,719 @@
|
||||
import myTischtennisUrlParserService from '../services/myTischtennisUrlParserService.js';
|
||||
import myTischtennisService from '../services/myTischtennisService.js';
|
||||
import MemberService from '../services/memberService.js';
|
||||
import autoFetchMatchResultsService from '../services/autoFetchMatchResultsService.js';
|
||||
import apiLogService from '../services/apiLogService.js';
|
||||
import axios from 'axios';
|
||||
import ClubTeam from '../models/ClubTeam.js';
|
||||
import League from '../models/League.js';
|
||||
import Season from '../models/Season.js';
|
||||
import User from '../models/User.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const teamDataFetchJobs = new Map();
|
||||
const TEAM_DATA_JOB_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
const cleanupFinishedTeamDataJobs = () => {
|
||||
const now = Date.now();
|
||||
for (const [jobId, job] of teamDataFetchJobs.entries()) {
|
||||
if (job.finishedAt && (now - job.finishedAt) > TEAM_DATA_JOB_TTL_MS) {
|
||||
teamDataFetchJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
class MyTischtennisUrlController {
|
||||
async startFetchTeamDataJob(req, res, next) {
|
||||
try {
|
||||
const { clubTeamId } = req.body || {};
|
||||
if (!clubTeamId) {
|
||||
throw new HttpError('clubTeamId is required', 400);
|
||||
}
|
||||
|
||||
cleanupFinishedTeamDataJobs();
|
||||
|
||||
const jobId = randomUUID();
|
||||
const startedAt = Date.now();
|
||||
teamDataFetchJobs.set(jobId, {
|
||||
jobId,
|
||||
status: 'queued',
|
||||
startedAt,
|
||||
updatedAt: startedAt,
|
||||
finishedAt: null,
|
||||
clubTeamId,
|
||||
result: null,
|
||||
error: null
|
||||
});
|
||||
|
||||
const authHeaders = {
|
||||
authcode: req.headers.authcode,
|
||||
userid: req.headers.userid
|
||||
};
|
||||
const internalPort = process.env.PORT || 3050;
|
||||
const internalUrl = `http://127.0.0.1:${internalPort}/api/mytischtennis/fetch-team-data`;
|
||||
|
||||
// Background execution; response is returned immediately.
|
||||
(async () => {
|
||||
const job = teamDataFetchJobs.get(jobId);
|
||||
if (!job) return;
|
||||
job.status = 'running';
|
||||
job.updatedAt = Date.now();
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
internalUrl,
|
||||
{ clubTeamId },
|
||||
{
|
||||
headers: authHeaders,
|
||||
timeout: 10 * 60 * 1000,
|
||||
validateStatus: () => true
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status >= 200 && response.status < 300 && response.data?.success) {
|
||||
job.status = 'completed';
|
||||
job.result = response.data;
|
||||
job.error = null;
|
||||
} else {
|
||||
job.status = 'failed';
|
||||
job.result = null;
|
||||
job.error = response.data?.error || response.data?.message || `Job failed with status ${response.status}`;
|
||||
}
|
||||
} catch (error) {
|
||||
job.status = 'failed';
|
||||
job.result = null;
|
||||
job.error = error?.message || String(error);
|
||||
} finally {
|
||||
job.updatedAt = Date.now();
|
||||
job.finishedAt = Date.now();
|
||||
}
|
||||
})();
|
||||
|
||||
return res.status(202).json({
|
||||
success: true,
|
||||
jobId,
|
||||
status: 'queued'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getFetchTeamDataJobStatus(req, res, next) {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
cleanupFinishedTeamDataJobs();
|
||||
const job = teamDataFetchJobs.get(jobId);
|
||||
|
||||
if (!job) {
|
||||
throw new HttpError('Job not found', 404);
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
job: {
|
||||
jobId: job.jobId,
|
||||
status: job.status,
|
||||
startedAt: job.startedAt,
|
||||
updatedAt: job.updatedAt,
|
||||
finishedAt: job.finishedAt,
|
||||
clubTeamId: job.clubTeamId,
|
||||
result: job.result,
|
||||
error: job.error
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse myTischtennis URL and return configuration data
|
||||
* POST /api/mytischtennis/parse-url
|
||||
* Body: { url: string }
|
||||
*/
|
||||
async parseUrl(req, res, next) {
|
||||
try {
|
||||
const { url } = req.body;
|
||||
|
||||
if (!url) {
|
||||
throw new HttpError('URL is required', 400);
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
if (!myTischtennisUrlParserService.isValidTeamUrl(url)) {
|
||||
throw new HttpError('Invalid myTischtennis URL format', 400);
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
const parsedData = myTischtennisUrlParserService.parseUrl(url);
|
||||
|
||||
// Try to fetch additional data if user is authenticated
|
||||
const userIdOrEmail = req.headers.userid;
|
||||
let completeData = parsedData;
|
||||
|
||||
if (userIdOrEmail) {
|
||||
// Get actual user ID
|
||||
let userId = userIdOrEmail;
|
||||
if (isNaN(userIdOrEmail)) {
|
||||
const user = await User.findOne({ where: { email: userIdOrEmail } });
|
||||
if (user) userId = user.id;
|
||||
}
|
||||
|
||||
try {
|
||||
const account = await myTischtennisService.getAccount(userId);
|
||||
|
||||
if (account && account.accessToken) {
|
||||
completeData = await myTischtennisUrlParserService.fetchTeamData(
|
||||
parsedData,
|
||||
account.cookie,
|
||||
account.accessToken
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with parsed data only
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: completeData
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure team from myTischtennis URL
|
||||
* POST /api/mytischtennis/configure-team
|
||||
* Body: { url: string, clubTeamId: number, createLeague?: boolean, createSeason?: boolean }
|
||||
*/
|
||||
async configureTeam(req, res, next) {
|
||||
try {
|
||||
const { url, clubTeamId, createLeague, createSeason } = req.body;
|
||||
const userIdOrEmail = req.headers.userid;
|
||||
|
||||
if (!url || !clubTeamId) {
|
||||
throw new HttpError('URL and clubTeamId are required', 400);
|
||||
}
|
||||
|
||||
// Get actual user ID
|
||||
let userId = userIdOrEmail;
|
||||
if (isNaN(userIdOrEmail)) {
|
||||
const user = await User.findOne({ where: { email: userIdOrEmail } });
|
||||
if (!user) {
|
||||
throw new HttpError('User not found', 404);
|
||||
}
|
||||
userId = user.id;
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
const parsedData = myTischtennisUrlParserService.parseUrl(url);
|
||||
|
||||
// Try to fetch additional data
|
||||
let completeData = parsedData;
|
||||
const account = await myTischtennisService.getAccount(userId);
|
||||
|
||||
if (account && account.accessToken) {
|
||||
try {
|
||||
completeData = await myTischtennisUrlParserService.fetchTeamData(
|
||||
parsedData,
|
||||
account.cookie,
|
||||
account.accessToken
|
||||
);
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Find or create season
|
||||
let season = await Season.findOne({
|
||||
where: { season: completeData.season }
|
||||
});
|
||||
|
||||
if (!season && createSeason) {
|
||||
season = await Season.create({
|
||||
season: completeData.season
|
||||
});
|
||||
}
|
||||
|
||||
if (!season) {
|
||||
throw new HttpError(`Season ${completeData.season} not found. Set createSeason=true to create it.`, 404);
|
||||
}
|
||||
|
||||
// Find or create league
|
||||
const team = await ClubTeam.findByPk(clubTeamId);
|
||||
if (!team) {
|
||||
throw new HttpError('Club team not found', 404);
|
||||
}
|
||||
|
||||
let league;
|
||||
|
||||
// First, try to find existing league by name and season
|
||||
const leagueName = completeData.leagueName || completeData.groupname;
|
||||
league = await League.findOne({
|
||||
where: {
|
||||
name: leagueName,
|
||||
seasonId: season.id,
|
||||
clubId: team.clubId
|
||||
}
|
||||
});
|
||||
|
||||
if (league) {
|
||||
devLog(`Found existing league: ${league.name} (ID: ${league.id})`);
|
||||
// Update myTischtennis fields
|
||||
await league.update({
|
||||
myTischtennisGroupId: completeData.groupId,
|
||||
association: completeData.association,
|
||||
groupname: completeData.groupname
|
||||
});
|
||||
} else if (team.leagueId) {
|
||||
// Team has a league assigned, update it
|
||||
league = await League.findByPk(team.leagueId);
|
||||
|
||||
if (league) {
|
||||
devLog(`Updating team's existing league: ${league.name} (ID: ${league.id})`);
|
||||
await league.update({
|
||||
name: leagueName,
|
||||
myTischtennisGroupId: completeData.groupId,
|
||||
association: completeData.association,
|
||||
groupname: completeData.groupname
|
||||
});
|
||||
}
|
||||
} else if (createLeague) {
|
||||
// Create new league
|
||||
devLog(`Creating new league: ${leagueName}`);
|
||||
league = await League.create({
|
||||
name: leagueName,
|
||||
seasonId: season.id,
|
||||
clubId: team.clubId,
|
||||
myTischtennisGroupId: completeData.groupId,
|
||||
association: completeData.association,
|
||||
groupname: completeData.groupname
|
||||
});
|
||||
} else {
|
||||
throw new HttpError('League not found and team has no league assigned. Set createLeague=true to create one.', 400);
|
||||
}
|
||||
|
||||
// Update team
|
||||
await team.update({
|
||||
myTischtennisTeamId: completeData.teamId,
|
||||
leagueId: league.id,
|
||||
seasonId: season.id
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Team configured successfully',
|
||||
data: {
|
||||
team: {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
myTischtennisTeamId: completeData.teamId
|
||||
},
|
||||
league: {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
myTischtennisGroupId: completeData.groupId,
|
||||
association: completeData.association,
|
||||
groupname: completeData.groupname
|
||||
},
|
||||
season: {
|
||||
id: season.id,
|
||||
name: season.season
|
||||
},
|
||||
parsedData: completeData
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually fetch team data from myTischtennis
|
||||
* POST /api/mytischtennis/fetch-team-data
|
||||
* Body: { clubTeamId: number }
|
||||
*/
|
||||
async fetchTeamData(req, res, next) {
|
||||
// Define outside of try/catch so catch has access
|
||||
let account = null;
|
||||
let team = null;
|
||||
let myTischtennisUrl = null;
|
||||
let requestStartTime = null;
|
||||
try {
|
||||
const { clubTeamId } = req.body;
|
||||
const userIdOrEmail = req.headers.userid;
|
||||
|
||||
if (!clubTeamId) {
|
||||
throw new HttpError('clubTeamId is required', 400);
|
||||
}
|
||||
|
||||
if (!userIdOrEmail) {
|
||||
throw new HttpError('User-ID fehlt. Bitte melden Sie sich an.', 401);
|
||||
}
|
||||
|
||||
// Get actual user ID (userid header might be email address)
|
||||
let userId = userIdOrEmail;
|
||||
if (isNaN(userIdOrEmail)) {
|
||||
// It's an email, find the user
|
||||
const user = await User.findOne({ where: { email: userIdOrEmail } });
|
||||
if (!user) {
|
||||
throw new HttpError('User not found', 404);
|
||||
}
|
||||
userId = user.id;
|
||||
}
|
||||
|
||||
// Get myTischtennis session (similar to memberService.updateRatingsFromMyTischtennis)
|
||||
let session;
|
||||
|
||||
try {
|
||||
session = await myTischtennisService.getSession(userId);
|
||||
} catch (sessionError) {
|
||||
// Versuche automatischen Login mit gespeicherten Credentials
|
||||
try {
|
||||
// Check if account exists and has password
|
||||
const accountCheck = await myTischtennisService.getAccount(userId);
|
||||
if (!accountCheck) {
|
||||
throw new Error('MyTischtennis-Account nicht gefunden');
|
||||
}
|
||||
|
||||
if (!accountCheck.encryptedPassword) {
|
||||
throw new Error('Kein Passwort gespeichert. Bitte melden Sie sich in den MyTischtennis-Einstellungen an und speichern Sie Ihr Passwort.');
|
||||
}
|
||||
|
||||
await myTischtennisService.verifyLogin(userId);
|
||||
session = await myTischtennisService.getSession(userId);
|
||||
} catch (loginError) {
|
||||
const errorMessage = loginError.message || 'Automatischer Login fehlgeschlagen';
|
||||
throw new HttpError(`MyTischtennis-Session abgelaufen und automatischer Login fehlgeschlagen: ${errorMessage}. Bitte melden Sie sich in den MyTischtennis-Einstellungen an.`, 401);
|
||||
}
|
||||
}
|
||||
|
||||
// Get account data (for clubId, etc.)
|
||||
account = await myTischtennisService.getAccount(userId);
|
||||
|
||||
if (!account) {
|
||||
throw new HttpError('MyTischtennis-Account nicht verknüpft. Bitte verknüpfen Sie Ihren Account in den MyTischtennis-Einstellungen.', 404);
|
||||
}
|
||||
|
||||
|
||||
// Get team with league and season
|
||||
team = await ClubTeam.findByPk(clubTeamId, {
|
||||
include: [
|
||||
{
|
||||
model: League,
|
||||
as: 'league',
|
||||
include: [
|
||||
{
|
||||
model: Season,
|
||||
as: 'season'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new HttpError(`Team mit ID ${clubTeamId} nicht gefunden`, 404);
|
||||
}
|
||||
|
||||
// Verbesserte Validierung mit detaillierten Fehlermeldungen
|
||||
if (!team.myTischtennisTeamId) {
|
||||
throw new HttpError(`Team "${team.name}" (interne ID: ${team.id}) ist nicht für myTischtennis konfiguriert: myTischtennisTeamId fehlt. Bitte konfigurieren Sie das Team zuerst über die MyTischtennis-URL.`, 400);
|
||||
}
|
||||
|
||||
if (!team.league) {
|
||||
throw new HttpError('Team ist keiner Liga zugeordnet. Bitte ordnen Sie das Team einer Liga zu.', 400);
|
||||
}
|
||||
|
||||
if (!team.league.myTischtennisGroupId) {
|
||||
throw new HttpError('Liga ist nicht für myTischtennis konfiguriert: myTischtennisGroupId fehlt. Bitte konfigurieren Sie die Liga zuerst über die MyTischtennis-URL.', 400);
|
||||
}
|
||||
|
||||
// Validate season before proceeding
|
||||
if (!team.league.season || !team.league.season.season) {
|
||||
throw new HttpError('Liga ist keiner Saison zugeordnet. Bitte ordnen Sie die Liga einer Saison zu.', 400);
|
||||
}
|
||||
|
||||
// Build the URL that will be used - do this early so we can log it even if errors occur
|
||||
const seasonFull = team.league.season.season;
|
||||
const seasonParts = seasonFull.split('/');
|
||||
const seasonShort = seasonParts.length === 2
|
||||
? `${seasonParts[0].slice(-2)}/${seasonParts[1].slice(-2)}`
|
||||
: seasonFull;
|
||||
const seasonStr = seasonShort.replace('/', '--');
|
||||
const teamnameEncoded = encodeURIComponent(team.name.replace(/\s/g, '_'));
|
||||
myTischtennisUrl = `https://www.mytischtennis.de/click-tt/${team.league.association}/${seasonStr}/ligen/${team.league.groupname}/gruppe/${team.league.myTischtennisGroupId}/mannschaft/${team.myTischtennisTeamId}/${teamnameEncoded}/spielerbilanzen/gesamt`;
|
||||
|
||||
// Log the request to myTischtennis BEFORE making the call
|
||||
// This ensures we always see what WILL BE sent, even if the call fails
|
||||
requestStartTime = Date.now();
|
||||
try {
|
||||
await apiLogService.logRequest({
|
||||
userId: account.userId,
|
||||
method: 'GET',
|
||||
path: myTischtennisUrl.replace('https://www.mytischtennis.de', ''),
|
||||
statusCode: null,
|
||||
requestBody: JSON.stringify({
|
||||
url: myTischtennisUrl,
|
||||
myTischtennisTeamId: team.myTischtennisTeamId,
|
||||
clubTeamId: team.id,
|
||||
teamName: team.name,
|
||||
leagueName: team.league.name,
|
||||
association: team.league.association,
|
||||
groupId: team.league.myTischtennisGroupId,
|
||||
groupname: team.league.groupname,
|
||||
season: seasonFull
|
||||
}),
|
||||
responseBody: null,
|
||||
executionTime: null,
|
||||
errorMessage: 'Request wird ausgeführt...',
|
||||
logType: 'api_request',
|
||||
schedulerJobType: 'mytischtennis_fetch'
|
||||
});
|
||||
} catch (logError) {
|
||||
// Silent fail - logging errors shouldn't break the request
|
||||
}
|
||||
|
||||
// Fetch data for this specific team
|
||||
// Note: fetchTeamResults will also log and update with actual response
|
||||
const result = await autoFetchMatchResultsService.fetchTeamResults(
|
||||
{
|
||||
userId: account.userId,
|
||||
email: account.email,
|
||||
cookie: session.cookie,
|
||||
accessToken: session.accessToken,
|
||||
expiresAt: session.expiresAt,
|
||||
getPassword: () => null // Not needed for manual fetch
|
||||
},
|
||||
team
|
||||
);
|
||||
|
||||
// Also fetch and update league table data
|
||||
let tableUpdateResult = null;
|
||||
try {
|
||||
await autoFetchMatchResultsService.fetchAndUpdateLeagueTable(account.userId, team.league.id);
|
||||
tableUpdateResult = 'League table updated successfully';
|
||||
} catch (error) {
|
||||
tableUpdateResult = 'League table update failed: ' + error.message;
|
||||
// Don't fail the entire request if table update fails
|
||||
}
|
||||
|
||||
// Additionally update (Q)TTR ratings for the club
|
||||
let ratingsUpdate = null;
|
||||
try {
|
||||
// Use already resolved userId instead of authcode to avoid header dependency
|
||||
const ratingsResult = await MemberService.updateRatingsFromMyTischtennisByUserId(userId, team.clubId);
|
||||
ratingsUpdate = ratingsResult?.response?.message || `Ratings update status: ${ratingsResult?.status}`;
|
||||
} catch (ratingsErr) {
|
||||
ratingsUpdate = 'Ratings update failed: ' + (ratingsErr.message || String(ratingsErr));
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${result.fetchedCount} Datensätze abgerufen und verarbeitet`,
|
||||
data: {
|
||||
fetchedCount: result.fetchedCount,
|
||||
teamName: team.name,
|
||||
tableUpdate: tableUpdateResult,
|
||||
ratingsUpdate
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
// Update log with error information if we got far enough to build the URL
|
||||
if (myTischtennisUrl && account && team) {
|
||||
const requestExecutionTime = requestStartTime ? (Date.now() - requestStartTime) : null;
|
||||
try {
|
||||
await apiLogService.logRequest({
|
||||
userId: account.userId,
|
||||
method: 'GET',
|
||||
path: myTischtennisUrl.replace('https://www.mytischtennis.de', ''),
|
||||
statusCode: 0,
|
||||
requestBody: JSON.stringify({
|
||||
url: myTischtennisUrl,
|
||||
myTischtennisTeamId: team.myTischtennisTeamId,
|
||||
clubTeamId: team.id,
|
||||
teamName: team.name,
|
||||
leagueName: team.league?.name,
|
||||
association: team.league?.association,
|
||||
groupname: team.league?.groupname,
|
||||
groupId: team.league?.myTischtennisGroupId
|
||||
}),
|
||||
responseBody: null,
|
||||
executionTime: requestExecutionTime,
|
||||
errorMessage: error.message || String(error),
|
||||
logType: 'api_request',
|
||||
schedulerJobType: 'mytischtennis_fetch'
|
||||
});
|
||||
} catch (logError) {
|
||||
// Silent fail - logging errors shouldn't break the request
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize HTTP status code (guard against strings)
|
||||
const rawCode = error && (error.statusCode != null ? error.statusCode : error.status);
|
||||
const parsed = Number(rawCode);
|
||||
const status = Number.isInteger(parsed) && parsed >= 100 && parsed <= 599 ? parsed : 500;
|
||||
const debug = {
|
||||
message: error.message || String(error),
|
||||
name: error.name,
|
||||
stack: (process.env.NODE_ENV === 'dev' || process.env.NODE_ENV === 'development') ? (error.stack || null) : undefined,
|
||||
team: team ? { id: team.id, name: team.name } : null,
|
||||
league: team && team.league ? { id: team.league.id, name: team.league.name, association: team.league.association, groupId: team.league.myTischtennisGroupId, groupname: team.league.groupname } : null,
|
||||
url: typeof myTischtennisUrl !== 'undefined' ? myTischtennisUrl : null
|
||||
};
|
||||
try {
|
||||
if (!res.headersSent) {
|
||||
// Spezieller Fall: myTischtennis-Reauth nötig → nicht 401 an FE senden, um App-Logout zu vermeiden
|
||||
const isMyTischtennisAuthIssue = status === 401 && /MyTischtennis-Session abgelaufen|Automatischer Login fehlgeschlagen|Passwort gespeichert/i.test(debug.message || '');
|
||||
if (isMyTischtennisAuthIssue) {
|
||||
return res.status(200).json({ success: false, error: debug.message, debug, needsMyTischtennisReauth: true });
|
||||
}
|
||||
res.status(status).json({ success: false, error: debug.message, debug });
|
||||
}
|
||||
} catch (writeErr) {
|
||||
// Fallback, falls Headers schon gesendet wurden
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[fetchTeamData] Response write failed:', writeErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get myTischtennis URL for a team
|
||||
* GET /api/mytischtennis/team-url/:teamId
|
||||
*/
|
||||
async getTeamUrl(req, res, next) {
|
||||
try {
|
||||
const { teamId } = req.params;
|
||||
|
||||
const team = await ClubTeam.findByPk(teamId, {
|
||||
include: [
|
||||
{
|
||||
model: League,
|
||||
as: 'league',
|
||||
include: [
|
||||
{
|
||||
model: Season,
|
||||
as: 'season'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new HttpError('Team not found', 404);
|
||||
}
|
||||
|
||||
if (!team.myTischtennisTeamId || !team.league || !team.league.myTischtennisGroupId) {
|
||||
throw new HttpError('Team is not configured for myTischtennis', 400);
|
||||
}
|
||||
|
||||
const url = myTischtennisUrlParserService.buildUrl({
|
||||
association: team.league.association,
|
||||
season: team.league.season?.season,
|
||||
groupname: team.league.groupname,
|
||||
groupId: team.league.myTischtennisGroupId,
|
||||
teamId: team.myTischtennisTeamId,
|
||||
teamname: team.name
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
url
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure league from myTischtennis table URL
|
||||
* POST /api/mytischtennis/configure-league
|
||||
* Body: { url: string, createSeason?: boolean }
|
||||
*/
|
||||
async configureLeague(req, res, next) {
|
||||
try {
|
||||
const { url, createSeason } = req.body;
|
||||
const userIdOrEmail = req.headers.userid;
|
||||
|
||||
if (!url) {
|
||||
throw new HttpError('URL is required', 400);
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
const parsedData = myTischtennisUrlParserService.parseUrl(url);
|
||||
|
||||
if (parsedData.urlType !== 'table') {
|
||||
throw new HttpError('URL must be a table URL (not a team URL)', 400);
|
||||
}
|
||||
|
||||
// Find or create season
|
||||
let season = await Season.findOne({
|
||||
where: { season: parsedData.season }
|
||||
});
|
||||
|
||||
if (!season && createSeason) {
|
||||
season = await Season.create({
|
||||
season: parsedData.season,
|
||||
startDate: new Date(),
|
||||
endDate: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 Jahr später
|
||||
});
|
||||
}
|
||||
|
||||
// Find or create league
|
||||
let league = await League.findOne({
|
||||
where: {
|
||||
myTischtennisGroupId: parsedData.groupId,
|
||||
association: parsedData.association
|
||||
}
|
||||
});
|
||||
|
||||
if (!league) {
|
||||
league = await League.create({
|
||||
name: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version
|
||||
myTischtennisGroupId: parsedData.groupId,
|
||||
association: parsedData.association,
|
||||
groupname: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version
|
||||
seasonId: season?.id || null
|
||||
});
|
||||
} else {
|
||||
// Update existing league - aber nur wenn es sich wirklich geändert hat
|
||||
if (league.name !== parsedData.groupnameOriginal) {
|
||||
league.name = parsedData.groupnameOriginal;
|
||||
league.groupname = parsedData.groupnameOriginal;
|
||||
}
|
||||
league.seasonId = season?.id || league.seasonId;
|
||||
await league.save();
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'League configured successfully',
|
||||
data: {
|
||||
league: {
|
||||
id: league.id,
|
||||
name: league.name,
|
||||
myTischtennisGroupId: league.myTischtennisGroupId,
|
||||
association: league.association,
|
||||
groupname: league.groupname
|
||||
},
|
||||
season: season ? {
|
||||
id: season.id,
|
||||
name: season.season
|
||||
} : null,
|
||||
parsedData
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MyTischtennisUrlController();
|
||||
@@ -1,16 +1,21 @@
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const pdfParse = require('pdf-parse/lib/pdf-parse.js');
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import OfficialTournament from '../models/OfficialTournament.js';
|
||||
import OfficialCompetition from '../models/OfficialCompetition.js';
|
||||
import OfficialCompetitionMember from '../models/OfficialCompetitionMember.js';
|
||||
import Member from '../models/Member.js';
|
||||
import { Op } from 'sequelize';
|
||||
import officialTournamentService from '../services/officialTournamentService.js';
|
||||
import clickTtTournamentRegistrationService from '../services/clickTtTournamentRegistrationService.js';
|
||||
|
||||
// In-Memory Store (einfacher Start); später DB-Modell
|
||||
const parsedTournaments = new Map(); // key: id, value: { id, clubId, rawText, parsedData }
|
||||
let seq = 1;
|
||||
export const updateOfficialTournament = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
|
||||
const result = await officialTournamentService.updateOfficialTournament(clubId, id, req.body);
|
||||
if (!result) return res.status(404).json({ error: 'not found' });
|
||||
res.status(200).json(result);
|
||||
} catch (e) {
|
||||
console.error('[updateOfficialTournament] Error:', e);
|
||||
res.status(500).json({ error: 'Failed to update tournament' });
|
||||
}
|
||||
};
|
||||
|
||||
export const uploadTournamentPdf = async (req, res) => {
|
||||
try {
|
||||
@@ -18,45 +23,9 @@ export const uploadTournamentPdf = async (req, res) => {
|
||||
const { clubId } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
if (!req.file || !req.file.buffer) return res.status(400).json({ error: 'No pdf provided' });
|
||||
const data = await pdfParse(req.file.buffer);
|
||||
const parsed = parseTournamentText(data.text);
|
||||
const t = await OfficialTournament.create({
|
||||
clubId,
|
||||
title: parsed.title || null,
|
||||
eventDate: parsed.termin || null,
|
||||
organizer: null,
|
||||
host: null,
|
||||
venues: JSON.stringify(parsed.austragungsorte || []),
|
||||
competitionTypes: JSON.stringify(parsed.konkurrenztypen || []),
|
||||
registrationDeadlines: JSON.stringify(parsed.meldeschluesse || []),
|
||||
entryFees: JSON.stringify(parsed.entryFees || {}),
|
||||
});
|
||||
// competitions persistieren
|
||||
for (const c of parsed.competitions || []) {
|
||||
// Korrigiere Fehlzuordnung: Wenn die Zeile mit "Stichtag" fälschlich in performanceClass steht
|
||||
let performanceClass = c.leistungsklasse || c.performanceClass || null;
|
||||
let cutoffDate = c.stichtag || c.cutoffDate || null;
|
||||
if (performanceClass && /^stichtag\b/i.test(performanceClass)) {
|
||||
cutoffDate = performanceClass.replace(/^stichtag\s*:?\s*/i, '').trim();
|
||||
performanceClass = null;
|
||||
}
|
||||
await OfficialCompetition.create({
|
||||
tournamentId: t.id,
|
||||
ageClassCompetition: c.altersklasseWettbewerb || c.ageClassCompetition || null,
|
||||
performanceClass,
|
||||
startTime: c.startzeit || c.startTime || null,
|
||||
registrationDeadlineDate: c.meldeschlussDatum || c.registrationDeadlineDate || null,
|
||||
registrationDeadlineOnline: c.meldeschlussOnline || c.registrationDeadlineOnline || null,
|
||||
cutoffDate,
|
||||
ttrRelevant: c.ttrRelevant || null,
|
||||
openTo: c.offenFuer || c.openTo || null,
|
||||
preliminaryRound: c.vorrunde || c.preliminaryRound || null,
|
||||
finalRound: c.endrunde || c.finalRound || null,
|
||||
maxParticipants: c.maxTeilnehmer || c.maxParticipants || null,
|
||||
entryFee: c.startgeld || c.entryFee || null,
|
||||
});
|
||||
}
|
||||
res.status(201).json({ id: String(t.id) });
|
||||
|
||||
const result = await officialTournamentService.uploadTournamentPdf(clubId, req.file.buffer);
|
||||
res.status(201).json(result);
|
||||
} catch (e) {
|
||||
console.error('[uploadTournamentPdf] Error:', e);
|
||||
res.status(500).json({ error: 'Failed to parse pdf' });
|
||||
@@ -68,64 +37,10 @@ export const getParsedTournament = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const t = await OfficialTournament.findOne({ where: { id, clubId } });
|
||||
if (!t) return res.status(404).json({ error: 'not found' });
|
||||
const comps = await OfficialCompetition.findAll({ where: { tournamentId: id } });
|
||||
const entries = await OfficialCompetitionMember.findAll({ where: { tournamentId: id } });
|
||||
const competitions = comps.map((c) => {
|
||||
const j = c.toJSON();
|
||||
return {
|
||||
id: j.id,
|
||||
tournamentId: j.tournamentId,
|
||||
ageClassCompetition: j.ageClassCompetition || null,
|
||||
performanceClass: j.performanceClass || null,
|
||||
startTime: j.startTime || null,
|
||||
registrationDeadlineDate: j.registrationDeadlineDate || null,
|
||||
registrationDeadlineOnline: j.registrationDeadlineOnline || null,
|
||||
cutoffDate: j.cutoffDate || null,
|
||||
ttrRelevant: j.ttrRelevant || null,
|
||||
openTo: j.openTo || null,
|
||||
preliminaryRound: j.preliminaryRound || null,
|
||||
finalRound: j.finalRound || null,
|
||||
maxParticipants: j.maxParticipants || null,
|
||||
entryFee: j.entryFee || null,
|
||||
// Legacy Felder zusätzlich, falls Frontend sie noch nutzt
|
||||
altersklasseWettbewerb: j.ageClassCompetition || null,
|
||||
leistungsklasse: j.performanceClass || null,
|
||||
startzeit: j.startTime || null,
|
||||
meldeschlussDatum: j.registrationDeadlineDate || null,
|
||||
meldeschlussOnline: j.registrationDeadlineOnline || null,
|
||||
stichtag: j.cutoffDate || null,
|
||||
offenFuer: j.openTo || null,
|
||||
vorrunde: j.preliminaryRound || null,
|
||||
endrunde: j.finalRound || null,
|
||||
maxTeilnehmer: j.maxParticipants || null,
|
||||
startgeld: j.entryFee || null,
|
||||
};
|
||||
});
|
||||
res.status(200).json({
|
||||
id: String(t.id),
|
||||
clubId: String(t.clubId),
|
||||
parsedData: {
|
||||
title: t.title,
|
||||
termin: t.eventDate,
|
||||
austragungsorte: JSON.parse(t.venues || '[]'),
|
||||
konkurrenztypen: JSON.parse(t.competitionTypes || '[]'),
|
||||
meldeschluesse: JSON.parse(t.registrationDeadlines || '[]'),
|
||||
entryFees: JSON.parse(t.entryFees || '{}'),
|
||||
competitions,
|
||||
},
|
||||
participation: entries.map(e => ({
|
||||
id: e.id,
|
||||
tournamentId: e.tournamentId,
|
||||
competitionId: e.competitionId,
|
||||
memberId: e.memberId,
|
||||
wants: !!e.wants,
|
||||
registered: !!e.registered,
|
||||
participated: !!e.participated,
|
||||
placement: e.placement || null,
|
||||
})),
|
||||
});
|
||||
|
||||
const result = await officialTournamentService.getParsedTournament(clubId, id);
|
||||
if (!result) return res.status(404).json({ error: 'not found' });
|
||||
res.status(200).json(result);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to fetch parsed tournament' });
|
||||
}
|
||||
@@ -134,30 +49,14 @@ export const getParsedTournament = async (req, res) => {
|
||||
export const upsertCompetitionMember = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id } = req.params; // id = tournamentId
|
||||
const { clubId, id } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const { competitionId, memberId, wants, registered, participated, placement } = req.body;
|
||||
if (!competitionId || !memberId) return res.status(400).json({ error: 'competitionId and memberId required' });
|
||||
const [row] = await OfficialCompetitionMember.findOrCreate({
|
||||
where: { competitionId, memberId },
|
||||
defaults: {
|
||||
tournamentId: id,
|
||||
competitionId,
|
||||
memberId,
|
||||
wants: !!wants,
|
||||
registered: !!registered,
|
||||
participated: !!participated,
|
||||
placement: placement || null,
|
||||
}
|
||||
});
|
||||
row.wants = wants !== undefined ? !!wants : row.wants;
|
||||
row.registered = registered !== undefined ? !!registered : row.registered;
|
||||
row.participated = participated !== undefined ? !!participated : row.participated;
|
||||
if (placement !== undefined) row.placement = placement;
|
||||
await row.save();
|
||||
return res.status(200).json({ success: true, id: row.id });
|
||||
|
||||
const result = await officialTournamentService.upsertCompetitionMember(id, req.body);
|
||||
return res.status(200).json(result);
|
||||
} catch (e) {
|
||||
console.error('[upsertCompetitionMember] Error:', e);
|
||||
if (e?.status) return res.status(e.status).json({ error: e.message });
|
||||
res.status(500).json({ error: 'Failed to save participation' });
|
||||
}
|
||||
};
|
||||
@@ -165,64 +64,14 @@ export const upsertCompetitionMember = async (req, res) => {
|
||||
export const updateParticipantStatus = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id } = req.params; // id = tournamentId
|
||||
const { clubId, id } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const { competitionId, memberId, action } = req.body;
|
||||
|
||||
if (!competitionId || !memberId || !action) {
|
||||
return res.status(400).json({ error: 'competitionId, memberId and action required' });
|
||||
}
|
||||
|
||||
const [row] = await OfficialCompetitionMember.findOrCreate({
|
||||
where: { competitionId, memberId },
|
||||
defaults: {
|
||||
tournamentId: id,
|
||||
competitionId,
|
||||
memberId,
|
||||
wants: false,
|
||||
registered: false,
|
||||
participated: false,
|
||||
placement: null,
|
||||
}
|
||||
});
|
||||
|
||||
// Status-Update basierend auf Aktion
|
||||
switch (action) {
|
||||
case 'register':
|
||||
// Von "möchte teilnehmen" zu "angemeldet"
|
||||
row.wants = true;
|
||||
row.registered = true;
|
||||
row.participated = false;
|
||||
break;
|
||||
case 'participate':
|
||||
// Von "angemeldet" zu "hat gespielt"
|
||||
row.wants = true;
|
||||
row.registered = true;
|
||||
row.participated = true;
|
||||
break;
|
||||
case 'reset':
|
||||
// Zurück zu "möchte teilnehmen"
|
||||
row.wants = true;
|
||||
row.registered = false;
|
||||
row.participated = false;
|
||||
break;
|
||||
default:
|
||||
return res.status(400).json({ error: 'Invalid action. Use: register, participate, or reset' });
|
||||
}
|
||||
|
||||
await row.save();
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
id: row.id,
|
||||
status: {
|
||||
wants: row.wants,
|
||||
registered: row.registered,
|
||||
participated: row.participated,
|
||||
placement: row.placement
|
||||
}
|
||||
});
|
||||
const result = await officialTournamentService.updateParticipantStatus(id, req.body);
|
||||
return res.status(200).json(result);
|
||||
} catch (e) {
|
||||
console.error('[updateParticipantStatus] Error:', e);
|
||||
if (e?.status) return res.status(e.status).json({ error: e.message });
|
||||
res.status(500).json({ error: 'Failed to update participant status' });
|
||||
}
|
||||
};
|
||||
@@ -232,10 +81,13 @@ export const listOfficialTournaments = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const list = await OfficialTournament.findAll({ where: { clubId } });
|
||||
|
||||
const list = await officialTournamentService.listOfficialTournaments(clubId);
|
||||
res.status(200).json(list);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to list tournaments' });
|
||||
console.error('[listOfficialTournaments] Error:', e);
|
||||
const errorMessage = e.message || 'Failed to list tournaments';
|
||||
res.status(e.statusCode || 500).json({ error: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -244,99 +96,8 @@ export const listClubParticipations = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const tournaments = await OfficialTournament.findAll({ where: { clubId } });
|
||||
if (!tournaments || tournaments.length === 0) return res.status(200).json([]);
|
||||
const tournamentIds = tournaments.map(t => t.id);
|
||||
|
||||
const rows = await OfficialCompetitionMember.findAll({
|
||||
where: { tournamentId: { [Op.in]: tournamentIds }, participated: true },
|
||||
include: [
|
||||
{ model: OfficialCompetition, as: 'competition', attributes: ['id', 'tournamentId', 'ageClassCompetition', 'startTime'] },
|
||||
{ model: OfficialTournament, as: 'tournament', attributes: ['id', 'title', 'eventDate'] },
|
||||
{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] },
|
||||
]
|
||||
});
|
||||
|
||||
const parseDmy = (s) => {
|
||||
if (!s) return null;
|
||||
const m = String(s).match(/(\d{1,2})\.(\d{1,2})\.(\d{4})/);
|
||||
if (!m) return null;
|
||||
const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
};
|
||||
const fmtDmy = (d) => {
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const yyyy = d.getFullYear();
|
||||
return `${dd}.${mm}.${yyyy}`;
|
||||
};
|
||||
|
||||
const byTournament = new Map();
|
||||
for (const r of rows) {
|
||||
const t = r.tournament;
|
||||
const c = r.competition;
|
||||
const m = r.member;
|
||||
if (!t || !c || !m) continue;
|
||||
if (!byTournament.has(t.id)) {
|
||||
byTournament.set(t.id, {
|
||||
tournamentId: String(t.id),
|
||||
title: t.title || null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
entries: [],
|
||||
_dates: [],
|
||||
_eventDate: t.eventDate || null,
|
||||
});
|
||||
}
|
||||
const bucket = byTournament.get(t.id);
|
||||
const compDate = parseDmy(c.startTime || '') || null;
|
||||
if (compDate) bucket._dates.push(compDate);
|
||||
bucket.entries.push({
|
||||
memberId: m.id,
|
||||
memberName: `${m.firstName || ''} ${m.lastName || ''}`.trim(),
|
||||
competitionId: c.id,
|
||||
competitionName: c.ageClassCompetition || '',
|
||||
placement: r.placement || null,
|
||||
date: compDate ? fmtDmy(compDate) : null,
|
||||
});
|
||||
}
|
||||
|
||||
const out = [];
|
||||
for (const t of tournaments) {
|
||||
const bucket = byTournament.get(t.id) || {
|
||||
tournamentId: String(t.id),
|
||||
title: t.title || null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
entries: [],
|
||||
_dates: [],
|
||||
_eventDate: t.eventDate || null,
|
||||
};
|
||||
// Ableiten Start/Ende
|
||||
if (bucket._dates.length) {
|
||||
bucket._dates.sort((a, b) => a - b);
|
||||
bucket.startDate = fmtDmy(bucket._dates[0]);
|
||||
bucket.endDate = fmtDmy(bucket._dates[bucket._dates.length - 1]);
|
||||
} else if (bucket._eventDate) {
|
||||
const all = String(bucket._eventDate).match(/(\d{1,2}\.\d{1,2}\.\d{4})/g) || [];
|
||||
if (all.length >= 1) {
|
||||
const d1 = parseDmy(all[0]);
|
||||
const d2 = all.length >= 2 ? parseDmy(all[1]) : d1;
|
||||
if (d1) bucket.startDate = fmtDmy(d1);
|
||||
if (d2) bucket.endDate = fmtDmy(d2);
|
||||
}
|
||||
}
|
||||
// Sort entries: Mitglied, dann Konkurrenz
|
||||
bucket.entries.sort((a, b) => {
|
||||
const mcmp = (a.memberName || '').localeCompare(b.memberName || '', 'de', { sensitivity: 'base' });
|
||||
if (mcmp !== 0) return mcmp;
|
||||
return (a.competitionName || '').localeCompare(b.competitionName || '', 'de', { sensitivity: 'base' });
|
||||
});
|
||||
delete bucket._dates;
|
||||
delete bucket._eventDate;
|
||||
out.push(bucket);
|
||||
}
|
||||
|
||||
const out = await officialTournamentService.listClubParticipations(clubId);
|
||||
res.status(200).json(out);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to list club participations' });
|
||||
@@ -348,272 +109,34 @@ export const deleteOfficialTournament = async (req, res) => {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id } = req.params;
|
||||
await checkAccess(userToken, clubId);
|
||||
const t = await OfficialTournament.findOne({ where: { id, clubId } });
|
||||
if (!t) return res.status(404).json({ error: 'not found' });
|
||||
await OfficialCompetition.destroy({ where: { tournamentId: id } });
|
||||
await OfficialTournament.destroy({ where: { id } });
|
||||
|
||||
const deleted = await officialTournamentService.deleteOfficialTournament(clubId, id);
|
||||
if (!deleted) return res.status(404).json({ error: 'not found' });
|
||||
res.status(204).send();
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Failed to delete tournament' });
|
||||
}
|
||||
};
|
||||
|
||||
function parseTournamentText(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const normLines = lines.map(l => l.replace(/\s+/g, ' ').trim());
|
||||
export const autoRegisterOfficialTournamentParticipants = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id: tournamentId } = req.params;
|
||||
const userId = req.user?.id;
|
||||
|
||||
const findTitle = () => {
|
||||
const idx = normLines.findIndex(l => /Kreiseinzelmeisterschaften/i.test(l));
|
||||
return idx >= 0 ? normLines[idx] : null;
|
||||
};
|
||||
const result = await clickTtTournamentRegistrationService.autoRegisterPendingParticipants({
|
||||
userToken,
|
||||
userId,
|
||||
clubId,
|
||||
tournamentId
|
||||
});
|
||||
|
||||
// Neue Funktion: Teilnahmegebühren pro Spielklasse extrahieren
|
||||
const extractEntryFees = () => {
|
||||
const entryFees = {};
|
||||
|
||||
// Verschiedene Patterns für Teilnahmegebühren suchen
|
||||
const feePatterns = [
|
||||
// Pattern 1: "Startgeld: U12: 5€, U14: 7€, U16: 10€"
|
||||
/startgeld\s*:?\s*(.+)/i,
|
||||
// Pattern 2: "Teilnahmegebühr: U12: 5€, U14: 7€"
|
||||
/teilnahmegebühr\s*:?\s*(.+)/i,
|
||||
// Pattern 3: "Gebühr: U12: 5€, U14: 7€"
|
||||
/gebühr\s*:?\s*(.+)/i,
|
||||
// Pattern 4: "Einschreibegebühr: U12: 5€, U14: 7€"
|
||||
/einschreibegebühr\s*:?\s*(.+)/i,
|
||||
// Pattern 5: "Anmeldegebühr: U12: 5€, U14: 7€"
|
||||
/anmeldegebühr\s*:?\s*(.+)/i
|
||||
];
|
||||
|
||||
for (const pattern of feePatterns) {
|
||||
for (let i = 0; i < normLines.length; i++) {
|
||||
const line = normLines[i];
|
||||
const match = line.match(pattern);
|
||||
if (match) {
|
||||
const feeText = match[1];
|
||||
|
||||
// Extrahiere Gebühren aus dem Text
|
||||
// Unterstützt verschiedene Formate:
|
||||
// "U12: 5€, U14: 7€, U16: 10€"
|
||||
// "U12: 5 Euro, U14: 7 Euro"
|
||||
// "U12 5€, U14 7€"
|
||||
// "U12: 5,00€, U14: 7,00€"
|
||||
const feeMatches = feeText.matchAll(/(U\d+|AK\s*\d+)\s*:?\s*(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/gi);
|
||||
|
||||
for (const feeMatch of feeMatches) {
|
||||
const ageClass = feeMatch[1].toUpperCase().replace(/\s+/g, '');
|
||||
const amount = feeMatch[2].replace(',', '.');
|
||||
const numericAmount = parseFloat(amount);
|
||||
|
||||
if (!isNaN(numericAmount)) {
|
||||
entryFees[ageClass] = {
|
||||
amount: numericAmount,
|
||||
currency: '€',
|
||||
rawText: feeMatch[0]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn wir Gebühren gefunden haben, brechen wir ab
|
||||
if (Object.keys(entryFees).length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(entryFees).length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return entryFees;
|
||||
};
|
||||
|
||||
const extractBlockAfter = (labels, multiline = false) => {
|
||||
const idx = normLines.findIndex(l => labels.some(lb => l.toLowerCase().startsWith(lb)));
|
||||
if (idx === -1) return multiline ? [] : null;
|
||||
const line = normLines[idx];
|
||||
const afterColon = line.includes(':') ? line.split(':').slice(1).join(':').trim() : '';
|
||||
if (!multiline) {
|
||||
if (afterColon) return afterColon;
|
||||
// sonst nächste nicht-leere Zeile
|
||||
for (let i = idx + 1; i < normLines.length; i++) {
|
||||
if (normLines[i]) return normLines[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// multiline bis zur nächsten Leerzeile oder nächsten bekannten Section
|
||||
const out = [];
|
||||
if (afterColon) out.push(afterColon);
|
||||
for (let i = idx + 1; i < normLines.length; i++) {
|
||||
const ln = normLines[i];
|
||||
if (!ln) break;
|
||||
if (/^(termin|austragungsort|austragungsorte|konkurrenz|konkurrenzen|konkurrenztypen|meldeschluss|altersklassen|startzeiten)/i.test(ln)) break;
|
||||
out.push(ln);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const extractAllMatches = (regex) => {
|
||||
const results = [];
|
||||
for (const l of normLines) {
|
||||
const m = l.match(regex);
|
||||
if (m) results.push(m);
|
||||
}
|
||||
return results;
|
||||
};
|
||||
|
||||
const title = findTitle();
|
||||
const termin = extractBlockAfter(['termin', 'termin '], false);
|
||||
const austragungsorte = extractBlockAfter(['austragungsort', 'austragungsorte'], true);
|
||||
let konkurrenzRaw = extractBlockAfter(['konkurrenz', 'konkurrenzen', 'konkurrenztypen'], true);
|
||||
if (konkurrenzRaw && !Array.isArray(konkurrenzRaw)) konkurrenzRaw = [konkurrenzRaw];
|
||||
const konkurrenztypen = (konkurrenzRaw || []).flatMap(l => l.split(/[;,]/)).map(s => s.trim()).filter(Boolean);
|
||||
|
||||
// Meldeschlüsse mit Position und Zuordnung zu AK ermitteln
|
||||
const meldeschluesseRaw = [];
|
||||
for (let i = 0; i < normLines.length; i++) {
|
||||
const l = normLines[i];
|
||||
const m = l.match(/meldeschluss\s*:?\s*(.+)$/i);
|
||||
if (m) meldeschluesseRaw.push({ line: i, value: m[1].trim() });
|
||||
res.status(200).json(result);
|
||||
} catch (e) {
|
||||
console.error('[autoRegisterOfficialTournamentParticipants] Error:', e);
|
||||
res.status(e.statusCode || e.status || 500).json({
|
||||
success: false,
|
||||
error: e.message || 'Teilnehmer konnten nicht automatisch in click-TT angemeldet werden'
|
||||
});
|
||||
}
|
||||
|
||||
let altersRaw = extractBlockAfter(['altersklassen', 'altersklasse'], true);
|
||||
if (altersRaw && !Array.isArray(altersRaw)) altersRaw = [altersRaw];
|
||||
const altersklassen = (altersRaw || []).flatMap(l => l.split(/[;,]/)).map(s => s.trim()).filter(Boolean);
|
||||
|
||||
// Wettbewerbe/Konkurrenzen parsen (Block ab "3. Konkurrenzen")
|
||||
const competitions = [];
|
||||
const konkIdx = normLines.findIndex(l => /^\s*3\.?\s+Konkurrenzen/i.test(l) || /^Konkurrenzen\b/i.test(l));
|
||||
// Bestimme Start-Sektionsnummer (z. B. 3 bei "3. Konkurrenzen"), fallback 3
|
||||
const startSectionNum = (() => {
|
||||
if (konkIdx === -1) return 3;
|
||||
const m = normLines[konkIdx].match(/^\s*(\d+)\./);
|
||||
return m ? parseInt(m[1], 10) : 3;
|
||||
})();
|
||||
const nextSectionIdx = () => {
|
||||
for (let i = konkIdx + 1; i < normLines.length; i++) {
|
||||
const m = normLines[i].match(/^\s*(\d+)\.\s+/);
|
||||
if (m) {
|
||||
const num = parseInt(m[1], 10);
|
||||
if (!Number.isNaN(num) && num > startSectionNum) return i;
|
||||
}
|
||||
// Hinweis: Seitenfußzeilen wie "nu.Dokument ..." ignorieren wir, damit mehrseitige Blöcke nicht abbrechen
|
||||
}
|
||||
return normLines.length;
|
||||
};
|
||||
if (konkIdx !== -1) {
|
||||
const endIdx = nextSectionIdx();
|
||||
let i = konkIdx + 1;
|
||||
while (i < endIdx) {
|
||||
const line = normLines[i];
|
||||
if (/^Altersklasse\/Wettbewerb\s*:/i.test(line)) {
|
||||
const comp = {};
|
||||
comp.altersklasseWettbewerb = line.split(':').slice(1).join(':').trim();
|
||||
i++;
|
||||
while (i < endIdx && !/^Altersklasse\/Wettbewerb\s*:/i.test(normLines[i])) {
|
||||
const ln = normLines[i];
|
||||
const m = ln.match(/^([^:]+):\s*(.*)$/);
|
||||
if (m) {
|
||||
const key = m[1].trim().toLowerCase();
|
||||
const val = m[2].trim();
|
||||
if (key.startsWith('leistungsklasse')) comp.leistungsklasse = val;
|
||||
else if (key === 'startzeit') {
|
||||
// Erwartet: 20.09.2025 13:30 Uhr -> wir extrahieren Datum+Zeit
|
||||
const sm = val.match(/(\d{2}\.\d{2}\.\d{4})\s+(\d{1,2}:\d{2})/);
|
||||
comp.startzeit = sm ? `${sm[1]} ${sm[2]}` : val;
|
||||
}
|
||||
else if (key.startsWith('meldeschluss datum')) comp.meldeschlussDatum = val;
|
||||
else if (key.startsWith('meldeschluss online')) comp.meldeschlussOnline = val;
|
||||
else if (key === 'stichtag') comp.stichtag = val;
|
||||
else if (key === 'ttr-relevant') comp.ttrRelevant = val;
|
||||
else if (key === 'offen für') comp.offenFuer = val;
|
||||
else if (key.startsWith('austragungssys. vorrunde')) comp.vorrunde = val;
|
||||
else if (key.startsWith('austragungssys. endrunde')) comp.endrunde = val;
|
||||
else if (key.startsWith('max. teilnehmerzahl')) comp.maxTeilnehmer = val;
|
||||
else if (key === 'startgeld') {
|
||||
comp.startgeld = val;
|
||||
// Versuche auch spezifische Gebühren für diese Altersklasse zu extrahieren
|
||||
const ageClassMatch = comp.altersklasseWettbewerb?.match(/(U\d+|AK\s*\d+)/i);
|
||||
if (ageClassMatch) {
|
||||
const ageClass = ageClassMatch[1].toUpperCase().replace(/\s+/g, '');
|
||||
const feeMatch = val.match(/(\d+(?:[,.]\d+)?)\s*(?:€|Euro|EUR)?/);
|
||||
if (feeMatch) {
|
||||
const amount = feeMatch[1].replace(',', '.');
|
||||
const numericAmount = parseFloat(amount);
|
||||
if (!isNaN(numericAmount)) {
|
||||
comp.entryFeeDetails = {
|
||||
amount: numericAmount,
|
||||
currency: '€',
|
||||
ageClass: ageClass
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
competitions.push(comp);
|
||||
continue; // schon auf nächster Zeile
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
// Altersklassen-Positionen im Text (zur Zuordnung von Meldeschlüssen)
|
||||
const akPositions = [];
|
||||
for (let i = 0; i < normLines.length; i++) {
|
||||
const l = normLines[i];
|
||||
const m = l.match(/\b(U\d+|AK\s*\d+)\b/i);
|
||||
if (m) akPositions.push({ line: i, ak: m[1].toUpperCase().replace(/\s+/g, '') });
|
||||
}
|
||||
|
||||
const meldeschluesseByAk = {};
|
||||
for (const ms of meldeschluesseRaw) {
|
||||
// Nächste AK im Umkreis von 3 Zeilen suchen
|
||||
let best = null;
|
||||
let bestDist = Infinity;
|
||||
for (const ak of akPositions) {
|
||||
const dist = Math.abs(ak.line - ms.line);
|
||||
if (dist < bestDist && dist <= 3) { best = ak; bestDist = dist; }
|
||||
}
|
||||
if (best) {
|
||||
if (!meldeschluesseByAk[best.ak]) meldeschluesseByAk[best.ak] = new Set();
|
||||
meldeschluesseByAk[best.ak].add(ms.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup global
|
||||
const meldeschluesse = Array.from(new Set(meldeschluesseRaw.map(x => x.value)));
|
||||
// Sets zu Arrays
|
||||
const meldeschluesseByAkOut = Object.fromEntries(Object.entries(meldeschluesseByAk).map(([k,v]) => [k, Array.from(v)]));
|
||||
|
||||
// Vorhandene einfache Personenerkennung (optional, zu Analysezwecken)
|
||||
const entries = [];
|
||||
for (const l of normLines) {
|
||||
const m = l.match(/^([A-Za-zÄÖÜäöüß\-\s']{3,})(?:\s+\((m|w|d)\))?$/i);
|
||||
if (m && /\s/.test(m[1])) {
|
||||
entries.push({ name: m[1].trim(), genderHint: m[2] || null });
|
||||
}
|
||||
}
|
||||
|
||||
// Extrahiere Teilnahmegebühren
|
||||
const entryFees = extractEntryFees();
|
||||
|
||||
return {
|
||||
title,
|
||||
termin,
|
||||
austragungsorte,
|
||||
konkurrenztypen,
|
||||
meldeschluesse,
|
||||
meldeschluesseByAk: meldeschluesseByAkOut,
|
||||
altersklassen,
|
||||
startzeiten: {},
|
||||
competitions,
|
||||
entries,
|
||||
entryFees, // Neue: Teilnahmegebühren pro Spielklasse
|
||||
debug: { normLines },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
@@ -1,23 +1,98 @@
|
||||
import Participant from '../models/Participant.js';
|
||||
import DiaryDates from '../models/DiaryDates.js';
|
||||
import DiaryMemberActivity from '../models/DiaryMemberActivity.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
import { emitParticipantAdded, emitParticipantRemoved, emitParticipantUpdated } from '../services/socketService.js';
|
||||
|
||||
const PARTICIPANT_ATTRIBUTES = ['id', 'diaryDateId', 'memberId', 'attendanceStatus', 'groupId', 'notes', 'createdAt', 'updatedAt'];
|
||||
|
||||
export const getParticipants = async (req, res) => {
|
||||
try {
|
||||
const { dateId } = req.params;
|
||||
const participants = await Participant.findAll({ where: { diaryDateId: dateId } });
|
||||
const participants = await Participant.findAll({
|
||||
where: { diaryDateId: dateId },
|
||||
attributes: PARTICIPANT_ATTRIBUTES
|
||||
});
|
||||
res.status(200).json(participants);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
devLog(error);
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Teilnehmer' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateParticipantGroup = async (req, res) => {
|
||||
try {
|
||||
const { dateId, memberId } = req.params;
|
||||
const { groupId } = req.body;
|
||||
|
||||
const participant = await Participant.findOne({
|
||||
where: {
|
||||
diaryDateId: dateId,
|
||||
memberId: memberId
|
||||
},
|
||||
include: [{
|
||||
model: DiaryDates,
|
||||
as: 'diaryDate',
|
||||
attributes: ['clubId']
|
||||
}]
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
return res.status(404).json({ error: 'Teilnehmer nicht gefunden' });
|
||||
}
|
||||
|
||||
participant.groupId = groupId || null;
|
||||
await participant.save();
|
||||
|
||||
// Lade den Participant erneut aus der DB, um sicherzustellen, dass wir den aktuellen Wert haben
|
||||
const updatedParticipant = await Participant.findOne({
|
||||
where: {
|
||||
diaryDateId: dateId,
|
||||
memberId: memberId
|
||||
},
|
||||
include: [{
|
||||
model: DiaryDates,
|
||||
as: 'diaryDate',
|
||||
attributes: ['clubId']
|
||||
}]
|
||||
});
|
||||
|
||||
// Emit Socket-Event mit dem aktualisierten Participant
|
||||
if (updatedParticipant?.diaryDate?.clubId) {
|
||||
emitParticipantUpdated(updatedParticipant.diaryDate.clubId, dateId, updatedParticipant);
|
||||
}
|
||||
|
||||
res.status(200).json(updatedParticipant || participant);
|
||||
} catch (error) {
|
||||
devLog(error);
|
||||
res.status(500).json({ error: 'Fehler beim Aktualisieren der Teilnehmer-Gruppenzuordnung' });
|
||||
}
|
||||
};
|
||||
|
||||
export const addParticipant = async (req, res) => {
|
||||
try {
|
||||
const { diaryDateId, memberId } = req.body;
|
||||
const participant = await Participant.create({ diaryDateId, memberId });
|
||||
const [participant, created] = await Participant.findOrCreate({
|
||||
where: { diaryDateId, memberId },
|
||||
defaults: { diaryDateId, memberId, attendanceStatus: 'present' }
|
||||
});
|
||||
|
||||
participant.attendanceStatus = 'present';
|
||||
await participant.save();
|
||||
|
||||
// Hole DiaryDate für clubId
|
||||
const diaryDate = await DiaryDates.findByPk(diaryDateId);
|
||||
if (diaryDate?.clubId) {
|
||||
if (created) {
|
||||
emitParticipantAdded(diaryDate.clubId, diaryDateId, participant);
|
||||
} else {
|
||||
emitParticipantUpdated(diaryDate.clubId, diaryDateId, participant);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(participant);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
devLog(error);
|
||||
res.status(500).json({ error: 'Fehler beim Hinzufügen des Teilnehmers' });
|
||||
}
|
||||
};
|
||||
@@ -25,10 +100,80 @@ export const addParticipant = async (req, res) => {
|
||||
export const removeParticipant = async (req, res) => {
|
||||
try {
|
||||
const { diaryDateId, memberId } = req.body;
|
||||
|
||||
// Hole DiaryDate für clubId vor dem Löschen
|
||||
const diaryDate = await DiaryDates.findByPk(diaryDateId);
|
||||
const clubId = diaryDate?.clubId;
|
||||
|
||||
const participant = await Participant.findOne({
|
||||
where: { diaryDateId, memberId },
|
||||
attributes: ['id']
|
||||
});
|
||||
|
||||
if (participant) {
|
||||
await DiaryMemberActivity.destroy({ where: { participantId: participant.id } });
|
||||
}
|
||||
|
||||
await Participant.destroy({ where: { diaryDateId, memberId } });
|
||||
|
||||
// Emit Socket-Event
|
||||
if (clubId) {
|
||||
emitParticipantRemoved(clubId, diaryDateId, memberId);
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'Teilnehmer entfernt' });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
devLog(error);
|
||||
res.status(500).json({ error: 'Fehler beim Entfernen des Teilnehmers' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateParticipantStatus = async (req, res) => {
|
||||
try {
|
||||
const { dateId, memberId } = req.params;
|
||||
const { attendanceStatus } = req.body;
|
||||
|
||||
if (!['excused', 'cancelled'].includes(attendanceStatus)) {
|
||||
return res.status(400).json({ error: 'Ungültiger Teilnehmerstatus' });
|
||||
}
|
||||
|
||||
const diaryDate = await DiaryDates.findByPk(dateId);
|
||||
if (!diaryDate) {
|
||||
return res.status(404).json({ error: 'Trainingstag nicht gefunden' });
|
||||
}
|
||||
|
||||
const [participant] = await Participant.findOrCreate({
|
||||
where: {
|
||||
diaryDateId: dateId,
|
||||
memberId
|
||||
},
|
||||
defaults: {
|
||||
diaryDateId: dateId,
|
||||
memberId,
|
||||
attendanceStatus
|
||||
}
|
||||
});
|
||||
|
||||
participant.attendanceStatus = attendanceStatus;
|
||||
participant.groupId = null;
|
||||
await participant.save();
|
||||
await DiaryMemberActivity.destroy({ where: { participantId: participant.id } });
|
||||
|
||||
const updatedParticipant = await Participant.findOne({
|
||||
where: {
|
||||
diaryDateId: dateId,
|
||||
memberId
|
||||
},
|
||||
attributes: PARTICIPANT_ATTRIBUTES
|
||||
});
|
||||
|
||||
if (diaryDate.clubId && updatedParticipant) {
|
||||
emitParticipantUpdated(diaryDate.clubId, dateId, updatedParticipant);
|
||||
}
|
||||
|
||||
res.status(200).json(updatedParticipant || participant);
|
||||
} catch (error) {
|
||||
devLog(error);
|
||||
res.status(500).json({ error: 'Fehler beim Aktualisieren des Teilnehmerstatus' });
|
||||
}
|
||||
};
|
||||
|
||||
250
backend/controllers/permissionController.js
Normal file
250
backend/controllers/permissionController.js
Normal file
@@ -0,0 +1,250 @@
|
||||
import permissionService from '../services/permissionService.js';
|
||||
|
||||
/**
|
||||
* Get user's permissions for a club
|
||||
*/
|
||||
export const getUserPermissions = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Validierung: clubId muss eine gültige Zahl sein
|
||||
const parsedClubId = parseInt(clubId, 10);
|
||||
if (isNaN(parsedClubId) || parsedClubId <= 0) {
|
||||
return res.status(400).json({ error: 'Ungültige Club-ID' });
|
||||
}
|
||||
|
||||
const permissions = await permissionService.getUserClubPermissions(userId, parsedClubId);
|
||||
|
||||
if (!permissions) {
|
||||
return res.status(404).json({ error: 'Keine Berechtigungen gefunden' });
|
||||
}
|
||||
|
||||
res.json(permissions);
|
||||
} catch (error) {
|
||||
console.error('Error getting user permissions:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Berechtigungen' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all club members with their permissions
|
||||
*/
|
||||
export const getClubMembersWithPermissions = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
const members = await permissionService.getClubMembersWithPermissions(
|
||||
parseInt(clubId),
|
||||
userId
|
||||
);
|
||||
|
||||
res.json(members);
|
||||
} catch (error) {
|
||||
console.error('Error getting club members with permissions:', error);
|
||||
if (error.message === 'Keine Berechtigung zum Anzeigen von Berechtigungen') {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Mitglieder' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update user role
|
||||
*/
|
||||
export const updateUserRole = async (req, res) => {
|
||||
try {
|
||||
const { clubId, userId: targetUserId } = req.params;
|
||||
const { role } = req.body;
|
||||
const updatingUserId = req.user.id;
|
||||
|
||||
const result = await permissionService.setUserRole(
|
||||
parseInt(targetUserId),
|
||||
parseInt(clubId),
|
||||
role,
|
||||
updatingUserId
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error updating user role:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateUserRoles = async (req, res) => {
|
||||
try {
|
||||
const { clubId, userId: targetUserId } = req.params;
|
||||
const { roleIds } = req.body;
|
||||
const updatingUserId = req.user.id;
|
||||
|
||||
const result = await permissionService.setUserRoles(
|
||||
parseInt(targetUserId),
|
||||
parseInt(clubId),
|
||||
Array.isArray(roleIds) ? roleIds : [],
|
||||
updatingUserId
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error updating user roles:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update user custom permissions
|
||||
*/
|
||||
export const updateUserPermissions = async (req, res) => {
|
||||
try {
|
||||
const { clubId, userId: targetUserId } = req.params;
|
||||
const { permissions } = req.body;
|
||||
const updatingUserId = req.user.id;
|
||||
|
||||
const result = await permissionService.setCustomPermissions(
|
||||
parseInt(targetUserId),
|
||||
parseInt(clubId),
|
||||
permissions,
|
||||
updatingUserId
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error updating user permissions:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available roles
|
||||
*/
|
||||
export const getAvailableRoles = async (req, res) => {
|
||||
try {
|
||||
const roles = permissionService.getAvailableRoles();
|
||||
res.json(roles);
|
||||
} catch (error) {
|
||||
console.error('Error getting available roles:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Rollen' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get permission structure
|
||||
*/
|
||||
export const getPermissionStructure = async (req, res) => {
|
||||
try {
|
||||
const structure = permissionService.getPermissionStructure();
|
||||
res.json(structure);
|
||||
} catch (error) {
|
||||
console.error('Error getting permission structure:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Berechtigungsstruktur' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getClubRoles = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const roles = await permissionService.getClubRoles(parseInt(clubId, 10), req.user.id);
|
||||
res.json(roles);
|
||||
} catch (error) {
|
||||
console.error('Error getting club roles:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubRole = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const role = await permissionService.createClubRole(parseInt(clubId, 10), req.body || {}, req.user.id);
|
||||
res.status(201).json(role);
|
||||
} catch (error) {
|
||||
console.error('Error creating club role:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubRole = async (req, res) => {
|
||||
try {
|
||||
const { clubId, roleId } = req.params;
|
||||
const role = await permissionService.updateClubRole(parseInt(clubId, 10), parseInt(roleId, 10), req.body || {}, req.user.id);
|
||||
res.json(role);
|
||||
} catch (error) {
|
||||
console.error('Error updating club role:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubRole = async (req, res) => {
|
||||
try {
|
||||
const { clubId, roleId } = req.params;
|
||||
const result = await permissionService.deleteClubRole(parseInt(clubId, 10), parseInt(roleId, 10), req.user.id);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error deleting club role:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update user status (activate/deactivate)
|
||||
*/
|
||||
export const updateUserStatus = async (req, res) => {
|
||||
try {
|
||||
const { clubId, userId: targetUserId } = req.params;
|
||||
const { approved } = req.body;
|
||||
const updatingUserId = req.user.id;
|
||||
|
||||
const result = await permissionService.setUserStatus(
|
||||
parseInt(targetUserId),
|
||||
parseInt(clubId),
|
||||
approved,
|
||||
updatingUserId
|
||||
);
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error updating user status:', error);
|
||||
if (error.message && error.message.toLowerCase().includes('keine berechtigung')) {
|
||||
return res.status(403).json({ error: error.message });
|
||||
}
|
||||
res.status(400).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
getUserPermissions,
|
||||
getClubMembersWithPermissions,
|
||||
updateUserRole,
|
||||
updateUserRoles,
|
||||
updateUserPermissions,
|
||||
updateUserStatus,
|
||||
getAvailableRoles,
|
||||
getPermissionStructure,
|
||||
getClubRoles,
|
||||
createClubRole,
|
||||
updateClubRole,
|
||||
deleteClubRole,
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ import fs from 'fs';
|
||||
|
||||
export const createPredefinedActivity = async (req, res) => {
|
||||
try {
|
||||
const { name, code, description, durationText, duration, imageLink, drawingData } = req.body;
|
||||
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, code, description, durationText, duration, imageLink, drawingData });
|
||||
const { name, code, description, durationText, duration, imageLink, drawingData, excludeFromStats } = req.body;
|
||||
const predefinedActivity = await predefinedActivityService.createPredefinedActivity({ name, code, description, durationText, duration, imageLink, drawingData, excludeFromStats });
|
||||
res.status(201).json(predefinedActivity);
|
||||
} catch (error) {
|
||||
console.error('[createPredefinedActivity] - Error:', error);
|
||||
@@ -16,7 +16,8 @@ export const createPredefinedActivity = async (req, res) => {
|
||||
|
||||
export const getAllPredefinedActivities = async (req, res) => {
|
||||
try {
|
||||
const predefinedActivities = await predefinedActivityService.getAllPredefinedActivities();
|
||||
const { scope = 'all' } = req.query;
|
||||
const predefinedActivities = await predefinedActivityService.getAllPredefinedActivities(scope);
|
||||
res.status(200).json(predefinedActivities);
|
||||
} catch (error) {
|
||||
console.error('[getAllPredefinedActivities] - Error:', error);
|
||||
@@ -42,8 +43,8 @@ export const getPredefinedActivityById = async (req, res) => {
|
||||
export const updatePredefinedActivity = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, code, description, durationText, duration, imageLink, drawingData } = req.body;
|
||||
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, code, description, durationText, duration, imageLink, drawingData });
|
||||
const { name, code, description, durationText, duration, imageLink, drawingData, excludeFromStats } = req.body;
|
||||
const updatedActivity = await predefinedActivityService.updatePredefinedActivity(id, { name, code, description, durationText, duration, imageLink, drawingData, excludeFromStats });
|
||||
res.status(200).json(updatedActivity);
|
||||
} catch (error) {
|
||||
console.error('[updatePredefinedActivity] - Error:', error);
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
export const uploadPredefinedActivityImage = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params; // predefinedActivityId
|
||||
@@ -35,7 +36,6 @@ export const uploadPredefinedActivityImage = async (req, res) => {
|
||||
|
||||
// Extrahiere Zeichnungsdaten aus dem Request
|
||||
const drawingData = req.body.drawingData ? JSON.parse(req.body.drawingData) : null;
|
||||
console.log('[uploadPredefinedActivityImage] - drawingData:', drawingData);
|
||||
|
||||
const imageRecord = await PredefinedActivityImage.create({
|
||||
predefinedActivityId: id,
|
||||
|
||||
103
backend/controllers/seasonController.js
Normal file
103
backend/controllers/seasonController.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import SeasonService from '../services/seasonService.js';
|
||||
import { getUserByToken } from '../utils/userUtils.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
export const getSeasons = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const seasons = await SeasonService.getAllSeasons();
|
||||
|
||||
res.status(200).json(seasons);
|
||||
} catch (error) {
|
||||
console.error('[getSeasons] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getCurrentSeason = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const season = await SeasonService.getOrCreateCurrentSeason();
|
||||
|
||||
res.status(200).json(season);
|
||||
} catch (error) {
|
||||
console.error('[getCurrentSeason] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const createSeason = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { season } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
if (!season) {
|
||||
return res.status(400).json({ error: "missingseason" });
|
||||
}
|
||||
|
||||
// Validiere Saison-Format (z.B. "2023/2024")
|
||||
const seasonRegex = /^\d{4}\/\d{4}$/;
|
||||
if (!seasonRegex.test(season)) {
|
||||
return res.status(400).json({ error: "invalidseasonformat" });
|
||||
}
|
||||
|
||||
const newSeason = await SeasonService.createSeason(season);
|
||||
|
||||
res.status(201).json(newSeason);
|
||||
} catch (error) {
|
||||
console.error('[createSeason] - Error:', error);
|
||||
if (error.message === 'Season already exists') {
|
||||
res.status(409).json({ error: "alreadyexists" });
|
||||
} else {
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getSeason = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { seasonid: seasonId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const season = await SeasonService.getSeasonById(seasonId);
|
||||
if (!season) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json(season);
|
||||
} catch (error) {
|
||||
console.error('[getSeason] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSeason = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { seasonid: seasonId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const success = await SeasonService.deleteSeason(seasonId);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: "deleted" });
|
||||
} catch (error) {
|
||||
console.error('[deleteSeason] - Error:', error);
|
||||
if (error.message === 'Season is used by teams' || error.message === 'Season is used by leagues') {
|
||||
res.status(409).json({ error: "seasoninuse" });
|
||||
} else {
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
}
|
||||
};
|
||||
130
backend/controllers/teamController.js
Normal file
130
backend/controllers/teamController.js
Normal file
@@ -0,0 +1,130 @@
|
||||
import TeamService from '../services/teamService.js';
|
||||
import { getUserByToken } from '../utils/userUtils.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
export const getTeams = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { seasonid: seasonId } = req.query;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
// Check if user has access to this club
|
||||
const teams = await TeamService.getAllTeamsByClub(clubId, seasonId);
|
||||
|
||||
res.status(200).json(teams);
|
||||
} catch (error) {
|
||||
console.error('[getTeams] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { teamid: teamId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const team = await TeamService.getTeamById(teamId);
|
||||
if (!team) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json(team);
|
||||
} catch (error) {
|
||||
console.error('[getTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const createTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { name, leagueId, seasonId } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: "missingname" });
|
||||
}
|
||||
|
||||
const teamData = {
|
||||
name,
|
||||
clubId: parseInt(clubId),
|
||||
leagueId: leagueId ? parseInt(leagueId) : null,
|
||||
seasonId: seasonId ? parseInt(seasonId) : null
|
||||
};
|
||||
|
||||
const newTeam = await TeamService.createTeam(teamData);
|
||||
|
||||
res.status(201).json(newTeam);
|
||||
} catch (error) {
|
||||
console.error('[createTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { teamid: teamId } = req.params;
|
||||
const { name, leagueId, seasonId } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (leagueId !== undefined) updateData.leagueId = leagueId ? parseInt(leagueId) : null;
|
||||
if (seasonId !== undefined) updateData.seasonId = seasonId ? parseInt(seasonId) : null;
|
||||
|
||||
const success = await TeamService.updateTeam(teamId, updateData);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
const updatedTeam = await TeamService.getTeamById(teamId);
|
||||
res.status(200).json(updatedTeam);
|
||||
} catch (error) {
|
||||
console.error('[updateTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { teamid: teamId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const success = await TeamService.deleteTeam(teamId);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: "deleted" });
|
||||
} catch (error) {
|
||||
console.error('[deleteTeam] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getLeagues = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { seasonid: seasonId } = req.query;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const leagues = await TeamService.getLeaguesByClub(clubId, seasonId);
|
||||
|
||||
res.status(200).json(leagues);
|
||||
} catch (error) {
|
||||
console.error('[getLeagues] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
223
backend/controllers/teamDocumentController.js
Normal file
223
backend/controllers/teamDocumentController.js
Normal file
@@ -0,0 +1,223 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import TeamDocumentService from '../services/teamDocumentService.js';
|
||||
import PDFParserService from '../services/pdfParserService.js';
|
||||
import { getUserByToken } from '../utils/userUtils.js';
|
||||
import { devLog } from '../utils/logger.js';
|
||||
|
||||
// Multer-Konfiguration für Datei-Uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
try {
|
||||
fs.mkdirSync('uploads/temp', { recursive: true });
|
||||
} catch (mkdirError) {
|
||||
console.error('[multer] - Failed to ensure temp upload directory exists:', mkdirError);
|
||||
}
|
||||
cb(null, 'uploads/temp/');
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // 10MB Limit
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Erlaube nur PDF, DOC, DOCX, TXT, CSV Dateien
|
||||
const allowedExtensions = ['.pdf', '.doc', '.docx', '.txt', '.csv'];
|
||||
const allowedMimePatterns = ['pdf', 'msword', 'wordprocessingml.document', 'text/plain', 'csv', 'excel'];
|
||||
|
||||
const extensionValid = allowedExtensions.includes(path.extname(file.originalname).toLowerCase());
|
||||
const mimetypeValid = allowedMimePatterns.some((pattern) => file.mimetype && file.mimetype.toLowerCase().includes(pattern));
|
||||
|
||||
if (extensionValid && mimetypeValid) {
|
||||
return cb(null, true);
|
||||
}
|
||||
|
||||
cb(new Error('Nur PDF, DOC, DOCX, TXT und CSV Dateien sind erlaubt!'));
|
||||
}
|
||||
});
|
||||
|
||||
export const uploadMiddleware = upload.single('document');
|
||||
|
||||
export const uploadDocument = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
const { documentType } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: "nofile" });
|
||||
}
|
||||
|
||||
if (!documentType || !['code_list', 'pin_list'].includes(documentType)) {
|
||||
return res.status(400).json({ error: "invaliddocumenttype" });
|
||||
}
|
||||
|
||||
const document = await TeamDocumentService.uploadDocument(req.file, clubTeamId, documentType);
|
||||
|
||||
res.status(201).json(document);
|
||||
} catch (error) {
|
||||
console.error('[uploadDocument] - Error:', error);
|
||||
|
||||
// Lösche temporäre Datei bei Fehler
|
||||
if (req.file && req.file.path) {
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch (cleanupError) {
|
||||
console.error('Fehler beim Löschen der temporären Datei:', cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
if (error.message === 'Club-Team nicht gefunden') {
|
||||
return res.status(404).json({ error: "clubteamnotfound" });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getDocuments = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const documents = await TeamDocumentService.getDocumentsByClubTeam(clubTeamId);
|
||||
|
||||
res.status(200).json(documents);
|
||||
} catch (error) {
|
||||
console.error('[getDocuments] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const getDocument = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { documentid: documentId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const document = await TeamDocumentService.getDocumentById(documentId);
|
||||
if (!document) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json(document);
|
||||
} catch (error) {
|
||||
console.error('[getDocument] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadDocument = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { documentid: documentId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const document = await TeamDocumentService.getDocumentById(documentId);
|
||||
if (!document) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
const filePath = await TeamDocumentService.getDocumentPath(documentId);
|
||||
if (!filePath) {
|
||||
return res.status(404).json({ error: "filenotfound" });
|
||||
}
|
||||
|
||||
// Prüfe ob Datei existiert
|
||||
const fs = await import('fs');
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({ error: "filenotfound" });
|
||||
}
|
||||
|
||||
// Setze Headers für Inline-Anzeige (PDF-Viewer)
|
||||
res.setHeader('Content-Disposition', `inline; filename="${document.originalFileName}"`);
|
||||
res.setHeader('Content-Type', document.mimeType);
|
||||
|
||||
// Sende die Datei
|
||||
res.sendFile(filePath);
|
||||
} catch (error) {
|
||||
console.error('[downloadDocument] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDocument = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { documentid: documentId } = req.params;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
const success = await TeamDocumentService.deleteDocument(documentId);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: "notfound" });
|
||||
}
|
||||
|
||||
res.status(200).json({ message: "Document deleted successfully" });
|
||||
} catch (error) {
|
||||
console.error('[deleteDocument] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
|
||||
export const parsePDF = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { documentid: documentId } = req.params;
|
||||
const { leagueid: leagueId } = req.query;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
if (!leagueId) {
|
||||
return res.status(400).json({ error: "missingleagueid" });
|
||||
}
|
||||
|
||||
// Hole Dokument-Informationen
|
||||
const document = await TeamDocumentService.getDocumentById(documentId);
|
||||
if (!document) {
|
||||
return res.status(404).json({ error: "documentnotfound" });
|
||||
}
|
||||
|
||||
// Prüfe ob es eine PDF- oder TXT-Datei ist
|
||||
if (!document.mimeType.includes('pdf') && !document.mimeType.includes('text/plain')) {
|
||||
return res.status(400).json({ error: "notapdfortxt" });
|
||||
}
|
||||
|
||||
// Parse PDF
|
||||
const parseResult = await PDFParserService.parsePDF(document.filePath, document.clubTeam.clubId);
|
||||
|
||||
// Speichere Matches in Datenbank
|
||||
const saveResult = await PDFParserService.saveMatchesToDatabase(parseResult.matches, parseInt(leagueId));
|
||||
|
||||
res.status(200).json({
|
||||
parseResult: {
|
||||
matchesFound: parseResult.matches.length,
|
||||
debugInfo: parseResult.debugInfo,
|
||||
allLines: parseResult.allLines,
|
||||
rawText: parseResult.rawText
|
||||
},
|
||||
saveResult: {
|
||||
created: saveResult.created,
|
||||
updated: saveResult.updated,
|
||||
errors: saveResult.errors
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[parsePDF] - Error:', error);
|
||||
res.status(500).json({ error: "internalerror" });
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
// controllers/tournamentController.js
|
||||
import tournamentService from "../services/tournamentService.js";
|
||||
import { emitTournamentChanged } from '../services/socketService.js';
|
||||
import TournamentClass from '../models/TournamentClass.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
// 1. Alle Turniere eines Vereins
|
||||
export const getTournaments = async (req, res) => {
|
||||
@@ -10,6 +13,11 @@ export const getTournaments = async (req, res) => {
|
||||
res.status(200).json(tournaments);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof HttpError) {
|
||||
res.set('x-debug-tournament-clubid', String(clubId));
|
||||
res.set('x-debug-tournament-clubid-num', String(Number(clubId)));
|
||||
return res.status(error.statusCode || 500).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
@@ -17,36 +25,66 @@ export const getTournaments = async (req, res) => {
|
||||
// 2. Neues Turnier anlegen
|
||||
export const addTournament = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentName, date } = req.body;
|
||||
const { clubId, tournamentName, date, winningSets, allowsExternal } = req.body;
|
||||
try {
|
||||
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date);
|
||||
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets, allowsExternal);
|
||||
// Emit Socket-Event
|
||||
if (clubId && tournament && tournament.id) {
|
||||
emitTournamentChanged(clubId, tournament.id);
|
||||
}
|
||||
res.status(201).json(tournament);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('[addTournament] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Teilnehmer hinzufügen
|
||||
// 3. Teilnehmer hinzufügen - klassengebunden
|
||||
export const addParticipant = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, participant: participantId } = req.body;
|
||||
const { clubId, classId, participant: participantId, tournamentId } = req.body;
|
||||
try {
|
||||
await tournamentService.addParticipant(token, clubId, tournamentId, participantId);
|
||||
const participants = await tournamentService.getParticipants(token, clubId, tournamentId);
|
||||
// Payloads:
|
||||
// - Mit Klasse (klassengebunden): { clubId, classId, participant }
|
||||
// - Ohne Klasse (turnierweit): { clubId, tournamentId, participant, classId: null }
|
||||
if (!participantId) {
|
||||
return res.status(400).json({ error: 'Teilnehmer-ID ist erforderlich' });
|
||||
}
|
||||
// Allow adding a participant either to a specific class (classId) or to the whole tournament (no class)
|
||||
if (!classId && !tournamentId) {
|
||||
return res.status(400).json({ error: 'Klasse oder tournamentId ist erforderlich' });
|
||||
}
|
||||
|
||||
// Pass through to service. If classId is present it will be used, otherwise the service should add the participant with classId = null for the given tournamentId
|
||||
await tournamentService.addParticipant(token, clubId, classId || null, participantId, tournamentId || null);
|
||||
|
||||
// Determine tournamentId for response and event emission
|
||||
let respTournamentId = tournamentId;
|
||||
if (classId && !respTournamentId) {
|
||||
const tournamentClass = await TournamentClass.findByPk(classId);
|
||||
if (!tournamentClass) {
|
||||
return res.status(404).json({ error: 'Klasse nicht gefunden' });
|
||||
}
|
||||
respTournamentId = tournamentClass.tournamentId;
|
||||
}
|
||||
|
||||
// Fetch updated participants for the (optional) class or whole tournament
|
||||
const participants = await tournamentService.getParticipants(token, clubId, respTournamentId, classId || null);
|
||||
// Emit Socket-Event
|
||||
if (respTournamentId) emitTournamentChanged(clubId, respTournamentId);
|
||||
res.status(200).json(participants);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('[addParticipant] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Teilnehmerliste abrufen
|
||||
// 4. Teilnehmerliste abrufen - nach Klasse oder Turnier
|
||||
export const getParticipants = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.body;
|
||||
const { clubId, tournamentId, classId } = req.body;
|
||||
try {
|
||||
const participants = await tournamentService.getParticipants(token, clubId, tournamentId);
|
||||
const participants = await tournamentService.getParticipants(token, clubId, tournamentId, classId || null);
|
||||
res.status(200).json(participants);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -60,6 +98,8 @@ export const setModus = async (req, res) => {
|
||||
const { clubId, tournamentId, type, numberOfGroups, advancingPerGroup } = req.body;
|
||||
try {
|
||||
await tournamentService.setModus(token, clubId, tournamentId, type, numberOfGroups, advancingPerGroup);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -70,9 +110,48 @@ export const setModus = async (req, res) => {
|
||||
// 6. Gruppen-Strukturen anlegen (leere Gruppen)
|
||||
export const createGroups = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.body;
|
||||
const { clubId, tournamentId, numberOfGroups } = req.body;
|
||||
try {
|
||||
await tournamentService.createGroups(token, clubId, tournamentId);
|
||||
// DEBUG: Eingehende Daten sichtbar machen (temporär)
|
||||
console.log('[tournamentController.createGroups] body:', req.body);
|
||||
console.log('[tournamentController.createGroups] types:', {
|
||||
clubId: typeof clubId,
|
||||
tournamentId: typeof tournamentId,
|
||||
numberOfGroups: typeof numberOfGroups,
|
||||
});
|
||||
|
||||
// Turniere ohne Klassen: `numberOfGroups: 0` kommt aus der UI (Default) vor.
|
||||
// Statt „nichts passiert“ normalisieren wir auf mindestens 1 Gruppe.
|
||||
let normalizedNumberOfGroups = numberOfGroups;
|
||||
if (normalizedNumberOfGroups !== undefined && normalizedNumberOfGroups !== null) {
|
||||
const n = Number(normalizedNumberOfGroups);
|
||||
console.log('[tournamentController.createGroups] parsed numberOfGroups:', n);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
return res.status(400).json({ error: 'numberOfGroups muss eine ganze Zahl >= 0 sein' });
|
||||
}
|
||||
normalizedNumberOfGroups = Math.max(1, n);
|
||||
}
|
||||
|
||||
console.log('[tournamentController.createGroups] normalizedNumberOfGroups:', normalizedNumberOfGroups);
|
||||
|
||||
await tournamentService.createGroups(token, clubId, tournamentId, normalizedNumberOfGroups);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 6b. Gruppen-Strukturen pro Klasse anlegen
|
||||
export const createGroupsPerClass = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, groupsPerClass } = req.body;
|
||||
try {
|
||||
await tournamentService.createGroupsPerClass(token, clubId, tournamentId, groupsPerClass);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -86,6 +165,8 @@ export const fillGroups = async (req, res) => {
|
||||
const { clubId, tournamentId } = req.body;
|
||||
try {
|
||||
const updatedMembers = await tournamentService.fillGroups(token, clubId, tournamentId);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(updatedMembers);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -93,6 +174,21 @@ export const fillGroups = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 7b. Gruppenspiele erstellen ohne Gruppenzuordnungen zu ändern
|
||||
export const createGroupMatches = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, classId } = req.body;
|
||||
try {
|
||||
await tournamentService.createGroupMatches(token, clubId, tournamentId, classId);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 8. Gruppen mit ihren Teilnehmern abfragen
|
||||
export const getGroups = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
@@ -119,6 +215,25 @@ export const getTournament = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Update Turnier
|
||||
export const updateTournament = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.params;
|
||||
const { name, date, winningSets, numberOfTables } = req.body;
|
||||
try {
|
||||
// Debug: log incoming payload for troubleshooting Android client
|
||||
console.log('[updateTournament] incoming body:', req.body);
|
||||
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets, numberOfTables);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(tournament);
|
||||
} catch (error) {
|
||||
console.error('[updateTournament] Error:', error);
|
||||
const status = error.message.includes('existiert bereits') ? 400 : 500;
|
||||
res.status(status).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 10. Alle Spiele eines Turniers abfragen
|
||||
export const getTournamentMatches = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
@@ -132,12 +247,43 @@ export const getTournamentMatches = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Setze Tischnummer für ein Spiel
|
||||
export const setMatchTable = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, matchId } = req.params;
|
||||
const { tableNumber } = req.body;
|
||||
try {
|
||||
const updated = await tournamentService.setMatchTable(token, clubId, tournamentId, matchId, tableNumber);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(updated);
|
||||
} catch (error) {
|
||||
console.error('[setMatchTable] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Freie Tische verteilen (Batch)
|
||||
export const distributeTables = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.body;
|
||||
try {
|
||||
const updated = await tournamentService.distributeTables(token, clubId, tournamentId);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ updated, message: 'Tische wurden verteilt.' });
|
||||
} catch (error) {
|
||||
console.error('[distributeTables] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 11. Satz-Ergebnis speichern
|
||||
export const addMatchResult = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, matchId, set, result } = req.body;
|
||||
try {
|
||||
await tournamentService.addMatchResult(token, clubId, tournamentId, matchId, set, result);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: "Result added successfully" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -151,6 +297,8 @@ export const finishMatch = async (req, res) => {
|
||||
const { clubId, tournamentId, matchId } = req.body;
|
||||
try {
|
||||
await tournamentService.finishMatch(token, clubId, tournamentId, matchId);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: "Match finished successfully" });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -164,6 +312,8 @@ export const startKnockout = async (req, res) => {
|
||||
|
||||
try {
|
||||
await tournamentService.startKnockout(token, clubId, tournamentId);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: "K.o.-Runde erfolgreich gestartet" });
|
||||
} catch (error) {
|
||||
const status = /Gruppenmodus|Zu wenige Qualifikanten/.test(error.message) ? 400 : 500;
|
||||
@@ -190,6 +340,8 @@ export const manualAssignGroups = async (req, res) => {
|
||||
numberOfGroups, // neu
|
||||
maxGroupSize // neu
|
||||
);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(groupsWithParts);
|
||||
} catch (error) {
|
||||
console.error('Error in manualAssignGroups:', error);
|
||||
@@ -197,11 +349,35 @@ export const manualAssignGroups = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const assignParticipantToGroup = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, participantId, groupNumber, isExternal } = req.body;
|
||||
|
||||
try {
|
||||
const groups = await tournamentService.assignParticipantToGroup(
|
||||
token,
|
||||
clubId,
|
||||
tournamentId,
|
||||
participantId,
|
||||
groupNumber,
|
||||
isExternal || false
|
||||
);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(groups);
|
||||
} catch (error) {
|
||||
console.error('Error in assignParticipantToGroup:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const resetGroups = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.body;
|
||||
try {
|
||||
await tournamentService.resetGroups(token, clubId, tournamentId);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.sendStatus(204);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -211,9 +387,11 @@ export const resetGroups = async (req, res) => {
|
||||
|
||||
export const resetMatches = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.body;
|
||||
const { clubId, tournamentId, classId } = req.body;
|
||||
try {
|
||||
await tournamentService.resetMatches(token, clubId, tournamentId);
|
||||
await tournamentService.resetMatches(token, clubId, tournamentId, classId || null);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.sendStatus(204);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -227,6 +405,8 @@ export const removeParticipant = async (req, res) => {
|
||||
try {
|
||||
await tournamentService.removeParticipant(token, clubId, tournamentId, participantId);
|
||||
const participants = await tournamentService.getParticipants(token, clubId, tournamentId);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(participants);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -234,6 +414,21 @@ export const removeParticipant = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const updateParticipantSeeded = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, participantId } = req.params;
|
||||
const { seeded } = req.body;
|
||||
try {
|
||||
await tournamentService.updateParticipantSeeded(token, clubId, tournamentId, participantId, seeded);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Gesetzt-Status aktualisiert' });
|
||||
} catch (err) {
|
||||
console.error('[updateParticipantSeeded] Error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteMatchResult = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, matchId, set } = req.body;
|
||||
@@ -245,6 +440,8 @@ export const deleteMatchResult = async (req, res) => {
|
||||
matchId,
|
||||
set
|
||||
);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Einzelsatz gelöscht' });
|
||||
} catch (error) {
|
||||
console.error('Error in deleteMatchResult:', error);
|
||||
@@ -258,6 +455,8 @@ export const reopenMatch = async (req, res) => {
|
||||
const { clubId, tournamentId, matchId } = req.body;
|
||||
try {
|
||||
await tournamentService.reopenMatch(token, clubId, tournamentId, matchId);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
// Gib optional das aktualisierte Match zurück
|
||||
res.status(200).json({ message: "Match reopened" });
|
||||
} catch (error) {
|
||||
@@ -268,13 +467,210 @@ export const reopenMatch = async (req, res) => {
|
||||
|
||||
export const deleteKnockoutMatches = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.body;
|
||||
const { clubId, tournamentId, classId } = req.body;
|
||||
try {
|
||||
await tournamentService.resetKnockout(token, clubId, tournamentId);
|
||||
await tournamentService.resetKnockout(token, clubId, tournamentId, classId);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: "K.o.-Runde gelöscht" });
|
||||
} catch (error) {
|
||||
console.error("Error in deleteKnockoutMatches:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const setMatchActive = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, matchId } = req.params;
|
||||
const { isActive } = req.body;
|
||||
try {
|
||||
await tournamentService.setMatchActive(token, clubId, tournamentId, matchId, isActive);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Match-Status aktualisiert' });
|
||||
} catch (err) {
|
||||
console.error('[setMatchActive] Error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Externe Teilnehmer hinzufügen
|
||||
export const addExternalParticipant = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, classId, firstName, lastName, club, birthDate, gender } = req.body;
|
||||
try {
|
||||
await tournamentService.addExternalParticipant(token, clubId, classId, firstName, lastName, club, birthDate, gender);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Externer Teilnehmer hinzugefügt' });
|
||||
} catch (error) {
|
||||
console.error('[addExternalParticipant] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Externe Teilnehmer abrufen - nach Klasse oder Turnier
|
||||
export const getExternalParticipants = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, classId } = req.body;
|
||||
try {
|
||||
const participants = await tournamentService.getExternalParticipants(token, clubId, tournamentId, classId || null);
|
||||
res.status(200).json(participants);
|
||||
} catch (error) {
|
||||
console.error('[getExternalParticipants] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Externe Teilnehmer löschen
|
||||
export const removeExternalParticipant = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, participantId } = req.body;
|
||||
try {
|
||||
await tournamentService.removeExternalParticipant(token, clubId, tournamentId, participantId);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Externer Teilnehmer entfernt' });
|
||||
} catch (error) {
|
||||
console.error('[removeExternalParticipant] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Gesetzt-Status für externe Teilnehmer aktualisieren
|
||||
export const updateExternalParticipantSeeded = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, participantId } = req.params;
|
||||
const { seeded } = req.body;
|
||||
try {
|
||||
await tournamentService.updateExternalParticipantSeeded(token, clubId, tournamentId, participantId, seeded);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Gesetzt-Status aktualisiert' });
|
||||
} catch (error) {
|
||||
console.error('[updateExternalParticipantSeeded] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Tournament Classes
|
||||
export const getTournamentClasses = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.params;
|
||||
try {
|
||||
const classes = await tournamentService.getTournamentClasses(token, clubId, tournamentId);
|
||||
res.status(200).json(classes);
|
||||
} catch (error) {
|
||||
console.error('[getTournamentClasses] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const addTournamentClass = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.params;
|
||||
const { name, isDoubles, gender, minBirthYear } = req.body;
|
||||
try {
|
||||
const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name, isDoubles, gender, minBirthYear);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(tournamentClass);
|
||||
} catch (error) {
|
||||
console.error('[addTournamentClass] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTournamentClass = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, classId } = req.params;
|
||||
const { name, sortOrder, isDoubles, gender, minBirthYear } = req.body;
|
||||
try {
|
||||
console.log('[updateTournamentClass] Request body:', { name, sortOrder, isDoubles, gender, minBirthYear });
|
||||
const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear);
|
||||
console.log('[updateTournamentClass] Updated class:', JSON.stringify(tournamentClass.toJSON(), null, 2));
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(tournamentClass);
|
||||
} catch (error) {
|
||||
console.error('[updateTournamentClass] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTournamentClass = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, classId } = req.params;
|
||||
try {
|
||||
await tournamentService.deleteTournamentClass(token, clubId, tournamentId, classId);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Klasse gelöscht' });
|
||||
} catch (error) {
|
||||
console.error('[deleteTournamentClass] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateParticipantClass = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, participantId } = req.params;
|
||||
const { classId, isExternal } = req.body;
|
||||
try {
|
||||
await tournamentService.updateParticipantClass(token, clubId, tournamentId, participantId, classId, isExternal);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Klasse aktualisiert' });
|
||||
} catch (error) {
|
||||
console.error('[updateParticipantClass] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Tournament Pairings
|
||||
export const getPairings = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, classId } = req.params;
|
||||
try {
|
||||
const pairings = await tournamentService.getPairings(token, clubId, tournamentId, classId);
|
||||
res.status(200).json(pairings);
|
||||
} catch (error) {
|
||||
console.error('[getPairings] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const createPairing = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, classId } = req.params;
|
||||
const { player1Type, player1Id, player2Type, player2Id, seeded, groupId } = req.body;
|
||||
try {
|
||||
const pairing = await tournamentService.createPairing(token, clubId, tournamentId, classId, player1Type, player1Id, player2Type, player2Id, seeded, groupId);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(pairing);
|
||||
} catch (error) {
|
||||
console.error('[createPairing] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const updatePairing = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, pairingId } = req.params;
|
||||
const { player1Type, player1Id, player2Type, player2Id, seeded, groupId } = req.body;
|
||||
try {
|
||||
const pairing = await tournamentService.updatePairing(token, clubId, tournamentId, pairingId, player1Type, player1Id, player2Type, player2Id, seeded, groupId);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(pairing);
|
||||
} catch (error) {
|
||||
console.error('[updatePairing] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deletePairing = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, pairingId } = req.params;
|
||||
try {
|
||||
await tournamentService.deletePairing(token, clubId, tournamentId, pairingId);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Paarung gelöscht' });
|
||||
} catch (error) {
|
||||
console.error('[deletePairing] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
70
backend/controllers/tournamentStagesController.js
Normal file
70
backend/controllers/tournamentStagesController.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import tournamentService from '../services/tournamentService.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
export const getStages = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.query;
|
||||
try {
|
||||
if (clubId == null || tournamentId == null) {
|
||||
return res.status(400).json({ error: 'clubId und tournamentId sind erforderlich.' });
|
||||
}
|
||||
const data = await tournamentService.getTournamentStages(token, Number(clubId), Number(tournamentId));
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof HttpError) {
|
||||
// Debug-Hilfe: zeigt, welche IDs tatsächlich am Endpoint ankamen (ohne sensible Daten)
|
||||
res.set('x-debug-stages-clubid', String(clubId));
|
||||
res.set('x-debug-stages-tournamentid', String(tournamentId));
|
||||
res.set('x-debug-stages-clubid-num', String(Number(clubId)));
|
||||
return res.status(error.statusCode || 500).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertStages = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, stages, advancement, advancements } = req.body;
|
||||
try {
|
||||
const data = await tournamentService.upsertTournamentStages(
|
||||
token,
|
||||
Number(clubId),
|
||||
Number(tournamentId),
|
||||
stages,
|
||||
advancement,
|
||||
advancements
|
||||
);
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof HttpError) {
|
||||
res.set('x-debug-stages-clubid', String(clubId));
|
||||
res.set('x-debug-stages-tournamentid', String(tournamentId));
|
||||
res.set('x-debug-stages-clubid-num', String(Number(clubId)));
|
||||
return res.status(error.statusCode || 500).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const advanceStage = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, fromStageIndex, toStageIndex } = req.body;
|
||||
try {
|
||||
const data = await tournamentService.advanceTournamentStage(
|
||||
token,
|
||||
Number(clubId),
|
||||
Number(tournamentId),
|
||||
Number(fromStageIndex || 1),
|
||||
(toStageIndex == null ? null : Number(toStageIndex))
|
||||
);
|
||||
res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof HttpError) {
|
||||
return res.status(error.statusCode || 500).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
50
backend/controllers/trainingCancellationController.js
Normal file
50
backend/controllers/trainingCancellationController.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import trainingCancellationService from '../services/trainingCancellationService.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorUtils.js';
|
||||
|
||||
export const getTrainingCancellations = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const { year } = req.query;
|
||||
const result = await trainingCancellationService.getTrainingCancellations(userToken, clubId, year);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[getTrainingCancellations] - Error:', error);
|
||||
const message = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingsausfälle');
|
||||
res.status(error.statusCode || 500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const upsertTrainingCancellation = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const { date, startDate, endDate, reason, trainingGroupIds } = req.body;
|
||||
const result = await trainingCancellationService.upsertTrainingCancellation(
|
||||
userToken,
|
||||
clubId,
|
||||
startDate || date,
|
||||
reason,
|
||||
endDate || date || startDate,
|
||||
trainingGroupIds
|
||||
);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[upsertTrainingCancellation] - Error:', error);
|
||||
const message = getSafeErrorMessage(error, 'Fehler beim Speichern des Trainingsausfalls');
|
||||
res.status(error.statusCode || 500).json({ error: message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTrainingCancellation = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, cancellationId } = req.params;
|
||||
const result = await trainingCancellationService.deleteTrainingCancellation(userToken, clubId, cancellationId);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[deleteTrainingCancellation] - Error:', error);
|
||||
const message = getSafeErrorMessage(error, 'Fehler beim Löschen des Trainingsausfalls');
|
||||
res.status(error.statusCode || 500).json({ error: message });
|
||||
}
|
||||
};
|
||||
127
backend/controllers/trainingGroupController.js
Normal file
127
backend/controllers/trainingGroupController.js
Normal file
@@ -0,0 +1,127 @@
|
||||
import trainingGroupService from '../services/trainingGroupService.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorUtils.js';
|
||||
|
||||
export const getTrainingGroups = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const groups = await trainingGroupService.getTrainingGroups(userToken, clubId);
|
||||
res.status(200).json(groups);
|
||||
} catch (error) {
|
||||
console.error('[getTrainingGroups] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingsgruppen');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const createTrainingGroup = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const { name, sortOrder } = req.body;
|
||||
const group = await trainingGroupService.createTrainingGroup(userToken, clubId, name, sortOrder);
|
||||
res.status(201).json(group);
|
||||
} catch (error) {
|
||||
console.error('[createTrainingGroup] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Trainingsgruppe');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTrainingGroup = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, groupId } = req.params;
|
||||
const group = await trainingGroupService.updateTrainingGroup(userToken, clubId, groupId, req.body);
|
||||
res.status(200).json(group);
|
||||
} catch (error) {
|
||||
console.error('[updateTrainingGroup] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Aktualisieren der Trainingsgruppe');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTrainingGroup = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, groupId } = req.params;
|
||||
await trainingGroupService.deleteTrainingGroup(userToken, clubId, groupId);
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteTrainingGroup] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen der Trainingsgruppe');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const addMemberToGroup = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, groupId, memberId } = req.params;
|
||||
const memberGroup = await trainingGroupService.addMemberToGroup(userToken, clubId, groupId, memberId);
|
||||
res.status(201).json(memberGroup);
|
||||
} catch (error) {
|
||||
console.error('[addMemberToGroup] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Hinzufügen des Mitglieds zur Gruppe');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const removeMemberFromGroup = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, groupId, memberId } = req.params;
|
||||
await trainingGroupService.removeMemberFromGroup(userToken, clubId, groupId, memberId);
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[removeMemberFromGroup] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Entfernen des Mitglieds aus der Gruppe');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const getMemberGroups = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, memberId } = req.params;
|
||||
const groups = await trainingGroupService.getMemberGroups(userToken, clubId, memberId);
|
||||
res.status(200).json(groups);
|
||||
} catch (error) {
|
||||
console.error('[getMemberGroups] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Gruppen des Mitglieds');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const ensurePresetGroups = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const groups = await trainingGroupService.ensurePresetGroups(userToken, clubId);
|
||||
res.status(200).json({
|
||||
message: 'Preset-Gruppen wurden erstellt/überprüft',
|
||||
groups: groups.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ensurePresetGroups] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Preset-Gruppen');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const enablePresetGroup = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, presetType } = req.params;
|
||||
const group = await trainingGroupService.enablePresetGroup(userToken, clubId, presetType);
|
||||
res.status(200).json({
|
||||
message: 'Preset-Gruppe wurde aktiviert',
|
||||
group
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[enablePresetGroup] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Aktivieren der Preset-Gruppe');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,125 +1,16 @@
|
||||
import { DiaryDate, Member, Participant } from '../models/index.js';
|
||||
import { Op } from 'sequelize';
|
||||
import trainingStatsService from '../services/trainingStatsService.js';
|
||||
|
||||
class TrainingStatsController {
|
||||
async getTrainingStats(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
|
||||
// Aktuelle Datum für Berechnungen
|
||||
const now = new Date();
|
||||
const twelveMonthsAgo = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
||||
|
||||
// Alle aktiven Mitglieder des spezifischen Vereins laden
|
||||
const members = await Member.findAll({
|
||||
where: {
|
||||
active: true,
|
||||
clubId: parseInt(clubId)
|
||||
}
|
||||
});
|
||||
|
||||
const stats = [];
|
||||
|
||||
for (const member of members) {
|
||||
// Trainingsteilnahmen der letzten 12 Monate über Participant-Model
|
||||
const participation12Months = await Participant.count({
|
||||
include: [{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
where: {
|
||||
clubId: parseInt(clubId),
|
||||
date: {
|
||||
[Op.gte]: twelveMonthsAgo
|
||||
}
|
||||
}
|
||||
}],
|
||||
where: {
|
||||
memberId: member.id
|
||||
}
|
||||
});
|
||||
|
||||
// Trainingsteilnahmen der letzten 3 Monate über Participant-Model
|
||||
const participation3Months = await Participant.count({
|
||||
include: [{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
where: {
|
||||
clubId: parseInt(clubId),
|
||||
date: {
|
||||
[Op.gte]: threeMonthsAgo
|
||||
}
|
||||
}
|
||||
}],
|
||||
where: {
|
||||
memberId: member.id
|
||||
}
|
||||
});
|
||||
|
||||
// Trainingsteilnahmen insgesamt über Participant-Model
|
||||
const participationTotal = await Participant.count({
|
||||
include: [{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
where: {
|
||||
clubId: parseInt(clubId)
|
||||
}
|
||||
}],
|
||||
where: {
|
||||
memberId: member.id
|
||||
}
|
||||
});
|
||||
|
||||
// Detaillierte Trainingsdaten (absteigend sortiert) über Participant-Model
|
||||
const trainingDetails = await Participant.findAll({
|
||||
include: [{
|
||||
model: DiaryDate,
|
||||
as: 'diaryDate',
|
||||
where: {
|
||||
clubId: parseInt(clubId)
|
||||
}
|
||||
}],
|
||||
where: {
|
||||
memberId: member.id
|
||||
},
|
||||
order: [['diaryDate', 'date', 'DESC']],
|
||||
limit: 50 // Begrenzen auf die letzten 50 Trainingseinheiten
|
||||
});
|
||||
|
||||
// Trainingsteilnahmen für den Member formatieren
|
||||
const formattedTrainingDetails = trainingDetails.map(participation => ({
|
||||
id: participation.id,
|
||||
date: participation.diaryDate.date,
|
||||
activityName: 'Training',
|
||||
startTime: '--:--',
|
||||
endTime: '--:--'
|
||||
}));
|
||||
|
||||
// Letztes Training
|
||||
const lastTrainingDate = trainingDetails.length ? trainingDetails[0].diaryDate.date : null;
|
||||
const lastTrainingTs = lastTrainingDate ? new Date(lastTrainingDate).getTime() : 0;
|
||||
|
||||
stats.push({
|
||||
id: member.id,
|
||||
firstName: member.firstName,
|
||||
lastName: member.lastName,
|
||||
birthDate: member.birthDate,
|
||||
participation12Months,
|
||||
participation3Months,
|
||||
participationTotal,
|
||||
lastTraining: lastTrainingDate,
|
||||
lastTrainingTs,
|
||||
trainingDetails: formattedTrainingDetails
|
||||
});
|
||||
}
|
||||
|
||||
// Nach Gesamtteilnahme absteigend sortieren
|
||||
stats.sort((a, b) => b.participationTotal - a.participationTotal);
|
||||
|
||||
const stats = await trainingStatsService.getTrainingStats(clubId);
|
||||
res.json(stats);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Trainings-Statistik:', error);
|
||||
if (error?.status) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
res.status(500).json({ error: 'Fehler beim Laden der Trainings-Statistik' });
|
||||
}
|
||||
}
|
||||
|
||||
80
backend/controllers/trainingTimeController.js
Normal file
80
backend/controllers/trainingTimeController.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import trainingTimeService from '../services/trainingTimeService.js';
|
||||
import { getSafeErrorMessage } from '../utils/errorUtils.js';
|
||||
|
||||
export const getTrainingTimes = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const groups = await trainingTimeService.getTrainingTimes(userToken, clubId);
|
||||
res.status(200).json(groups);
|
||||
} catch (error) {
|
||||
console.error('[getTrainingTimes] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Laden der Trainingszeiten');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const createTrainingTime = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const { trainingGroupId, weekday, startTime, endTime } = req.body;
|
||||
|
||||
if (!trainingGroupId || weekday === undefined || !startTime || !endTime) {
|
||||
return res.status(400).json({ error: 'Alle Felder müssen ausgefüllt sein' });
|
||||
}
|
||||
|
||||
const trainingTime = await trainingTimeService.createTrainingTime(
|
||||
userToken,
|
||||
clubId,
|
||||
trainingGroupId,
|
||||
weekday,
|
||||
startTime,
|
||||
endTime
|
||||
);
|
||||
|
||||
res.status(201).json(trainingTime);
|
||||
} catch (error) {
|
||||
console.error('[createTrainingTime] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Erstellen der Trainingszeit');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTrainingTime = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, timeId } = req.params;
|
||||
const { weekday, startTime, endTime } = req.body;
|
||||
|
||||
const trainingTime = await trainingTimeService.updateTrainingTime(
|
||||
userToken,
|
||||
clubId,
|
||||
timeId,
|
||||
weekday,
|
||||
startTime,
|
||||
endTime
|
||||
);
|
||||
|
||||
res.status(200).json(trainingTime);
|
||||
} catch (error) {
|
||||
console.error('[updateTrainingTime] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Aktualisieren der Trainingszeit');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteTrainingTime = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, timeId } = req.params;
|
||||
|
||||
const result = await trainingTimeService.deleteTrainingTime(userToken, clubId, timeId);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[deleteTrainingTime] - Error:', error);
|
||||
const msg = getSafeErrorMessage(error, 'Fehler beim Löschen der Trainingszeit');
|
||||
res.status(error.statusCode || 500).json({ error: msg });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,7 +10,8 @@ const sequelize = new Sequelize(
|
||||
host: development.host,
|
||||
dialect: development.dialect,
|
||||
define: development.define,
|
||||
logging: false, // SQL-Logging deaktivieren
|
||||
logging: development.logging ?? false,
|
||||
storage: development.storage,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,54 @@
|
||||
/**
|
||||
* HttpError mit Unterstützung für Fehlercodes
|
||||
*
|
||||
* Verwendung:
|
||||
* - new HttpError('Fehlermeldung', 400) - Legacy, wird weiterhin unterstützt
|
||||
* - new HttpError({ code: 'ERROR_USER_NOT_FOUND' }, 404) - Mit Fehlercode
|
||||
* - new HttpError({ code: 'ERROR_MEMBER_NOT_FOUND', params: { memberId: 123 } }, 404) - Mit Parametern
|
||||
*/
|
||||
class HttpError extends Error {
|
||||
constructor(message, statusCode) {
|
||||
super(message);
|
||||
constructor(messageOrError, statusCode) {
|
||||
// Unterstützung für beide Formate:
|
||||
// 1. Legacy: new HttpError('Fehlermeldung', 400)
|
||||
// 2. Neu: new HttpError({ code: 'ERROR_CODE', params: {...} }, 400)
|
||||
if (typeof messageOrError === 'string') {
|
||||
// Legacy-Format
|
||||
super(messageOrError);
|
||||
this.errorCode = null;
|
||||
this.errorParams = null;
|
||||
} else if (messageOrError && typeof messageOrError === 'object' && messageOrError.code) {
|
||||
// Neues Format mit Fehlercode
|
||||
super(messageOrError.code); // Für Stack-Trace
|
||||
this.errorCode = messageOrError.code;
|
||||
this.errorParams = messageOrError.params || null;
|
||||
} else {
|
||||
// Fallback
|
||||
super('Unknown error');
|
||||
this.errorCode = null;
|
||||
this.errorParams = null;
|
||||
}
|
||||
|
||||
this.name = this.constructor.name;
|
||||
this.statusCode = statusCode;
|
||||
this.statusCode = statusCode || 500;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt das Fehler-Objekt für die API-Antwort zurück
|
||||
* @returns {object} Fehler-Objekt mit code und optional params
|
||||
*/
|
||||
toJSON() {
|
||||
if (this.errorCode) {
|
||||
return {
|
||||
code: this.errorCode,
|
||||
...(this.errorParams && { params: this.errorParams })
|
||||
};
|
||||
}
|
||||
// Legacy: Gib die Nachricht zurück
|
||||
return {
|
||||
message: this.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default HttpError;
|
||||
|
||||
@@ -6,6 +6,9 @@ export const authenticate = async (req, res, next) => {
|
||||
if (!token) {
|
||||
token = req.headers['authcode'];
|
||||
}
|
||||
if (!token) {
|
||||
token = req.query?.authcode || req.query?.token || null;
|
||||
}
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Unauthorized: Token fehlt' });
|
||||
}
|
||||
|
||||
217
backend/middleware/authorizationMiddleware.js
Normal file
217
backend/middleware/authorizationMiddleware.js
Normal file
@@ -0,0 +1,217 @@
|
||||
import permissionService from '../services/permissionService.js';
|
||||
|
||||
/**
|
||||
* Authorization Middleware
|
||||
* Checks if user has permission to access a resource
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if user has permission for a specific resource and action
|
||||
* @param {string} resource - Resource name (diary, members, teams, etc.)
|
||||
* @param {string} action - Action type (read, write, delete)
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
export const authorize = (resource, action = 'read') => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Nicht authentifiziert' });
|
||||
}
|
||||
|
||||
// Get clubId from various possible sources
|
||||
const clubId =
|
||||
req.params.clubId ??
|
||||
req.params.clubid ??
|
||||
req.params.id ??
|
||||
req.body.clubId ??
|
||||
req.body.clubid ??
|
||||
req.query.clubId ??
|
||||
req.query.clubid;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ error: 'Club-ID fehlt' });
|
||||
}
|
||||
|
||||
// Check permission
|
||||
const hasPermission = await permissionService.hasPermission(
|
||||
userId,
|
||||
parseInt(clubId),
|
||||
resource,
|
||||
action
|
||||
);
|
||||
|
||||
if (!hasPermission) {
|
||||
return res.status(403).json({
|
||||
error: 'Keine Berechtigung',
|
||||
details: `Fehlende Berechtigung: ${resource}.${action}`
|
||||
});
|
||||
}
|
||||
|
||||
// Store permissions in request for later use
|
||||
const userPermissions = await permissionService.getUserClubPermissions(
|
||||
userId,
|
||||
parseInt(clubId)
|
||||
);
|
||||
req.userPermissions = userPermissions;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Authorization error:', error);
|
||||
res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user is club owner
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
export const requireOwner = () => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Nicht authentifiziert' });
|
||||
}
|
||||
|
||||
const clubId =
|
||||
req.params.clubId ??
|
||||
req.params.clubid ??
|
||||
req.params.id ??
|
||||
req.body.clubId ??
|
||||
req.body.clubid ??
|
||||
req.query.clubId ??
|
||||
req.query.clubid;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ error: 'Club-ID fehlt' });
|
||||
}
|
||||
|
||||
const userPermissions = await permissionService.getUserClubPermissions(
|
||||
userId,
|
||||
parseInt(clubId)
|
||||
);
|
||||
|
||||
if (!userPermissions || !userPermissions.isOwner) {
|
||||
return res.status(403).json({
|
||||
error: 'Keine Berechtigung',
|
||||
details: 'Nur der Club-Ersteller hat Zugriff'
|
||||
});
|
||||
}
|
||||
|
||||
req.userPermissions = userPermissions;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Owner check error:', error);
|
||||
res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user is admin (owner or admin role)
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
export const requireAdmin = () => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Nicht authentifiziert' });
|
||||
}
|
||||
|
||||
const clubId =
|
||||
req.params.clubId ??
|
||||
req.params.clubid ??
|
||||
req.params.id ??
|
||||
req.body.clubId ??
|
||||
req.body.clubid ??
|
||||
req.query.clubId ??
|
||||
req.query.clubid;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ error: 'Club-ID fehlt' });
|
||||
}
|
||||
|
||||
const userPermissions = await permissionService.getUserClubPermissions(
|
||||
userId,
|
||||
parseInt(clubId)
|
||||
);
|
||||
|
||||
if (!userPermissions || (!userPermissions.isAdmin && !userPermissions.isOwner)) {
|
||||
return res.status(403).json({
|
||||
error: 'Keine Berechtigung',
|
||||
details: 'Administrator-Rechte erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
req.userPermissions = userPermissions;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Admin check error:', error);
|
||||
res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has any of the specified roles
|
||||
* @param {string[]} roles - Array of allowed roles
|
||||
* @returns {Function} Express middleware function
|
||||
*/
|
||||
export const requireRole = (roles) => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: 'Nicht authentifiziert' });
|
||||
}
|
||||
|
||||
const clubId =
|
||||
req.params.clubId ??
|
||||
req.params.clubid ??
|
||||
req.params.id ??
|
||||
req.body.clubId ??
|
||||
req.body.clubid ??
|
||||
req.query.clubId ??
|
||||
req.query.clubid;
|
||||
|
||||
if (!clubId) {
|
||||
return res.status(400).json({ error: 'Club-ID fehlt' });
|
||||
}
|
||||
|
||||
const userPermissions = await permissionService.getUserClubPermissions(
|
||||
userId,
|
||||
parseInt(clubId)
|
||||
);
|
||||
|
||||
const assignedRoleKeys = Array.isArray(userPermissions?.roles)
|
||||
? userPermissions.roles.map((role) => role.roleKey)
|
||||
: [];
|
||||
if (!userPermissions || (!roles.includes(userPermissions.role) && !assignedRoleKeys.some((roleKey) => roles.includes(roleKey)))) {
|
||||
return res.status(403).json({
|
||||
error: 'Keine Berechtigung',
|
||||
details: `Erforderliche Rolle: ${roles.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
req.userPermissions = userPermissions;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Role check error:', error);
|
||||
res.status(500).json({ error: 'Fehler bei der Berechtigungsprüfung' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
authorize,
|
||||
requireOwner,
|
||||
requireAdmin,
|
||||
requireRole
|
||||
};
|
||||
13
backend/middleware/requestLoggingMiddleware.js
Normal file
13
backend/middleware/requestLoggingMiddleware.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Middleware to log all API requests and responses
|
||||
* Should be added early in the middleware chain, but after authentication
|
||||
*
|
||||
* HINWEIS: Logging wurde deaktiviert - keine API-Requests werden mehr geloggt
|
||||
* (früher wurden nur MyTischtennis-Requests geloggt, dies wurde entfernt)
|
||||
*/
|
||||
export const requestLoggingMiddleware = async (req, res, next) => {
|
||||
// Logging wurde deaktiviert - keine API-Requests werden mehr geloggt
|
||||
// (früher wurden nur MyTischtennis-Requests geloggt, dies wurde entfernt)
|
||||
next();
|
||||
};
|
||||
|
||||
17
backend/migrations/20251111_add_member_images.sql
Normal file
17
backend/migrations/20251111_add_member_images.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Create table for storing multiple images per member
|
||||
CREATE TABLE IF NOT EXISTS `member_image` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`member_id` INT NOT NULL,
|
||||
`file_name` VARCHAR(255) NOT NULL,
|
||||
`sort_order` INT NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_member_image_member_id` (`member_id`),
|
||||
CONSTRAINT `fk_member_image_member`
|
||||
FOREIGN KEY (`member_id`)
|
||||
REFERENCES `member` (`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
58
backend/migrations/20251213_add_tournament_stages.sql
Normal file
58
backend/migrations/20251213_add_tournament_stages.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- Adds multi-stage tournaments (rounds) support
|
||||
-- MariaDB/MySQL compatible migration (manual execution)
|
||||
|
||||
-- 1) New table: tournament_stage
|
||||
CREATE TABLE IF NOT EXISTS tournament_stage (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tournament_id INT NOT NULL,
|
||||
stage_index INT NOT NULL,
|
||||
name VARCHAR(255) NULL,
|
||||
type VARCHAR(32) NOT NULL, -- 'groups' | 'knockout'
|
||||
number_of_groups INT NULL,
|
||||
advancing_per_group INT NULL,
|
||||
max_group_size INT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_tournament_stage_tournament
|
||||
FOREIGN KEY (tournament_id) REFERENCES tournament(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE INDEX idx_tournament_stage_tournament_id ON tournament_stage (tournament_id);
|
||||
CREATE UNIQUE INDEX uq_tournament_stage_tournament_id_index ON tournament_stage (tournament_id, stage_index);
|
||||
|
||||
-- 2) New table: tournament_stage_advancement
|
||||
CREATE TABLE IF NOT EXISTS tournament_stage_advancement (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
tournament_id INT NOT NULL,
|
||||
from_stage_id INT NOT NULL,
|
||||
to_stage_id INT NOT NULL,
|
||||
mode VARCHAR(32) NOT NULL DEFAULT 'pools',
|
||||
config JSON NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_tournament_stage_adv_tournament
|
||||
FOREIGN KEY (tournament_id) REFERENCES tournament(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tournament_stage_adv_from
|
||||
FOREIGN KEY (from_stage_id) REFERENCES tournament_stage(id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_tournament_stage_adv_to
|
||||
FOREIGN KEY (to_stage_id) REFERENCES tournament_stage(id)
|
||||
ON DELETE CASCADE
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE INDEX idx_tournament_stage_adv_tournament_id ON tournament_stage_advancement (tournament_id);
|
||||
CREATE INDEX idx_tournament_stage_adv_from_stage_id ON tournament_stage_advancement (from_stage_id);
|
||||
CREATE INDEX idx_tournament_stage_adv_to_stage_id ON tournament_stage_advancement (to_stage_id);
|
||||
|
||||
-- 3) Add stage_id to tournament_group and tournament_match
|
||||
-- MariaDB has no IF NOT EXISTS for columns; run each ALTER once.
|
||||
-- If you rerun, comment out the ALTERs or check INFORMATION_SCHEMA first.
|
||||
ALTER TABLE tournament_group ADD COLUMN stage_id INT NULL;
|
||||
ALTER TABLE tournament_match ADD COLUMN stage_id INT NULL;
|
||||
|
||||
CREATE INDEX idx_tournament_group_tournament_stage ON tournament_group (tournament_id, stage_id);
|
||||
CREATE INDEX idx_tournament_match_tournament_stage ON tournament_match (tournament_id, stage_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Allow NULL placeholders for KO (e.g. "Spiel um Platz 3")
|
||||
-- MariaDB/MySQL manual migration
|
||||
--
|
||||
-- Background: We create placeholder matches with player1_id/player2_id = NULL.
|
||||
-- Some prod DBs still have NOT NULL on these columns.
|
||||
|
||||
-- 1) Make player columns nullable
|
||||
ALTER TABLE tournament_match MODIFY COLUMN player1_id INT NULL;
|
||||
ALTER TABLE tournament_match MODIFY COLUMN player2_id INT NULL;
|
||||
|
||||
-- 2) (Optional) If you have foreign keys to tournament_member/external participant IDs,
|
||||
-- ensure they also allow NULL. (Not adding here because not all installations have FKs.)
|
||||
|
||||
-- 3) Verify
|
||||
-- SHOW COLUMNS FROM tournament_match LIKE 'player1_id';
|
||||
-- SHOW COLUMNS FROM tournament_match LIKE 'player2_id';
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Add pool_id to tournament_group for pooled group phases
|
||||
ALTER TABLE `tournament_group`
|
||||
ADD COLUMN `pool_id` INT NULL AFTER `class_id`;
|
||||
|
||||
-- Add out_of_competition flags
|
||||
ALTER TABLE `tournament_member`
|
||||
ADD COLUMN `out_of_competition` TINYINT(1) NOT NULL DEFAULT 0 AFTER `class_id`;
|
||||
|
||||
ALTER TABLE `external_tournament_participant`
|
||||
ADD COLUMN `out_of_competition` TINYINT(1) NOT NULL DEFAULT 0 AFTER `class_id`;
|
||||
|
||||
3
backend/migrations/20260107_change_accident_to_text.sql
Normal file
3
backend/migrations/20260107_change_accident_to_text.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Change accident field from VARCHAR to TEXT to allow longer descriptions
|
||||
ALTER TABLE `accident`
|
||||
MODIFY COLUMN `accident` TEXT NOT NULL;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- E-Mail und Adresse für externe Teilnehmer (für Weitermeldung)
|
||||
-- Die Felder werden verschlüsselt gespeichert (siehe Model)
|
||||
|
||||
ALTER TABLE `external_tournament_participant`
|
||||
ADD COLUMN `email` VARCHAR(500) NULL AFTER `club`,
|
||||
ADD COLUMN `address` TEXT NULL AFTER `email`;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Add gave_up (Aufgabe) to tournament participants
|
||||
-- Wenn ein Spieler aufgibt: alle seine Spiele zählen für den Gegner (11:0), beide aufgegeben = 0:0, kein Sieger
|
||||
|
||||
ALTER TABLE `tournament_member`
|
||||
ADD COLUMN `gave_up` TINYINT(1) NOT NULL DEFAULT 0 AFTER `out_of_competition`;
|
||||
|
||||
ALTER TABLE `external_tournament_participant`
|
||||
ADD COLUMN `gave_up` TINYINT(1) NOT NULL DEFAULT 0 AFTER `out_of_competition`;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Minimeisterschaften: Turnier-Jahr und Alters-Obergrenze pro Klasse
|
||||
-- tournament.mini_championship_year: Jahr der Minimeisterschaft (z.B. 2025); nur gesetzt bei Minimeisterschaften
|
||||
-- tournament_class.max_birth_year: Geboren im Jahr X oder früher (<=); für Altersklassen 12/10
|
||||
|
||||
ALTER TABLE `tournament`
|
||||
ADD COLUMN `mini_championship_year` INT NULL AFTER `allows_external`;
|
||||
|
||||
ALTER TABLE `tournament_class`
|
||||
ADD COLUMN `max_birth_year` INT NULL AFTER `min_birth_year`;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Anzahl der Tische im Turnier
|
||||
ALTER TABLE tournament
|
||||
ADD COLUMN number_of_tables INT NULL DEFAULT NULL
|
||||
COMMENT 'Anzahl der Tische, auf denen gespielt wird';
|
||||
|
||||
-- Tischnummer pro Match
|
||||
ALTER TABLE tournament_match
|
||||
ADD COLUMN table_number INT NULL DEFAULT NULL
|
||||
COMMENT 'Tischnummer, an der das Match stattfindet';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Felder für "Passwort vergessen"-Funktion
|
||||
ALTER TABLE user
|
||||
ADD COLUMN reset_token VARCHAR(255) NULL DEFAULT NULL
|
||||
COMMENT 'Token für Passwort-Reset';
|
||||
|
||||
ALTER TABLE user
|
||||
ADD COLUMN reset_token_expires DATETIME NULL DEFAULT NULL
|
||||
COMMENT 'Ablaufzeitpunkt des Reset-Tokens';
|
||||
10
backend/migrations/20260310_add_clicktt_payload_logging.sql
Normal file
10
backend/migrations/20260310_add_clicktt_payload_logging.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
ALTER TABLE http_page_fetch_log
|
||||
ADD COLUMN IF NOT EXISTS request_method VARCHAR(16) NULL COMMENT 'HTTP-Methode des ausgehenden Requests' AFTER club_id_param,
|
||||
ADD COLUMN IF NOT EXISTS request_headers LONGTEXT NULL COMMENT 'Gesendete Request-Header als JSON' AFTER request_method,
|
||||
ADD COLUMN IF NOT EXISTS request_body LONGTEXT NULL COMMENT 'Gesendeter Request-Body im Originalformat' AFTER request_headers,
|
||||
ADD COLUMN IF NOT EXISTS response_headers LONGTEXT NULL COMMENT 'Empfangene Response-Header als JSON' AFTER content_type,
|
||||
ADD COLUMN IF NOT EXISTS response_body LONGTEXT NULL COMMENT 'Vollstaendiger Response-Body' AFTER response_headers,
|
||||
ADD COLUMN IF NOT EXISTS response_url TEXT NULL COMMENT 'Finale Response-URL nach Redirects' AFTER response_body;
|
||||
|
||||
ALTER TABLE http_page_fetch_log
|
||||
MODIFY COLUMN response_snippet LONGTEXT NULL COMMENT 'Gekuerzter oder kompletter Response-Anfang zur Strukturanalyse';
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration: Add myTischtennis rankings settings to clubs table
|
||||
-- Enables per-club configuration of TTR/QTTR rankings fetch.
|
||||
-- Club number comes from association_member_number (Verbands-Mitgliedsnummer).
|
||||
|
||||
ALTER TABLE clubs
|
||||
ADD COLUMN IF NOT EXISTS my_tischtennis_fed_nickname VARCHAR(50) NULL
|
||||
COMMENT 'Federation short name for rankings (e.g. HeTTV)',
|
||||
ADD COLUMN IF NOT EXISTS auto_fetch_rankings BOOLEAN NOT NULL DEFAULT FALSE
|
||||
COMMENT 'Enable automatic TTR/QTTR rankings fetch for this club';
|
||||
36
backend/migrations/20260324_create_member_orders_tables.sql
Normal file
36
backend/migrations/20260324_create_member_orders_tables.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE IF NOT EXISTS member_orders (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
member_id INT NOT NULL,
|
||||
club_id INT NOT NULL,
|
||||
item VARCHAR(255) NOT NULL,
|
||||
status ENUM('requested', 'ordered', 'arrived', 'handed_over') NOT NULL DEFAULT 'requested',
|
||||
order_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
status_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
cost DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_member_orders_member_id (member_id),
|
||||
KEY idx_member_orders_club_id (club_id),
|
||||
KEY idx_member_orders_status (status)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS member_order_history (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
member_order_id INT NOT NULL,
|
||||
member_id INT NOT NULL,
|
||||
club_id INT NOT NULL,
|
||||
item VARCHAR(255) NOT NULL,
|
||||
status ENUM('requested', 'ordered', 'arrived', 'handed_over') NOT NULL,
|
||||
changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
cost DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_member_order_history_order_id (member_order_id),
|
||||
KEY idx_member_order_history_member_id (member_id),
|
||||
KEY idx_member_order_history_club_id (club_id),
|
||||
KEY idx_member_order_history_changed_at (changed_at)
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Jugend-Freigaben (Schema wie backend/models/Member.js)
|
||||
-- Fehlende Spalten verursachen SequelizeDatabaseError ER_BAD_FIELD_ERROR bei getClubMembers.
|
||||
|
||||
ALTER TABLE `member`
|
||||
ADD COLUMN `adult_release_approved` TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT 'Jugendspieler mit Freigabe fuer Erwachsene'
|
||||
AFTER `member_form_handed_over`,
|
||||
ADD COLUMN `adult_reserve_approved` TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT 'Jugendspieler als Ersatz bei Erwachsenen zugelassen'
|
||||
AFTER `adult_release_approved`;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- club_team: Felder wie backend/models/ClubTeam.js (teamGender, teamAgeGroup)
|
||||
-- Fehlen in der DB -> SequelizeDatabaseError ER_BAD_FIELD_ERROR bei getClubTeams.
|
||||
|
||||
ALTER TABLE `club_team`
|
||||
ADD COLUMN `team_gender` ENUM('open', 'female') NOT NULL DEFAULT 'open'
|
||||
COMMENT 'Geschlecht Team (offen / nur weiblich)'
|
||||
AFTER `my_tischtennis_team_id`,
|
||||
ADD COLUMN `team_age_group` ENUM('adult', 'J19', 'J17', 'J15', 'J13', 'J11') NOT NULL DEFAULT 'adult'
|
||||
COMMENT 'Altersklasse Mannschaft'
|
||||
AFTER `team_gender`;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user