Compare commits
262 Commits
dd93755e6b
...
multifunct
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac97332e6f | ||
|
|
90e6c2f9f6 | ||
|
|
dd91c07694 | ||
|
|
302ed20a9b | ||
|
|
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 |
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"
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -9,3 +9,12 @@ 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
|
||||
|
||||
0
CODEx_MIGRATION_RULES.md
Normal file
0
CODEx_MIGRATION_RULES.md
Normal file
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.
|
||||
@@ -9,12 +9,12 @@ Dieses System ermöglicht den automatischen Abruf von Spielergebnissen und Stati
|
||||
### 6:00 Uhr - Rating Updates
|
||||
- **Service:** `autoUpdateRatingsService.js`
|
||||
- **Funktion:** Aktualisiert TTR/QTTR-Werte für Spieler
|
||||
- **TODO:** Implementierung der eigentlichen Rating-Update-Logik
|
||||
- **Status:** ✅ Aktiv. Nutzt `memberService.updateRatingsFromMyTischtennisByUserId(...)` pro Verein ueber einen freigeschalteten Benutzer mit gespeichertem myTischtennis-Login.
|
||||
|
||||
### 6:30 Uhr - Spielergebnisse
|
||||
- **Service:** `autoFetchMatchResultsService.js`
|
||||
- **Funktion:** Ruft Spielerbilanzen für konfigurierte Teams ab
|
||||
- **Status:** ✅ Grundlegende Implementierung fertig
|
||||
- **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
|
||||
|
||||
@@ -107,6 +107,7 @@ Von der myTischtennis API werden folgende Daten abgerufen:
|
||||
- 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
|
||||
@@ -134,7 +135,8 @@ Von der myTischtennis API werden folgende Daten abgerufen:
|
||||
- Parst JSON-Response
|
||||
- Matched Spieler anhand von ID oder Name
|
||||
- Speichert myTischtennis Player-ID bei Mitgliedern
|
||||
- Loggt Statistiken
|
||||
- verarbeitet auch Doppelpartner-Zuordnungen
|
||||
- speichert/aktualisiert Spiele und Ligatabellen
|
||||
|
||||
### Player-Matching-Algorithmus
|
||||
|
||||
@@ -148,25 +150,21 @@ Von der myTischtennis API werden folgende Daten abgerufen:
|
||||
|
||||
## TODO / Offene Punkte
|
||||
|
||||
### Noch zu implementieren:
|
||||
### Noch offen:
|
||||
|
||||
1. **TTR/QTTR Updates** (6:00 Uhr Job):
|
||||
- Endpoint für TTR/QTTR-Daten identifizieren
|
||||
- Daten abrufen und in Member-Tabelle speichern
|
||||
|
||||
2. **Spielergebnis-Details**:
|
||||
1. **Spielergebnis-Details**:
|
||||
- Einzelne Matches mit Satzständen speichern
|
||||
- Tabelle für Match-Historie erstellen
|
||||
|
||||
3. **History-Tabelle für Spielergebnis-Abrufe** (optional):
|
||||
2. **History-Tabelle für Spielergebnis-Abrufe** (optional):
|
||||
- Ähnlich zu `my_tischtennis_update_history`
|
||||
- Speichert Erfolg/Fehler der Abrufe
|
||||
|
||||
4. **Benachrichtigungen** (optional):
|
||||
3. **Benachrichtigungen** (optional):
|
||||
- Email/Push bei neuen Ergebnissen
|
||||
- Highlights für besondere Siege
|
||||
|
||||
5. **Performance-Optimierung**:
|
||||
4. **Performance-Optimierung**:
|
||||
- Caching für Player-Matches
|
||||
- Incremental Updates (nur neue Daten)
|
||||
|
||||
@@ -183,6 +181,14 @@ await schedulerService.triggerRatingUpdates();
|
||||
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
|
||||
@@ -209,4 +215,3 @@ https://www.mytischtennis.de/click-tt/{association}/{season}/ligen/{groupname}/g
|
||||
- ✅ Passwörter verschlüsselt gespeichert
|
||||
- ✅ Fehlerbehandlung und Logging
|
||||
- ✅ Graceful Degradation (einzelne Team-Fehler stoppen nicht den gesamten Prozess)
|
||||
|
||||
|
||||
@@ -869,7 +869,8 @@ class MyTischtennisClient {
|
||||
* @param {string} fedNickname - Federation nickname (e.g., "HeTTV")
|
||||
* @returns {Promise<Object>} Rankings with player entries (all pages)
|
||||
*/
|
||||
async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes') {
|
||||
async getClubRankings(cookie, clubId, fedNickname, currentRanking = 'yes', options = {}) {
|
||||
const { includeHistoryPlayerIds = false } = options;
|
||||
const allEntries = [];
|
||||
let currentPage = 0;
|
||||
let hasMorePages = true;
|
||||
@@ -877,8 +878,6 @@ class MyTischtennisClient {
|
||||
|
||||
while (hasMorePages) {
|
||||
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'
|
||||
});
|
||||
@@ -917,15 +916,39 @@ 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
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
// 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;
|
||||
} else if (rankingData.numberOfPages && currentPage >= rankingData.numberOfPages - 1) {
|
||||
hasMorePages = false;
|
||||
@@ -946,6 +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,4 +1,4 @@
|
||||
import { register, activateUser, login, logout, requestPasswordReset, resetPassword } from '../services/authService.js';
|
||||
import { register, activateUser, login, logout, deleteOwnAccount, requestPasswordReset, resetPassword } from '../services/authService.js';
|
||||
|
||||
const registerUser = async (req, res, next) => {
|
||||
try {
|
||||
@@ -27,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);
|
||||
@@ -37,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) {
|
||||
@@ -45,6 +45,16 @@ const logoutUser = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -65,4 +75,4 @@ const resetUserPassword = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
export { registerUser, activate, loginUser, logoutUser, forgotPassword, resetUserPassword };
|
||||
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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
55
backend/controllers/calendarEventController.js
Normal file
55
backend/controllers/calendarEventController.js
Normal file
@@ -0,0 +1,55 @@
|
||||
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, req.user?.id || null);
|
||||
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 updateClubCalendarEvent = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, eventId } = req.params;
|
||||
const event = await calendarEventService.updateClubEvent(userToken, clubId, eventId, req.body, req.user?.id || null);
|
||||
res.status(200).json(event);
|
||||
} catch (error) {
|
||||
console.error('[updateClubCalendarEvent] - 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();
|
||||
93
backend/controllers/clubAccountController.js
Normal file
93
backend/controllers/clubAccountController.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import clubAccountService from '../services/clubAccountService.js';
|
||||
|
||||
class ClubAccountController {
|
||||
async listClubAccounts(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = await clubAccountService.listClubAccounts(Number(clubId));
|
||||
res.json(payload);
|
||||
} 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.' });
|
||||
}
|
||||
}
|
||||
|
||||
async createTransaction(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const transaction = await clubAccountService.createTransaction(Number(clubId), req.user?.id || null, req.body || {});
|
||||
res.status(201).json({ transaction });
|
||||
} catch (error) {
|
||||
console.error('[createClubAccountTransaction] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateTransaction(req, res) {
|
||||
try {
|
||||
const { clubId, transactionId } = req.params;
|
||||
const transaction = await clubAccountService.updateTransaction(Number(clubId), Number(transactionId), req.body || {});
|
||||
res.json({ transaction });
|
||||
} catch (error) {
|
||||
console.error('[updateClubAccountTransaction] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTransaction(req, res) {
|
||||
try {
|
||||
const { clubId, transactionId } = req.params;
|
||||
await clubAccountService.deleteTransaction(Number(clubId), Number(transactionId));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteClubAccountTransaction] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Kontenbewegung 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();
|
||||
132
backend/controllers/clubCommunicationController.js
Normal file
132
backend/controllers/clubCommunicationController.js
Normal file
@@ -0,0 +1,132 @@
|
||||
import clubCommunicationService from '../services/clubCommunicationService.js';
|
||||
|
||||
function handleError(res, scope, error) {
|
||||
console.error(`[${scope}] - Error:`, error);
|
||||
res.status(error?.status || 500).json({
|
||||
error: error?.message || 'internalerror',
|
||||
});
|
||||
}
|
||||
|
||||
const clubCommunicationController = {
|
||||
async list(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = await clubCommunicationService.listClubCommunication(Number(clubId));
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
handleError(res, 'listClubCommunication', error);
|
||||
}
|
||||
},
|
||||
|
||||
async createThread(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const thread = await clubCommunicationService.createThread(Number(clubId), req.user?.id || null, req.body || {});
|
||||
res.status(201).json({ thread });
|
||||
} catch (error) {
|
||||
handleError(res, 'createClubCommunicationThread', error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateThread(req, res) {
|
||||
try {
|
||||
const { clubId, threadId } = req.params;
|
||||
const thread = await clubCommunicationService.updateThread(Number(clubId), Number(threadId), req.body || {});
|
||||
res.json({ thread });
|
||||
} catch (error) {
|
||||
handleError(res, 'updateClubCommunicationThread', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteThread(req, res) {
|
||||
try {
|
||||
const { clubId, threadId } = req.params;
|
||||
await clubCommunicationService.deleteThread(Number(clubId), Number(threadId));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
handleError(res, 'deleteClubCommunicationThread', error);
|
||||
}
|
||||
},
|
||||
|
||||
async addMessage(req, res) {
|
||||
try {
|
||||
const { clubId, threadId } = req.params;
|
||||
const message = await clubCommunicationService.addMessage(Number(clubId), Number(threadId), req.user?.id || null, req.body || {});
|
||||
res.status(201).json({ message });
|
||||
} catch (error) {
|
||||
handleError(res, 'addClubCommunicationMessage', error);
|
||||
}
|
||||
},
|
||||
|
||||
async sendThread(req, res) {
|
||||
try {
|
||||
const { clubId, threadId } = req.params;
|
||||
const thread = await clubCommunicationService.sendThread(Number(clubId), Number(threadId), req.user?.id || null);
|
||||
res.json({ thread });
|
||||
} catch (error) {
|
||||
handleError(res, 'sendClubCommunicationThread', error);
|
||||
}
|
||||
},
|
||||
|
||||
async createGroup(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const group = await clubCommunicationService.createGroup(Number(clubId), req.body || {});
|
||||
res.status(201).json({ group });
|
||||
} catch (error) {
|
||||
handleError(res, 'createClubDistributionGroup', error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateGroup(req, res) {
|
||||
try {
|
||||
const { clubId, groupId } = req.params;
|
||||
const group = await clubCommunicationService.updateGroup(Number(clubId), Number(groupId), req.body || {});
|
||||
res.json({ group });
|
||||
} catch (error) {
|
||||
handleError(res, 'updateClubDistributionGroup', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteGroup(req, res) {
|
||||
try {
|
||||
const { clubId, groupId } = req.params;
|
||||
await clubCommunicationService.deleteGroup(Number(clubId), Number(groupId));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
handleError(res, 'deleteClubDistributionGroup', error);
|
||||
}
|
||||
},
|
||||
|
||||
async createTemplate(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const template = await clubCommunicationService.createTemplate(Number(clubId), req.body || {});
|
||||
res.status(201).json({ template });
|
||||
} catch (error) {
|
||||
handleError(res, 'createClubCommunicationTemplate', error);
|
||||
}
|
||||
},
|
||||
|
||||
async updateTemplate(req, res) {
|
||||
try {
|
||||
const { clubId, templateId } = req.params;
|
||||
const template = await clubCommunicationService.updateTemplate(Number(clubId), Number(templateId), req.body || {});
|
||||
res.json({ template });
|
||||
} catch (error) {
|
||||
handleError(res, 'updateClubCommunicationTemplate', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteTemplate(req, res) {
|
||||
try {
|
||||
const { clubId, templateId } = req.params;
|
||||
await clubCommunicationService.deleteTemplate(Number(clubId), Number(templateId));
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
handleError(res, 'deleteClubCommunicationTemplate', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default clubCommunicationController;
|
||||
671
backend/controllers/clubDashboardController.js
Normal file
671
backend/controllers/clubDashboardController.js
Normal file
@@ -0,0 +1,671 @@
|
||||
import { Op } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import {
|
||||
CalendarEvent,
|
||||
ClubCommunicationThread,
|
||||
ClubInvoice,
|
||||
ClubPaymentClaim,
|
||||
ClubRequest,
|
||||
ClubSepaMandate,
|
||||
ClubTask,
|
||||
Match,
|
||||
Member,
|
||||
TrainingGroup,
|
||||
} from '../models/index.js';
|
||||
import clubArchiveService from '../services/clubArchiveService.js';
|
||||
import { hasClubPaymentClaimPaidAmountCentsColumn } from '../services/clubPaymentClaimCompatibility.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',
|
||||
sponsoring_offer_prepared: 'Sponsoringangebot vorbereitet',
|
||||
sponsoring_followed_up: 'Sponsoring nachgefasst',
|
||||
}[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',
|
||||
sponsoring_prepare_offer: 'Sponsoringangebot vorbereiten',
|
||||
sponsoring_follow_up: 'Sponsoring nachfassen',
|
||||
document_review_required: 'Dokument prüfen',
|
||||
sponsor_contract_renewal: 'Sponsoring verlängern',
|
||||
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 formatCommunicationStatus(status) {
|
||||
return {
|
||||
draft: 'Entwurf',
|
||||
scheduled: 'Geplant',
|
||||
sent: 'Versendet',
|
||||
archived: 'Archiviert',
|
||||
}[status] || status || 'Status';
|
||||
}
|
||||
|
||||
function formatInvoiceStatus(status) {
|
||||
return {
|
||||
draft: 'Entwurf',
|
||||
issued: 'Gestellt',
|
||||
partially_paid: 'Teilbezahlt',
|
||||
paid: 'Bezahlt',
|
||||
cancelled: 'Storniert',
|
||||
archived: 'Archiviert',
|
||||
}[status] || status || 'Status';
|
||||
}
|
||||
|
||||
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 hasPaidAmountCentsColumn = await hasClubPaymentClaimPaidAmountCentsColumn();
|
||||
|
||||
const [
|
||||
requests,
|
||||
tasks,
|
||||
members,
|
||||
mandates,
|
||||
paymentClaims,
|
||||
upcomingEvents,
|
||||
trainingGroups,
|
||||
upcomingMatches,
|
||||
communicationThreads,
|
||||
invoices,
|
||||
archive,
|
||||
] = 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'],
|
||||
})),
|
||||
hasPaidAmountCentsColumn
|
||||
? loadOptionalTableData(availableTables, 'club_payment_claims', () => ClubPaymentClaim.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
status: { [Op.in]: ['open', 'partially_paid'] },
|
||||
archivedAt: null,
|
||||
},
|
||||
order: [['dueOn', 'ASC']],
|
||||
}))
|
||||
: Promise.resolve([]),
|
||||
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,
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'club_communication_threads', () => ClubCommunicationThread.findAll({
|
||||
where: { clubId },
|
||||
attributes: ['id', 'subject', 'status', 'threadType', 'scheduledAt', 'sentAt', 'updatedAt'],
|
||||
order: [['updatedAt', 'DESC'], ['createdAt', 'DESC']],
|
||||
limit: 8,
|
||||
})),
|
||||
loadOptionalTableData(availableTables, 'club_invoices', () => ClubInvoice.findAll({
|
||||
where: {
|
||||
clubId,
|
||||
archivedAt: null,
|
||||
},
|
||||
attributes: ['id', 'invoiceNumber', 'invoiceDirection', 'invoiceType', 'status', 'dueOn', 'grossAmountCents', 'currencyCode', 'updatedAt'],
|
||||
order: [['updatedAt', 'DESC'], ['dueOn', 'ASC']],
|
||||
limit: 8,
|
||||
})),
|
||||
clubArchiveService.getClubArchive(clubId),
|
||||
]);
|
||||
|
||||
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 openInvoices = invoices.filter((invoice) => ['draft', 'issued', 'partially_paid'].includes(invoice.status));
|
||||
const outgoingOpenInvoices = openInvoices.filter((invoice) => invoice.invoiceDirection === 'outgoing');
|
||||
const incomingOpenInvoices = openInvoices.filter((invoice) => invoice.invoiceDirection === 'incoming');
|
||||
const communicationDraftCount = communicationThreads.filter((thread) => thread.status === 'draft').length;
|
||||
const communicationScheduledCount = communicationThreads.filter((thread) => thread.status === 'scheduled').length;
|
||||
const communicationSentCount = communicationThreads.filter((thread) => thread.status === 'sent').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',
|
||||
accent: 'amber',
|
||||
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',
|
||||
accent: 'green',
|
||||
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: [
|
||||
duePaymentCount > 0 ? `${duePaymentCount} fällig offen` : null,
|
||||
reminderCount > 0 ? `${reminderCount} mit Mahnstufe` : null,
|
||||
].filter(Boolean).join(' · ') || 'Keine Mahnungen aktiv',
|
||||
to: '/club-payments',
|
||||
accent: 'red',
|
||||
items: paymentClaims.slice(0, 3).map((claim) => {
|
||||
const amount = `${(Number(claim.amountCents) / 100).toFixed(2)} ${claim.currencyCode || 'EUR'}`;
|
||||
return createDashboardItem(
|
||||
`${amount} · ${claim.dueOn ? `fällig ${claim.dueOn}` : 'ohne Fälligkeit'}`,
|
||||
claim.memberId ? buildMemberRoute(claim.memberId, 'active') : '/club-payments'
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: 'Fehlende Daten',
|
||||
value: `${missingFields.email + missingFields.birthDate + missingMandateCount}`,
|
||||
to: '/members',
|
||||
accent: 'blue',
|
||||
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',
|
||||
accent: overdueTaskCount > 0 ? 'red' : 'green',
|
||||
items: [
|
||||
createDashboardItem(`${automatedOpenTasks.length} automatisch erzeugte Schritte`, '/club-tasks'),
|
||||
...visibleDashboardTasks.slice(0, 3).map((task) => taskDashboardItem(task, task.title)),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'operations-room',
|
||||
title: 'Kommunikation, Finanzen und Archiv',
|
||||
cards: [
|
||||
{
|
||||
title: 'Kommunikation',
|
||||
value: `${communicationThreads.length}`,
|
||||
meta: `${communicationDraftCount} Entwürfe, ${communicationScheduledCount} geplant, ${communicationSentCount} versendet`,
|
||||
to: '/club-communication',
|
||||
accent: 'blue',
|
||||
items: communicationThreads.slice(0, 3).map((thread) => createDashboardItem(
|
||||
`${thread.subject || 'Ohne Betreff'} · ${formatCommunicationStatus(thread.status)}`,
|
||||
'/club-communication'
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: 'Rechnungen',
|
||||
value: `${openInvoices.length}`,
|
||||
meta: `${outgoingOpenInvoices.length} ausgehend, ${incomingOpenInvoices.length} eingehend`,
|
||||
to: '/club-invoices',
|
||||
accent: 'amber',
|
||||
items: openInvoices.slice(0, 3).map((invoice) => createDashboardItem(
|
||||
`${invoice.invoiceNumber || `Rechnung #${invoice.id}`} · ${formatInvoiceStatus(invoice.status)}`,
|
||||
'/club-invoices'
|
||||
)),
|
||||
},
|
||||
{
|
||||
title: 'Archiv',
|
||||
value: `${archive.summary.archivedRequests + archive.summary.archivedTasks + archive.summary.archivedClaims}`,
|
||||
meta: `${archive.summary.inactiveMembers} inaktive Mitglieder`,
|
||||
to: '/club-archive',
|
||||
accent: 'neutral',
|
||||
items: [
|
||||
createDashboardItem(`${archive.summary.archivedRequests} archivierte Anfragen`, '/club-archive'),
|
||||
createDashboardItem(`${archive.summary.archivedTasks} archivierte Aufgaben`, '/club-archive'),
|
||||
createDashboardItem(`${archive.summary.archivedClaims} archivierte Beitragsfälle`, '/club-archive'),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
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 === 'club_documents'
|
||||
? 'Dokument'
|
||||
: task.automationSource === 'club_invoice_parties'
|
||||
? 'Sponsor'
|
||||
: task.automationSource === 'club_invoices'
|
||||
? 'Rechnung'
|
||||
: task.automationSource === 'club_communication'
|
||||
? 'Kommunikation'
|
||||
: 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.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
111
backend/controllers/clubDocumentController.js
Normal file
111
backend/controllers/clubDocumentController.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import multer from 'multer';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import clubDocumentService from '../services/clubDocumentService.js';
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
fs.mkdirSync('uploads/temp', { recursive: true });
|
||||
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,
|
||||
limits: {
|
||||
fileSize: 20 * 1024 * 1024,
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedExtensions = ['.pdf', '.doc', '.docx', '.txt', '.csv', '.odt', '.xlsx'];
|
||||
const allowedMimePatterns = ['pdf', 'msword', 'wordprocessingml.document', 'text/plain', 'csv', 'excel', 'opendocument.text'];
|
||||
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, CSV, ODT und XLSX Dateien sind erlaubt!'));
|
||||
},
|
||||
});
|
||||
|
||||
export const uploadMiddleware = upload.single('document');
|
||||
|
||||
function cleanupTempFile(file) {
|
||||
if (file?.path && fs.existsSync(file.path)) {
|
||||
fs.unlinkSync(file.path);
|
||||
}
|
||||
}
|
||||
|
||||
export const listClubDocuments = async (req, res) => {
|
||||
try {
|
||||
const documents = await clubDocumentService.listClubDocuments(Number(req.params.clubId), req.query || {});
|
||||
res.status(200).json({ documents });
|
||||
} catch (error) {
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createClubDocument = async (req, res) => {
|
||||
try {
|
||||
const document = await clubDocumentService.createClubDocument(Number(req.params.clubId), req.user?.id || null, req.body || {}, req.file || null);
|
||||
res.status(201).json({ document });
|
||||
} catch (error) {
|
||||
cleanupTempFile(req.file);
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateClubDocument = async (req, res) => {
|
||||
try {
|
||||
const document = await clubDocumentService.updateClubDocument(Number(req.params.clubId), Number(req.params.documentId), req.user?.id || null, req.body || {}, req.file || null);
|
||||
res.status(200).json({ document });
|
||||
} catch (error) {
|
||||
cleanupTempFile(req.file);
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const archiveClubDocument = async (req, res) => {
|
||||
try {
|
||||
const document = await clubDocumentService.archiveClubDocument(Number(req.params.clubId), Number(req.params.documentId));
|
||||
res.status(200).json({ document });
|
||||
} catch (error) {
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteClubDocument = async (req, res) => {
|
||||
try {
|
||||
await clubDocumentService.deleteClubDocument(Number(req.params.clubId), Number(req.params.documentId));
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadClubDocument = async (req, res) => {
|
||||
try {
|
||||
const versionNo = req.query.versionNo ? Number(req.query.versionNo) : null;
|
||||
const { document, version } = await clubDocumentService.getDocumentDownload(Number(req.params.clubId), Number(req.params.documentId), versionNo);
|
||||
if (!fs.existsSync(version.storagePath)) {
|
||||
return res.status(404).json({ error: 'filenotfound' });
|
||||
}
|
||||
res.setHeader('Content-Disposition', `inline; filename="${document.title || version.fileName}"`);
|
||||
res.setHeader('Content-Type', version.mimeType || 'application/octet-stream');
|
||||
res.sendFile(version.storagePath);
|
||||
} catch (error) {
|
||||
res.status(error.status || 500).json({ error: error.message || 'internalerror' });
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
listClubDocuments,
|
||||
createClubDocument,
|
||||
updateClubDocument,
|
||||
archiveClubDocument,
|
||||
deleteClubDocument,
|
||||
downloadClubDocument,
|
||||
};
|
||||
93
backend/controllers/clubInvoiceController.js
Normal file
93
backend/controllers/clubInvoiceController.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import clubInvoiceService from '../services/clubInvoiceService.js';
|
||||
|
||||
class ClubInvoiceController {
|
||||
async listClubInvoices(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const payload = await clubInvoiceService.listClubInvoices(Number(clubId));
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
console.error('[listClubInvoices] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungen konnten nicht geladen werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async createInvoiceParty(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const party = await clubInvoiceService.createInvoiceParty(Number(clubId), req.body || {});
|
||||
res.status(201).json({ party });
|
||||
} catch (error) {
|
||||
console.error('[createInvoiceParty] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateInvoiceParty(req, res) {
|
||||
try {
|
||||
const { clubId, partyId } = req.params;
|
||||
const party = await clubInvoiceService.updateInvoiceParty(Number(clubId), Number(partyId), req.body || {});
|
||||
res.json({ party });
|
||||
} catch (error) {
|
||||
console.error('[updateInvoiceParty] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteInvoiceParty(req, res) {
|
||||
try {
|
||||
const { clubId, partyId } = req.params;
|
||||
await clubInvoiceService.deleteInvoiceParty(Number(clubId), Number(partyId));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteInvoiceParty] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungspartei konnte nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async createInvoice(req, res) {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const invoice = await clubInvoiceService.createInvoice(Number(clubId), req.user?.id || null, req.body || {});
|
||||
res.status(201).json({ invoice });
|
||||
} catch (error) {
|
||||
console.error('[createInvoice] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateInvoice(req, res) {
|
||||
try {
|
||||
const { clubId, invoiceId } = req.params;
|
||||
const invoice = await clubInvoiceService.updateInvoice(Number(clubId), Number(invoiceId), req.body || {});
|
||||
res.json({ invoice });
|
||||
} catch (error) {
|
||||
console.error('[updateInvoice] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateInvoiceStatus(req, res) {
|
||||
try {
|
||||
const { clubId, invoiceId } = req.params;
|
||||
const invoice = await clubInvoiceService.updateInvoiceStatus(Number(clubId), Number(invoiceId), String(req.body?.status || ''));
|
||||
res.json({ invoice });
|
||||
} catch (error) {
|
||||
console.error('[updateInvoiceStatus] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnungsstatus konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteInvoice(req, res) {
|
||||
try {
|
||||
const { clubId, invoiceId } = req.params;
|
||||
await clubInvoiceService.deleteInvoice(Number(clubId), Number(invoiceId));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteInvoice] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Rechnung konnte nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubInvoiceController();
|
||||
73
backend/controllers/clubPaymentClaimController.js
Normal file
73
backend/controllers/clubPaymentClaimController.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import clubPaymentClaimService from '../services/clubPaymentClaimService.js';
|
||||
|
||||
class ClubPaymentClaimController {
|
||||
async list(req, res) {
|
||||
try {
|
||||
const payload = await clubPaymentClaimService.listClaims(Number(req.params.clubId));
|
||||
res.json(payload);
|
||||
} catch (error) {
|
||||
console.error('[listClubPaymentClaims] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderungen konnten nicht geladen werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req, res) {
|
||||
try {
|
||||
const claim = await clubPaymentClaimService.createClaim(Number(req.params.clubId), req.body || {});
|
||||
res.status(201).json({ claim });
|
||||
} catch (error) {
|
||||
console.error('[createClubPaymentClaim] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async update(req, res) {
|
||||
try {
|
||||
const claim = await clubPaymentClaimService.updateClaim(Number(req.params.clubId), Number(req.params.claimId), req.body || {});
|
||||
res.json({ claim });
|
||||
} catch (error) {
|
||||
console.error('[updateClubPaymentClaim] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(req, res) {
|
||||
try {
|
||||
const claim = await clubPaymentClaimService.updateClaimStatus(
|
||||
Number(req.params.clubId),
|
||||
Number(req.params.claimId),
|
||||
String(req.body?.status || '')
|
||||
);
|
||||
res.json({ claim });
|
||||
} catch (error) {
|
||||
console.error('[updateClubPaymentClaimStatus] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Status konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async registerPayment(req, res) {
|
||||
try {
|
||||
const claim = await clubPaymentClaimService.registerPayment(
|
||||
Number(req.params.clubId),
|
||||
Number(req.params.claimId),
|
||||
req.body || {}
|
||||
);
|
||||
res.json({ claim });
|
||||
} catch (error) {
|
||||
console.error('[registerClubPaymentClaimPayment] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Teilzahlung konnte nicht gespeichert werden.' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req, res) {
|
||||
try {
|
||||
await clubPaymentClaimService.deleteClaim(Number(req.params.clubId), Number(req.params.claimId));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[deleteClubPaymentClaim] - Error:', error);
|
||||
res.status(error?.status || 500).json({ error: error?.message || 'Zahlungsforderung konnte nicht gelöscht werden.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ClubPaymentClaimController();
|
||||
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();
|
||||
275
backend/controllers/clubTaskController.js
Normal file
275
backend/controllers/clubTaskController.js
Normal file
@@ -0,0 +1,275 @@
|
||||
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,
|
||||
workflowSources: automationOverview.workflowSources || [],
|
||||
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.'),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -42,7 +42,7 @@ export const createClubTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid: clubId } = req.params;
|
||||
const { name, leagueId, seasonId } = req.body;
|
||||
const { name, leagueId, seasonId, teamGender, teamAgeGroup, plannedLeagueName } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
@@ -50,11 +50,17 @@ export const createClubTeam = async (req, res) => {
|
||||
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
|
||||
seasonId: seasonId ? parseInt(seasonId) : null,
|
||||
teamGender: teamGender || 'open',
|
||||
teamAgeGroup: teamAgeGroup || 'adult',
|
||||
...(planned !== undefined ? { plannedLeagueName: planned } : {})
|
||||
};
|
||||
|
||||
const newClubTeam = await ClubTeamService.createClubTeam(clubTeamData);
|
||||
@@ -70,7 +76,7 @@ export const updateClubTeam = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubteamid: clubTeamId } = req.params;
|
||||
const { name, leagueId, seasonId } = req.body;
|
||||
const { name, leagueId, seasonId, teamGender, teamAgeGroup, plannedLeagueName } = req.body;
|
||||
|
||||
const user = await getUserByToken(token);
|
||||
|
||||
@@ -78,6 +84,13 @@ export const updateClubTeam = async (req, res) => {
|
||||
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) {
|
||||
@@ -126,3 +139,47 @@ export const getLeagues = async (req, res) => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -60,8 +60,34 @@ export const updateClubSettings = async (req, res) => {
|
||||
try {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubid } = req.params;
|
||||
const { greetingText, associationMemberNumber } = req.body;
|
||||
const updated = await ClubService.updateClubSettings(token, clubid, { greetingText, associationMemberNumber });
|
||||
const {
|
||||
greetingText,
|
||||
associationMemberNumber,
|
||||
myTischtennisFedNickname,
|
||||
autoFetchRankings,
|
||||
countryCode,
|
||||
stateCode,
|
||||
memberDataQualityRequirements,
|
||||
feeRules,
|
||||
outgoingInvoicePrefix,
|
||||
outgoingInvoiceNextNumber,
|
||||
incomingInvoicePrefix,
|
||||
incomingInvoiceNextNumber
|
||||
} = req.body;
|
||||
const updated = await ClubService.updateClubSettings(token, clubid, {
|
||||
greetingText,
|
||||
associationMemberNumber,
|
||||
myTischtennisFedNickname,
|
||||
autoFetchRankings,
|
||||
countryCode,
|
||||
stateCode,
|
||||
memberDataQualityRequirements,
|
||||
feeRules,
|
||||
outgoingInvoicePrefix,
|
||||
outgoingInvoiceNextNumber,
|
||||
incomingInvoicePrefix,
|
||||
incomingInvoiceNextNumber
|
||||
});
|
||||
res.status(200).json(updated);
|
||||
} catch (error) {
|
||||
if (error.message === 'noaccess') {
|
||||
|
||||
@@ -18,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);
|
||||
@@ -37,15 +37,22 @@ const updateTrainingTimes = async (req, res) => {
|
||||
try {
|
||||
const { clubId } = req.params;
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { dateId, trainingStart, trainingEnd } = req.body;
|
||||
if (!dateId || !trainingStart) {
|
||||
devLog(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 });
|
||||
emitDiaryDateUpdated(clubId, dateId, { trainingStart, trainingEnd, excludeFromBilling });
|
||||
|
||||
res.status(200).json(updatedDate);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import fs from 'fs';
|
||||
import diaryDateActivityService from '../services/diaryDateActivityService.js';
|
||||
import { emitActivityChanged } from '../services/socketService.js';
|
||||
import DiaryDate from '../models/DiaryDates.js';
|
||||
|
||||
import { devLog } from '../utils/logger.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
|
||||
@@ -32,7 +39,11 @@ export const createDiaryDateActivity = async (req, res) => {
|
||||
|
||||
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, {
|
||||
@@ -60,7 +71,11 @@ 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
|
||||
@@ -86,7 +101,11 @@ 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);
|
||||
@@ -108,21 +127,47 @@ export const updateDiaryDateActivityOrder = async (req, res) => {
|
||||
|
||||
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) {
|
||||
// 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, predefinedActivityId, timeblockId } = req.body;
|
||||
const activityItem = await diaryDateActivityService.addGroupActivity(userToken, clubId, diaryDateId, groupId, activity, predefinedActivityId, timeblockId);
|
||||
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);
|
||||
@@ -141,8 +186,17 @@ export const updateGroupActivity = async(req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, groupActivityId } = req.params;
|
||||
const { predefinedActivityId } = req.body;
|
||||
const activityItem = await diaryDateActivityService.updateGroupActivity(userToken, clubId, groupActivityId, predefinedActivityId);
|
||||
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;
|
||||
@@ -197,4 +251,4 @@ export const deleteGroupActivity = async(req, res) => {
|
||||
devLog(error);
|
||||
res.status(500).json({ error: 'Error deleting group activity' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,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);
|
||||
@@ -74,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) {
|
||||
|
||||
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.' });
|
||||
}
|
||||
};
|
||||
@@ -51,7 +51,8 @@ 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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -27,12 +28,13 @@ const getWaitingApprovals = async(req, res) => {
|
||||
|
||||
const setClubMembers = async (req, res) => {
|
||||
try {
|
||||
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
|
||||
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, contacts } = req.body;
|
||||
const { id: memberId, firstname: firstName, lastname: lastName, street, city, postalCode, birthdate, phone, email, active,
|
||||
testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved,
|
||||
contributionGroupCode, 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, postalCode, birthdate,
|
||||
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, contacts);
|
||||
phone, email, active, testMembership, picsInInternetAllowed, gender, ttr, qttr, memberFormHandedOver, adultReleaseApproved, adultReserveApproved, contributionGroupCode, contacts);
|
||||
|
||||
// Emit Socket-Event wenn Member erfolgreich erstellt/aktualisiert wurde
|
||||
if (addResult.status === 200) {
|
||||
@@ -46,6 +48,70 @@ const setClubMembers = async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -92,6 +158,30 @@ const updateRatingsFromMyTischtennis = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -207,6 +297,28 @@ const quickDeactivateMember = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -243,15 +355,22 @@ 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' });
|
||||
}
|
||||
};
|
||||
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
|
||||
};
|
||||
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' });
|
||||
}
|
||||
};
|
||||
@@ -10,6 +10,7 @@ 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 { hasUserClubAccess } from '../utils/userUtils.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const teamDataFetchJobs = new Map();
|
||||
@@ -635,15 +636,34 @@ class MyTischtennisUrlController {
|
||||
/**
|
||||
* Configure league from myTischtennis table URL
|
||||
* POST /api/mytischtennis/configure-league
|
||||
* Body: { url: string, createSeason?: boolean }
|
||||
* Body: { url: string, clubId: number, createSeason?: boolean }
|
||||
*/
|
||||
async configureLeague(req, res, next) {
|
||||
try {
|
||||
const { url, createSeason } = req.body;
|
||||
const { url, createSeason, clubId } = req.body;
|
||||
const userIdOrEmail = req.headers.userid;
|
||||
|
||||
if (!url) {
|
||||
throw new HttpError('URL is required', 400);
|
||||
if (!url || !clubId) {
|
||||
throw new HttpError('URL and clubId are required', 400);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const normalizedClubId = Number.parseInt(clubId, 10);
|
||||
if (!Number.isInteger(normalizedClubId) || normalizedClubId <= 0) {
|
||||
throw new HttpError('clubId must be a valid number', 400);
|
||||
}
|
||||
|
||||
const hasAccess = await hasUserClubAccess(userId, normalizedClubId);
|
||||
if (!hasAccess) {
|
||||
throw new HttpError('Keine Berechtigung für diesen Verein', 403);
|
||||
}
|
||||
|
||||
// Parse URL
|
||||
@@ -669,6 +689,7 @@ class MyTischtennisUrlController {
|
||||
// Find or create league
|
||||
let league = await League.findOne({
|
||||
where: {
|
||||
clubId: normalizedClubId,
|
||||
myTischtennisGroupId: parsedData.groupId,
|
||||
association: parsedData.association
|
||||
}
|
||||
@@ -677,6 +698,7 @@ class MyTischtennisUrlController {
|
||||
if (!league) {
|
||||
league = await League.create({
|
||||
name: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version
|
||||
clubId: normalizedClubId,
|
||||
myTischtennisGroupId: parsedData.groupId,
|
||||
association: parsedData.association,
|
||||
groupname: parsedData.groupnameOriginal, // Verwende die originale URL-kodierte Version
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { checkAccess } from '../utils/userUtils.js';
|
||||
import officialTournamentService from '../services/officialTournamentService.js';
|
||||
import clickTtTournamentRegistrationService from '../services/clickTtTournamentRegistrationService.js';
|
||||
|
||||
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 {
|
||||
@@ -101,3 +117,26 @@ export const deleteOfficialTournament = async (req, res) => {
|
||||
res.status(500).json({ error: 'Failed to delete tournament' });
|
||||
}
|
||||
};
|
||||
|
||||
export const autoRegisterOfficialTournamentParticipants = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, id: tournamentId } = req.params;
|
||||
const userId = req.user?.id;
|
||||
|
||||
const result = await clickTtTournamentRegistrationService.autoRegisterPendingParticipants({
|
||||
userToken,
|
||||
userId,
|
||||
clubId,
|
||||
tournamentId
|
||||
});
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
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 },
|
||||
attributes: ['id', 'diaryDateId', 'memberId', 'groupId', 'notes', 'createdAt', 'updatedAt']
|
||||
attributes: PARTICIPANT_ATTRIBUTES
|
||||
});
|
||||
res.status(200).json(participants);
|
||||
} catch (error) {
|
||||
@@ -68,12 +72,22 @@ export const updateParticipantGroup = async (req, res) => {
|
||||
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) {
|
||||
emitParticipantAdded(diaryDate.clubId, diaryDateId, participant);
|
||||
if (created) {
|
||||
emitParticipantAdded(diaryDate.clubId, diaryDateId, participant);
|
||||
} else {
|
||||
emitParticipantUpdated(diaryDate.clubId, diaryDateId, participant);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(participant);
|
||||
@@ -90,6 +104,15 @@ export const removeParticipant = async (req, res) => {
|
||||
// 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 } });
|
||||
|
||||
@@ -104,3 +127,53 @@ export const removeParticipant = async (req, res) => {
|
||||
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' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,6 +76,29 @@ export const updateUserRole = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -128,6 +151,62 @@ export const getPermissionStructure = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
*/
|
||||
@@ -158,10 +237,14 @@ export default {
|
||||
getUserPermissions,
|
||||
getClubMembersWithPermissions,
|
||||
updateUserRole,
|
||||
updateUserRoles,
|
||||
updateUserPermissions,
|
||||
updateUserStatus,
|
||||
getAvailableRoles,
|
||||
getPermissionStructure
|
||||
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);
|
||||
|
||||
@@ -4,49 +4,12 @@ import { emitTournamentChanged } from '../services/socketService.js';
|
||||
import TournamentClass from '../models/TournamentClass.js';
|
||||
import HttpError from '../exceptions/HttpError.js';
|
||||
|
||||
// Pools (zusammengelegte Gruppenphasen)
|
||||
export const mergeClassesIntoPool = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, sourceClassId, targetClassId, strategy, outOfCompetitionForSource } = req.body;
|
||||
try {
|
||||
await tournamentService.mergeClassesIntoPool(
|
||||
token,
|
||||
clubId,
|
||||
tournamentId,
|
||||
sourceClassId,
|
||||
targetClassId,
|
||||
strategy, // 'singleGroup' | 'distribute'
|
||||
!!outOfCompetitionForSource
|
||||
);
|
||||
// Broadcast
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[mergeClassesIntoPool] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const resetPool = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, poolId } = req.body;
|
||||
try {
|
||||
await tournamentService.resetPool(token, clubId, tournamentId, poolId);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[resetPool] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Alle Turniere eines Vereins (query: type = 'internal' | 'external' | 'mini')
|
||||
// 1. Alle Turniere eines Vereins
|
||||
export const getTournaments = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId } = req.params;
|
||||
const type = req.query.type || null;
|
||||
try {
|
||||
const tournaments = await tournamentService.getTournaments(token, clubId, type);
|
||||
const tournaments = await tournamentService.getTournaments(token, clubId);
|
||||
res.status(200).json(tournaments);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -65,6 +28,7 @@ export const addTournament = async (req, res) => {
|
||||
const { clubId, tournamentName, date, winningSets, allowsExternal } = req.body;
|
||||
try {
|
||||
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets, allowsExternal);
|
||||
// Emit Socket-Event
|
||||
if (clubId && tournament && tournament.id) {
|
||||
emitTournamentChanged(clubId, tournament.id);
|
||||
}
|
||||
@@ -75,22 +39,6 @@ export const addTournament = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Minimeisterschaft anlegen (Turnier + 6 Klassen); Name: "Minimeisterschaften <Jahr> Ortsentscheid <ort>"
|
||||
export const addMiniChampionship = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, ort, date, year, winningSets } = req.body;
|
||||
try {
|
||||
const tournament = await tournamentService.addMiniChampionship(token, clubId, ort, date, year, winningSets);
|
||||
if (clubId && tournament && tournament.id) {
|
||||
emitTournamentChanged(clubId, tournament.id);
|
||||
}
|
||||
res.status(201).json(tournament);
|
||||
} catch (error) {
|
||||
console.error('[addMiniChampionship] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Teilnehmer hinzufügen - klassengebunden
|
||||
export const addParticipant = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
@@ -273,6 +221,8 @@ export const updateTournament = async (req, res) => {
|
||||
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);
|
||||
@@ -297,6 +247,35 @@ 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;
|
||||
@@ -420,19 +399,6 @@ export const resetMatches = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const cleanupOrphanedMatches = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.body;
|
||||
try {
|
||||
const result = await tournamentService.cleanupOrphanedMatches(token, clubId, tournamentId);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const removeParticipant = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, participantId } = req.body;
|
||||
@@ -463,20 +429,6 @@ export const updateParticipantSeeded = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const setParticipantGaveUp = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, participantId } = req.params;
|
||||
const { gaveUp } = req.body;
|
||||
try {
|
||||
await tournamentService.setParticipantGaveUp(token, clubId, tournamentId, participantId, gaveUp);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Aufgabe-Status aktualisiert' });
|
||||
} catch (err) {
|
||||
console.error('[setParticipantGaveUp] 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;
|
||||
@@ -542,27 +494,12 @@ export const setMatchActive = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const setMatchTableNumber = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, matchId } = req.params;
|
||||
const { tableNumber } = req.body;
|
||||
try {
|
||||
await tournamentService.setMatchTableNumber(token, clubId, tournamentId, matchId, tableNumber);
|
||||
// Emit Socket-Event
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Tischnummer aktualisiert' });
|
||||
} catch (err) {
|
||||
console.error('[setMatchTableNumber] 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, email, address } = req.body;
|
||||
const { clubId, tournamentId, classId, firstName, lastName, club, birthDate, gender } = req.body;
|
||||
try {
|
||||
await tournamentService.addExternalParticipant(token, clubId, classId, firstName, lastName, club, birthDate, gender, email, address);
|
||||
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) {
|
||||
@@ -613,20 +550,6 @@ export const updateExternalParticipantSeeded = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const setExternalParticipantGaveUp = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, participantId } = req.params;
|
||||
const { gaveUp } = req.body;
|
||||
try {
|
||||
await tournamentService.setExternalParticipantGaveUp(token, clubId, tournamentId, participantId, gaveUp);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json({ message: 'Aufgabe-Status aktualisiert' });
|
||||
} catch (error) {
|
||||
console.error('[setExternalParticipantGaveUp] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// Tournament Classes
|
||||
export const getTournamentClasses = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
@@ -643,9 +566,9 @@ export const getTournamentClasses = async (req, res) => {
|
||||
export const addTournamentClass = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId } = req.params;
|
||||
const { name, isDoubles, gender, minBirthYear, maxBirthYear } = req.body;
|
||||
const { name, isDoubles, gender, minBirthYear } = req.body;
|
||||
try {
|
||||
const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name, isDoubles, gender, minBirthYear, maxBirthYear);
|
||||
const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name, isDoubles, gender, minBirthYear);
|
||||
emitTournamentChanged(clubId, tournamentId);
|
||||
res.status(200).json(tournamentClass);
|
||||
} catch (error) {
|
||||
@@ -657,9 +580,11 @@ export const addTournamentClass = async (req, res) => {
|
||||
export const updateTournamentClass = async (req, res) => {
|
||||
const { authcode: token } = req.headers;
|
||||
const { clubId, tournamentId, classId } = req.params;
|
||||
const { name, sortOrder, isDoubles, gender, minBirthYear, maxBirthYear } = req.body;
|
||||
const { name, sortOrder, isDoubles, gender, minBirthYear } = req.body;
|
||||
try {
|
||||
const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear, maxBirthYear);
|
||||
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) {
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -32,8 +32,7 @@ export const updateTrainingGroup = async (req, res) => {
|
||||
try {
|
||||
const { authcode: userToken } = req.headers;
|
||||
const { clubId, groupId } = req.params;
|
||||
const { name, sortOrder } = req.body;
|
||||
const group = await trainingGroupService.updateTrainingGroup(userToken, clubId, groupId, name, sortOrder);
|
||||
const group = await trainingGroupService.updateTrainingGroup(userToken, clubId, groupId, req.body);
|
||||
res.status(200).json(group);
|
||||
} catch (error) {
|
||||
console.error('[updateTrainingGroup] - Error:', error);
|
||||
|
||||
@@ -142,7 +142,7 @@ export const requireAdmin = () => {
|
||||
parseInt(clubId)
|
||||
);
|
||||
|
||||
if (!userPermissions || (userPermissions.role !== 'admin' && !userPermissions.isOwner)) {
|
||||
if (!userPermissions || (!userPermissions.isAdmin && !userPermissions.isOwner)) {
|
||||
return res.status(403).json({
|
||||
error: 'Keine Berechtigung',
|
||||
details: 'Administrator-Rechte erforderlich'
|
||||
@@ -190,7 +190,10 @@ export const requireRole = (roles) => {
|
||||
parseInt(clubId)
|
||||
);
|
||||
|
||||
if (!userPermissions || !roles.includes(userPermissions.role)) {
|
||||
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(', ')}`
|
||||
@@ -212,4 +215,3 @@ export default {
|
||||
requireAdmin,
|
||||
requireRole
|
||||
};
|
||||
|
||||
|
||||
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`;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Optional: manuell gepflegte geplante Spielklasse (unabhängig von league / MyTischtennis)
|
||||
|
||||
ALTER TABLE `club_team`
|
||||
ADD COLUMN `planned_league_name` VARCHAR(512) NULL
|
||||
COMMENT 'Geplante Spielklasse (freier Text, optional)'
|
||||
AFTER `team_age_group`;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Migration: Add per-club member data quality requirements.
|
||||
-- Controls which optional contact/address fields count as required on /members.
|
||||
|
||||
ALTER TABLE clubs
|
||||
ADD COLUMN IF NOT EXISTS member_data_quality_requirements JSON NULL
|
||||
COMMENT 'Configures which member fields are required for data quality checks';
|
||||
28
backend/migrations/20260415_create_member_group_photo.sql
Normal file
28
backend/migrations/20260415_create_member_group_photo.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Migration: Store group photos for later member photo cropping.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_group_photo` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`club_id` INT NOT NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`file_name` VARCHAR(255) NOT NULL,
|
||||
`original_file_name` VARCHAR(255) NULL,
|
||||
`mime_type` VARCHAR(100) NULL,
|
||||
`file_size` INT NULL,
|
||||
`width` INT NULL,
|
||||
`height` INT NULL,
|
||||
`taken_at` DATETIME NULL,
|
||||
`created_by_user_id` INT NULL,
|
||||
`created_at` DATETIME NOT NULL,
|
||||
`updated_at` DATETIME NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_member_group_photo_club_id` (`club_id`),
|
||||
CONSTRAINT `fk_member_group_photo_club`
|
||||
FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
CONSTRAINT `fk_member_group_photo_created_by`
|
||||
FOREIGN KEY (`created_by_user_id`) REFERENCES `user` (`id`)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE
|
||||
);
|
||||
16
backend/migrations/20260415_create_member_play_interest.sql
Normal file
16
backend/migrations/20260415_create_member_play_interest.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Halbserienbasierte Spielinteressen (pro Mitglied, Club, Saison und Halbserie)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_play_interest` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`club_id` INT NOT NULL,
|
||||
`member_id` INT NOT NULL,
|
||||
`season_id` INT NOT NULL,
|
||||
`lineup_half` ENUM('first_half', 'second_half') NOT NULL,
|
||||
`interested` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_member_play_interest_half` (`club_id`, `member_id`, `season_id`, `lineup_half`),
|
||||
KEY `idx_member_play_interest_member` (`member_id`),
|
||||
KEY `idx_member_play_interest_season` (`season_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
122
backend/migrations/20260420_create_billing_tables.sql
Normal file
122
backend/migrations/20260420_create_billing_tables.sql
Normal file
@@ -0,0 +1,122 @@
|
||||
-- Abrechnungsmodul: Vorlagen, Feld-Mapping, Abrechnungslauf und erzeugte Dokumente
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `billing_template` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`club_id` INT NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`description` TEXT NULL,
|
||||
`pdf_storage_path` VARCHAR(1000) NOT NULL,
|
||||
`pdf_filename` VARCHAR(255) NOT NULL,
|
||||
`pdf_mime_type` VARCHAR(100) NOT NULL DEFAULT 'application/pdf',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`version` INT NOT NULL DEFAULT 1,
|
||||
`created_by_user_id` INT NULL,
|
||||
`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_billing_template_club` (`club_id`),
|
||||
UNIQUE KEY `uniq_billing_template_club_name_version` (`club_id`, `name`, `version`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `billing_template_field` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`template_id` INT NOT NULL,
|
||||
`field_key` VARCHAR(120) NOT NULL,
|
||||
`label` VARCHAR(255) NOT NULL,
|
||||
`field_type` ENUM('text', 'number', 'currency', 'date', 'checkbox', 'formula', 'table_row') NOT NULL,
|
||||
`source_type` ENUM('manual', 'member', 'trainer', 'club', 'system', 'constant', 'formula') NOT NULL DEFAULT 'manual',
|
||||
`source_path` VARCHAR(255) NULL,
|
||||
`constant_value` VARCHAR(500) NULL,
|
||||
`formatter` ENUM('none', 'iban_no_country', 'date_dd_mm_yyyy', 'currency_eur_2') NOT NULL DEFAULT 'none',
|
||||
`is_required` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`mapping_mode` ENUM('acroform', 'overlay') NOT NULL DEFAULT 'overlay',
|
||||
`acroform_field_name` VARCHAR(255) NULL,
|
||||
`page_number` INT NULL,
|
||||
`x` DECIMAL(10, 2) NULL,
|
||||
`y` DECIMAL(10, 2) NULL,
|
||||
`width` DECIMAL(10, 2) NULL,
|
||||
`height` DECIMAL(10, 2) NULL,
|
||||
`font_size` DECIMAL(5, 2) NULL,
|
||||
`align` ENUM('left', 'center', 'right') NULL,
|
||||
`formula_expression` VARCHAR(1000) NULL,
|
||||
`table_group` VARCHAR(100) NULL,
|
||||
`row_index` INT 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`),
|
||||
KEY `idx_billing_template_field_template` (`template_id`),
|
||||
UNIQUE KEY `uniq_billing_template_field_key` (`template_id`, `field_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `billing_run` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`club_id` INT NOT NULL,
|
||||
`template_id` INT NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`period_start` DATE NOT NULL,
|
||||
`period_end` DATE NOT NULL,
|
||||
`self_recipient_user_id` INT NOT NULL,
|
||||
`self_recipient_name` VARCHAR(255) NOT NULL,
|
||||
`hourly_rate` DECIMAL(10, 2) NOT NULL,
|
||||
`computed_hours_total` DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||
`iban` VARCHAR(64) NULL,
|
||||
`iban_without_country` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`session_label` VARCHAR(255) NULL,
|
||||
`same_account_checkbox` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`omit_self_recipient_name` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`omit_iban` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`omit_location_text` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`omit_document_date` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`omit_session_label` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`location_text` VARCHAR(255) NULL,
|
||||
`document_date` DATE NULL,
|
||||
`status` ENUM('draft', 'generated', 'finalized', 'cancelled') NOT NULL DEFAULT 'draft',
|
||||
`created_by_user_id` INT NULL,
|
||||
`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_billing_run_club` (`club_id`),
|
||||
KEY `idx_billing_run_template` (`template_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `billing_user_setting` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`club_id` INT NOT NULL,
|
||||
`user_id` INT NOT NULL,
|
||||
`last_hourly_rate` DECIMAL(10, 2) NOT NULL DEFAULT 0,
|
||||
`last_self_recipient_name` VARCHAR(255) NULL,
|
||||
`last_location_text` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uniq_billing_user_setting_club_user` (`club_id`, `user_id`),
|
||||
KEY `idx_billing_user_setting_club` (`club_id`),
|
||||
KEY `idx_billing_user_setting_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `billing_document` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`run_id` INT NOT NULL,
|
||||
`display_name` VARCHAR(255) NOT NULL,
|
||||
`status` ENUM('draft', 'generated', 'error') NOT NULL DEFAULT 'draft',
|
||||
`pdf_storage_path` VARCHAR(1000) NULL,
|
||||
`pdf_filename` VARCHAR(255) NULL,
|
||||
`error_message` TEXT NULL,
|
||||
`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_billing_document_run` (`run_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `billing_document_value` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`billing_document_id` INT NOT NULL,
|
||||
`field_key` VARCHAR(120) NOT NULL,
|
||||
`resolved_value` TEXT NULL,
|
||||
`resolved_source` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_billing_doc_value_doc` (`billing_document_id`),
|
||||
UNIQUE KEY `uniq_billing_doc_value_field` (`billing_document_id`, `field_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE `member_orders`
|
||||
ADD COLUMN `budget` DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER `paid_amount`;
|
||||
|
||||
ALTER TABLE `member_order_history`
|
||||
ADD COLUMN `budget` DECIMAL(10,2) NOT NULL DEFAULT 0.00 AFTER `paid_amount`;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE `member_orders`
|
||||
ADD COLUMN `paid_confirmed` TINYINT(1) NOT NULL DEFAULT 0 AFTER `budget`;
|
||||
|
||||
ALTER TABLE `member_order_history`
|
||||
ADD COLUMN `paid_confirmed` TINYINT(1) NOT NULL DEFAULT 0 AFTER `budget`;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `group_activity`
|
||||
ADD COLUMN `duration` INT NULL AFTER `custom_activity`,
|
||||
ADD COLUMN `duration_text` VARCHAR(255) NULL AFTER `duration`,
|
||||
ADD COLUMN `order_id` INT NOT NULL DEFAULT 1 AFTER `duration_text`;
|
||||
@@ -0,0 +1,30 @@
|
||||
ALTER TABLE `diary_date_activities`
|
||||
ADD COLUMN `group_id` INT NULL AFTER `predefined_activity_id`,
|
||||
ADD CONSTRAINT `fk_diary_date_activities_group_id`
|
||||
FOREIGN KEY (`group_id`) REFERENCES `group`(`id`)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO `diary_date_activities` (
|
||||
`diary_date_id`,
|
||||
`is_timeblock`,
|
||||
`predefined_activity_id`,
|
||||
`group_id`,
|
||||
`duration`,
|
||||
`duration_text`,
|
||||
`order_id`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
d.`diary_date_id`,
|
||||
0 AS `is_timeblock`,
|
||||
ga.`custom_activity` AS `predefined_activity_id`,
|
||||
ga.`group_id`,
|
||||
ga.`duration`,
|
||||
ga.`duration_text`,
|
||||
(d.`order_id` * 100) + COALESCE(ga.`order_id`, 1) AS `order_id`,
|
||||
COALESCE(ga.`created_at`, NOW()) AS `created_at`,
|
||||
COALESCE(ga.`updated_at`, NOW()) AS `updated_at`
|
||||
FROM `group_activity` ga
|
||||
INNER JOIN `diary_date_activities` d ON d.`id` = ga.`diary_date_activity`;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Trainingsgruppen vom Tagebuch-Schnellanlegen ausnehmen (optional)
|
||||
ALTER TABLE training_group
|
||||
ADD COLUMN exclude_from_quick_diary_create TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT '1 = Gruppe bei Schnellanlegen-Terminsuche ignorieren';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `friendly_match`
|
||||
ADD COLUMN IF NOT EXISTS `result_details` JSON NULL AFTER `guest_participants`;
|
||||
30
backend/migrations/20260518_create_friendly_match.sql
Normal file
30
backend/migrations/20260518_create_friendly_match.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS `friendly_match` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`club_id` INT NOT NULL,
|
||||
`date` DATE NOT NULL,
|
||||
`time` TIME NULL,
|
||||
`home_team_name` VARCHAR(255) NOT NULL,
|
||||
`guest_team_name` VARCHAR(255) NOT NULL,
|
||||
`location_name` VARCHAR(255) NULL,
|
||||
`location_address` VARCHAR(255) NULL,
|
||||
`location_city` VARCHAR(255) NULL,
|
||||
`location_zip` VARCHAR(32) NULL,
|
||||
`match_system` VARCHAR(120) NOT NULL DEFAULT 'Braunschweiger System',
|
||||
`singles_count` INT NOT NULL DEFAULT 12,
|
||||
`doubles_count` INT NOT NULL DEFAULT 4,
|
||||
`winning_sets` INT NOT NULL DEFAULT 3,
|
||||
`home_match_points` INT NOT NULL DEFAULT 0,
|
||||
`guest_match_points` INT NOT NULL DEFAULT 0,
|
||||
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`home_participants` JSON NULL,
|
||||
`guest_participants` JSON NULL,
|
||||
`result_details` JSON NULL,
|
||||
`players_ready` JSON NULL,
|
||||
`players_planned` JSON NULL,
|
||||
`players_played` JSON NULL,
|
||||
`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_friendly_match_club_date` (`club_id`, `date`),
|
||||
KEY `idx_friendly_match_completed` (`club_id`, `is_completed`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,84 @@
|
||||
-- Manual migration for cross-club friendly match concept
|
||||
-- Created: 2026-05-30
|
||||
|
||||
-- 1) Invitation table
|
||||
CREATE TABLE IF NOT EXISTS `friendly_match_invitation` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`from_club_id` INT NOT NULL,
|
||||
`to_club_id` INT NOT NULL,
|
||||
`proposed_date` DATE NOT NULL,
|
||||
`proposed_start_time` TIME NULL,
|
||||
`proposed_match_name` VARCHAR(255) NOT NULL,
|
||||
`message` TEXT NULL,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
`created_by_user_id` INT NULL,
|
||||
`accepted_by_user_id` INT NULL,
|
||||
`accepted_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `chk_friendly_match_invitation_clubs_different`
|
||||
CHECK (`from_club_id` <> `to_club_id`),
|
||||
CONSTRAINT `fk_friendly_match_invitation_from_club`
|
||||
FOREIGN KEY (`from_club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_friendly_match_invitation_to_club`
|
||||
FOREIGN KEY (`to_club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_friendly_match_invitation_created_by`
|
||||
FOREIGN KEY (`created_by_user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_friendly_match_invitation_accepted_by`
|
||||
FOREIGN KEY (`accepted_by_user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL,
|
||||
KEY `idx_friendly_match_invitation_to_status_date` (`to_club_id`, `status`, `proposed_date`),
|
||||
KEY `idx_friendly_match_invitation_from_status_date` (`from_club_id`, `status`, `proposed_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 2) Shared match table
|
||||
CREATE TABLE IF NOT EXISTS `friendly_match_shared` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`home_club_id` INT NOT NULL,
|
||||
`guest_club_id` INT NOT NULL,
|
||||
`date` DATE NOT NULL,
|
||||
`start_time` TIME NULL,
|
||||
`match_name` VARCHAR(255) NULL,
|
||||
`home_team_name` VARCHAR(255) NOT NULL,
|
||||
`guest_team_name` VARCHAR(255) NOT NULL,
|
||||
`location_name` VARCHAR(255) NULL,
|
||||
`location_address` VARCHAR(255) NULL,
|
||||
`location_city` VARCHAR(255) NULL,
|
||||
`location_zip` VARCHAR(32) NULL,
|
||||
`match_system` VARCHAR(120) NOT NULL DEFAULT 'Braunschweiger System',
|
||||
`singles_count` INT NOT NULL DEFAULT 12,
|
||||
`doubles_count` INT NOT NULL DEFAULT 4,
|
||||
`winning_sets` INT NOT NULL DEFAULT 3,
|
||||
`home_match_points` INT NOT NULL DEFAULT 0,
|
||||
`guest_match_points` INT NOT NULL DEFAULT 0,
|
||||
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`home_participants` JSON NULL,
|
||||
`guest_participants` JSON NULL,
|
||||
`result_details` JSON NULL,
|
||||
`players_ready` JSON NULL,
|
||||
`players_planned` JSON NULL,
|
||||
`players_played` JSON NULL,
|
||||
`status` VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
`created_by_user_id` INT NULL,
|
||||
`created_from_invitation_id` INT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
CONSTRAINT `chk_friendly_match_shared_clubs_different`
|
||||
CHECK (`home_club_id` <> `guest_club_id`),
|
||||
CONSTRAINT `fk_friendly_match_shared_home_club`
|
||||
FOREIGN KEY (`home_club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_friendly_match_shared_guest_club`
|
||||
FOREIGN KEY (`guest_club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_friendly_match_shared_created_by`
|
||||
FOREIGN KEY (`created_by_user_id`) REFERENCES `user` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_friendly_match_shared_from_invitation`
|
||||
FOREIGN KEY (`created_from_invitation_id`) REFERENCES `friendly_match_invitation` (`id`) ON DELETE SET NULL,
|
||||
KEY `idx_friendly_match_shared_home_date_time` (`home_club_id`, `date`, `start_time`),
|
||||
KEY `idx_friendly_match_shared_guest_date_time` (`guest_club_id`, `date`, `start_time`),
|
||||
KEY `idx_friendly_match_shared_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Optional rollback statements (manual use):
|
||||
-- DROP TABLE IF EXISTS `friendly_match_shared`;
|
||||
-- DROP TABLE IF EXISTS `friendly_match_invitation`;
|
||||
15
backend/migrations/20260605_create_club_venue.sql
Normal file
15
backend/migrations/20260605_create_club_venue.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS `club_venue` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`club_id` INT NOT NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`address` VARCHAR(255) NULL,
|
||||
`zip` VARCHAR(32) NULL,
|
||||
`city` VARCHAR(255) 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`),
|
||||
KEY `idx_club_venue_club_sort` (`club_id`, `sort_order`),
|
||||
KEY `idx_club_venue_club_name` (`club_id`, `name`),
|
||||
CONSTRAINT `fk_club_venue_club` FOREIGN KEY (`club_id`) REFERENCES `clubs` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- club_payment_claims: Feld wie backend/models/ClubPaymentClaim.js (paidAmountCents)
|
||||
-- Fehlt in der DB -> SequelizeDatabaseError ER_BAD_FIELD_ERROR in Club-Dashboard,
|
||||
-- Aufgaben-Automation, Konten und Zahlungsforderungen.
|
||||
--
|
||||
-- Diese Migration ist idempotent:
|
||||
-- - fuegt `paid_amount_cents` nur hinzu, wenn die Spalte noch fehlt
|
||||
-- - initialisiert bestehende Datensaetze mit Status `paid` auf `amount_cents`
|
||||
-- Hinweis:
|
||||
-- Bereits teilweise bezahlte Altfaelle koennen ohne historische Buchungsdaten
|
||||
-- nicht exakt rekonstruiert werden und bleiben daher initial bei 0.
|
||||
|
||||
SET @column_exists := (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'club_payment_claims'
|
||||
AND COLUMN_NAME = 'paid_amount_cents'
|
||||
);
|
||||
|
||||
SET @add_column_sql := IF(
|
||||
@column_exists = 0,
|
||||
'ALTER TABLE `club_payment_claims`
|
||||
ADD COLUMN `paid_amount_cents` BIGINT NOT NULL DEFAULT 0
|
||||
COMMENT ''Bereits bezahlter Anteil in Cent''
|
||||
AFTER `amount_cents`',
|
||||
'SELECT ''Column paid_amount_cents already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_column_stmt FROM @add_column_sql;
|
||||
EXECUTE add_column_stmt;
|
||||
DEALLOCATE PREPARE add_column_stmt;
|
||||
|
||||
UPDATE `club_payment_claims`
|
||||
SET `paid_amount_cents` = `amount_cents`
|
||||
WHERE `status` = 'paid'
|
||||
AND COALESCE(`paid_amount_cents`, 0) = 0;
|
||||
@@ -72,6 +72,7 @@
|
||||
|
||||
## API & Logging
|
||||
51. `api_log` - API-Logs
|
||||
52. `http_page_fetch_log` - HTTP-Aufrufe an click-TT/HTTV-Seiten (Logging)
|
||||
|
||||
## Gesamt: 51 Tabellen
|
||||
## Gesamt: 52 Tabellen
|
||||
|
||||
|
||||
3
backend/migrations/add_calendar_region_to_clubs.sql
Normal file
3
backend/migrations/add_calendar_region_to_clubs.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE clubs
|
||||
ADD COLUMN IF NOT EXISTS country_code VARCHAR(2) NOT NULL DEFAULT 'DE',
|
||||
ADD COLUMN IF NOT EXISTS state_code VARCHAR(16) NULL;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Add my_tischtennis_history_player_id column
|
||||
ALTER TABLE member
|
||||
ADD COLUMN my_tischtennis_history_player_id VARCHAR(255) NULL COMMENT 'TTR history player ID from myTischtennis (e.g. P14EC4981D)';
|
||||
|
||||
CREATE INDEX idx_member_my_tischtennis_history_player_id ON member(my_tischtennis_history_player_id);
|
||||
13
backend/migrations/add_range_to_training_cancellations.sql
Normal file
13
backend/migrations/add_range_to_training_cancellations.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE training_cancellations
|
||||
ADD COLUMN IF NOT EXISTS start_date DATE NULL,
|
||||
ADD COLUMN IF NOT EXISTS end_date DATE NULL;
|
||||
|
||||
UPDATE training_cancellations
|
||||
SET
|
||||
start_date = COALESCE(start_date, date),
|
||||
end_date = COALESCE(end_date, date)
|
||||
WHERE start_date IS NULL OR end_date IS NULL;
|
||||
|
||||
ALTER TABLE training_cancellations
|
||||
MODIFY start_date DATE NOT NULL,
|
||||
MODIFY end_date DATE NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE training_cancellations
|
||||
ADD COLUMN IF NOT EXISTS training_group_ids JSON NULL;
|
||||
23
backend/migrations/create_calendar_events_table.sql
Normal file
23
backend/migrations/create_calendar_events_table.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
club_id INT NOT NULL,
|
||||
event_type VARCHAR(32) NOT NULL DEFAULT 'club_event',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'planning',
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NULL,
|
||||
location VARCHAR(255) NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
registration_deadline DATE NULL,
|
||||
organizer_user_id INT NULL,
|
||||
category VARCHAR(64) NULL,
|
||||
notes TEXT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_calendar_events_club_start (club_id, start_date),
|
||||
KEY idx_calendar_events_club_status (club_id, status),
|
||||
KEY idx_calendar_events_club_event_type (club_id, event_type),
|
||||
CONSTRAINT fk_calendar_events_club
|
||||
FOREIGN KEY (club_id) REFERENCES clubs(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
31
backend/migrations/create_http_page_fetch_log.sql
Normal file
31
backend/migrations/create_http_page_fetch_log.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Migration: Create http_page_fetch_log table for logging HTTP page fetches (HTTV/click-TT etc.)
|
||||
-- Dient zum Verständnis der externen Seiten-Struktur und URL-Varianten je nach Verband/Saison
|
||||
|
||||
CREATE TABLE IF NOT EXISTS http_page_fetch_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NULL COMMENT 'Optional: User der den Aufruf ausgelöst hat',
|
||||
fetch_type VARCHAR(64) NOT NULL COMMENT 'z.B. leaguePage, clubInfoDisplay, regionMeetingFilter',
|
||||
base_domain VARCHAR(255) NOT NULL COMMENT 'z.B. httv.click-tt.de',
|
||||
full_url TEXT NOT NULL COMMENT 'Vollständige aufgerufene URL',
|
||||
association VARCHAR(64) NULL COMMENT 'Verband (z.B. HeTTV, RTTV)',
|
||||
championship VARCHAR(128) NULL COMMENT 'Championship-Parameter (z.B. HTTV 25/26)',
|
||||
club_id_param VARCHAR(64) NULL COMMENT 'Club-ID falls clubInfoDisplay',
|
||||
request_method VARCHAR(16) NULL COMMENT 'HTTP-Methode des ausgehenden Requests',
|
||||
request_headers LONGTEXT NULL COMMENT 'Gesendete Request-Header als JSON',
|
||||
request_body LONGTEXT NULL COMMENT 'Gesendeter Request-Body im Originalformat',
|
||||
http_status INT NULL COMMENT 'HTTP-Status der Response',
|
||||
success BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
response_snippet TEXT NULL COMMENT 'Gekürzter Response-Anfang (max 2000 Zeichen) zur Strukturanalyse',
|
||||
content_type VARCHAR(128) NULL COMMENT 'Content-Type der Response',
|
||||
response_headers LONGTEXT NULL COMMENT 'Empfangene Response-Header als JSON',
|
||||
response_body LONGTEXT NULL COMMENT 'Vollständiger Response-Body',
|
||||
response_url TEXT NULL COMMENT 'Finale Response-URL nach Redirects',
|
||||
error_message TEXT NULL,
|
||||
execution_time_ms INT NULL COMMENT 'Laufzeit in Millisekunden',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_created_at (created_at),
|
||||
INDEX idx_base_domain_fetch_type (base_domain, fetch_type),
|
||||
INDEX idx_association_championship (association, championship),
|
||||
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
15
backend/migrations/create_training_cancellations_table.sql
Normal file
15
backend/migrations/create_training_cancellations_table.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE IF NOT EXISTS training_cancellations (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
club_id INT NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
date DATE NULL,
|
||||
reason VARCHAR(255) NULL,
|
||||
training_group_ids JSON NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_training_cancellation_club_range (club_id, start_date, end_date),
|
||||
CONSTRAINT fk_training_cancellations_club
|
||||
FOREIGN KEY (club_id) REFERENCES clubs(id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
47
backend/models/BillingDocument.js
Normal file
47
backend/models/BillingDocument.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const BillingDocument = sequelize.define('BillingDocument', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
runId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'run_id'
|
||||
},
|
||||
displayName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'display_name'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('draft', 'generated', 'error'),
|
||||
allowNull: false,
|
||||
defaultValue: 'draft'
|
||||
},
|
||||
pdfStoragePath: {
|
||||
type: DataTypes.STRING(1000),
|
||||
allowNull: true,
|
||||
field: 'pdf_storage_path'
|
||||
},
|
||||
pdfFilename: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'pdf_filename'
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'error_message'
|
||||
}
|
||||
}, {
|
||||
tableName: 'billing_document',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default BillingDocument;
|
||||
37
backend/models/BillingDocumentValue.js
Normal file
37
backend/models/BillingDocumentValue.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const BillingDocumentValue = sequelize.define('BillingDocumentValue', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
billingDocumentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'billing_document_id'
|
||||
},
|
||||
fieldKey: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: false,
|
||||
field: 'field_key'
|
||||
},
|
||||
resolvedValue: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'resolved_value'
|
||||
},
|
||||
resolvedSource: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'resolved_source'
|
||||
}
|
||||
}, {
|
||||
tableName: 'billing_document_value',
|
||||
underscored: true,
|
||||
timestamps: false
|
||||
});
|
||||
|
||||
export default BillingDocumentValue;
|
||||
133
backend/models/BillingRun.js
Normal file
133
backend/models/BillingRun.js
Normal file
@@ -0,0 +1,133 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const BillingRun = sequelize.define('BillingRun', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id'
|
||||
},
|
||||
templateId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'template_id'
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
periodStart: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
field: 'period_start'
|
||||
},
|
||||
periodEnd: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
field: 'period_end'
|
||||
},
|
||||
selfRecipientUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'self_recipient_user_id'
|
||||
},
|
||||
selfRecipientName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'self_recipient_name'
|
||||
},
|
||||
hourlyRate: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
field: 'hourly_rate'
|
||||
},
|
||||
computedHoursTotal: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'computed_hours_total'
|
||||
},
|
||||
iban: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true
|
||||
},
|
||||
ibanWithoutCountry: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'iban_without_country'
|
||||
},
|
||||
sessionLabel: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'session_label'
|
||||
},
|
||||
sameAccountCheckbox: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'same_account_checkbox'
|
||||
},
|
||||
omitSelfRecipientName: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'omit_self_recipient_name'
|
||||
},
|
||||
omitIban: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'omit_iban'
|
||||
},
|
||||
omitLocationText: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'omit_location_text'
|
||||
},
|
||||
omitDocumentDate: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'omit_document_date'
|
||||
},
|
||||
omitSessionLabel: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'omit_session_label'
|
||||
},
|
||||
locationText: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'location_text'
|
||||
},
|
||||
documentDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'document_date'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('draft', 'generated', 'finalized', 'cancelled'),
|
||||
allowNull: false,
|
||||
defaultValue: 'draft'
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id'
|
||||
}
|
||||
}, {
|
||||
tableName: 'billing_run',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default BillingRun;
|
||||
62
backend/models/BillingTemplate.js
Normal file
62
backend/models/BillingTemplate.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const BillingTemplate = sequelize.define('BillingTemplate', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id'
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
pdfStoragePath: {
|
||||
type: DataTypes.STRING(1000),
|
||||
allowNull: false,
|
||||
field: 'pdf_storage_path'
|
||||
},
|
||||
pdfFilename: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'pdf_filename'
|
||||
},
|
||||
pdfMimeType: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false,
|
||||
defaultValue: 'application/pdf',
|
||||
field: 'pdf_mime_type'
|
||||
},
|
||||
isActive: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
field: 'is_active'
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id'
|
||||
}
|
||||
}, {
|
||||
tableName: 'billing_template',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default BillingTemplate;
|
||||
125
backend/models/BillingTemplateField.js
Normal file
125
backend/models/BillingTemplateField.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const BillingTemplateField = sequelize.define('BillingTemplateField', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
templateId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'template_id'
|
||||
},
|
||||
fieldKey: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: false,
|
||||
field: 'field_key'
|
||||
},
|
||||
label: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false
|
||||
},
|
||||
fieldType: {
|
||||
type: DataTypes.ENUM('text', 'number', 'currency', 'date', 'checkbox', 'formula', 'table_row'),
|
||||
allowNull: false,
|
||||
field: 'field_type'
|
||||
},
|
||||
sourceType: {
|
||||
type: DataTypes.ENUM('manual', 'member', 'trainer', 'club', 'system', 'constant', 'formula'),
|
||||
allowNull: false,
|
||||
defaultValue: 'manual',
|
||||
field: 'source_type'
|
||||
},
|
||||
sourcePath: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'source_path'
|
||||
},
|
||||
constantValue: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: true,
|
||||
field: 'constant_value'
|
||||
},
|
||||
formatter: {
|
||||
type: DataTypes.ENUM('none', 'iban_no_country', 'date_dd_mm_yyyy', 'currency_eur_2'),
|
||||
allowNull: false,
|
||||
defaultValue: 'none'
|
||||
},
|
||||
isRequired: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_required'
|
||||
},
|
||||
mappingMode: {
|
||||
type: DataTypes.ENUM('acroform', 'overlay'),
|
||||
allowNull: false,
|
||||
defaultValue: 'overlay',
|
||||
field: 'mapping_mode'
|
||||
},
|
||||
acroformFieldName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'acroform_field_name'
|
||||
},
|
||||
pageNumber: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'page_number'
|
||||
},
|
||||
x: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true
|
||||
},
|
||||
y: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true
|
||||
},
|
||||
width: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true
|
||||
},
|
||||
height: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true
|
||||
},
|
||||
fontSize: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: true,
|
||||
field: 'font_size'
|
||||
},
|
||||
align: {
|
||||
type: DataTypes.ENUM('left', 'center', 'right'),
|
||||
allowNull: true
|
||||
},
|
||||
formulaExpression: {
|
||||
type: DataTypes.STRING(1000),
|
||||
allowNull: true,
|
||||
field: 'formula_expression'
|
||||
},
|
||||
tableGroup: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: true,
|
||||
field: 'table_group'
|
||||
},
|
||||
rowIndex: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'row_index'
|
||||
},
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'sort_order'
|
||||
}
|
||||
}, {
|
||||
tableName: 'billing_template_field',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default BillingTemplateField;
|
||||
43
backend/models/BillingUserSetting.js
Normal file
43
backend/models/BillingUserSetting.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const BillingUserSetting = sequelize.define('BillingUserSetting', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id'
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'user_id'
|
||||
},
|
||||
lastHourlyRate: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'last_hourly_rate'
|
||||
},
|
||||
lastSelfRecipientName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'last_self_recipient_name'
|
||||
},
|
||||
lastLocationText: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'last_location_text'
|
||||
}
|
||||
}, {
|
||||
tableName: 'billing_user_setting',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
export default BillingUserSetting;
|
||||
53
backend/models/CalendarEvent.js
Normal file
53
backend/models/CalendarEvent.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import Club from './Club.js';
|
||||
|
||||
const CalendarEvent = sequelize.define('CalendarEvent', {
|
||||
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: { model: Club, key: 'id' },
|
||||
onDelete: 'CASCADE',
|
||||
},
|
||||
eventType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'club_event',
|
||||
field: 'event_type',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'planning',
|
||||
},
|
||||
title: { type: DataTypes.STRING(255), allowNull: false },
|
||||
description: { type: DataTypes.TEXT, allowNull: true },
|
||||
location: { type: DataTypes.STRING(255), allowNull: true },
|
||||
startDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'start_date' },
|
||||
endDate: { type: DataTypes.DATEONLY, allowNull: false, field: 'end_date' },
|
||||
registrationDeadline: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'registration_deadline',
|
||||
},
|
||||
organizerUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'organizer_user_id',
|
||||
},
|
||||
category: { type: DataTypes.STRING(64), allowNull: true },
|
||||
notes: { type: DataTypes.TEXT, allowNull: true },
|
||||
}, {
|
||||
tableName: 'calendar_events',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['club_id', 'start_date'] },
|
||||
{ fields: ['club_id', 'event_type'] },
|
||||
{ fields: ['club_id', 'status'] },
|
||||
{ fields: ['club_id', 'registration_deadline'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default CalendarEvent;
|
||||
107
backend/models/ClickTtAccount.js
Normal file
107
backend/models/ClickTtAccount.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
import { encryptData, decryptData } from '../utils/encrypt.js';
|
||||
|
||||
const ClickTtAccount = sequelize.define('ClickTtAccount', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE',
|
||||
field: 'user_id'
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
set(value) {
|
||||
this.setDataValue('username', encryptData(value));
|
||||
},
|
||||
get() {
|
||||
const encryptedValue = this.getDataValue('username');
|
||||
return encryptedValue ? decryptData(encryptedValue) : null;
|
||||
}
|
||||
},
|
||||
encryptedPassword: {
|
||||
type: DataTypes.TEXT('long'),
|
||||
allowNull: true,
|
||||
field: 'encrypted_password'
|
||||
},
|
||||
savePassword: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
allowNull: false,
|
||||
field: 'save_password'
|
||||
},
|
||||
lastLoginAttempt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_login_attempt'
|
||||
},
|
||||
lastLoginSuccess: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_login_success'
|
||||
},
|
||||
playwrightStorageState: {
|
||||
type: DataTypes.TEXT('long'),
|
||||
allowNull: true,
|
||||
field: 'playwright_storage_state',
|
||||
set(value) {
|
||||
if (value === null || value === undefined) {
|
||||
this.setDataValue('playwrightStorageState', null);
|
||||
} else {
|
||||
const jsonString = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
this.setDataValue('playwrightStorageState', encryptData(jsonString));
|
||||
}
|
||||
},
|
||||
get() {
|
||||
const encrypted = this.getDataValue('playwrightStorageState');
|
||||
if (!encrypted) return null;
|
||||
try {
|
||||
return JSON.parse(decryptData(encrypted));
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'click_tt_account',
|
||||
timestamps: true,
|
||||
hooks: {
|
||||
beforeSave: async (instance) => {
|
||||
if (!instance.savePassword) {
|
||||
instance.encryptedPassword = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ClickTtAccount.prototype.setPassword = function(password) {
|
||||
if (password && this.savePassword) {
|
||||
this.encryptedPassword = encryptData(password);
|
||||
} else {
|
||||
this.encryptedPassword = null;
|
||||
}
|
||||
};
|
||||
|
||||
ClickTtAccount.prototype.getPassword = function() {
|
||||
if (!this.encryptedPassword) return null;
|
||||
try {
|
||||
return decryptData(this.encryptedPassword);
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default ClickTtAccount;
|
||||
@@ -17,6 +17,68 @@ const Club = sequelize.define('Club', {
|
||||
allowNull: true,
|
||||
field: 'association_member_number'
|
||||
},
|
||||
myTischtennisFedNickname: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
field: 'my_tischtennis_fed_nickname',
|
||||
comment: 'Federation short name for rankings (e.g. HeTTV)'
|
||||
},
|
||||
autoFetchRankings: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'auto_fetch_rankings',
|
||||
comment: 'Enable automatic TTR/QTTR rankings fetch for this club'
|
||||
},
|
||||
countryCode: {
|
||||
type: DataTypes.STRING(2),
|
||||
allowNull: false,
|
||||
defaultValue: 'DE',
|
||||
field: 'country_code',
|
||||
comment: 'ISO 3166-1 alpha-2 country code for regional calendar data'
|
||||
},
|
||||
stateCode: {
|
||||
type: DataTypes.STRING(16),
|
||||
allowNull: true,
|
||||
field: 'state_code',
|
||||
comment: 'ISO 3166-2 subdivision code for regional calendar data, e.g. DE-NW'
|
||||
},
|
||||
memberDataQualityRequirements: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'member_data_quality_requirements',
|
||||
comment: 'Configures which member fields are required for data quality checks'
|
||||
},
|
||||
feeRules: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'fee_rules',
|
||||
comment: 'Manual contribution rules for common club member groups'
|
||||
},
|
||||
outgoingInvoicePrefix: {
|
||||
type: DataTypes.STRING(24),
|
||||
allowNull: false,
|
||||
defaultValue: 'RE',
|
||||
field: 'outgoing_invoice_prefix'
|
||||
},
|
||||
outgoingInvoiceNextNumber: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
field: 'outgoing_invoice_next_number'
|
||||
},
|
||||
incomingInvoicePrefix: {
|
||||
type: DataTypes.STRING(24),
|
||||
allowNull: false,
|
||||
defaultValue: 'EI',
|
||||
field: 'incoming_invoice_prefix'
|
||||
},
|
||||
incomingInvoiceNextNumber: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
field: 'incoming_invoice_next_number'
|
||||
}
|
||||
}, {
|
||||
tableName: 'clubs',
|
||||
underscored: true,
|
||||
|
||||
94
backend/models/ClubAccount.js
Normal file
94
backend/models/ClubAccount.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubAccount = sequelize.define('ClubAccount', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(160),
|
||||
allowNull: false,
|
||||
},
|
||||
accountHolder: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'account_holder',
|
||||
},
|
||||
bankName: {
|
||||
type: DataTypes.STRING(160),
|
||||
allowNull: true,
|
||||
field: 'bank_name',
|
||||
},
|
||||
iban: {
|
||||
type: DataTypes.STRING(34),
|
||||
allowNull: true,
|
||||
},
|
||||
bic: {
|
||||
type: DataTypes.STRING(11),
|
||||
allowNull: true,
|
||||
},
|
||||
accountType: {
|
||||
type: DataTypes.ENUM('bank', 'cash', 'virtual'),
|
||||
allowNull: false,
|
||||
defaultValue: 'bank',
|
||||
field: 'account_type',
|
||||
},
|
||||
usageType: {
|
||||
type: DataTypes.ENUM('general', 'membership_fees', 'donations', 'expenses', 'reserve', 'petty_cash'),
|
||||
allowNull: false,
|
||||
defaultValue: 'general',
|
||||
field: 'usage_type',
|
||||
},
|
||||
currencyCode: {
|
||||
type: DataTypes.STRING(3),
|
||||
allowNull: false,
|
||||
defaultValue: 'EUR',
|
||||
field: 'currency_code',
|
||||
},
|
||||
allowSepaCollections: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'allow_sepa_collections',
|
||||
},
|
||||
allowOutgoingPayments: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
field: 'allow_outgoing_payments',
|
||||
},
|
||||
isDefault: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_default',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('active', 'inactive', 'archived'),
|
||||
allowNull: false,
|
||||
defaultValue: 'active',
|
||||
},
|
||||
notes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
archivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'archived_at',
|
||||
},
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'sort_order',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_accounts',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubAccount;
|
||||
87
backend/models/ClubAccountTransaction.js
Normal file
87
backend/models/ClubAccountTransaction.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubAccountTransaction = sequelize.define('ClubAccountTransaction', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
accountId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'account_id',
|
||||
},
|
||||
invoiceId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'invoice_id',
|
||||
},
|
||||
paymentClaimId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'payment_claim_id',
|
||||
},
|
||||
direction: {
|
||||
type: DataTypes.ENUM('credit', 'debit'),
|
||||
allowNull: false,
|
||||
defaultValue: 'credit',
|
||||
},
|
||||
bookingType: {
|
||||
type: DataTypes.ENUM('manual', 'invoice', 'adjustment', 'payment_claim'),
|
||||
allowNull: false,
|
||||
defaultValue: 'manual',
|
||||
field: 'booking_type',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('planned', 'booked', 'cancelled'),
|
||||
allowNull: false,
|
||||
defaultValue: 'booked',
|
||||
},
|
||||
bookingDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
field: 'booking_date',
|
||||
},
|
||||
valueDate: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'value_date',
|
||||
},
|
||||
amountCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'amount_cents',
|
||||
},
|
||||
currencyCode: {
|
||||
type: DataTypes.STRING(3),
|
||||
allowNull: false,
|
||||
defaultValue: 'EUR',
|
||||
field: 'currency_code',
|
||||
},
|
||||
reference: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
notes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_account_transactions',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubAccountTransaction;
|
||||
73
backend/models/ClubCommunicationDeliveryLog.js
Normal file
73
backend/models/ClubCommunicationDeliveryLog.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationDeliveryLog = sequelize.define('ClubCommunicationDeliveryLog', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
threadId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'thread_id',
|
||||
},
|
||||
recipientId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'recipient_id',
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('sent', 'failed', 'skipped'),
|
||||
allowNull: false,
|
||||
defaultValue: 'failed',
|
||||
},
|
||||
attemptNo: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
field: 'attempt_no',
|
||||
},
|
||||
retryable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
errorCode: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'error_code',
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'error_message',
|
||||
},
|
||||
transportMessageId: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'transport_message_id',
|
||||
},
|
||||
transportResponse: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'transport_response',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_delivery_logs',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationDeliveryLog;
|
||||
47
backend/models/ClubCommunicationMessage.js
Normal file
47
backend/models/ClubCommunicationMessage.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationMessage = sequelize.define('ClubCommunicationMessage', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
threadId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'thread_id',
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
messageType: {
|
||||
type: DataTypes.ENUM('message', 'note'),
|
||||
allowNull: false,
|
||||
defaultValue: 'message',
|
||||
field: 'message_type',
|
||||
},
|
||||
direction: {
|
||||
type: DataTypes.ENUM('outbound', 'internal', 'inbound'),
|
||||
allowNull: false,
|
||||
defaultValue: 'outbound',
|
||||
},
|
||||
body: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_messages',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationMessage;
|
||||
84
backend/models/ClubCommunicationRecipient.js
Normal file
84
backend/models/ClubCommunicationRecipient.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationRecipient = sequelize.define('ClubCommunicationRecipient', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
threadId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'thread_id',
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
memberId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'member_id',
|
||||
},
|
||||
recipientName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'recipient_name',
|
||||
},
|
||||
emailSnapshot: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'email_snapshot',
|
||||
},
|
||||
deliveryStatus: {
|
||||
type: DataTypes.ENUM('pending', 'sent', 'failed', 'skipped'),
|
||||
allowNull: false,
|
||||
defaultValue: 'pending',
|
||||
field: 'delivery_status',
|
||||
},
|
||||
deliveredAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'delivered_at',
|
||||
},
|
||||
lastAttemptAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'last_attempt_at',
|
||||
},
|
||||
attemptCount: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'attempt_count',
|
||||
},
|
||||
retryable: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
errorCode: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'error_code',
|
||||
},
|
||||
transportMessageId: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'transport_message_id',
|
||||
},
|
||||
errorMessage: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'error_message',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_recipients',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationRecipient;
|
||||
58
backend/models/ClubCommunicationTemplate.js
Normal file
58
backend/models/ClubCommunicationTemplate.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationTemplate = sequelize.define('ClubCommunicationTemplate', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(160),
|
||||
allowNull: false,
|
||||
},
|
||||
category: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
defaultValue: 'general',
|
||||
},
|
||||
subjectTemplate: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'subject_template',
|
||||
},
|
||||
bodyTemplate: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
field: 'body_template',
|
||||
},
|
||||
variablesHint: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'variables_hint',
|
||||
},
|
||||
isSystemTemplate: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_system_template',
|
||||
},
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'sort_order',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_templates',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationTemplate;
|
||||
67
backend/models/ClubCommunicationThread.js
Normal file
67
backend/models/ClubCommunicationThread.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubCommunicationThread = sequelize.define('ClubCommunicationThread', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
threadType: {
|
||||
type: DataTypes.ENUM('direct', 'group', 'broadcast'),
|
||||
allowNull: false,
|
||||
defaultValue: 'direct',
|
||||
field: 'thread_type',
|
||||
},
|
||||
subject: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('draft', 'scheduled', 'sent', 'archived'),
|
||||
allowNull: false,
|
||||
defaultValue: 'draft',
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
distributionGroupId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'distribution_group_id',
|
||||
},
|
||||
recipientMemberId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'recipient_member_id',
|
||||
},
|
||||
scheduledAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'scheduled_at',
|
||||
},
|
||||
recipientFilters: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'recipient_filters',
|
||||
},
|
||||
sentAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'sent_at',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_communication_threads',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubCommunicationThread;
|
||||
47
backend/models/ClubDistributionGroup.js
Normal file
47
backend/models/ClubDistributionGroup.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDistributionGroup = sequelize.define('ClubDistributionGroup', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
clubId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
groupType: {
|
||||
type: DataTypes.ENUM('manual', 'training_group', 'team', 'custom'),
|
||||
allowNull: false,
|
||||
defaultValue: 'manual',
|
||||
field: 'group_type',
|
||||
},
|
||||
isSystemGroup: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
field: 'is_system_group',
|
||||
},
|
||||
filterDefinition: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
field: 'filter_definition',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_distribution_groups',
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubDistributionGroup;
|
||||
33
backend/models/ClubDistributionGroupMember.js
Normal file
33
backend/models/ClubDistributionGroupMember.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDistributionGroupMember = sequelize.define('ClubDistributionGroupMember', {
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false,
|
||||
},
|
||||
groupId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'group_id',
|
||||
},
|
||||
memberId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'member_id',
|
||||
},
|
||||
}, {
|
||||
underscored: true,
|
||||
tableName: 'club_distribution_group_members',
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['group_id', 'member_id'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubDistributionGroupMember;
|
||||
61
backend/models/ClubDocument.js
Normal file
61
backend/models/ClubDocument.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDocument = sequelize.define('ClubDocument', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
documentType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
field: 'document_type',
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'active',
|
||||
},
|
||||
visibilityScope: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'board',
|
||||
field: 'visibility_scope',
|
||||
},
|
||||
ownerUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'owner_user_id',
|
||||
},
|
||||
currentVersionNo: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
field: 'current_version_no',
|
||||
},
|
||||
archivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'archived_at',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_documents',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
indexes: [
|
||||
{ fields: ['club_id', 'document_type', 'status'] },
|
||||
{ fields: ['club_id', 'visibility_scope'] },
|
||||
{ fields: ['club_id', 'current_version_no'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubDocument;
|
||||
35
backend/models/ClubDocumentLink.js
Normal file
35
backend/models/ClubDocumentLink.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDocumentLink = sequelize.define('ClubDocumentLink', {
|
||||
documentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'document_id',
|
||||
},
|
||||
linkedEntityType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
field: 'linked_entity_type',
|
||||
},
|
||||
linkedEntityId: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
field: 'linked_entity_id',
|
||||
},
|
||||
createdAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'created_at',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_document_links',
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ fields: ['linked_entity_type', 'linked_entity_id'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubDocumentLink;
|
||||
65
backend/models/ClubDocumentVersion.js
Normal file
65
backend/models/ClubDocumentVersion.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubDocumentVersion = sequelize.define('ClubDocumentVersion', {
|
||||
documentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'document_id',
|
||||
},
|
||||
versionNo: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'version_no',
|
||||
},
|
||||
fileName: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: false,
|
||||
field: 'file_name',
|
||||
},
|
||||
storagePath: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false,
|
||||
field: 'storage_path',
|
||||
},
|
||||
mimeType: {
|
||||
type: DataTypes.STRING(120),
|
||||
allowNull: true,
|
||||
field: 'mime_type',
|
||||
},
|
||||
fileSizeBytes: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'file_size_bytes',
|
||||
},
|
||||
checksumSha256: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'checksum_sha256',
|
||||
},
|
||||
uploadedByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'uploaded_by_user_id',
|
||||
},
|
||||
uploadedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW,
|
||||
field: 'uploaded_at',
|
||||
},
|
||||
changeNote: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'change_note',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_document_versions',
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ unique: true, fields: ['document_id', 'version_no'] },
|
||||
],
|
||||
});
|
||||
|
||||
export default ClubDocumentVersion;
|
||||
109
backend/models/ClubInvoice.js
Normal file
109
backend/models/ClubInvoice.js
Normal file
@@ -0,0 +1,109 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubInvoice = sequelize.define('ClubInvoice', {
|
||||
clubId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'club_id',
|
||||
},
|
||||
invoiceDirection: {
|
||||
type: DataTypes.STRING(16),
|
||||
allowNull: false,
|
||||
field: 'invoice_direction',
|
||||
},
|
||||
invoiceType: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
field: 'invoice_type',
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING(32),
|
||||
allowNull: false,
|
||||
defaultValue: 'draft',
|
||||
},
|
||||
invoiceNumber: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: true,
|
||||
field: 'invoice_number',
|
||||
},
|
||||
externalReference: {
|
||||
type: DataTypes.STRING(255),
|
||||
allowNull: true,
|
||||
field: 'external_reference',
|
||||
},
|
||||
partyId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'party_id',
|
||||
},
|
||||
accountId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'account_id',
|
||||
},
|
||||
issuedOn: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'issued_on',
|
||||
},
|
||||
dueOn: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'due_on',
|
||||
},
|
||||
paidOn: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
field: 'paid_on',
|
||||
},
|
||||
netAmountCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'net_amount_cents',
|
||||
},
|
||||
taxAmountCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'tax_amount_cents',
|
||||
},
|
||||
grossAmountCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'gross_amount_cents',
|
||||
},
|
||||
currencyCode: {
|
||||
type: DataTypes.STRING(3),
|
||||
allowNull: false,
|
||||
defaultValue: 'EUR',
|
||||
field: 'currency_code',
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
documentId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'document_id',
|
||||
},
|
||||
createdByUserId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
field: 'created_by_user_id',
|
||||
},
|
||||
archivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
field: 'archived_at',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_invoices',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubInvoice;
|
||||
49
backend/models/ClubInvoiceItem.js
Normal file
49
backend/models/ClubInvoiceItem.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { DataTypes } from 'sequelize';
|
||||
import sequelize from '../database.js';
|
||||
|
||||
const ClubInvoiceItem = sequelize.define('ClubInvoiceItem', {
|
||||
invoiceId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
field: 'invoice_id',
|
||||
},
|
||||
lineNo: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
field: 'line_no',
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
quantity: {
|
||||
type: DataTypes.DECIMAL(12, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
},
|
||||
unitPriceCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'unit_price_cents',
|
||||
},
|
||||
taxRate: {
|
||||
type: DataTypes.DECIMAL(5, 2),
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'tax_rate',
|
||||
},
|
||||
totalCents: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
field: 'total_cents',
|
||||
},
|
||||
}, {
|
||||
tableName: 'club_invoice_items',
|
||||
underscored: true,
|
||||
timestamps: true,
|
||||
});
|
||||
|
||||
export default ClubInvoiceItem;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user