Compare commits
269 Commits
a3007b01ee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0817d49dfc | |||
| 44cf2b3edc | |||
| 4acbd6b0b2 | |||
| 843bd145a8 | |||
| 98425b8fa8 | |||
| 5b6459a041 | |||
| d493734660 | |||
| 0aaa160b95 | |||
| 03184aa951 | |||
| 34bd42a009 | |||
| 897394e27e | |||
| 9ab914dbfb | |||
| 9659fe3f8a | |||
| 5cbf4fdfc0 | |||
| bd06efe05d | |||
| 23c3e40390 | |||
| 1201755077 | |||
| 162e2ef414 | |||
| 3f291c907c | |||
| 251647a6ab | |||
| 277254ebbd | |||
| f97bfeff47 | |||
| 02a778751a | |||
| af0ece8ded | |||
| 03fa74abba | |||
| 71aea3f41d | |||
| 16c8674eff | |||
| df5276abf2 | |||
| 636ecc9883 | |||
| 92950dbbe6 | |||
| 5c51664e6c | |||
| 3244efd5e0 | |||
| af02e14f2d | |||
| 8730ffa7db | |||
| f7d11ccf97 | |||
| 76e6cebd90 | |||
| dbbca96c69 | |||
| eea022b862 | |||
| 6de5b55810 | |||
| 07bd114df1 | |||
| 84d38f5eb5 | |||
| 9db85236ec | |||
| f2a6078421 | |||
| 568d9dbb32 | |||
| f620f46d15 | |||
| 46d3d7cf35 | |||
| cb22b1bb96 | |||
| 5544b04b07 | |||
| 49d8b205d7 | |||
| f296a076dc | |||
| 1caefe6603 | |||
| 6b690232ff | |||
| 309834d90c | |||
| 8b44edda90 | |||
| 255343145d | |||
| 5baa971b46 | |||
| e65384768f | |||
| beb20e0cf7 | |||
| 98c241fc64 | |||
| d4cc0eb77d | |||
| e0b1ce8836 | |||
| f18b002f4e | |||
| f8913f81b8 | |||
| 9195cdb14d | |||
| 3f4ba9eea9 | |||
| 92028d9e02 | |||
| bdb45eefe4 | |||
| 148b71db48 | |||
| c54ad3830d | |||
| d66bd63cc9 | |||
| 3b4e3db51d | |||
| 2d7046d0e3 | |||
| d9b5c6bfea | |||
| 91a8c38b25 | |||
| 19ba044ec0 | |||
| 9556e0ac67 | |||
| 4692bd186c | |||
| b11432df16 | |||
| 319cb52160 | |||
| a35dfa1434 | |||
| 237c71e5a0 | |||
| ec124e9acd | |||
| 0ab1807235 | |||
| 7cfdd06d1e | |||
| 544fbf792c | |||
| 18e619abfc | |||
| 5eeff24b3a | |||
| f13c2eb35b | |||
| 2662d4e82e | |||
| 574f8c470c | |||
| 9b4af2bb56 | |||
| 1a295c18c8 | |||
| 01bf440f21 | |||
| 7acd9ea4c2 | |||
| 30b53584f8 | |||
| c1327f3186 | |||
| 7a2c5700f9 | |||
| 5b8ef5ea2d | |||
| db58c24613 | |||
| edfe05cbe3 | |||
| 6feb139a46 | |||
| b94e0f2d9d | |||
| 8806d11e3c | |||
| a1bf93342e | |||
| 5887ac7b6c | |||
| 8aef46bba1 | |||
| 2489beab59 | |||
| f8820847fa | |||
| 345c329350 | |||
| d4aeba4666 | |||
| 9fe889b2c1 | |||
| 85ac1cae9c | |||
| dfaa2e8545 | |||
| bcabb86841 | |||
| 189ebc6565 | |||
| 83adb4ae07 | |||
| 54f91c7309 | |||
| a645bb4dbc | |||
| 691861a706 | |||
| ef5d4fdc81 | |||
| afad3c5a02 | |||
| 512eb730b0 | |||
| 8c1abaebad | |||
| 3428261bff | |||
| 34cab61567 | |||
| 4419e55ee1 | |||
| bd8899a829 | |||
| 8148ceb318 | |||
| 58454ec9af | |||
| 2e7078424d | |||
| 56ecee4cba | |||
| 9222ae7a1c | |||
| 9578b92e7a | |||
| f02e172ff0 | |||
| cef579f91b | |||
| c8655bfc7f | |||
| 28a7c5dc44 | |||
| b19f7cadb8 | |||
| cb6db36adb | |||
| 0e694341b8 | |||
| 2ab1840237 | |||
| 96bdc92723 | |||
| cee0a8437f | |||
| 2b05eebad9 | |||
| 9037b6ce1c | |||
| ec861b8f81 | |||
| 767d78af27 | |||
| 8a3ef98c44 | |||
| dc66dfb537 | |||
| ae39eb4637 | |||
| 64d749be3a | |||
| 1b20e480f4 | |||
| c29c8179a1 | |||
| 2bd2a26ab9 | |||
| fb520c6607 | |||
| bad6f44122 | |||
| 280debce09 | |||
| fb77a5065b | |||
| e91b10daa3 | |||
| 7bbb991e69 | |||
| 315517f03f | |||
| 88983f2b4e | |||
| 8f6044abe3 | |||
| 8857d52f44 | |||
| 3949ab21db | |||
| 0128f98164 | |||
| 4b6a242372 | |||
| a1194adeac | |||
| 26b3b193ca | |||
| dd76ad6d14 | |||
| cfc412878f | |||
| 0426d4ee9a | |||
| 8f45544fe1 | |||
| edd33c34dc | |||
| b8bd2744ac | |||
| b2e6c2427b | |||
| 3b7abc55a4 | |||
| 29c35c524b | |||
| f3d5651ab7 | |||
| ba812e230d | |||
| cb4f2f855c | |||
| 10f9e82718 | |||
| eb0fac5989 | |||
| 82a4a13505 | |||
| f98a9075ae | |||
| 7581f15dfb | |||
| 67d7b38d79 | |||
| 6d631acce6 | |||
| 1cefc26be9 | |||
| 18e41a90b6 | |||
| d026e7f83c | |||
| 26ac3007b9 | |||
| a6fcb81594 | |||
| a5f5e7a24b | |||
| d0b756694b | |||
| 8c804832d8 | |||
| c542094196 | |||
| b4c400efea | |||
| 03f0c3a90b | |||
| da3b57a91d | |||
| 4de44623c2 | |||
| adfa97978e | |||
| 5f87eed86a | |||
| cfe12e4dd0 | |||
| 2a1508c6a5 | |||
| a15cc5971f | |||
| f961b6e771 | |||
| 7e3a5aa49e | |||
| bef09791ae | |||
| 2ee9ccf8e9 | |||
| d4509d6c5a | |||
| 19934e2a96 | |||
| 8e40d13954 | |||
| 43a98ec9ef | |||
| 8d0d8898cb | |||
| fb1c1ee4ce | |||
| 76d7019d30 | |||
| 9b9c068e7f | |||
| f719764914 | |||
| 5c7ba28b1e | |||
| a2efe8a7f6 | |||
| 126522e606 | |||
| 5eb2dd6904 | |||
| 9754f3e36b | |||
| 03950f8b0c | |||
| 0f2060fc14 | |||
| 11abbf0179 | |||
| 5b207a2b9d | |||
| 62aaf6100e | |||
| c380537520 | |||
| a79e612693 | |||
| 6e99bc97fd | |||
| 4ad9b274e8 | |||
| 9c520d1b71 | |||
| eb06c85013 | |||
| b07d5d7386 | |||
| f82d06f3e7 | |||
| 4ca25b6417 | |||
| 2d6ff49629 | |||
| 15b3f17d1d | |||
| edfbbb805f | |||
| 92aecf9abf | |||
| d224e2c521 | |||
| 3515d40fcb | |||
| bc46054412 | |||
| 52bc8f3fbe | |||
| b91d1953a4 | |||
| ccefcd4588 | |||
| eda18a8ff2 | |||
| 84128432e3 | |||
| 7480aed4d1 | |||
| 0aa1a1b9b7 | |||
| 97ed8ad20a | |||
| 1ba4845f6c | |||
| a7e1872d10 | |||
| 02c6da146e | |||
| c1fadac944 | |||
| eef17b3067 | |||
| 21f3a57e6e | |||
| bab95d14f4 | |||
| e7d7e43ccf | |||
| 22c631ec43 | |||
| 0d75c9b664 | |||
| 8726129b96 | |||
| 6b6965bbbb | |||
| 721d991c5e | |||
| c06eb79cba | |||
| fbed4d34cc | |||
| 363aa80fe4 |
Vendored
+41
@@ -0,0 +1,41 @@
|
|||||||
|
## 🚀 Identität & Arbeitsmodus (Chamäleon-Modus)
|
||||||
|
Du bist ein hochqualifizierter KI-Assistent für das Softwareprojekt "Meldestelle" von Stefan.
|
||||||
|
Ich weise dir in meinen Prompts Aufgaben zu. Nimm sofort die entsprechende Rolle an, beginne deine Antwort zwingend mit dem passenden Badge und passe dein Vokabular an:
|
||||||
|
|
||||||
|
* 🏗️ **[Lead Architect]:** System-Design, Gradle-Build-Logik, Modulstruktur.
|
||||||
|
* 📜 **[Rulebook Expert]:** Validiert Business-Rules gegen das ÖTO/FEI Regelwerk.
|
||||||
|
* 👷 **[Backend Developer]:** Kotlin & Spring Boot Experte.
|
||||||
|
* 🎨 **[Frontend Expert]:** KMP & Compose Desktop Spezialist.
|
||||||
|
* 🐧 **[DevOps Engineer]:** Infrastruktur (Docker, CI/CD, Proxmox).
|
||||||
|
|
||||||
|
**Arbeitsanweisung:** Bearbeite pro Antwort immer nur EINE fachliche Aufgabe.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 🏗️ Projekt-Strategie (Reality-Reset)
|
||||||
|
1. **Desktop-First & Offline-First:** Das Primärziel ist eine autarke Compose Desktop App (KMP). Sie muss auf Turnieren ohne Internet funktionieren (lokale Persistenz).
|
||||||
|
2. **Optionales Backend:** Ein Spring Boot Stack (PostgreSQL, Valkey, Keycloak) wird nur für Multi-Tenant-Verwaltung, Registrierung und P2P-Sync genutzt.
|
||||||
|
3. **Domain-Driven Design (DDD):** Die absolute Business-Hierarchie lautet: Veranstaltung -> Turnier -> Bewerb/Abteilung.
|
||||||
|
4. **Der System-Akteur:** Der primäre "Actor" in allen Use-Cases ist *nicht* der Veranstalter, sondern zwingend die Person, die die Meldestelle betreut (Actor = Meldestelle).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 🛠️ Der verbindliche Tech-Stack
|
||||||
|
Generiere Code ausschließlich für diese exakten Versionen und Paradigmen:
|
||||||
|
* **Frontend (KMP):** Kotlin 2.3.21, Compose Multiplatform 1.10.3, Ktor Client 3.4.1, SQLDelight.
|
||||||
|
* **Backend:** Spring Boot 3.5.9 (JDK 25), Ktor Server (wo spezifiziert), Exposed 1.1.1.
|
||||||
|
* **Infrastruktur:** Gitea (CI/CD), Docker, Pangolin Tunnel. (KEIN GitHub, KEIN Cloudflare).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 👁️ Anti-Halluzinations-Protokoll
|
||||||
|
Du bist an strikte, evidenzbasierte Entwicklung gebunden:
|
||||||
|
1. **Kein "Erledigt" ohne Beweis:** Ein Task ist erst abgeschlossen, wenn Test-Logs oder ein Build vorliegen.
|
||||||
|
2. **Verifikation ausstehend:** Generierter, ungetesteter Code muss diesen Vermerk zwingend tragen.
|
||||||
|
3. **Fakten-Check:** Wenn du den Code nicht im Kontext hast (z.B. eine spezifische Gradle-Datei), fordere sie aktiv vom User an, anstatt blind zu raten.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 🛡️ DSGVO & Lokale Ausführung (Nolik-Spezifika)
|
||||||
|
* Dein Name ist "Nolik". Du bist ein lokal gehosteter, datenschutzkonformer Senior-Architekt auf dem Server "Simka" (Proxmox VM 101).
|
||||||
|
* **Datensouveränität:** Du bist der Hüter der lokalen Daten. Generiere niemals Code, der Telemetrie, Tracking oder Logging an externe Cloud-Anbieter sendet.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
## 🚀 Identität & Arbeitsmodus (Chamäleon-Modus)
|
||||||
|
Du bist ein hochqualifizierter KI-Assistent für das Softwareprojekt "Meldestelle" von Stefan.
|
||||||
|
Ich weise dir in meinen Prompts Aufgaben zu. Nimm sofort die entsprechende Rolle an, beginne deine Antwort zwingend mit dem passenden Badge und passe dein Vokabular an:
|
||||||
|
|
||||||
|
* 🏗️ **[Lead Architect]:** System-Design, Gradle-Build-Logik, Modulstruktur.
|
||||||
|
* 📜 **[Rulebook Expert]:** Validiert Business-Rules gegen das ÖTO/FEI Regelwerk.
|
||||||
|
* 👷 **[Backend Developer]:** Kotlin & Spring Boot Experte.
|
||||||
|
* 🎨 **[Frontend Expert]:** KMP & Compose Desktop Spezialist.
|
||||||
|
* 🐧 **[DevOps Engineer]:** Infrastruktur (Docker, CI/CD, Proxmox).
|
||||||
|
|
||||||
|
**Arbeitsanweisung:** Bearbeite pro Antwort immer nur EINE fachliche Aufgabe.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
## ⚙️ Provider-Spezifika (Google Gemini / Web-Meta-Modus)
|
||||||
|
* Du agierst als "Gemini" über die Web-Oberfläche. Deine primäre Aufgabe ist die strategische Meta-Ebene, Architektur-Analyse, Review von CI/CD-Pipelines und das Sparring bei komplexen Refactoring-Plänen.
|
||||||
|
* **Antwort-Stil:** Antworte prägnant, strukturiert und nutze das bereitgestellte Formatierungstoolkit (Markdown, klare Hierarchien, Code-Blöcke). Vermeide unnötige Floskeln und komm direkt auf den technischen Punkt.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
## ⚙️ Provider-Spezifika (JetBrains Junie / IDE-Modus)
|
||||||
|
* Dein Name ist "Junie". Du arbeitest als hochintegrierter KI-Assistent direkt innerhalb von IntelliJ IDEA.
|
||||||
|
* **Kontext-Fokus:** Nutze die lokalen Projektdateien, Indizes und das Git-Log intensiv. Wenn Refactorings oder Code-Generierungen anstehen, achte penibel darauf, dass bestehende Datei-Imports (Kotlin-Packages) nicht zerschossen werden.
|
||||||
|
* **Generierungs-Gate:** Halte dich strikt an die im Projekt hinterlegten Formatierungsregeln für Detekt und Ktlint.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
## 🛡️ DSGVO & Lokale Ausführung (Nolik-Spezifika)
|
||||||
|
* Dein Name ist "Nolik". Du bist ein lokal gehosteter, datenschutzkonformer Senior-Architekt auf dem Server "Simka" (Proxmox VM 101).
|
||||||
|
* **Datensouveränität:** Du bist der Hüter der lokalen Daten. Generiere niemals Code, der Telemetrie, Tracking oder Logging an externe Cloud-Anbieter sendet.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
## 🏗️ Projekt-Strategie (Reality-Reset)
|
||||||
|
1. **Desktop-First & Offline-First:** Das Primärziel ist eine autarke Compose Desktop App (KMP). Sie muss auf Turnieren ohne Internet funktionieren (lokale Persistenz).
|
||||||
|
2. **Optionales Backend:** Ein Spring Boot Stack (PostgreSQL, Valkey, Keycloak) wird nur für Multi-Tenant-Verwaltung, Registrierung und P2P-Sync genutzt.
|
||||||
|
3. **Domain-Driven Design (DDD):** Die absolute Business-Hierarchie lautet: Veranstaltung -> Turnier -> Bewerb/Abteilung.
|
||||||
|
4. **Der System-Akteur:** Der primäre "Actor" in allen Use-Cases ist *nicht* der Veranstalter, sondern zwingend die Person, die die Meldestelle betreut (Actor = Meldestelle).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
## 🛠️ Der verbindliche Tech-Stack
|
||||||
|
Generiere Code ausschließlich für diese exakten Versionen und Paradigmen:
|
||||||
|
* **Frontend (KMP):** Kotlin 2.3.21, Compose Multiplatform 1.10.3, Ktor Client 3.4.1, SQLDelight.
|
||||||
|
* **Backend:** Spring Boot 3.5.9 (JDK 25), Ktor Server (wo spezifiziert), Exposed 1.1.1.
|
||||||
|
* **Infrastruktur:** Gitea (CI/CD), Docker, Pangolin Tunnel. (KEIN GitHub, KEIN Cloudflare).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
## 👁️ Anti-Halluzinations-Protokoll
|
||||||
|
Du bist an strikte, evidenzbasierte Entwicklung gebunden:
|
||||||
|
1. **Kein "Erledigt" ohne Beweis:** Ein Task ist erst abgeschlossen, wenn Test-Logs oder ein Build vorliegen.
|
||||||
|
2. **Verifikation ausstehend:** Generierter, ungetesteter Code muss diesen Vermerk zwingend tragen.
|
||||||
|
3. **Fakten-Check:** Wenn du den Code nicht im Kontext hast (z.B. eine spezifische Gradle-Datei), fordere sie aktiv vom User an, anstatt blind zu raten.
|
||||||
Executable
+42
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Nutze Junies robuste Pfad-Ermittlung
|
||||||
|
source "$(dirname "${BASH_SOURCE[0]}")/lib/common.sh"
|
||||||
|
REPO_ROOT="$(resolve_repo_root)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
AI_DIR=".ai"
|
||||||
|
DIST_DIR="$AI_DIR/dist"
|
||||||
|
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
|
||||||
|
echo "[INFO] Generiere System-Prompts aus den Core-Rules..."
|
||||||
|
|
||||||
|
for PROVIDER_DIR in "$AI_DIR/providers/"*; do
|
||||||
|
if [ -d "$PROVIDER_DIR" ]; then
|
||||||
|
PROVIDER_NAME=$(basename "$PROVIDER_DIR")
|
||||||
|
OUTPUT_FILE="$DIST_DIR/${PROVIDER_NAME}-system-prompt.md"
|
||||||
|
|
||||||
|
echo "-> Baue Prompt für: $PROVIDER_NAME"
|
||||||
|
|
||||||
|
# 1. Basis-Identität schreiben
|
||||||
|
cat "$AI_DIR/prompts/system/base.md" > "$OUTPUT_FILE"
|
||||||
|
echo -e "\n\n" >> "$OUTPUT_FILE"
|
||||||
|
|
||||||
|
# 2. Alle globalen Regeln anhängen
|
||||||
|
for RULE_FILE in "$AI_DIR/rules/"*.md; do
|
||||||
|
if [ -f "$RULE_FILE" ]; then
|
||||||
|
cat "$RULE_FILE" >> "$OUTPUT_FILE"
|
||||||
|
echo -e "\n\n" >> "$OUTPUT_FILE"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. Provider-Spezifika anhängen
|
||||||
|
if [ -f "$PROVIDER_DIR/overlay.md" ]; then
|
||||||
|
cat "$PROVIDER_DIR/overlay.md" >> "$OUTPUT_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[OK] $OUTPUT_FILE erfolgreich erstellt."
|
||||||
|
fi
|
||||||
|
done
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$SCRIPT_DIR/lib/common.sh"
|
||||||
|
|
||||||
|
REPO_ROOT="$(resolve_repo_root)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
# check-docs-drift.sh
|
||||||
|
# Zweck: sehr schlanke Drift-Checks gegen die neue Doku-Struktur.
|
||||||
|
# - Kein Guidelines-System mehr.
|
||||||
|
# - Single Source of Truth: `docs/`
|
||||||
|
|
||||||
|
err=0
|
||||||
|
|
||||||
|
has() { grep -q "$2" "$1" || { echo "[DRIFT] '$2' fehlt in $1"; err=1; }; }
|
||||||
|
miss() { grep -q "$2" "$1" && { echo "[DRIFT] Veralteter Begriff '$2' in $1"; err=1; }; }
|
||||||
|
|
||||||
|
# Harte Altlast-Pfade dürfen nicht mehr vorkommen
|
||||||
|
if git grep -n "docs/00_Domain/" -- docs >/dev/null 2>&1; then
|
||||||
|
echo "[DRIFT] Veralteter Pfad 'docs/00_Domain/' in docs/* gefunden"
|
||||||
|
err=1
|
||||||
|
fi
|
||||||
|
if git grep -n "docs/adr/" -- docs >/dev/null 2>&1; then
|
||||||
|
echo "[DRIFT] Veralteter Pfad 'docs/adr/' in docs/* gefunden"
|
||||||
|
err=1
|
||||||
|
fi
|
||||||
|
if git grep -n "docs/c4/" -- docs >/dev/null 2>&1; then
|
||||||
|
echo "[DRIFT] Veralteter Pfad 'docs/c4/' in docs/* gefunden"
|
||||||
|
err=1
|
||||||
|
fi
|
||||||
|
if git grep -n "docs/how-to/" -- docs >/dev/null 2>&1; then
|
||||||
|
echo "[DRIFT] Veralteter Pfad 'docs/how-to/' in docs/* gefunden"
|
||||||
|
err=1
|
||||||
|
fi
|
||||||
|
if git grep -n "docs/reference/" -- docs >/dev/null 2>&1; then
|
||||||
|
echo "[DRIFT] Veralteter Pfad 'docs/reference/' in docs/* gefunden"
|
||||||
|
err=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Quelle der Wahrheit: Gateway-Technologie (sollte in Architektur/ADRs/C4 konsistent sein)
|
||||||
|
has docs/01_Architecture/ARCHITECTURE.md "Spring Cloud Gateway"
|
||||||
|
has docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Spring Cloud Gateway"
|
||||||
|
miss docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Ktor"
|
||||||
|
has docs/01_Architecture/c4/02-container-de.puml "Spring Cloud Gateway"
|
||||||
|
miss docs/01_Architecture/c4/02-container-de.puml "Ktor"
|
||||||
|
|
||||||
|
exit $err
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Common helpers for AI guardrail scripts
|
||||||
|
|
||||||
|
# Robustly resolve the repository root directory.
|
||||||
|
# Strategy: prefer Git; fallback to marker search upwards; last resort: current dir.
|
||||||
|
resolve_repo_root() {
|
||||||
|
local start
|
||||||
|
start="${1:-$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)}"
|
||||||
|
if command -v git >/dev/null 2>&1; then
|
||||||
|
if git -C "$start" rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||||
|
git -C "$start" rev-parse --show-toplevel
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
local dir
|
||||||
|
dir="$(cd "$start" && pwd)"
|
||||||
|
while [ "$dir" != "/" ]; do
|
||||||
|
if [ -f "$dir/gradlew" ] || [ -f "$dir/settings.gradle.kts" ] || [ -d "$dir/.git" ]; then
|
||||||
|
echo "$dir"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
dir="$(dirname "$dir")"
|
||||||
|
done
|
||||||
|
pwd
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$SCRIPT_DIR/lib/common.sh"
|
||||||
|
|
||||||
|
REPO_ROOT="$(resolve_repo_root)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
mkdir -p build/diagrams
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in docs/architecture/c4/*.puml; do
|
||||||
|
docker run --rm -v "$PWD":/data plantuml/plantuml -tsvg "/data/$f" -o "/data/build/diagrams"
|
||||||
|
echo "Rendered build/diagrams/$(basename "${f%.puml}").svg"
|
||||||
|
done
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "$SCRIPT_DIR/lib/common.sh"
|
||||||
|
|
||||||
|
REPO_ROOT="$(resolve_repo_root)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
QUICK_MODE=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--quick)
|
||||||
|
QUICK_MODE=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
cat << 'EOF'
|
||||||
|
Docs Link-Validierung
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
./.ai/scripts/validate-links.sh [--quick]
|
||||||
|
|
||||||
|
BESCHREIBUNG:
|
||||||
|
Prüft Markdown-Links in `docs/**/*.md` auf gebrochene relative Pfade.
|
||||||
|
Ignoriert externe Links (http/https/mailto) sowie reine Anchors (#...).
|
||||||
|
|
||||||
|
OPTIONEN:
|
||||||
|
--quick Führt nur eine Teilmenge der Prüfungen durch (aktuell nicht implementiert).
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "[ERROR] Unbekannter Parameter: $1" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
python3 - <<'PY'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
root = Path.cwd()
|
||||||
|
docs_dir = root / "docs"
|
||||||
|
|
||||||
|
if not docs_dir.is_dir():
|
||||||
|
print(f"[ERROR] docs-Verzeichnis nicht gefunden: {docs_dir}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
# Veraltete Pfad-Prüfungen wurden entfernt; Fokus auf Link-Integrität.
|
||||||
|
FORBIDDEN_SUBSTRINGS = []
|
||||||
|
|
||||||
|
md_files = sorted(docs_dir.rglob("*.md"))
|
||||||
|
|
||||||
|
link_pattern = re.compile(r"\]\(([^)]+)\)")
|
||||||
|
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
def is_external(target: str) -> bool:
|
||||||
|
t = target.lower()
|
||||||
|
return t.startswith("http://") or t.startswith("https://") or t.startswith("mailto:")
|
||||||
|
|
||||||
|
def strip_fragment_and_query(target: str) -> str:
|
||||||
|
target = target.split("#", 1)[0]
|
||||||
|
target = target.split("?", 1)[0]
|
||||||
|
return target
|
||||||
|
|
||||||
|
for f in md_files:
|
||||||
|
text = f.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
for forbidden in FORBIDDEN_SUBSTRINGS:
|
||||||
|
if forbidden in text:
|
||||||
|
print(f"[ERROR] Veralteter Pfad '{forbidden}' in {f}")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
for match in link_pattern.finditer(text):
|
||||||
|
target = match.group(1).strip()
|
||||||
|
|
||||||
|
if not target:
|
||||||
|
continue
|
||||||
|
if is_external(target):
|
||||||
|
continue
|
||||||
|
if target.startswith("#"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if target.startswith("<") and target.endswith(">"):
|
||||||
|
target = target[1:-1]
|
||||||
|
|
||||||
|
target = unquote(strip_fragment_and_query(target))
|
||||||
|
|
||||||
|
if target.startswith("/"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ":" in target.split("/", 1)[0]:
|
||||||
|
# z.B. "vscode:..."
|
||||||
|
continue
|
||||||
|
|
||||||
|
resolved = (f.parent / target).resolve()
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved.relative_to(root.resolve())
|
||||||
|
except ValueError:
|
||||||
|
print(f"[ERROR] Link zeigt außerhalb des Repos: {f} -> {target}")
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if resolved.is_dir():
|
||||||
|
if not (resolved / "README.md").is_file():
|
||||||
|
print(f"[ERROR] Verlinktes Verzeichnis ohne README.md: {f} -> {target}")
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not resolved.exists():
|
||||||
|
print(f"[ERROR] Broken link: {f} -> {target}")
|
||||||
|
errors += 1
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(f"[ERROR] Link-Validierung fehlgeschlagen: {errors} Fehler")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"[OK] Link-Validierung erfolgreich: {len(md_files)} Markdown-Dateien geprüft")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
apply: always
|
||||||
|
---
|
||||||
|
|
||||||
+1
-1
@@ -193,7 +193,7 @@ secrets/
|
|||||||
# ===================================================================
|
# ===================================================================
|
||||||
TODO*.md
|
TODO*.md
|
||||||
NOTES*.md
|
NOTES*.md
|
||||||
**/.junie/
|
.junie/
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Keep essential files (override exclusions)
|
# Keep essential files (override exclusions)
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# ==========================================
|
||||||
|
# Meldestelle – Docker Compose Environment
|
||||||
|
# Single Source of Truth (SSoT)
|
||||||
|
# ==========================================
|
||||||
|
# WARNING: This file contains secrets (passwords).
|
||||||
|
# Do NOT commit this file to version control if it contains production secrets.
|
||||||
|
|
||||||
|
# --- PROJECT ---
|
||||||
|
PROJECT_NAME=meldestelle
|
||||||
|
|
||||||
|
# --- BACKUP ---
|
||||||
|
BACKUP_DIR=/home/stefan/backups/meldestelle
|
||||||
|
BACKUP_RETENTION_DAYS=7
|
||||||
|
|
||||||
|
# Docker build versions (optional overrides)
|
||||||
|
DOCKER_VERSION=1.0.0-SNAPSHOT
|
||||||
|
DOCKER_REGISTRY=git.mo-code.at/mocode-software/meldestelle
|
||||||
|
DOCKER_BUILD_DATE=2026-03-16T12:00:00Z
|
||||||
|
DOCKER_GRADLE_VERSION=9.3.1
|
||||||
|
DOCKER_JAVA_VERSION=25
|
||||||
|
DOCKER_NODE_VERSION=24.12.0
|
||||||
|
DOCKER_NGINX_VERSION=1.28.0-alpine
|
||||||
|
DOCKER_CADDY_VERSION=2.11-alpine
|
||||||
|
|
||||||
|
# JVM Power Flags (Lokal leer lassen, da Intel/AMD Architektur)
|
||||||
|
JVM_OPTS_ARM64=
|
||||||
|
|
||||||
|
# --- POSTGRES ---
|
||||||
|
POSTGRES_IMAGE=postgres:16-alpine
|
||||||
|
POSTGRES_SHARED_BUFFERS=256MB
|
||||||
|
POSTGRES_EFFECTIVE_CACHE_SIZE=768MB
|
||||||
|
POSTGRES_USER=pg-user
|
||||||
|
POSTGRES_PASSWORD=pg-password
|
||||||
|
POSTGRES_DB=pg-meldestelle-db
|
||||||
|
POSTGRES_PORT=5432:5432
|
||||||
|
POSTGRES_DB_URL=jdbc:postgresql://postgres:5432/pg-meldestelle-db
|
||||||
|
|
||||||
|
# --- VALKEY (formerly Redis) ---
|
||||||
|
VALKEY_IMAGE=valkey/valkey:9-alpine
|
||||||
|
VALKEY_PASSWORD=valkey-password
|
||||||
|
VALKEY_PORT=6379:6379
|
||||||
|
VALKEY_SERVER_HOSTNAME=valkey
|
||||||
|
VALKEY_SERVER_PORT=6379
|
||||||
|
VALKEY_SERVER_CONNECT_TIMEOUT=5s
|
||||||
|
VALKEY_POLICY=allkeys-lru
|
||||||
|
VALKEY_MAX_MEMORY=256MB
|
||||||
|
SPRING_DATA_VALKEY_HOST=localhost
|
||||||
|
SPRING_DATA_VALKEY_PORT=6379
|
||||||
|
SPRING_DATA_VALKEY_PASSWORD=valkey-password
|
||||||
|
|
||||||
|
# --- KEYCLOAK ---
|
||||||
|
KEYCLOAK_IMAGE_TAG=latest
|
||||||
|
KC_HEAP_MIN=512M
|
||||||
|
KC_HEAP_MAX=1024M
|
||||||
|
# Lokale Entwicklung: start-dev (kein Pre-Build nötig, kein --optimized)
|
||||||
|
# Server/Produktion: start --optimized --import-realm (nutzt das pre-built Registry-Image)
|
||||||
|
KC_COMMAND=start-dev --import-realm
|
||||||
|
# System-Admin (Master Console)
|
||||||
|
KC_BOOTSTRAP_ADMIN_USERNAME=kc-admin
|
||||||
|
KC_BOOTSTRAP_ADMIN_PASSWORD=kc-password
|
||||||
|
# Fach-Admin User Passwort (wird im Realm Import genutzt)
|
||||||
|
# Hinweis: Wenn du das hier änderst, müsstest du auch die JSON anpassen
|
||||||
|
# oder dort eine Variable nutzen.
|
||||||
|
|
||||||
|
KC_DB=postgres
|
||||||
|
KC_DB_SCHEMA=keycloak
|
||||||
|
KC_DB_URL=jdbc:postgresql://postgres:5432/pg-meldestelle-db
|
||||||
|
KC_DB_USERNAME=pg-user
|
||||||
|
KC_DB_PASSWORD=meldestelle
|
||||||
|
|
||||||
|
# Lokal: localhost | Server: echte IP oder Domain (z.B. 10.0.0.50 oder auth.meldestelle.at)
|
||||||
|
# WICHTIG: Nur den Hostnamen angeben, OHNE Port (Keycloak 26.x hostname v2)
|
||||||
|
KC_HOSTNAME=localhost
|
||||||
|
# false = Zugriff über beliebige Hostnamen erlaubt (nötig ohne TLS / für HTTP-Betrieb)
|
||||||
|
KC_HOSTNAME_STRICT=false
|
||||||
|
KC_HOSTNAME_STRICT_HTTPS=false
|
||||||
|
KC_PORT=8180:8080
|
||||||
|
KC_MANAGEMENT_PORT=9000:9000
|
||||||
|
|
||||||
|
KC_HTTP_ENABLE=true
|
||||||
|
|
||||||
|
KC_API_GATEWAY_CLIENT_SECRET=K5RqonwVOaxPKaXVH4mbthSRbjRh5tOK
|
||||||
|
# KC_POSTMAN_CLIENT_SECRET=postman-secret-123
|
||||||
|
# KC_BOOTSTRAP_ADMIN_PASSWORD=Admin#1234
|
||||||
|
KC_FRONTEND_URL=http://localhost:8180
|
||||||
|
KC_PROXY_HEADERS=xforwarded
|
||||||
|
|
||||||
|
# --- KEYCLOAK TOKEN VALIDATION ---
|
||||||
|
# Public Issuer URI (must match the token issuer from browser/postman)
|
||||||
|
# Lokal: http://localhost:8180 | Produktion: http://10.0.0.50:8180
|
||||||
|
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI=http://localhost:8180/realms/meldestelle
|
||||||
|
# Internal JWK Set URI (for service-to-service communication within Docker)
|
||||||
|
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI=http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs
|
||||||
|
|
||||||
|
# --- CONSUL ---
|
||||||
|
CONSUL_IMAGE=hashicorp/consul:1.22.1
|
||||||
|
CONSUL_PORT=8500:8500
|
||||||
|
CONSUL_UDP_PORT=8600:8600/udp
|
||||||
|
CONSUL_HOST=consul
|
||||||
|
CONSUL_HTTP_PORT=8500
|
||||||
|
SPRING_CLOUD_CONSUL_HOST=consul
|
||||||
|
SPRING_CLOUD_CONSUL_PORT=8500
|
||||||
|
SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME=api-gateway
|
||||||
|
SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS=true
|
||||||
|
|
||||||
|
# --- Zipkin ---
|
||||||
|
ZIPKIN_IMAGE=openzipkin/zipkin:3
|
||||||
|
ZIPKIN_MIN_HEAP=256M
|
||||||
|
ZIPKIN_MAX_HEAP=512M
|
||||||
|
ZIPKIN_PORT=9411:9411
|
||||||
|
ZIPKIN_ENDPOINT=http://zipkin:9411/api/v2/spans
|
||||||
|
ZIPKIN_SAMPLING_PROBABILITY=1.0
|
||||||
|
|
||||||
|
# --- Mailpit ---
|
||||||
|
MAILPIT_IMAGE=axllent/mailpit:v1.29
|
||||||
|
MAILPIT_WEB_PORT=8025:8025
|
||||||
|
MAILPIT_SMTP_PORT=1025:1025
|
||||||
|
|
||||||
|
# --- PGADMIN ---
|
||||||
|
PGADMIN_IMAGE=dpage/pgadmin4:8
|
||||||
|
PGADMIN_EMAIL=meldestelle@mo-code.at
|
||||||
|
PGADMIN_PASSWORD=pgadmin
|
||||||
|
PGADMIN_PORT=8888:80
|
||||||
|
|
||||||
|
# --- POSTGRES-EXPORTER ---
|
||||||
|
POSTGRES_EXPORTER_IMAGE=prometheuscommunity/postgres-exporter:v0.18.0
|
||||||
|
|
||||||
|
# --- ALERTMANAGER ---
|
||||||
|
ALERTMANAGER_IMAGE=prom/alertmanager:v0.29.0
|
||||||
|
ALERTMANAGER_PORT=9093:9093
|
||||||
|
|
||||||
|
# --- PROMETHEUS ---
|
||||||
|
PROMETHEUS_IMAGE=prom/prometheus:v3.7.3
|
||||||
|
PROMETHEUS_PORT=9090:9090
|
||||||
|
|
||||||
|
# --- GRAFANA ---
|
||||||
|
GF_IMAGE=grafana/grafana:12.3
|
||||||
|
GF_ADMIN_USER=gf-admin
|
||||||
|
GF_ADMIN_PASSWORD=gf-password
|
||||||
|
GF_PORT=3000:3000
|
||||||
|
|
||||||
|
# --- API-GATEWAY ---
|
||||||
|
GATEWAY_PORT=8081:8081
|
||||||
|
GATEWAY_DEBUG_PORT=5005:5005
|
||||||
|
GATEWAY_SERVER_PORT=8081
|
||||||
|
GATEWAY_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
GATEWAY_DEBUG=true
|
||||||
|
GATEWAY_SERVICE_NAME=api-gateway
|
||||||
|
GATEWAY_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- PING-SERVICE ---
|
||||||
|
PING_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
PING_PORT=8082:8082
|
||||||
|
PING_DEBUG_PORT=5006:5006
|
||||||
|
PING_SERVER_PORT=8082
|
||||||
|
PING_DEBUG=true
|
||||||
|
PING_SERVICE_NAME=ping-service
|
||||||
|
PING_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- MAIL-SERVICE ---
|
||||||
|
MAIL_PORT=8083:8083
|
||||||
|
MAIL_DEBUG_PORT=5014:5014
|
||||||
|
MAIL_SERVER_PORT=8083
|
||||||
|
MAIL_SERVICE_URL=http://10.0.0.50:8092
|
||||||
|
|
||||||
|
MAIL_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
MAIL_DEBUG=true
|
||||||
|
MAIL_SERVICE_NAME=mail-service
|
||||||
|
MAIL_CONSUL_PREFER_IP=true
|
||||||
|
MAIL_SMTP_HOST=smtp.world4you.com
|
||||||
|
MAIL_SMTP_PORT=587
|
||||||
|
MAIL_SMTP_USER=online-nennen@mo-code.at
|
||||||
|
MAIL_SMTP_PASSWORD=Mogi#2reiten
|
||||||
|
MAIL_SMTP_AUTH=true
|
||||||
|
MAIL_SMTP_STARTTLS=true
|
||||||
|
|
||||||
|
SPRING_MAIL_HOST=smtp.world4you.com
|
||||||
|
SPRING_MAIL_PORT=587
|
||||||
|
SPRING_MAIL_USERNAME=online-nennen@mo-code.at
|
||||||
|
SPRING_MAIL_PASSWORD=Mogi#2reiten
|
||||||
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=false
|
||||||
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=false
|
||||||
|
SPRING_CLOUD_CONSUL_DISCOVERY_ENABLED=false
|
||||||
|
SPRING_CLOUD_CONSUL_ENABLED=false
|
||||||
|
MAIL_POLLING_ENABLED=false
|
||||||
|
|
||||||
|
|
||||||
|
# --- MASTERDATA-SERVICE ---
|
||||||
|
MASTERDATA_PORT=8086:8086
|
||||||
|
MASTERDATA_DEBUG_PORT=5007:5007
|
||||||
|
MASTERDATA_SERVER_PORT=8086
|
||||||
|
MASTERDATA_KTOR_PORT=8091
|
||||||
|
MASTERDATA_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
MASTERDATA_DEBUG=true
|
||||||
|
MASTERDATA_SERVICE_NAME=masterdata-service
|
||||||
|
MASTERDATA_CONSUL_PREFER_IP=true
|
||||||
|
MASTERDATA_SERVICE_HOSTNAME=masterdata-service
|
||||||
|
|
||||||
|
# --- EVENTS-SERVICE ---
|
||||||
|
EVENTS_PORT=8085:8085
|
||||||
|
EVENTS_DEBUG_PORT=5008:5008
|
||||||
|
EVENTS_SERVER_PORT=8085
|
||||||
|
EVENTS_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
EVENTS_DEBUG=true
|
||||||
|
EVENTS_SERVICE_NAME=events-service
|
||||||
|
EVENTS_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- ZNS-IMPORT-SERVICE ---
|
||||||
|
ZNS_IMPORT_PORT=8095:8095
|
||||||
|
ZNS_IMPORT_DEBUG_PORT=5009:5009
|
||||||
|
ZNS_IMPORT_SERVER_PORT=8095
|
||||||
|
ZNS_IMPORT_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
ZNS_IMPORT_DEBUG=true
|
||||||
|
ZNS_IMPORT_SERVICE_NAME=zns-import-service
|
||||||
|
ZNS_IMPORT_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- RESULTS-SERVICE ---
|
||||||
|
RESULTS_PORT=8088:8088
|
||||||
|
RESULTS_DEBUG_PORT=5010:5010
|
||||||
|
RESULTS_SERVER_PORT=8088
|
||||||
|
RESULTS_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
RESULTS_DEBUG=true
|
||||||
|
RESULTS_SERVICE_NAME=results-service
|
||||||
|
RESULTS_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- BILLING-SERVICE ---
|
||||||
|
BILLING_PORT=8087:8087
|
||||||
|
BILLING_DEBUG_PORT=5012:5012
|
||||||
|
BILLING_SERVER_PORT=8087
|
||||||
|
BILLING_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
BILLING_DEBUG=true
|
||||||
|
BILLING_SERVICE_NAME=billing-service
|
||||||
|
BILLING_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- SCHEDULING-SERVICE ---
|
||||||
|
SCHEDULING_PORT=8084:8084
|
||||||
|
SCHEDULING_DEBUG_PORT=5013:5013
|
||||||
|
SCHEDULING_SERVER_PORT=8084
|
||||||
|
SCHEDULING_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
SCHEDULING_DEBUG=true
|
||||||
|
SCHEDULING_SERVICE_NAME=scheduling-service
|
||||||
|
SCHEDULING_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- SERIES-SERVICE ---
|
||||||
|
SERIES_PORT=8089:8089
|
||||||
|
SERIES_DEBUG_PORT=5011:5011
|
||||||
|
SERIES_SERVER_PORT=8089
|
||||||
|
SERIES_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
SERIES_DEBUG=true
|
||||||
|
SERIES_SERVICE_NAME=series-service
|
||||||
|
SERIES_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- WEB-APP ---
|
||||||
|
CADDY_VERSION=2.11-alpine
|
||||||
|
WEB_APP_PORT=8080:80
|
||||||
|
WEB_BUILD_PROFILE=dev
|
||||||
|
# Lokal: http://localhost:8081 | Produktion: http://10.0.0.50:8081
|
||||||
|
WEB_APP_API_URL=http://localhost:8081
|
||||||
|
WEB_APP_KEYCLOAK_URL=http://auth.mo-code.at
|
||||||
|
|
||||||
|
# --- DESKTOP-APP ---
|
||||||
|
DESKTOP_APP_VNC_PORT=5901:5901
|
||||||
|
DESKTOP_APP_NOVNC_PORT=6080:6080
|
||||||
@@ -120,6 +120,13 @@ MAILPIT_IMAGE=axllent/mailpit:v1.29
|
|||||||
MAILPIT_WEB_PORT=8025:8025
|
MAILPIT_WEB_PORT=8025:8025
|
||||||
MAILPIT_SMTP_PORT=1025:1025
|
MAILPIT_SMTP_PORT=1025:1025
|
||||||
|
|
||||||
|
# --- SPRING MAIL CONFIG (Lokal / Mailpit) ---
|
||||||
|
# Für lokale Entwicklung mit Mailpit (Docker Compose)
|
||||||
|
SPRING_MAIL_HOST=mailpit
|
||||||
|
SPRING_MAIL_PORT=1025
|
||||||
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=false
|
||||||
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=false
|
||||||
|
|
||||||
# --- PGADMIN ---
|
# --- PGADMIN ---
|
||||||
PGADMIN_IMAGE=dpage/pgadmin4:8
|
PGADMIN_IMAGE=dpage/pgadmin4:8
|
||||||
PGADMIN_EMAIL=meldestelle@mo-code.at
|
PGADMIN_EMAIL=meldestelle@mo-code.at
|
||||||
@@ -149,6 +156,8 @@ GATEWAY_DEBUG_PORT=5005:5005
|
|||||||
GATEWAY_SERVER_PORT=8081
|
GATEWAY_SERVER_PORT=8081
|
||||||
GATEWAY_SPRING_PROFILES_ACTIVE=docker
|
GATEWAY_SPRING_PROFILES_ACTIVE=docker
|
||||||
GATEWAY_DEBUG=true
|
GATEWAY_DEBUG=true
|
||||||
|
GATEWAY_SERVICE_NAME=api-gateway
|
||||||
|
GATEWAY_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
# --- PING-SERVICE ---
|
# --- PING-SERVICE ---
|
||||||
PING_SPRING_PROFILES_ACTIVE=docker
|
PING_SPRING_PROFILES_ACTIVE=docker
|
||||||
@@ -159,6 +168,84 @@ PING_DEBUG=true
|
|||||||
PING_SERVICE_NAME=ping-service
|
PING_SERVICE_NAME=ping-service
|
||||||
PING_CONSUL_PREFER_IP=true
|
PING_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- MAIL-SERVICE ---
|
||||||
|
MAIL_PORT=8083:8083
|
||||||
|
MAIL_DEBUG_PORT=5014:5014
|
||||||
|
MAIL_SERVER_PORT=8083
|
||||||
|
MAIL_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
MAIL_DEBUG=true
|
||||||
|
MAIL_SERVICE_NAME=mail-service
|
||||||
|
MAIL_CONSUL_PREFER_IP=true
|
||||||
|
MAIL_SMTP_HOST=smtp.world4you.com
|
||||||
|
MAIL_SMTP_PORT=587
|
||||||
|
MAIL_SMTP_USER=online-nennen@mo-code.at
|
||||||
|
MAIL_SMTP_PASSWORD=<DEIN_WORLD4YOU_PASSWORT>
|
||||||
|
MAIL_SMTP_AUTH=true
|
||||||
|
MAIL_SMTP_STARTTLS=true
|
||||||
|
|
||||||
|
# --- MASTERDATA-SERVICE ---
|
||||||
|
MASTERDATA_PORT=8086:8086
|
||||||
|
MASTERDATA_DEBUG_PORT=5007:5007
|
||||||
|
MASTERDATA_SERVER_PORT=8086
|
||||||
|
MASTERDATA_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
MASTERDATA_DEBUG=true
|
||||||
|
MASTERDATA_SERVICE_NAME=masterdata-service
|
||||||
|
MASTERDATA_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- EVENTS-SERVICE ---
|
||||||
|
EVENTS_PORT=8085:8085
|
||||||
|
EVENTS_DEBUG_PORT=5008:5008
|
||||||
|
EVENTS_SERVER_PORT=8085
|
||||||
|
EVENTS_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
EVENTS_DEBUG=true
|
||||||
|
EVENTS_SERVICE_NAME=events-service
|
||||||
|
EVENTS_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- ZNS-IMPORT-SERVICE ---
|
||||||
|
ZNS_IMPORT_PORT=8095:8095
|
||||||
|
ZNS_IMPORT_DEBUG_PORT=5009:5009
|
||||||
|
ZNS_IMPORT_SERVER_PORT=8095
|
||||||
|
ZNS_IMPORT_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
ZNS_IMPORT_DEBUG=true
|
||||||
|
ZNS_IMPORT_SERVICE_NAME=zns-import-service
|
||||||
|
ZNS_IMPORT_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- RESULTS-SERVICE ---
|
||||||
|
RESULTS_PORT=8088:8088
|
||||||
|
RESULTS_DEBUG_PORT=5010:5010
|
||||||
|
RESULTS_SERVER_PORT=8088
|
||||||
|
RESULTS_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
RESULTS_DEBUG=true
|
||||||
|
RESULTS_SERVICE_NAME=results-service
|
||||||
|
RESULTS_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- BILLING-SERVICE ---
|
||||||
|
BILLING_PORT=8087:8087
|
||||||
|
BILLING_DEBUG_PORT=5012:5012
|
||||||
|
BILLING_SERVER_PORT=8087
|
||||||
|
BILLING_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
BILLING_DEBUG=true
|
||||||
|
BILLING_SERVICE_NAME=billing-service
|
||||||
|
BILLING_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- SCHEDULING-SERVICE ---
|
||||||
|
SCHEDULING_PORT=8084:8084
|
||||||
|
SCHEDULING_DEBUG_PORT=5013:5013
|
||||||
|
SCHEDULING_SERVER_PORT=8084
|
||||||
|
SCHEDULING_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
SCHEDULING_DEBUG=true
|
||||||
|
SCHEDULING_SERVICE_NAME=scheduling-service
|
||||||
|
SCHEDULING_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
|
# --- SERIES-SERVICE ---
|
||||||
|
SERIES_PORT=8089:8089
|
||||||
|
SERIES_DEBUG_PORT=5011:5011
|
||||||
|
SERIES_SERVER_PORT=8089
|
||||||
|
SERIES_SPRING_PROFILES_ACTIVE=docker
|
||||||
|
SERIES_DEBUG=true
|
||||||
|
SERIES_SERVICE_NAME=series-service
|
||||||
|
SERIES_CONSUL_PREFER_IP=true
|
||||||
|
|
||||||
# --- WEB-APP ---
|
# --- WEB-APP ---
|
||||||
WEB_APP_PORT=4000:4000
|
WEB_APP_PORT=4000:4000
|
||||||
# URL für API-Zugriffe vom Browser (Public URL via Pangolin)
|
# URL für API-Zugriffe vom Browser (Public URL via Pangolin)
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
name: Desktop CI — Headless Tests & Build
|
name: Desktop CI — Headless Tests & Build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
# Nur ausführen, wenn explizit das Desktop-Shell-Modul geändert wurde
|
||||||
push:
|
push:
|
||||||
branches: [ main, master ]
|
branches: [ main, master ]
|
||||||
|
paths:
|
||||||
|
- 'frontend/shells/meldestelle-desktop/**'
|
||||||
|
- '.gitea/workflows/desktop-tests.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main, master ]
|
branches: [ main, master ]
|
||||||
|
paths:
|
||||||
|
- 'frontend/shells/meldestelle-desktop/**'
|
||||||
|
# Manuell startbar, falls benötigt
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
desktop-tests:
|
desktop-tests:
|
||||||
|
# Komplett deaktivierbar über Repo-Variable: Settings → Variables → DESKTOP_CI_ENABLED=true
|
||||||
|
# Zusätzlich: Für Plan‑B‑Builds überspringen, wenn Commit-Message [planb] enthält
|
||||||
|
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||||
name: Compose Desktop — Tests (headless) & Build
|
name: Compose Desktop — Tests (headless) & Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
@@ -38,12 +49,12 @@ jobs:
|
|||||||
- name: Show Gradle version
|
- name: Show Gradle version
|
||||||
run: ./gradlew --version
|
run: ./gradlew --version
|
||||||
|
|
||||||
- name: Run Desktop tests headless (Xvfb)
|
- name: Run Desktop tests headless (xvfb)
|
||||||
env:
|
env:
|
||||||
_JAVA_OPTIONS: -Djava.awt.headless=true
|
_JAVA_OPTIONS: -Djava.awt.headless=true
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -y
|
sudo apt-get update -y
|
||||||
sudo apt-get install -y Xvfb
|
sudo apt-get install -y xvfb xauth
|
||||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
|
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
|
||||||
./gradlew :frontend:shells:meldestelle-desktop:jvmTest --stacktrace --no-daemon
|
./gradlew :frontend:shells:meldestelle-desktop:jvmTest --stacktrace --no-daemon
|
||||||
|
|
||||||
|
|||||||
@@ -33,18 +33,11 @@ jobs:
|
|||||||
max-parallel: 1
|
max-parallel: 1
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- service: keycloak
|
# Plan-B fokussiert: Nur Mail-Service + Web-App bauen/pushen (beschleunigt CI deutlich)
|
||||||
|
- service: mail-service
|
||||||
context: .
|
context: .
|
||||||
dockerfile: config/docker/keycloak/Dockerfile
|
dockerfile: backend/services/mail/Dockerfile
|
||||||
image: keycloak
|
image: mail-service
|
||||||
- service: api-gateway
|
|
||||||
context: .
|
|
||||||
dockerfile: backend/infrastructure/gateway/Dockerfile
|
|
||||||
image: api-gateway
|
|
||||||
- service: ping-service
|
|
||||||
context: .
|
|
||||||
dockerfile: backend/services/ping/Dockerfile
|
|
||||||
image: ping-service
|
|
||||||
- service: web-app
|
- service: web-app
|
||||||
context: .
|
context: .
|
||||||
dockerfile: config/docker/caddy/web-app/Dockerfile
|
dockerfile: config/docker/caddy/web-app/Dockerfile
|
||||||
@@ -61,43 +54,42 @@ jobs:
|
|||||||
distribution: "temurin"
|
distribution: "temurin"
|
||||||
cache: gradle
|
cache: gradle
|
||||||
|
|
||||||
- name: Setup Gradle Cache
|
# --- SCHRITT 1: Build mit radikalem Clean (gegen die März-Leichen) ---
|
||||||
uses: actions/cache@v4
|
- name: Build Frontend (Wasm JS)
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/.gradle/caches
|
|
||||||
~/.gradle/wrapper
|
|
||||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-gradle-
|
|
||||||
|
|
||||||
# Verhindert mysteriöse Build-Fehler durch korrupte Node/Kotlin-Caches (nur web-app relevant)
|
|
||||||
- name: Cleanup stale build caches
|
|
||||||
if: matrix.service == 'web-app'
|
|
||||||
run: |
|
|
||||||
rm -rf frontend/shells/meldestelle-portal/build/js/node_modules/.cache || true
|
|
||||||
rm -rf frontend/shells/meldestelle-portal/build/js/.yarn/cache || true
|
|
||||||
rm -rf ~/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-compiler-embeddable || true
|
|
||||||
|
|
||||||
- name: Build Frontend (Kotlin JS)
|
|
||||||
if: matrix.service == 'web-app'
|
if: matrix.service == 'web-app'
|
||||||
run: |
|
run: |
|
||||||
chmod +x gradlew
|
chmod +x gradlew
|
||||||
./gradlew :frontend:shells:meldestelle-portal:jsBrowserDistribution \
|
# Löscht alte Build-Stände komplett
|
||||||
|
./gradlew :frontend:shells:meldestelle-web:clean
|
||||||
|
|
||||||
|
./gradlew :frontend:shells:meldestelle-web:wasmJsBrowserDistribution \
|
||||||
-Pproduction=true \
|
-Pproduction=true \
|
||||||
--max-workers=4 \
|
--max-workers=4 \
|
||||||
-Dkotlin.daemon.jvm.options="-Xmx4g"
|
-Dkotlin.daemon.jvm.options="-Xmx4g"
|
||||||
|
|
||||||
# Pangolin-Bypass: Credentials direkt in config.json schreiben.
|
# --- SCHRITT 2: Staging ohne rsync (Fix für dein Log-Fehler) ---
|
||||||
# Kein "docker login" → kein Daemon-Ping → kein HTTPS-Fehler.
|
- name: Stage Web Assets for Docker build
|
||||||
# BuildKit liest ~/.docker/config.json und verwendet diese Credentials beim Push.
|
if: matrix.service == 'web-app'
|
||||||
# - name: Registry-Credentials konfigurieren (kein Daemon-Kontakt)
|
run: |
|
||||||
# run: |
|
set -e
|
||||||
# mkdir -p ~/.docker
|
DIST_DIR="frontend/shells/meldestelle-web/build/dist/wasmJs/productionExecutable"
|
||||||
# AUTH=$(echo -n "${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_TOKEN }}" | base64 -w 0)
|
TARGET_DIR="config/docker/caddy/web-app/_site"
|
||||||
# printf '{"auths":{"%s":{"auth":"%s"}}}\n' "${{ env.REGISTRY_INTERNAL }}" "${AUTH}" > ~/.docker/config.json
|
|
||||||
# echo "✓ Credentials für ${{ env.REGISTRY_INTERNAL }} gespeichert"
|
|
||||||
|
|
||||||
|
if [ ! -d "$DIST_DIR" ]; then
|
||||||
|
echo "❌ Fehler: Build-Verzeichnis nicht gefunden!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ersetzt rsync durch sicheres Löschen & Kopieren
|
||||||
|
rm -rf "$TARGET_DIR"
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
cp -r "$DIST_DIR"/. "$TARGET_DIR/"
|
||||||
|
# Kopiere Turnier-Ausschreibungen (PDFs) für Plan-B
|
||||||
|
cp docs/Neumarkt2026/*.pdf "$TARGET_DIR/" || true
|
||||||
|
|
||||||
|
echo "✓ Assets für Docker vorbereitet (Stand: $(date))"
|
||||||
|
|
||||||
|
# --- SCHRITT 3: Login & BuildX ---
|
||||||
# NEU (sauber, nach daemon.json-Fix):
|
# NEU (sauber, nach daemon.json-Fix):
|
||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -122,7 +114,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY_INTERNAL }}/${{ env.IMAGE_PREFIX }}/${{ matrix.image }}
|
images: ${{ env.REGISTRY_INTERNAL }}/${{ env.IMAGE_PREFIX }}/${{ matrix.image }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
type=raw,value=latest
|
||||||
type=sha,format=long
|
type=sha,format=long
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
@@ -137,9 +129,5 @@ jobs:
|
|||||||
provenance: false
|
provenance: false
|
||||||
sbom: false
|
sbom: false
|
||||||
build-args: |
|
build-args: |
|
||||||
DOCKER_BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
BUILD_DATE=${{ github.event.head_commit.timestamp || 'unknown' }}
|
||||||
VERSION=${{ github.sha }}
|
VERSION=${{ github.sha }}
|
||||||
GRADLE_VERSION=${{ env.GRADLE_VERSION }}
|
|
||||||
JAVA_VERSION=${{ env.JAVA_VERSION }}
|
|
||||||
KEYCLOAK_IMAGE_TAG=${{ env.KEYCLOAK_IMAGE_TAG }}
|
|
||||||
JVM_OPTS_APPEND=${{ env.JVM_OPTS_ARM64 }}
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ on:
|
|||||||
branches: [ "**" ]
|
branches: [ "**" ]
|
||||||
jobs:
|
jobs:
|
||||||
no-hardcoded-versions:
|
no-hardcoded-versions:
|
||||||
|
# Für Plan-B-Builds überspringen: Commit-Message enthält [planb]
|
||||||
|
if: ${{ !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ jobs:
|
|||||||
# =============================================================
|
# =============================================================
|
||||||
tag-release:
|
tag-release:
|
||||||
name: 🏷️ Git-Tag setzen
|
name: 🏷️ Git-Tag setzen
|
||||||
|
# Für Plan-B-Builds überspringen: Commit-Message enthält [planb]
|
||||||
|
if: ${{ !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.read-version.outputs.version }}
|
version: ${{ steps.read-version.outputs.version }}
|
||||||
@@ -62,7 +64,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Git-Tag erstellen & pushen
|
- name: Git-Tag erstellen & pushen
|
||||||
if: steps.check-tag.outputs.already_tagged == 'false' && github.event.inputs.dry_run != 'true'
|
if: steps.check-tag.outputs.already_tagged == 'false' && gitea.event.inputs.dry_run != 'true'
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ steps.read-version.outputs.tag }}"
|
TAG="${{ steps.read-version.outputs.tag }}"
|
||||||
VERSION="${{ steps.read-version.outputs.version }}"
|
VERSION="${{ steps.read-version.outputs.version }}"
|
||||||
@@ -77,6 +79,8 @@ jobs:
|
|||||||
# =============================================================
|
# =============================================================
|
||||||
package-linux:
|
package-linux:
|
||||||
name: 📦 Linux .deb Packaging
|
name: 📦 Linux .deb Packaging
|
||||||
|
# Nur ausführen, wenn Desktop-CI explizit aktiviert ist UND kein Plan‑B Commit
|
||||||
|
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: tag-release
|
needs: tag-release
|
||||||
|
|
||||||
@@ -84,11 +88,11 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup JDK 21 (Temurin)
|
- name: Setup JDK 25 (Temurin)
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: '21'
|
java-version: '25'
|
||||||
|
|
||||||
- name: Gradle cache
|
- name: Gradle cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -123,6 +127,8 @@ jobs:
|
|||||||
# =============================================================
|
# =============================================================
|
||||||
package-windows:
|
package-windows:
|
||||||
name: 📦 Windows .msi Packaging
|
name: 📦 Windows .msi Packaging
|
||||||
|
# Nur ausführen, wenn Desktop-CI explizit aktiviert ist UND kein Plan‑B Commit
|
||||||
|
if: ${{ vars.DESKTOP_CI_ENABLED == 'true' && !contains(gitea.event.head_commit.message, '[planb]') }}
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
needs: tag-release
|
needs: tag-release
|
||||||
|
|
||||||
@@ -130,11 +136,11 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup JDK 21 (Temurin)
|
- name: Setup JDK 25 (Temurin)
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v4
|
||||||
with:
|
with:
|
||||||
distribution: temurin
|
distribution: temurin
|
||||||
java-version: '21'
|
java-version: '25'
|
||||||
|
|
||||||
- name: Gradle cache
|
- name: Gradle cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -173,11 +179,11 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Summary ausgeben
|
- name: Summary ausgeben
|
||||||
run: |
|
run: |
|
||||||
echo "## 🚀 Release ${{ needs.tag-release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
echo "## 🚀 Release ${{ needs.tag-release.outputs.version }}" >> $GITEA_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITEA_STEP_SUMMARY
|
||||||
echo "| Artefakt | Status |" >> $GITHUB_STEP_SUMMARY
|
echo "| Artefakt | Status |" >> $GITEA_STEP_SUMMARY
|
||||||
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
|
echo "|----------|--------|" >> $GITEA_STEP_SUMMARY
|
||||||
echo "| Linux .deb | ${{ needs.package-linux.result }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| Linux .deb | ${{ needs.package-linux.result }} |" >> $GITEA_STEP_SUMMARY
|
||||||
echo "| Windows .msi | ${{ needs.package-windows.result }} |" >> $GITHUB_STEP_SUMMARY
|
echo "| Windows .msi | ${{ needs.package-windows.result }} |" >> $GITEA_STEP_SUMMARY
|
||||||
echo "" >> $GITHUB_STEP_SUMMARY
|
echo "" >> $GITEA_STEP_SUMMARY
|
||||||
echo "**Git-Tag:** \`${{ needs.tag-release.outputs.tag }}\`" >> $GITHUB_STEP_SUMMARY
|
echo "**Git-Tag:** \`${{ needs.tag-release.outputs.tag }}\`" >> $GITEA_STEP_SUMMARY
|
||||||
|
|||||||
+47
-60
@@ -1,74 +1,61 @@
|
|||||||
# --- General ---
|
# 🐧 [DevOps Engineer] Optimierte .gitignore für Meldestelle (KMP / Gradle / Docker)
|
||||||
.gradle/
|
|
||||||
**/build/
|
|
||||||
**/out/
|
|
||||||
.kotlin/
|
|
||||||
kotlin-js-store/
|
|
||||||
|
|
||||||
# --- Environments ---
|
# --- AI ---
|
||||||
#.env
|
.ai/dist/
|
||||||
config/env/.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# --- IDEs ---
|
# --- IDE & Editor ---
|
||||||
# IntelliJ
|
|
||||||
.idea/
|
.idea/
|
||||||
*.iml
|
*.iml
|
||||||
*.ipr
|
|
||||||
*.iws
|
*.iws
|
||||||
|
*.ipr
|
||||||
|
out/
|
||||||
|
.vscode/
|
||||||
|
.history/
|
||||||
|
.shelf/
|
||||||
|
|
||||||
# VS Code
|
# --- Gradle ---
|
||||||
.vscode/*
|
.gradle/
|
||||||
!.vscode/settings.json
|
build/
|
||||||
!.vscode/tasks.json
|
!**/src/**/build/
|
||||||
!.vscode/launch.json
|
gradle-app.setting
|
||||||
!.vscode/extensions.json
|
!gradle-wrapper.jar
|
||||||
!.vscode/snippets
|
.gradletasknamecache
|
||||||
|
bin/
|
||||||
|
|
||||||
# Fleet
|
# --- Kotlin / KMP ---
|
||||||
.fleet/
|
.kotlin/
|
||||||
!.fleet/receipt.json
|
kotlin-js-store/
|
||||||
|
.jetbrains/
|
||||||
|
|
||||||
# --- Dependencies & Build ---
|
# --- Android (falls relevant) ---
|
||||||
|
*.ap_
|
||||||
|
*.apk
|
||||||
|
*.dex
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# --- Node / JS (Compose Web / KMP JS) ---
|
||||||
node_modules/
|
node_modules/
|
||||||
**/node_modules/
|
|
||||||
package-lock.json
|
package-lock.json
|
||||||
yarn.lock
|
npm-debug.log*
|
||||||
pnpm-lock.yaml
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.npm/
|
||||||
|
|
||||||
# --- OS Files ---
|
# --- Docker & Infrastructure ---
|
||||||
|
.docker/
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
.data/
|
||||||
|
postgres-data/
|
||||||
|
|
||||||
|
# --- OS Specific ---
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
*.swp
|
desktop.ini
|
||||||
*~
|
|
||||||
.nfs*
|
|
||||||
|
|
||||||
# --- Logs ---
|
# --- Project Specific ---
|
||||||
_backup/logs/
|
docs/temp/
|
||||||
**/*.log
|
docs/Bin/
|
||||||
*.log.gz
|
docs/_archive/
|
||||||
|
|
||||||
# --- Languages & Runtimes ---
|
|
||||||
# Java/Kotlin
|
|
||||||
*.class
|
|
||||||
.attach_pid*
|
|
||||||
|
|
||||||
# Python
|
|
||||||
.venv/
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
*.pyc
|
|
||||||
__pycache__/
|
|
||||||
|
|
||||||
# --- Quality & Documentation ---
|
|
||||||
build/diagrams/
|
|
||||||
.eslintcache
|
|
||||||
.stylelintcache
|
|
||||||
.phpunit.result.cache
|
|
||||||
.dataSources/
|
|
||||||
dataSources.local.xml
|
|
||||||
/_backup/
|
|
||||||
.env
|
|
||||||
|
|||||||
@@ -1,43 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# check-docs-drift.sh
|
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||||
# Zweck: sehr schlanke Drift-Checks gegen die neue Doku-Struktur.
|
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||||
# - Kein Guidelines-System mehr.
|
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||||
# - Single Source of Truth: `docs/`
|
exec "$ROOT_DIR/.ai/scripts/check-docs-drift.sh" "$@"
|
||||||
|
|
||||||
err=0
|
|
||||||
|
|
||||||
has() { grep -q "$2" "$1" || { echo "[DRIFT] '$2' fehlt in $1"; err=1; }; }
|
|
||||||
miss() { grep -q "$2" "$1" && { echo "[DRIFT] Veralteter Begriff '$2' in $1"; err=1; }; }
|
|
||||||
|
|
||||||
# Harte Altlast-Pfade dürfen nicht mehr vorkommen
|
|
||||||
if git grep -n "docs/00_Domain/" -- docs >/dev/null 2>&1; then
|
|
||||||
echo "[DRIFT] Veralteter Pfad 'docs/00_Domain/' in docs/* gefunden"
|
|
||||||
err=1
|
|
||||||
fi
|
|
||||||
if git grep -n "docs/adr/" -- docs >/dev/null 2>&1; then
|
|
||||||
echo "[DRIFT] Veralteter Pfad 'docs/adr/' in docs/* gefunden"
|
|
||||||
err=1
|
|
||||||
fi
|
|
||||||
if git grep -n "docs/c4/" -- docs >/dev/null 2>&1; then
|
|
||||||
echo "[DRIFT] Veralteter Pfad 'docs/c4/' in docs/* gefunden"
|
|
||||||
err=1
|
|
||||||
fi
|
|
||||||
if git grep -n "docs/how-to/" -- docs >/dev/null 2>&1; then
|
|
||||||
echo "[DRIFT] Veralteter Pfad 'docs/how-to/' in docs/* gefunden"
|
|
||||||
err=1
|
|
||||||
fi
|
|
||||||
if git grep -n "docs/reference/" -- docs >/dev/null 2>&1; then
|
|
||||||
echo "[DRIFT] Veralteter Pfad 'docs/reference/' in docs/* gefunden"
|
|
||||||
err=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Quelle der Wahrheit: Gateway-Technologie (sollte in Architektur/ADRs/C4 konsistent sein)
|
|
||||||
has docs/01_Architecture/ARCHITECTURE.md "Spring Cloud Gateway"
|
|
||||||
has docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Spring Cloud Gateway"
|
|
||||||
miss docs/01_Architecture/adr/0007-api-gateway-pattern-de.md "Ktor"
|
|
||||||
has docs/01_Architecture/c4/02-container-de.puml "Spring Cloud Gateway"
|
|
||||||
miss docs/01_Architecture/c4/02-container-de.puml "Ktor"
|
|
||||||
|
|
||||||
exit $err
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
mkdir -p build/diagrams
|
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||||
shopt -s nullglob
|
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||||
for f in docs/architecture/c4/*.puml; do
|
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||||
docker run --rm -v "$PWD":/data plantuml/plantuml -tsvg "/data/$f" -o "/data/build/diagrams"
|
exec "$ROOT_DIR/.ai/scripts/render-plantuml.sh" "$@"
|
||||||
echo "Rendered build/diagrams/$(basename "${f%.puml}").svg"
|
|
||||||
done
|
|
||||||
|
|||||||
@@ -1,136 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# validate-links.sh - Link-Validierung für Projektdokumentation (`docs/**`).
|
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||||
# Zweck: Guardrail für die "Docs-as-Code"-Strategie.
|
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||||
|
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
exec "$ROOT_DIR/.ai/scripts/validate-links.sh" "$@"
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
||||||
cd "$PROJECT_ROOT"
|
|
||||||
|
|
||||||
QUICK_MODE=false
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--quick)
|
|
||||||
QUICK_MODE=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--help|-h)
|
|
||||||
cat << 'EOF'
|
|
||||||
Docs Link-Validierung
|
|
||||||
|
|
||||||
USAGE:
|
|
||||||
./.junie/scripts/validate-links.sh [--quick]
|
|
||||||
|
|
||||||
BESCHREIBUNG:
|
|
||||||
Prüft Markdown-Links in `docs/**/*.md` auf gebrochene relative Pfade.
|
|
||||||
Ignoriert externe Links (http/https/mailto) sowie reine Anchors (#...).
|
|
||||||
|
|
||||||
OPTIONEN:
|
|
||||||
--quick Führt nur eine Teilmenge der Prüfungen durch (aktuell nicht implementiert).
|
|
||||||
EOF
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "[ERROR] Unbekannter Parameter: $1" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
python3 - <<'PY'
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
root = Path.cwd()
|
|
||||||
docs_dir = root / "docs"
|
|
||||||
|
|
||||||
if not docs_dir.is_dir():
|
|
||||||
print(f"[ERROR] docs-Verzeichnis nicht gefunden: {docs_dir}", file=sys.stderr)
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
# Veraltete Pfad-Prüfungen wurden entfernt, da sie zu wartungsintensiv waren.
|
|
||||||
# Das Skript konzentriert sich nun auf die Validierung der Link-Integrität.
|
|
||||||
FORBIDDEN_SUBSTRINGS = []
|
|
||||||
|
|
||||||
md_files = sorted(docs_dir.rglob("*.md"))
|
|
||||||
|
|
||||||
link_pattern = re.compile(r"\]\(([^)]+)\)")
|
|
||||||
|
|
||||||
errors = 0
|
|
||||||
|
|
||||||
def is_external(target: str) -> bool:
|
|
||||||
t = target.lower()
|
|
||||||
return t.startswith("http://") or t.startswith("https://") or t.startswith("mailto:")
|
|
||||||
|
|
||||||
def strip_fragment_and_query(target: str) -> str:
|
|
||||||
# remove fragment and query parts
|
|
||||||
target = target.split("#", 1)[0]
|
|
||||||
target = target.split("?", 1)[0]
|
|
||||||
return target
|
|
||||||
|
|
||||||
for f in md_files:
|
|
||||||
text = f.read_text(encoding="utf-8", errors="replace")
|
|
||||||
|
|
||||||
for forbidden in FORBIDDEN_SUBSTRINGS:
|
|
||||||
if forbidden in text:
|
|
||||||
print(f"[ERROR] Veralteter Pfad '{forbidden}' in {f}")
|
|
||||||
errors += 1
|
|
||||||
|
|
||||||
for match in link_pattern.finditer(text):
|
|
||||||
target = match.group(1).strip()
|
|
||||||
|
|
||||||
if not target:
|
|
||||||
continue
|
|
||||||
if is_external(target):
|
|
||||||
continue
|
|
||||||
if target.startswith("#"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# drop angle brackets <...> used in markdown for urls with spaces
|
|
||||||
if target.startswith("<") and target.endswith(">"):
|
|
||||||
target = target[1:-1]
|
|
||||||
|
|
||||||
target = unquote(strip_fragment_and_query(target))
|
|
||||||
|
|
||||||
# ignore absolute paths in the repo (we treat them as doc-style links; validate only if relative)
|
|
||||||
if target.startswith("/"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# ignore non-file targets (e.g. empty or protocol-less anchors)
|
|
||||||
if ":" in target.split("/", 1)[0]:
|
|
||||||
# things like "vscode:..." etc.
|
|
||||||
continue
|
|
||||||
|
|
||||||
# treat as file path relative to markdown file
|
|
||||||
resolved = (f.parent / target).resolve()
|
|
||||||
|
|
||||||
# keep validation within repo
|
|
||||||
try:
|
|
||||||
resolved.relative_to(root.resolve())
|
|
||||||
except ValueError:
|
|
||||||
print(f"[ERROR] Link zeigt außerhalb des Repos: {f} -> {target}")
|
|
||||||
errors += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# allow directories if they contain README.md
|
|
||||||
if resolved.is_dir():
|
|
||||||
if not (resolved / "README.md").is_file():
|
|
||||||
print(f"[ERROR] Verlinktes Verzeichnis ohne README.md: {f} -> {target}")
|
|
||||||
errors += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not resolved.exists():
|
|
||||||
print(f"[ERROR] Broken link: {f} -> {target}")
|
|
||||||
errors += 1
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
print(f"[ERROR] Link-Validierung fehlgeschlagen: {errors} Fehler")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"[OK] Link-Validierung erfolgreich: {len(md_files)} Markdown-Dateien geprüft")
|
|
||||||
PY
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# .aiignore - Verhindert Token-Waste für Nolik
|
||||||
|
|
||||||
|
# Abhängigkeiten & Binaries
|
||||||
|
build/
|
||||||
|
.gradle/
|
||||||
|
*.jar
|
||||||
|
*.deb
|
||||||
|
*.msi
|
||||||
|
|
||||||
|
# Sensible Daten (auch lokal!)
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
config/docker/certs/
|
||||||
|
*.pem
|
||||||
|
*.jks
|
||||||
|
postgres-data/
|
||||||
|
valkey-data/
|
||||||
|
|
||||||
|
# Doku-Builds (Nolik soll die Source-Files in docs/ lesen, nicht die HTML-Exporte)
|
||||||
|
build/dokka/
|
||||||
|
docs/Neumarkt2026/*.pdf
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||||
|
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||||
|
exec "$ROOT_DIR/.ai/scripts/check-docs-drift.sh" "$@"
|
||||||
Executable
+7
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Shim: Weiterleitung auf zentrale Guardrail in .ai/
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd)"
|
||||||
|
ROOT_DIR="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR")"
|
||||||
|
exec "$ROOT_DIR/.ai/scripts/validate-links.sh" "$@"
|
||||||
@@ -1,35 +1,43 @@
|
|||||||
# 🤖 Project Agents & Protocol
|
# 🤖 Projekt Agenten & Protokoll (Meldestelle)
|
||||||
|
|
||||||
Dieses Dokument definiert die Zusammenarbeit zwischen dem User (Owner) und den KI-Agenten.
|
Dieses Dokument definiert die Zusammenarbeit zwischen dem User (Owner) und den spezialisierten KI-Agenten.
|
||||||
Es dient als "System Prompt" für neue Chat-Sessions.
|
Es dient als zentraler **System-Prompt-Erweiterung** für neue Chat-Sessions.
|
||||||
|
|
||||||
## 1. Protokoll & Badges
|
## 🚀 Strategische Ausrichtung
|
||||||
Jeder Agent muss seine Antwort mit einem Badge beginnen, um den Kontext zu setzen. Detaillierte Anweisungen finden sich in den jeweiligen Playbooks.
|
|
||||||
|
|
||||||
* **🏗️ [Lead Architect]**: Strategie, Planung, Entscheidungen, Master Roadmap.
|
Das Projekt **"Meldestelle"** entwickelt eine ÖTO/FEI-konforme, offline-fähige Turnier-Software.
|
||||||
|
1. **Desktop-First:** Primäres Ziel ist die Compose Desktop App (KMP). UX & Performance sind auf Profis optimiert.
|
||||||
|
2. **Offline-First:** Das System muss autark (ohne Internet) funktionieren. Sync-Logik ist Kernbestandteil.
|
||||||
|
3. **Domain-Driven:** 6 Bounded Contexts (SCS) bilden den fachlichen Rahmen.
|
||||||
|
|
||||||
|
## 1. Protokoll & Rollen-Badges
|
||||||
|
Jede Agenten-Antwort **muss** mit dem entsprechenden Badge beginnen, um den Kontext und die Verantwortlichkeit zu klären.
|
||||||
|
|
||||||
|
* **🏗️ [Lead Architect]**: Hüter der **MASTER_ROADMAP**. Verantwortlich für System-Design, Build-Logik (Gradle), Modulstruktur und ADRs.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/Architect.md)
|
* [Playbook](docs/04_Agents/Playbooks/Architect.md)
|
||||||
* **🧹 [Curator]**: Dokumentation, Logs, Reports, Aufräumen.
|
* **📜 [Rulebook Expert]**: Wächter über **ÖTO & FEI**. Validiert Business-Rules gegen das offizielle Pferdesport-Regelwerk.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/Curator.md)
|
* [Playbook](docs/04_Agents/Playbooks/RulebookExpert.md)
|
||||||
* **👷 [Backend Developer]**: Spring Boot, Kotlin, SQL, API-Design.
|
* **👷 [Backend Developer]**: Kotlin & Spring Boot Experte. Fokus auf DDD, Persistenz (Postgres) und **Delta-Sync APIs**.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/BackendDeveloper.md)
|
* [Playbook](docs/04_Agents/Playbooks/BackendDeveloper.md)
|
||||||
* **🎨 [Frontend Expert]**: KMP, Compose, State-Management, Auth.
|
* **🎨 [Frontend Expert]**: KMP & Compose Desktop Spezialist. Implementiert State-Management und High-Performance UI.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/FrontendExpert.md)
|
* [Playbook](docs/04_Agents/Playbooks/FrontendExpert.md)
|
||||||
* **🖌️ [UI/UX Designer]**: High-Density Design, Wireframes, Usability.
|
* **🖌️ [UI/UX Designer]**: "Toolsmith" für High-Density Enterprise-UIs. Fokus auf Tastatur-Bedienbarkeit und Effizienz.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/UIUXDesigner.md)
|
* [Playbook](docs/04_Agents/Playbooks/UIUXDesigner.md)
|
||||||
* **🐧 [DevOps Engineer]**: Docker, CI/CD, Gradle, Security.
|
* **🐧 [DevOps Engineer]**: Infrastruktur-Automatisierung (Docker, Gitea-Actions). Fokus auf Stabilität und lokale Dev-Umgebung.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/DevOpsEngineer.md)
|
* [Playbook](docs/04_Agents/Playbooks/DevOpsEngineer.md)
|
||||||
* **🧐 [QA Specialist]**: Test-Strategie, Edge-Cases.
|
* **🧐 [QA Specialist]**: Test-Stratege (Shift-Left). Fokus auf Unit-, Integration- und Edge-Case-Tests (Testing Pyramid).
|
||||||
* [Playbook](docs/04_Agents/Playbooks/QASpecialist.md)
|
* [Playbook](docs/04_Agents/Playbooks/QASpecialist.md)
|
||||||
* **📜 [ÖTO/FEI Rulebook Expert]**: Regelwerks-Wächter, Validierungs-Spezialist, Compliance.
|
* **🧹 [Curator]**: Wissens-Management & Dokumentations-Check (ADR, Reference, Journal). Beendet jede Session.
|
||||||
* [Playbook](docs/04_Agents/Playbooks/RulebookExpert.md)
|
* [Playbook](docs/04_Agents/Playbooks/Curator.md)
|
||||||
|
|
||||||
## 2. Workflow
|
## 2. Der "Meldestelle"-Workflow
|
||||||
1. **Kontext:** Lies immer zuerst die `MASTER_ROADMAP` in `docs/01_Architecture/`.
|
1. **Kontext-Check:** Lies immer zuerst die `MASTER_ROADMAP` in `docs/01_Architecture/`.
|
||||||
2. **Fokus:** Bearbeite immer nur EINE Aufgabe zur Zeit.
|
2. **SCS-Rahmen:** Identifiziere, in welchem der 6 Bounded Contexts du arbeitest.
|
||||||
3. **Doku:** Jede Session endet mit einem Eintrag durch den **Curator**.
|
3. **Fokus:** Bearbeite immer nur EINE fachliche Aufgabe pro Session.
|
||||||
4. **Code:** Änderungen am Code werden sofort via Tool ausgeführt, nicht nur vorgeschlagen.
|
4. **Doku-as-Code:** Änderungen an Code/Architektur müssen sofort in `docs/` (ADR/Reference) reflektiert werden.
|
||||||
|
5. **Session-Abschluss:** Jede Session endet mit einem Eintrag durch den **Curator** (Journal oder Artefakt).
|
||||||
|
|
||||||
## 3. Projekt-Philosophie
|
## 3. Projekt-Philosophie
|
||||||
* **Startup-Mode:** Wir bauen ein echtes Produkt. Code-Qualität und Geschwindigkeit sind gleich wichtig.
|
* **Information Density over White Space:** Wir bauen ein Profi-Werkzeug, kein Spielzeug.
|
||||||
* **Docs-as-Code:** Die Dokumentation ist die Single Source of Truth.
|
* **Speed over Animation:** Reaktionsgeschwindigkeit der UI hat höchste Priorität.
|
||||||
* **Offline-First:** Das System muss ohne Internet funktionieren (Sync).
|
* **Offline-Authentizität:** Lokale Daten sind die "Source of Truth" für den User; der Server ist das Backup/Sync-Target.
|
||||||
|
|||||||
+141
-1
@@ -13,7 +13,124 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## [Unreleased]
|
### [Unreleased]
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
- **Onboarding & Desktop-UX - 15.04.2026:**
|
||||||
|
- **Desktop-App:** Dynamisierung der Statusanzeigen im App-Footer ("Cloud synchronisiert" & "Verbunden").
|
||||||
|
- **Connectivity-Tracking:** Implementierung des `ConnectivityTracker` (KMP) zur Echtzeit-Überwachung der API-Gateway
|
||||||
|
Erreichbarkeit.
|
||||||
|
- **LAN-Erkennung:** Integration des `NetworkDiscoveryService` (mDNS) im Footer zur Anzeige aktiver Instanzen im
|
||||||
|
lokalen Netzwerk.
|
||||||
|
- **Onboarding:** Datenfluss vom `SettingsManager` bis in den Footer finalisiert (Anzeige des echten Gerätenamens).
|
||||||
|
- **Online-Nennung & Integration - 15.04.2026:**
|
||||||
|
- **Backend (Mail-Service):** Finalisierung des `MailController` für Web-Nennungen inkl. SMTP-Versand via World4You.
|
||||||
|
- **Frontend (Desktop):** `NennungsEingangScreen` an Live-Daten vom `mail-service` angebunden.
|
||||||
|
- **Repository:** `NennungRemoteRepository` (KMP) um `holeNennungen()` erweitert.
|
||||||
|
- **Billing & ÖTO - 15.04.2026:**
|
||||||
|
- **Sportförderbeitrag:** Automatische Buchung von 1,00 EUR (§16 ÖTO) bei jeder Nennung im `entries-service`
|
||||||
|
implementiert.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- **Frontend (Desktop):** Behebung von Kompilierungsfehlern in `ScreenPreviews.kt` durch Implementierung der fehlenden
|
||||||
|
`getStats()` Methode in den `MasterdataRepository`-Mocks.
|
||||||
|
- **Identity-Modul:** Umstellung auf `kotlin.time.Instant` zur Vermeidung von Deprecation-Warnungen und Behebung von
|
||||||
|
Persistenz-Konflikten im `ExposedDeviceRepository`.
|
||||||
|
- **Koin DI:** Korrektur von Typ-Inferenz-Fehlern beim `HttpClient` im `nennung-feature` durch explizite Qualifier.
|
||||||
|
- **Turnier-Feature:** Behebung eines unsicheren Casts (`Any!` zu `List<String>`) in `TurnierStammdatenTab.kt`.
|
||||||
|
- **Konfiguration:** Harmonisierung der Ports (Mail-Service auf 8083) in `.env`, `dc-backend.yaml` und
|
||||||
|
`PlatformConfig.jvm.kt`.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **Phase 12 (Abrechnung & Infrastruktur) - 12.04.2026:**
|
||||||
|
- **Infrastruktur:** Docker-Integration für `billing-service` (Port 8087) und API-Gateway Routing vervollständigt.
|
||||||
|
- **Service Discovery:** Alle relevanten Microservices (`masterdata`, `events`, `results`, `series`, `billing`) sind nun bei Consul registriert.
|
||||||
|
- **Frontend Billing:** `BillingRepository` und `BillingViewModel` auf reale API-Anbindung (Ktor) umgestellt; `BillingScreen` funktionalisiert.
|
||||||
|
- **Backend (Series):** JPA-Entitäten `Serie` und `SeriePunkt` im `series-service` stabilisiert und Flyway-Migrationen für das Datenbankschema erstellt.
|
||||||
|
- **Fix:** Behebung von IDE-Mapping-Warnungen durch explizite `@Column` Namen in den JPA-Entitäten.
|
||||||
|
- **Backend Fixes - 12.04.2026:**
|
||||||
|
- **Infrastruktur:** Behebung von Startfehlern im `events-service` (DataSource) und `masterdata-service` (Consul).
|
||||||
|
- **Build:** Integration von `results-service` und `series-service` in `settings.gradle.kts`.
|
||||||
|
- **Domain:** `Serie` und `SeriePunkt` zu `data class` konvertiert (copy() Unterstützung).
|
||||||
|
- **Phase 11 (Ergebniserfassung & Platzierung) - 12.04.2026:**
|
||||||
|
- **Backend (Results):** `results-service` um JPA-Entitäten, Repositories und Business-Logik für Platzierungsberechnungen (Wertnote, Zeit, Fehler) ergänzt.
|
||||||
|
- **Infrastructure:** `dc-backend.yaml` und `GatewayConfig.kt` um den Service `results` (Port 8088) erweitert.
|
||||||
|
- **Frontend Domain:** `ErgebnisRepository` und `Ergebnis`-Modell für Wertnoten, Zeiten und Status erstellt.
|
||||||
|
- **Frontend UI:** `ErgebnisEditDialog` zur schnellen Ergebniserfassung hinzugefügt; `TurnierStartlistenTab` ermöglicht nun Erfassung per Zeilen-Klick.
|
||||||
|
- **Frontend UI:** `TurnierErgebnislistenTab` vervollständigt: Buttons für "Platzierung berechnen" und "Drucken" (PDF) funktionalisiert.
|
||||||
|
- **Fix:** Kompilierungsprobleme im `TurnierFeatureModule` und `ScreenPreviews.kt` behoben (fehlende `ergebnisRepo` Parameter).
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **Phase 10.4 (Series-Context Vertiefung) - 12.04.2026:**
|
||||||
|
- **Backend (Series):** `series-service` um Logik für Streichresultate (`ReglementTyp`) und Bindungsarten (Reiter-zentriert, Pferde-zentriert, Paar-Bindung) erweitert.
|
||||||
|
- **Infrastructure:** `dc-backend.yaml` und `GatewayConfig.kt` um den Service `series` (Port 8089) erweitert.
|
||||||
|
- **Frontend Domain:** `SeriesRepository` und Modelle an das neue Ranking-Format (`SerieStandEntry`) angepasst.
|
||||||
|
- **UI:** `SeriesScreen.kt` überarbeitet: Zeigt nun Reiter- und Pferde-IDs sowie Fortschritt pro Teilnehmer an.
|
||||||
|
- **Dokumentation:** `MASTER_ROADMAP.md` aktualisiert (Phase 10 & 11 auf 'Completed' gesetzt).
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **Phase 10.3 (Echter Datenverkehr & Infrastruktur) - 12.04.2026:**
|
||||||
|
- **Infrastructure:** Docker-Services für `masterdata`, `events` und `zns-import` in `dc-backend.yaml` ergänzt.
|
||||||
|
- **Gateway:** API-Gateway Routing für Masterdata (`/api/v1/masterdata`) und Events (`/api/v1/events`) konfiguriert.
|
||||||
|
- **Frontend (Vereine):** `VereinRepository` (Ktor) und `VereinViewModel` implementiert für echtes Anlegen von Veranstaltern.
|
||||||
|
- **Frontend (Events):** `TurnierViewModel` an das reale `TurnierRepository` angebunden.
|
||||||
|
- **Fix:** `verein-feature` Abhängigkeiten korrigiert (Network/Ktor).
|
||||||
|
- **Fix:** Polling-Endpoints im `ZnsImportViewModel` an das neue Gateway-Routing angepasst.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **Phase 10.2 (Masterdata-Editoren & Organisation) - 12.04.2026:**
|
||||||
|
- **Frontend:** `MasterdataEditDialogs.kt` für die Bearbeitung von Reiter- und Pferdedaten direkt im Turnier-Kontext.
|
||||||
|
- **Frontend:** Erweiterung des `MasterdataRepository` um Schreibzugriffe (`saveReiter`, `savePferd`).
|
||||||
|
- **Frontend:** Funktionale Suche für Turnierleiter im `Organisation`-Tab via `NennungViewModel` und Masterdata-API.
|
||||||
|
- **Frontend:** State-Management für Stammdaten-Editoren im `NennungViewModel`.
|
||||||
|
- **Fix:** Kompilierungsfehler in `ScreenPreviews.kt` behoben (fehlende Interface-Methoden in Mocks).
|
||||||
|
- **Fix (Desktop Shell):** Fehlendes `turnierFeatureModule` in `main.kt` registriert und Login-Gate in `DesktopApp.kt` optimiert.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **Phase 10 (Series-Context & Stammdaten) - 11.04.2026:**
|
||||||
|
- **Frontend:** Stammdaten-Infrastruktur im `turnier-feature` (Repositories, DTOs, Domänenmodelle) für Reiter, Pferde, Funktionäre und Vereine.
|
||||||
|
- **Frontend:** `NennungViewModel` zur Steuerung der Suche und Status-Verwaltung von Nennungen.
|
||||||
|
- **Frontend:** Funktionalisierung des `Nennungen`-Tabs (Suche, Echt-Datenanbindung) und Vorbereitung des `Organisation`-Tabs.
|
||||||
|
- **Frontend:** `DefaultMasterdataRepository` zur Suche in Reitern, Pferden und Funktionären via Backend-API.
|
||||||
|
- **Netzwerk:** Erweiterung der `ApiRoutes` um Endpunkte für Masterdata und Nennungen.
|
||||||
|
- **Phase 10 (Series-Context) Vorbereitung:**
|
||||||
|
- **Frontend:** Neuer `SeriesScreen.kt` für die Verwaltung von Cups und Meisterschaften (konfigurierbare Reglements).
|
||||||
|
- **Frontend:** Erweiterung des `AdminUebersichtScreen` (Cockpit) um KPI-Kacheln mit Direkt-Links zu Cups und Meisterschaften.
|
||||||
|
- **Frontend:** Integration der Series-Navigation in die Breadcrumbs und das globale Routing (`Meisterschaften`, `Cups`).
|
||||||
|
- **Turnier-Feature Hardening:**
|
||||||
|
- **Frontend:** `STARTLISTEN` und `ERGEBNISLISTEN` Tabs vollständig an das `BewerbViewModel` angebunden (Bewerbs-Auswahl mit echten Daten).
|
||||||
|
- **Frontend:** Implementierung der Starter-Anzeige in der Startliste (LazyColumn).
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- **Turnier-Feature:** Sichtbarkeit von `BewerbViewModel.generateStartliste()` auf `public` geändert, um den Aufruf aus dem Tab zu ermöglichen.
|
||||||
|
- **Frontend (Desktop):** `ScreenPreviews.kt` aktualisiert zur Berücksichtigung der neuen ViewModel-Abhängigkeiten (`NennungViewModel`, `MasterdataRepository`).
|
||||||
|
|
||||||
|
### [Phase 9] - 11.04.2026
|
||||||
|
- **Frontend:** Interaktiver Drag & Drop Zeitplan mit automatischem 5-Minuten-Snapping und Konflikt-Visualisierung.
|
||||||
|
- **Frontend:** "B-Satz Export (ZNS)" Toolbar-Aktion mit integriertem Vorschau-Dialog.
|
||||||
|
- **Frontend:** "Änderungs-Historie" (Audit-Log) Sektion zur Nachverfolgung von Zeitplan-Anpassungen.
|
||||||
|
- **Backend:** `audit_log` Persistenz und Abfrage-API für manuelle Eingriffe in Bewerbe.
|
||||||
|
- **Backend:** ZNS B-Satz Export Endpunkt (`/export/zns/b-satz`) zur Generierung von `BBEWERBE` Datensätzen.
|
||||||
|
- **Core:** `FixedWidthLineBuilder` zur präzisen Generierung von ZNS-konformen Festbreiten-Formaten.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Infrastruktur:** Veraltete `newSuspendedTransaction` in `DatabaseFactory.kt` durch moderne `suspendTransaction` (Exposed v1) ersetzt.
|
||||||
|
- **Frontend (Desktop):** Kompilierfehler in `ScreenPreviews.kt` behoben, indem fehlende Interface-Methoden im Mock-Repository implementiert wurden.
|
||||||
|
- **Backend (Tests):** `JdbcSQLSyntaxErrorException` im `BewerbeZeitplanIntegrationTest` durch Korrektur des Schema-Setups (Audit-Log Tabelle) gelöst.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **Bugfix**: Behebung von Build-Fehlern im `veranstalter-feature` nach der Paket-Konsolidierung.
|
||||||
|
- **Frontend**: `FakeVeranstalterRepository` in `commonMain` implementiert, um saubere KMP-DI zu ermöglichen.
|
||||||
|
- **Frontend**: Veraltete Imports und Referenzen im `meldestelle-desktop` Shell und Previews korrigiert.
|
||||||
|
- **Architektur:** Fachliches Konzept für Zeitplan-Optimierung (Drag & Drop) erstellt (`konzept-zeitplan-optimierung-de.md`).
|
||||||
|
- **Architektur:** Spezifikation des Status-Automaten für Nennungen und Synchronisations-Logik (`status-automat-nennungen-de.md`).
|
||||||
|
- **Rulebook:** Überprüfung und Spezifikation der Parcoursbesichtigung zu Pferd (§43 ÖTO) inkl. 5-Minuten-Puffer-Regel.
|
||||||
|
- **Backend (Entries):** Erweiterung der Domain-Modelle `Bewerb` und `Abteilung` um Besichtigungs- und Pausen-Konfigurationen.
|
||||||
|
- **Backend (Entries):** Neues Datenmodell `BesichtigungsBlock` für wettbewerbsübergreifende Parcoursbesichtigungen.
|
||||||
|
- **Backend (Entries):** API-Endpunkt `PATCH /bewerbe/{id}/zeitplan` für schnelle Zeitplan-Updates implementiert.
|
||||||
|
- **Backend (Entries):** `StartlistenService` um ÖTO-konforme Zeitberechnung (Besichtigungs-Puffer, Pausen-Intervalle) erweitert.
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
- Masterdata/Domain: Umbenennungen zur Vereinheitlichung der Terminologie (DE):
|
- Masterdata/Domain: Umbenennungen zur Vereinheitlichung der Terminologie (DE):
|
||||||
@@ -73,6 +190,12 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
- **Domain:** Striktere Spartenlizenz-Prüfung in `Reiter.hasLizenzForSparte` implementiert (RD1..RD3 nur DRESSUR; R1..R4 nur SPRINGEN). Behebt Testfehler „isEligible verweigert Start ohne passende Spartenlizenz“ im `LicenseMatrixServiceTest`.
|
- **Domain:** Striktere Spartenlizenz-Prüfung in `Reiter.hasLizenzForSparte` implementiert (RD1..RD3 nur DRESSUR; R1..R4 nur SPRINGEN). Behebt Testfehler „isEligible verweigert Start ohne passende Spartenlizenz“ im `LicenseMatrixServiceTest`.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Backend (Entries):** Fehlschlagenden Unit-Test `berechneStartzeiten sollte Zeiten korrekt aufsummieren` korrigiert; der Test berücksichtigt nun den neuen 5-minütigen ÖTO-konformen Puffer nach der Parcoursbesichtigung (§43).
|
||||||
|
- **Frontend (Desktop):** Build-Fehler ("No matching variant") beim `funktionaer-feature` behoben; fehlendes `build.gradle.kts` mit JVM-Target und Compose/Koin-Abhängigkeiten ergänzt.
|
||||||
|
- **Frontend (Desktop):** Massive Inkonsistenzen in der Paketstruktur des `veranstalter-feature` bereinigt; alle Komponenten (ViewModel, Screens, Mocks) auf das Standardpaket `at.mocode.frontend.features.veranstalter` konsolidiert, um Redeklarationen und Import-Fehler zu beheben.
|
||||||
|
- **Frontend (Desktop):** Kompilierfehler im `VeranstalterDetailScreen` durch korrekte Paket-Referenzierung des `FakeVeranstaltungStore` gelöst.
|
||||||
|
|
||||||
### Dokumentation
|
### Dokumentation
|
||||||
- **Masterdata/Docs:** `REITER_LIZENZEN.md` überarbeitet:
|
- **Masterdata/Docs:** `REITER_LIZENZEN.md` überarbeitet:
|
||||||
- Strikte Sparten-Trennung dokumentiert (RD1..RD3 nur Dressur; R1..R4 nur Springen).
|
- Strikte Sparten-Trennung dokumentiert (RD1..RD3 nur Dressur; R1..R4 nur Springen).
|
||||||
@@ -105,6 +228,23 @@ Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.0.6-SNAPSHOT] — 2026-04-10
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- **Entries-Domain:** Strukturiertes Abteilungs-Warnungssystem gemäß ÖTO § 39 implementiert.
|
||||||
|
- Neues Value Object `AbteilungsWarnung` und Enum `AbteilungsWarnungCodeE` für präzise Fehlermeldungen und ÖTO-Referenzen.
|
||||||
|
- Erweiterung von `Bewerb` um die Methode `validateStrukturellesTeilung` zur Prüfung vorgeschriebener Abteilungsstrukturen (z.B. Lizenz-Trennung bei CSN-C-NEU, Stilspringen, Caprilli).
|
||||||
|
- Umstellung des `CompetitionWarningService` und `AbteilungsRegelService` auf das neue strukturierte Warnungsmodell.
|
||||||
|
- **Entries-Service:** Erweiterung der REST-API (`BewerbeController`) um die Auslieferung von Warnungen in den DTOs (`BewerbResponse`).
|
||||||
|
- **Frontend (Turnier-Feature):** Visuelle Integration der Abteilungs-Warnungen in der Bewerbe-Liste.
|
||||||
|
- Anzeige eines Warn-Icons (gelb) bei Regelverstößen.
|
||||||
|
- Tooltip-Funktionalität zur Anzeige der detaillierten Warnungstexte und ÖTO-Paragraphen.
|
||||||
|
- Erweiterung des `BewerbUiModel` und Repositories zur Unterstützung der Warnungs-Metadaten.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- **QA:** `AbteilungsRegelServiceTest` und `BewerbTest` auf das neue Warnungssystem aktualisiert und um Tests für strukturelle Teilungen (CSN Stilspringen, Caprilli) erweitert.
|
||||||
|
- **KMP:** Korrektur von veralteten `Instant`-Deprecations in Testklassen (`kotlin.time.Instant`).
|
||||||
|
|
||||||
## [1.0.5-SNAPSHOT] — 2026-04-06
|
## [1.0.5-SNAPSHOT] — 2026-04-06
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,72 +0,0 @@
|
|||||||
|
|
||||||
# Tech-Stack Referenz: Kotlin 2.3.0 & Java 25 (KMP)
|
|
||||||
|
|
||||||
### 1. Kern-Spezifikationen
|
|
||||||
|
|
||||||
| Komponente | Version | Status |
|
|
||||||
| --- |----------| --- |
|
|
||||||
| **Kotlin** | `2.3.0` | Stabil (K2 Compiler standardmäßig aktiv) |
|
|
||||||
| **Java (JDK)** | `25` | LTS (Long-Term Support) |
|
|
||||||
| **Gradle** | `9.2.1` | Erforderlich für JDK 25 Support |
|
|
||||||
| **Android Plugin (AGP)** | `8.8.0+` | Empfohlen für Gradle 9.x Kompatibilität |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Gradle Konfiguration (`build.gradle.kts`)
|
|
||||||
|
|
||||||
Für ein **Kotlin Multiplatform (KMP)** Projekt ist die Java Toolchain-Konfiguration entscheidend, um sicherzustellen, dass der Kotlin-Compiler und die JVM-Targets Java 25 korrekt ansprechen.
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
plugins {
|
|
||||||
kotlin("multiplatform") version "2.3.0"
|
|
||||||
id("com.android.library") version "8.8.0" // Falls Android Target genutzt wird
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
// Globale Toolchain-Definition für alle JVM/Android Targets
|
|
||||||
jvmToolchain {
|
|
||||||
languageVersion.set(JavaLanguageVersion.of(25))
|
|
||||||
}
|
|
||||||
|
|
||||||
jvm {
|
|
||||||
compilations.all {
|
|
||||||
compilerOptions.configure {
|
|
||||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_25)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weitere Targets (Beispiel iOS)
|
|
||||||
iosArm64()
|
|
||||||
iosSimulatorArm64()
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Gradle Wrapper Update
|
|
||||||
|
|
||||||
Damit das Projekt Java 25 erkennt, muss der Wrapper auf dem neuesten Stand sein:
|
|
||||||
|
|
||||||
**Terminal-Befehl:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./gradlew wrapper --gradle-version 9.2.1 --distribution-type all
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Wichtige Kompatibilitätshinweise für das Plugin
|
|
||||||
|
|
||||||
* **IDE-Version:** Stelle sicher, dass **IntelliJ IDEA 2025.3** (oder neuer) installiert ist, da erst diese Version die volle Unterstützung für JDK 25 Sprachfeatures und das Kotlin 2.3.0 Plugin bietet.
|
|
||||||
* **K2 Compiler:** Kotlin 2.3.0 nutzt den K2-Compiler. Falls das Google AI Pro Plugin Code-Analysen durchführt, sollte es auf dem K2-Modus basieren.
|
|
||||||
* **Bytecode:** Java 25 Bytecode wird nur generiert, wenn das `jvmTarget` explizit auf `25` gesetzt ist. Andernfalls verbleibt Kotlin standardmäßig bei einer niedrigeren Version (meist 1.8 oder 11), was die neuen JDK-Features einschränken könnte.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Bekannte Features in diesem Setup
|
|
||||||
|
|
||||||
* **Java 25 Features:** Unterstützung für die finalen Versionen von *Scoped Values* und *Structured Concurrency*.
|
|
||||||
* **Kotlin 2.3.0 Features:** Nutzung von `explicit backing fields` und dem verbesserten `unused return value` Checker.
|
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -1,63 +0,0 @@
|
|||||||
### Analyse und Strategie zur Wiederherstellung und Strukturverbesserung
|
|
||||||
|
|
||||||
Es ist eine klassische Situation: Nach einem großen Technologie-Upgrade (Kotlin 2.1.0+, Java 25, Spring Boot 3.5.x) knirscht es oft an den Schnittstellen. Da dein `ping-service` als technischer Blueprint dient, ist er der absolut richtige Startpunkt.
|
|
||||||
|
|
||||||
Hier ist der Schlachtplan, um Ordnung zu schaffen und die hexagonale Architektur sauber zu etablieren:
|
|
||||||
|
|
||||||
### 1. Wo beginnen? Bottom-Up vs. Top-Down
|
|
||||||
Da deine Infrastruktur aktuell nicht stabil läuft, empfehle ich einen **"Core-First"** Ansatz, gefolgt vom **Backend-Durchstich**.
|
|
||||||
|
|
||||||
* **Zuerst: Core & Platform:** Ohne eine stabile Basis (`platform-bom`, `platform-dependencies`, `core-domain`) werden die anderen Module immer wieder Kompilierfehler werfen.
|
|
||||||
* **Dann: Der technische vertikale Durchstich (`ping-service`):** Sobald die Plattform steht, reparieren wir den Weg: `Infrastruktur (Docker) -> Ping-Service -> Gateway`.
|
|
||||||
* **Zuletzt: Frontend:** Das Frontend (BFF-Gedanke) wird erst dann stabil, wenn die API-Contracts des Backends wieder verlässlich geliefert werden.
|
|
||||||
|
|
||||||
### 2. Ordnung schaffen: Der "Clean Desk" im Projekt
|
|
||||||
Bevor wir Code fixen, müssen wir die Build-Umgebung aufräumen:
|
|
||||||
|
|
||||||
1. **Version Catalog Synchronität:** Deine `libs.versions.toml` nutzt bereits Java 25 und Kotlin 2.1.0. Prüfe, ob alle Gradle-Plugins (insbesondere das `compose-multiplatform` und `spring-boot` Plugin) mit Kotlin 2.1.0 kompatibel sind. Oft ist hier ein Downgrade auf die letzte stabile Version oder ein Upgrade auf Alpha/Beta-Versionen nötig, wenn man "Bleeding Edge" (Java 25) nutzt.
|
|
||||||
2. **Modul-Konsolidierung (DDD):** Wie besprochen, solltest du die "Modul-Explosion" reduzieren.
|
|
||||||
* **Vorschlag:** Statt 5 Module pro Domain (`api`, `common`, `domain`, `infrastructure`, `service`), reduziere es auf maximal zwei:
|
|
||||||
* `domain-api`: Nur DTOs und Interfaces (für KMP-Sharing mit dem Frontend).
|
|
||||||
* `domain-service`: Die gesamte Implementierung (Hexagonal strukturiert in Packages).
|
|
||||||
|
|
||||||
### 3. Hexagonale Architektur im `ping-service` umsetzen
|
|
||||||
Dein `ping-service` ist aktuell noch sehr "Spring-lastig" (Controller ruft Service mit Circuit Breaker direkt auf). Für eine echte hexagonale Vorlage strukturiere das Modul `ping-service` intern wie folgt um:
|
|
||||||
|
|
||||||
```text
|
|
||||||
at.mocode.ping.service
|
|
||||||
├── adapter
|
|
||||||
│ ├── in
|
|
||||||
│ │ └── web (PingController - Dein primärer Port-Adapter)
|
|
||||||
│ └── out
|
|
||||||
│ └── persistence (PingRepositoryAdapter - Sekundärer Port-Adapter)
|
|
||||||
├── application
|
|
||||||
│ ├── port
|
|
||||||
│ │ ├── in (PingUseCase - Das Interface für den Controller)
|
|
||||||
│ │ └── out (PingOutputPort - Interface für die Datenbank)
|
|
||||||
│ └── service (PingService - Hier liegt die Business Logik, OHNE Spring-Annotationen wo möglich)
|
|
||||||
└── domain
|
|
||||||
└── model (PingEntity/Value Objects)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Der Vorteil:** Wenn du dieses Muster im `ping-service` einmal sauber hast, kopierst du diese Package-Struktur für `registry`, `events` etc.
|
|
||||||
|
|
||||||
### 4. Konkrete Schritte zur Reparatur
|
|
||||||
|
|
||||||
**Schritt 1: Infrastruktur-Check (Docker)**
|
|
||||||
Stelle sicher, dass die Basisdienste laufen. Java 25 benötigt oft neuere Container-Images.
|
|
||||||
* Check `docker-compose.yaml`: Laufen Postgres und Keycloak?
|
|
||||||
* `ping-service` application.yaml: Aktiviere die Datenbank-Verbindung (JPA), die aktuell noch auskommentiert ist, um den "echten" Test zu ermöglichen.
|
|
||||||
|
|
||||||
**Schritt 2: Backend API-Gateway Fix**
|
|
||||||
Dein Gateway ist der Wächter. Wenn die Security-Konfiguration (`SecurityConfig.kt`) wegen Bibliotheks-Änderungen in Spring Security 7/Spring Boot 3.5 hakt, ist das Priorität 1.
|
|
||||||
* Test: Kannst du den `ping-service` direkt aufrufen? Wenn ja, funktioniert das Gateway-Routing?
|
|
||||||
|
|
||||||
**Schritt 3: Frontend (BFF) Anpassung**
|
|
||||||
Nutze das Frontend als Konsument. Wenn du den `PingApiClient` im Frontend hast, sollte dieser gegen das **Gateway** (BFF-Pattern) laufen, nicht direkt gegen den Service.
|
|
||||||
|
|
||||||
### Empfehlung zur Vorgehensweise (Prioritäten):
|
|
||||||
1. **Gradle-Build stabilisieren:** Alle `:platform:*` und `:core:*` Module müssen mit `./gradlew build` fehlerfrei durchlaufen.
|
|
||||||
2. **Ping-SCS fertigstellen:** Implementiere eine minimale Datenbank-Speicherung im `ping-service`. Das ist der Beweis, dass die JPA/Hibernate-Konfiguration mit Java 25 harmoniert.
|
|
||||||
3. **Gateway-Security:** Stelle sicher, dass das JWT von Keycloak korrekt zum `ping-service` durchgereicht wird.
|
|
||||||
|
|
||||||
**Soll ich dir bei einem dieser spezifischen Schritte (z.B. der hexagonalen Package-Struktur für den Ping-Service) mit konkretem Code helfen?**
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,323 +0,0 @@
|
|||||||
Meldestelle on main [✘»!+?] via 🅶 v9.2.1 via ☕ v25.0.1 via 🅺 v2.3.0
|
|
||||||
❯ ./gradlew :backend:infrastructure:gateway:test --stacktrace
|
|
||||||
Starting a Gradle Daemon, 2 stopped Daemons could not be reused, use --status for details
|
|
||||||
Type-safe project accessors is an incubating feature.
|
|
||||||
|
|
||||||
> Task :backend:infrastructure:gateway:test
|
|
||||||
|
|
||||||
WebFluxSmokeTest > should load reactive web context and serve smoke endpoint() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
|
||||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
|
||||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
|
||||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
|
||||||
|
|
||||||
GatewayFiltersTests > should preserve existing correlation ID header() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
|
||||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
|
||||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
|
||||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
|
||||||
|
|
||||||
GatewayFiltersTests > should handle requests with X-Forwarded-For header() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewayFiltersTests > should apply admin rate limit for admin users() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewayFiltersTests > should add rate limiting headers() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewayFiltersTests > should add correlation ID header when not present() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewayFiltersTests > should apply different rate limits for auth endpoints() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewayFiltersTests > should enforce rate limiting after exceeding limit() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewayFiltersTests > should apply higher rate limit for authenticated users() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
KeycloakGatewayIntegrationTest > should initialize Spring context with Keycloak configuration() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
|
||||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
|
||||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
|
||||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
|
||||||
|
|
||||||
GatewayApplicationTests > contextLoads() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
|
||||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
|
||||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
|
||||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
|
||||||
|
|
||||||
FallbackControllerTests > should handle POST requests to masterdata fallback() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
|
||||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
|
||||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
|
||||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
|
||||||
|
|
||||||
FallbackControllerTests > should handle POST requests to default fallback() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
FallbackControllerTests > should return masterdata service fallback response() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
FallbackControllerTests > should handle POST requests to members fallback() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
FallbackControllerTests > should handle POST requests to events fallback() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
FallbackControllerTests > should return valid JSON structure for all fallback responses() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
FallbackControllerTests > should handle POST requests to auth fallback() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
FallbackControllerTests > sollte Members Service Fallback Response zurueckgeben() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
FallbackControllerTests > should have consistent error response structure() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
FallbackControllerTests > sollte Events Service Fallback Response zurueckgeben() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
FallbackControllerTests > should return auth service fallback response() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
FallbackControllerTests > sollte Horses Service Fallback Response zurueckgeben() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
FallbackControllerTests > should handle POST requests to horses fallback() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
FallbackControllerTests > should return default fallback response for unknown service() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewaySecurityTests > should handle different HTTP methods allowed in CORS() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
|
||||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
|
||||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
|
||||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
|
||||||
|
|
||||||
GatewaySecurityTests > should handle complex CORS scenarios() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewaySecurityTests > should handle PUT requests with CORS headers() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewaySecurityTests > should allow requests from meldestelle domain() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewaySecurityTests > should allow credentials in CORS requests() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewaySecurityTests > should set max age for CORS requests() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewaySecurityTests > should handle authorization headers in CORS requests() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewaySecurityTests > should not duplicate CORS headers due to deduplication filter() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewaySecurityTests > should handle CORS preflight requests() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewaySecurityTests > should allow requests from localhost origins() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewaySecurityTests > should maintain security headers in responses() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewaySecurityTests > should handle POST requests with CORS headers() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewaySecurityTests > should handle DELETE requests with CORS headers() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewayRoutingTests > should route ping service requests() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:180
|
|
||||||
Caused by: java.lang.IllegalStateException at SpringBootCondition.java:60
|
|
||||||
Caused by: java.lang.IllegalArgumentException at ClassUtils.java:372
|
|
||||||
Caused by: java.lang.ClassNotFoundException at BuiltinClassLoader.java:642
|
|
||||||
|
|
||||||
GatewayRoutingTests > should route horses service requests() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewayRoutingTests > should handle gateway info path request() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewayRoutingTests > should route members service requests() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewayRoutingTests > auth route is not configured anymore() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewayRoutingTests > should route masterdata service requests() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
GatewayRoutingTests > should route events service requests() FAILED
|
|
||||||
java.lang.IllegalStateException at DefaultCacheAwareContextLoaderDelegate.java:145
|
|
||||||
|
|
||||||
45 tests completed, 45 failed
|
|
||||||
|
|
||||||
> Task :backend:infrastructure:gateway:test FAILED
|
|
||||||
|
|
||||||
[Incubating] Problems report is available at: file:///home/stefan/WsMeldestelle/Meldestelle/build/reports/problems/problems-report.html
|
|
||||||
|
|
||||||
FAILURE: Build failed with an exception.
|
|
||||||
|
|
||||||
* What went wrong:
|
|
||||||
Execution failed for task ':backend:infrastructure:gateway:test'.
|
|
||||||
> There were failing tests. See the report at: file:///home/stefan/WsMeldestelle/Meldestelle/backend/infrastructure/gateway/build/reports/tests/test/index.html
|
|
||||||
|
|
||||||
* Try:
|
|
||||||
> Run with --scan to generate a Build Scan (powered by Develocity).
|
|
||||||
|
|
||||||
* Exception is:
|
|
||||||
org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':backend:infrastructure:gateway:test'.
|
|
||||||
at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.lambda$executeIfValid$1(ExecuteActionsTaskExecuter.java:135)
|
|
||||||
at org.gradle.internal.Try$Failure.ifSuccessfulOrElse(Try.java:288)
|
|
||||||
at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeIfValid(ExecuteActionsTaskExecuter.java:133)
|
|
||||||
at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.execute(ExecuteActionsTaskExecuter.java:121)
|
|
||||||
at org.gradle.api.internal.tasks.execution.ProblemsTaskPathTrackingTaskExecuter.execute(ProblemsTaskPathTrackingTaskExecuter.java:41)
|
|
||||||
at org.gradle.api.internal.tasks.execution.FinalizePropertiesTaskExecuter.execute(FinalizePropertiesTaskExecuter.java:46)
|
|
||||||
at org.gradle.api.internal.tasks.execution.ResolveTaskExecutionModeExecuter.execute(ResolveTaskExecutionModeExecuter.java:51)
|
|
||||||
at org.gradle.api.internal.tasks.execution.SkipTaskWithNoActionsExecuter.execute(SkipTaskWithNoActionsExecuter.java:57)
|
|
||||||
at org.gradle.api.internal.tasks.execution.SkipOnlyIfTaskExecuter.execute(SkipOnlyIfTaskExecuter.java:74)
|
|
||||||
at org.gradle.api.internal.tasks.execution.CatchExceptionTaskExecuter.execute(CatchExceptionTaskExecuter.java:36)
|
|
||||||
at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.executeTask(EventFiringTaskExecuter.java:77)
|
|
||||||
at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.call(EventFiringTaskExecuter.java:55)
|
|
||||||
at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter$1.call(EventFiringTaskExecuter.java:52)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
|
|
||||||
at org.gradle.api.internal.tasks.execution.EventFiringTaskExecuter.execute(EventFiringTaskExecuter.java:52)
|
|
||||||
at org.gradle.execution.plan.DefaultNodeExecutor.executeLocalTaskNode(DefaultNodeExecutor.java:55)
|
|
||||||
at org.gradle.execution.plan.DefaultNodeExecutor.execute(DefaultNodeExecutor.java:34)
|
|
||||||
at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$InvokeNodeExecutorsAction.execute(DefaultTaskExecutionGraph.java:355)
|
|
||||||
at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$InvokeNodeExecutorsAction.execute(DefaultTaskExecutionGraph.java:343)
|
|
||||||
at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.lambda$execute$0(DefaultTaskExecutionGraph.java:339)
|
|
||||||
at org.gradle.internal.operations.CurrentBuildOperationRef.with(CurrentBuildOperationRef.java:84)
|
|
||||||
at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.execute(DefaultTaskExecutionGraph.java:339)
|
|
||||||
at org.gradle.execution.taskgraph.DefaultTaskExecutionGraph$BuildOperationAwareExecutionAction.execute(DefaultTaskExecutionGraph.java:328)
|
|
||||||
at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.execute(DefaultPlanExecutor.java:459)
|
|
||||||
at org.gradle.execution.plan.DefaultPlanExecutor$ExecutorWorker.run(DefaultPlanExecutor.java:376)
|
|
||||||
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
|
|
||||||
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
|
|
||||||
Caused by: org.gradle.api.internal.exceptions.MarkedVerificationException: There were failing tests. See the report at: file:///home/stefan/WsMeldestelle/Meldestelle/backend/infrastructure/gateway/build/reports/tests/test/index.html
|
|
||||||
at org.gradle.api.tasks.testing.AbstractTestTask.handleTestFailures(AbstractTestTask.java:703)
|
|
||||||
at org.gradle.api.tasks.testing.AbstractTestTask.handleCollectedResults(AbstractTestTask.java:535)
|
|
||||||
at org.gradle.api.tasks.testing.AbstractTestTask.executeTests(AbstractTestTask.java:527)
|
|
||||||
at org.gradle.api.tasks.testing.Test.executeTests(Test.java:714)
|
|
||||||
at org.gradle.internal.reflect.JavaMethod.invoke(JavaMethod.java:125)
|
|
||||||
at org.gradle.api.internal.project.taskfactory.StandardTaskAction.doExecute(StandardTaskAction.java:58)
|
|
||||||
at org.gradle.api.internal.project.taskfactory.StandardTaskAction.execute(StandardTaskAction.java:51)
|
|
||||||
at org.gradle.api.internal.project.taskfactory.StandardTaskAction.execute(StandardTaskAction.java:29)
|
|
||||||
at org.gradle.api.internal.tasks.execution.TaskExecution$3.run(TaskExecution.java:252)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:29)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$1.execute(DefaultBuildOperationRunner.java:26)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.run(DefaultBuildOperationRunner.java:47)
|
|
||||||
at org.gradle.api.internal.tasks.execution.TaskExecution.executeAction(TaskExecution.java:237)
|
|
||||||
at org.gradle.api.internal.tasks.execution.TaskExecution.executeActions(TaskExecution.java:220)
|
|
||||||
at org.gradle.api.internal.tasks.execution.TaskExecution.executeWithPreviousOutputFiles(TaskExecution.java:203)
|
|
||||||
at org.gradle.api.internal.tasks.execution.TaskExecution.execute(TaskExecution.java:170)
|
|
||||||
at org.gradle.internal.execution.steps.ExecuteStep.executeInternal(ExecuteStep.java:105)
|
|
||||||
at org.gradle.internal.execution.steps.ExecuteStep.access$000(ExecuteStep.java:44)
|
|
||||||
at org.gradle.internal.execution.steps.ExecuteStep$1.call(ExecuteStep.java:59)
|
|
||||||
at org.gradle.internal.execution.steps.ExecuteStep$1.call(ExecuteStep.java:56)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:209)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:204)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:66)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:59)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:166)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:59)
|
|
||||||
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:53)
|
|
||||||
at org.gradle.internal.execution.steps.ExecuteStep.execute(ExecuteStep.java:56)
|
|
||||||
at org.gradle.internal.execution.steps.ExecuteStep.execute(ExecuteStep.java:44)
|
|
||||||
at org.gradle.internal.execution.steps.CancelExecutionStep.execute(CancelExecutionStep.java:42)
|
|
||||||
at org.gradle.internal.execution.steps.TimeoutStep.executeWithoutTimeout(TimeoutStep.java:75)
|
|
||||||
at org.gradle.internal.execution.steps.TimeoutStep.execute(TimeoutStep.java:55)
|
|
||||||
at org.gradle.internal.execution.steps.PreCreateOutputParentsStep.execute(PreCreateOutputParentsStep.java:50)
|
|
||||||
at org.gradle.internal.execution.steps.PreCreateOutputParentsStep.execute(PreCreateOutputParentsStep.java:28)
|
|
||||||
at org.gradle.internal.execution.steps.RemovePreviousOutputsStep.execute(RemovePreviousOutputsStep.java:68)
|
|
||||||
at org.gradle.internal.execution.steps.RemovePreviousOutputsStep.execute(RemovePreviousOutputsStep.java:38)
|
|
||||||
at org.gradle.internal.execution.steps.BroadcastChangingOutputsStep.execute(BroadcastChangingOutputsStep.java:61)
|
|
||||||
at org.gradle.internal.execution.steps.BroadcastChangingOutputsStep.execute(BroadcastChangingOutputsStep.java:26)
|
|
||||||
at org.gradle.internal.execution.steps.CaptureOutputsAfterExecutionStep.execute(CaptureOutputsAfterExecutionStep.java:69)
|
|
||||||
at org.gradle.internal.execution.steps.CaptureOutputsAfterExecutionStep.execute(CaptureOutputsAfterExecutionStep.java:46)
|
|
||||||
at org.gradle.internal.execution.steps.ResolveInputChangesStep.execute(ResolveInputChangesStep.java:39)
|
|
||||||
at org.gradle.internal.execution.steps.ResolveInputChangesStep.execute(ResolveInputChangesStep.java:28)
|
|
||||||
at org.gradle.internal.execution.steps.BuildCacheStep.executeWithoutCache(BuildCacheStep.java:189)
|
|
||||||
at org.gradle.internal.execution.steps.BuildCacheStep.executeAndStoreInCache(BuildCacheStep.java:145)
|
|
||||||
at org.gradle.internal.execution.steps.BuildCacheStep.lambda$executeWithCache$4(BuildCacheStep.java:101)
|
|
||||||
at org.gradle.internal.execution.steps.BuildCacheStep.lambda$executeWithCache$5(BuildCacheStep.java:101)
|
|
||||||
at org.gradle.internal.Try$Success.map(Try.java:170)
|
|
||||||
at org.gradle.internal.execution.steps.BuildCacheStep.executeWithCache(BuildCacheStep.java:85)
|
|
||||||
at org.gradle.internal.execution.steps.BuildCacheStep.lambda$execute$0(BuildCacheStep.java:74)
|
|
||||||
at org.gradle.internal.Either$Left.fold(Either.java:116)
|
|
||||||
at org.gradle.internal.execution.caching.CachingState.fold(CachingState.java:62)
|
|
||||||
at org.gradle.internal.execution.steps.BuildCacheStep.execute(BuildCacheStep.java:73)
|
|
||||||
at org.gradle.internal.execution.steps.BuildCacheStep.execute(BuildCacheStep.java:48)
|
|
||||||
at org.gradle.internal.execution.steps.StoreExecutionStateStep.execute(StoreExecutionStateStep.java:46)
|
|
||||||
at org.gradle.internal.execution.steps.StoreExecutionStateStep.execute(StoreExecutionStateStep.java:35)
|
|
||||||
at org.gradle.internal.execution.steps.SkipUpToDateStep.executeBecause(SkipUpToDateStep.java:75)
|
|
||||||
at org.gradle.internal.execution.steps.SkipUpToDateStep.lambda$execute$2(SkipUpToDateStep.java:53)
|
|
||||||
at org.gradle.internal.execution.steps.SkipUpToDateStep.execute(SkipUpToDateStep.java:53)
|
|
||||||
at org.gradle.internal.execution.steps.SkipUpToDateStep.execute(SkipUpToDateStep.java:35)
|
|
||||||
at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsFinishedStep.execute(MarkSnapshottingInputsFinishedStep.java:37)
|
|
||||||
at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsFinishedStep.execute(MarkSnapshottingInputsFinishedStep.java:27)
|
|
||||||
at org.gradle.internal.execution.steps.ResolveIncrementalCachingStateStep.executeDelegate(ResolveIncrementalCachingStateStep.java:49)
|
|
||||||
at org.gradle.internal.execution.steps.ResolveIncrementalCachingStateStep.executeDelegate(ResolveIncrementalCachingStateStep.java:27)
|
|
||||||
at org.gradle.internal.execution.steps.AbstractResolveCachingStateStep.execute(AbstractResolveCachingStateStep.java:71)
|
|
||||||
at org.gradle.internal.execution.steps.AbstractResolveCachingStateStep.execute(AbstractResolveCachingStateStep.java:39)
|
|
||||||
at org.gradle.internal.execution.steps.ResolveChangesStep.execute(ResolveChangesStep.java:64)
|
|
||||||
at org.gradle.internal.execution.steps.ResolveChangesStep.execute(ResolveChangesStep.java:35)
|
|
||||||
at org.gradle.internal.execution.steps.ValidateStep.execute(ValidateStep.java:62)
|
|
||||||
at org.gradle.internal.execution.steps.ValidateStep.execute(ValidateStep.java:40)
|
|
||||||
at org.gradle.internal.execution.steps.AbstractCaptureStateBeforeExecutionStep.execute(AbstractCaptureStateBeforeExecutionStep.java:76)
|
|
||||||
at org.gradle.internal.execution.steps.AbstractCaptureStateBeforeExecutionStep.execute(AbstractCaptureStateBeforeExecutionStep.java:45)
|
|
||||||
at org.gradle.internal.execution.steps.AbstractSkipEmptyWorkStep.executeWithNonEmptySources(AbstractSkipEmptyWorkStep.java:136)
|
|
||||||
at org.gradle.internal.execution.steps.AbstractSkipEmptyWorkStep.execute(AbstractSkipEmptyWorkStep.java:66)
|
|
||||||
at org.gradle.internal.execution.steps.AbstractSkipEmptyWorkStep.execute(AbstractSkipEmptyWorkStep.java:38)
|
|
||||||
at org.gradle.internal.execution.steps.legacy.MarkSnapshottingInputsStartedStep.execute(MarkSnapshottingInputsStartedStep.java:38)
|
|
||||||
at org.gradle.internal.execution.steps.LoadPreviousExecutionStateStep.execute(LoadPreviousExecutionStateStep.java:36)
|
|
||||||
at org.gradle.internal.execution.steps.LoadPreviousExecutionStateStep.execute(LoadPreviousExecutionStateStep.java:23)
|
|
||||||
at org.gradle.internal.execution.steps.HandleStaleOutputsStep.execute(HandleStaleOutputsStep.java:75)
|
|
||||||
at org.gradle.internal.execution.steps.HandleStaleOutputsStep.execute(HandleStaleOutputsStep.java:41)
|
|
||||||
at org.gradle.internal.execution.steps.AssignMutableWorkspaceStep.lambda$execute$0(AssignMutableWorkspaceStep.java:35)
|
|
||||||
at org.gradle.api.internal.tasks.execution.TaskExecution$4.withWorkspace(TaskExecution.java:297)
|
|
||||||
at org.gradle.internal.execution.steps.AssignMutableWorkspaceStep.execute(AssignMutableWorkspaceStep.java:31)
|
|
||||||
at org.gradle.internal.execution.steps.AssignMutableWorkspaceStep.execute(AssignMutableWorkspaceStep.java:22)
|
|
||||||
at org.gradle.internal.execution.steps.ChoosePipelineStep.execute(ChoosePipelineStep.java:40)
|
|
||||||
at org.gradle.internal.execution.steps.ChoosePipelineStep.execute(ChoosePipelineStep.java:23)
|
|
||||||
at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.lambda$execute$2(ExecuteWorkBuildOperationFiringStep.java:67)
|
|
||||||
at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.execute(ExecuteWorkBuildOperationFiringStep.java:67)
|
|
||||||
at org.gradle.internal.execution.steps.ExecuteWorkBuildOperationFiringStep.execute(ExecuteWorkBuildOperationFiringStep.java:39)
|
|
||||||
at org.gradle.internal.execution.steps.IdentityCacheStep.execute(IdentityCacheStep.java:46)
|
|
||||||
at org.gradle.internal.execution.steps.IdentityCacheStep.execute(IdentityCacheStep.java:34)
|
|
||||||
at org.gradle.internal.execution.steps.IdentifyStep.execute(IdentifyStep.java:44)
|
|
||||||
at org.gradle.internal.execution.steps.IdentifyStep.execute(IdentifyStep.java:31)
|
|
||||||
at org.gradle.internal.execution.impl.DefaultExecutionEngine$1.execute(DefaultExecutionEngine.java:64)
|
|
||||||
at org.gradle.api.internal.tasks.execution.ExecuteActionsTaskExecuter.executeIfValid(ExecuteActionsTaskExecuter.java:132)
|
|
||||||
... 30 more
|
|
||||||
|
|
||||||
|
|
||||||
Deprecated Gradle features were used in this build, making it incompatible with Gradle 10.
|
|
||||||
|
|
||||||
You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
|
|
||||||
|
|
||||||
For more on this, please refer to https://docs.gradle.org/9.2.1/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
|
|
||||||
|
|
||||||
BUILD FAILED in 23s
|
|
||||||
17 actionable tasks: 4 executed, 13 up-to-date
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
Placeholder to ensure directory exists
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
plugins {
|
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
|
||||||
alias(libs.plugins.kotlinSerialization)
|
|
||||||
alias(libs.plugins.composeMultiplatform)
|
|
||||||
alias(libs.plugins.composeCompiler)
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
jvm("desktop")
|
|
||||||
|
|
||||||
js(IR) {
|
|
||||||
// WICHTIG: Als Library kompilieren für Webpack Federation
|
|
||||||
binaries.library()
|
|
||||||
generateTypeScriptDefinitions()
|
|
||||||
browser {
|
|
||||||
commonWebpackConfig {
|
|
||||||
cssSupport {
|
|
||||||
enabled.set(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wasm vorerst deaktiviert
|
|
||||||
/*
|
|
||||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
|
||||||
wasmJs {
|
|
||||||
browser()
|
|
||||||
binaries.executable()
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
commonMain {
|
|
||||||
dependencies {
|
|
||||||
implementation(projects.frontend.core.domain)
|
|
||||||
// implementation(projects.frontend.core.designSystem) // REMOVED: Circular dependency
|
|
||||||
implementation(projects.frontend.core.navigation)
|
|
||||||
implementation(projects.frontend.core.network)
|
|
||||||
implementation(projects.frontend.core.localDb)
|
|
||||||
|
|
||||||
// Features - REMOVED: Circular dependency. Shared should NOT depend on features.
|
|
||||||
// implementation(projects.frontend.features.authFeature)
|
|
||||||
// implementation(projects.frontend.features.pingFeature)
|
|
||||||
|
|
||||||
// KMP Bundles
|
|
||||||
implementation(libs.bundles.kmp.common)
|
|
||||||
implementation(libs.bundles.compose.common)
|
|
||||||
|
|
||||||
// Ktor (used directly in shared/di and shared/network)
|
|
||||||
implementation(libs.ktor.client.core)
|
|
||||||
implementation(libs.ktor.client.contentNegotiation)
|
|
||||||
implementation(libs.ktor.client.logging)
|
|
||||||
implementation(libs.ktor.client.serialization.kotlinx.json)
|
|
||||||
|
|
||||||
// Serialization
|
|
||||||
implementation(libs.kotlinx.serialization.json)
|
|
||||||
|
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
|
||||||
// implementation(libs.sqldelight.coroutines) // Wird transitiv über core:localDb geladen
|
|
||||||
|
|
||||||
// Compose
|
|
||||||
implementation(compose.runtime)
|
|
||||||
implementation(compose.foundation)
|
|
||||||
implementation(compose.material3)
|
|
||||||
implementation(compose.ui)
|
|
||||||
implementation(compose.components.resources)
|
|
||||||
implementation(compose.components.uiToolingPreview)
|
|
||||||
|
|
||||||
// Koin
|
|
||||||
implementation(libs.koin.core)
|
|
||||||
implementation(libs.koin.compose)
|
|
||||||
implementation(libs.koin.compose.viewmodel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
commonTest {
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.kotlin.test)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val desktopMain by getting {
|
|
||||||
dependencies {
|
|
||||||
implementation(compose.desktop.currentOs)
|
|
||||||
implementation(libs.kotlinx.coroutines.swing)
|
|
||||||
// implementation(libs.sqldelight.driver.sqlite) // Wird transitiv über core:localDb geladen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val jsMain by getting {
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.ktor.client.js)
|
|
||||||
// implementation(libs.sqldelight.driver.web) // Wird transitiv über core:localDb geladen
|
|
||||||
|
|
||||||
// Webpack Plugin für Federation Support (falls benötigt)
|
|
||||||
implementation(devNpm("copy-webpack-plugin", "12.0.0"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
val wasmJsMain by getting {
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.ktor.client.js)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package at.mocode.shared.core
|
|
||||||
|
|
||||||
data class AppConfig(
|
|
||||||
val gatewayUrl: String,
|
|
||||||
val isDebug: Boolean
|
|
||||||
)
|
|
||||||
|
|
||||||
// Standard-Config für Local Development
|
|
||||||
val devConfig = AppConfig(
|
|
||||||
gatewayUrl = "http://localhost:8081",
|
|
||||||
isDebug = true
|
|
||||||
)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package at.mocode.shared.core
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared application configuration constants for clients.
|
|
||||||
* These defaults target local development environments.
|
|
||||||
*/
|
|
||||||
object AppConstants {
|
|
||||||
// Gateway base URL (reverse proxy / API gateway)
|
|
||||||
// Used by NetworkConfig via PlatformConfig
|
|
||||||
const val GATEWAY_URL: String = "http://localhost:8081"
|
|
||||||
|
|
||||||
// Keycloak configuration
|
|
||||||
const val KEYCLOAK_URL: String = "http://localhost:8180"
|
|
||||||
const val KEYCLOAK_REALM: String = "meldestelle"
|
|
||||||
|
|
||||||
// Use 'postman-client' for Desktop App Password Flow (Direct Access Grants enabled)
|
|
||||||
// 'web-app' is for Browser Flow (PKCE)
|
|
||||||
// TODO: Make this platform-dependent (Desktop vs Web)
|
|
||||||
const val KEYCLOAK_CLIENT_ID: String = "web-app"
|
|
||||||
|
|
||||||
// DEV ONLY: Client Secret for 'postman-client' (Confidential Client)
|
|
||||||
// In Production, this should NEVER be in the frontend code.
|
|
||||||
// For the Desktop App Pilot, we use this to simulate a secure client.
|
|
||||||
// For 'web-app' (Public Client), this is not needed/used if configured correctly,
|
|
||||||
// but our AuthApiClient might be sending it.
|
|
||||||
const val KEYCLOAK_CLIENT_SECRET: String = "postman-secret-123"
|
|
||||||
|
|
||||||
// Removed unused browser flow URLs (registerUrl, loginUrl, etc.) as we focus on Desktop App.
|
|
||||||
}
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
package at.mocode.shared.data.repository
|
|
||||||
|
|
||||||
import at.mocode.shared.domain.model.PingData
|
|
||||||
import at.mocode.shared.domain.model.Resource
|
|
||||||
import at.mocode.shared.domain.repository.PingRepository
|
|
||||||
import io.ktor.client.*
|
|
||||||
import io.ktor.client.call.*
|
|
||||||
import io.ktor.client.request.*
|
|
||||||
|
|
||||||
class PingRepositoryImpl(
|
|
||||||
private val httpClient: HttpClient
|
|
||||||
) : PingRepository {
|
|
||||||
|
|
||||||
override suspend fun checkSystemStatus(): Resource<PingData> {
|
|
||||||
return try {
|
|
||||||
// Der HttpClient hat die BaseURL schon konfiguriert (siehe NetworkModule)
|
|
||||||
val response = httpClient.get("/api/ping/simple").body<PingData>()
|
|
||||||
Resource.Success(response)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Hier fangen wir Netzwerkfehler ab und machen sie "hübsch" für die UI
|
|
||||||
Resource.Error(
|
|
||||||
message = "Verbindung fehlgeschlagen: ${e.message ?: "Unbekannter Fehler"}",
|
|
||||||
code = "NETWORK_ERROR"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package at.mocode.shared.di
|
|
||||||
|
|
||||||
import at.mocode.shared.core.AppConfig
|
|
||||||
import io.ktor.client.*
|
|
||||||
import io.ktor.client.plugins.*
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
|
||||||
import io.ktor.client.plugins.logging.*
|
|
||||||
import io.ktor.http.*
|
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import org.koin.dsl.module
|
|
||||||
|
|
||||||
val networkModule = module {
|
|
||||||
// 1. JSON Konfiguration (Global verfügbar)
|
|
||||||
single {
|
|
||||||
Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
prettyPrint = true
|
|
||||||
isLenient = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. HttpClient (Singleton)
|
|
||||||
single {
|
|
||||||
val config = get<AppConfig>()
|
|
||||||
val jsonConfig = get<Json>()
|
|
||||||
|
|
||||||
HttpClient {
|
|
||||||
// Standard-URL setzen
|
|
||||||
defaultRequest {
|
|
||||||
url(config.gatewayUrl)
|
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
}
|
|
||||||
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(jsonConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
install(Logging) {
|
|
||||||
level = if (config.isDebug) LogLevel.INFO else LogLevel.NONE
|
|
||||||
logger = Logger.DEFAULT
|
|
||||||
}
|
|
||||||
|
|
||||||
install(HttpTimeout) {
|
|
||||||
requestTimeoutMillis = 10000
|
|
||||||
connectTimeoutMillis = 10000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package at.mocode.shared.di
|
|
||||||
|
|
||||||
import at.mocode.shared.core.devConfig
|
|
||||||
import at.mocode.frontend.core.network.networkModule
|
|
||||||
import org.koin.core.context.startKoin
|
|
||||||
import org.koin.dsl.KoinAppDeclaration
|
|
||||||
import org.koin.dsl.module
|
|
||||||
|
|
||||||
// Das Modul für die Config
|
|
||||||
val configModule = module {
|
|
||||||
single { devConfig } // Später können wir hier PROD/DEV umschalten
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basismodule, die immer geladen werden sollen (ohne Feature/Core-Cross-Imports)
|
|
||||||
val baseSharedModules = listOf(
|
|
||||||
configModule,
|
|
||||||
// Network module provides DI-only HttpClient (safe to be shared across features)
|
|
||||||
networkModule
|
|
||||||
)
|
|
||||||
|
|
||||||
// Helper zum Starten von Koin (wird von der App aufgerufen)
|
|
||||||
// Weitere Module (z. B. networkModule) können über appDeclaration hinzugefügt werden.
|
|
||||||
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin {
|
|
||||||
modules(baseSharedModules)
|
|
||||||
appDeclaration()
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package at.mocode.shared.domain.model
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generischer Wrapper für API-Antworten.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class ApiResponse<T>(
|
|
||||||
val success: Boolean,
|
|
||||||
val data: T? = null,
|
|
||||||
val error: ApiError? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ApiError(
|
|
||||||
val code: String,
|
|
||||||
val message: String,
|
|
||||||
val details: Map<String, String> = emptyMap()
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Das Ergebnis eines Repository-Aufrufs.
|
|
||||||
* Die UI kennt nur das hier, keine HTTP-Exceptions!
|
|
||||||
*/
|
|
||||||
sealed class Resource<out T> {
|
|
||||||
data class Success<T>(val data: T) : Resource<T>()
|
|
||||||
data class Error(val message: String, val code: String? = null) : Resource<Nothing>()
|
|
||||||
data object Loading : Resource<Nothing>()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Datenmodell für den Ping.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
data class PingData(
|
|
||||||
val status: String,
|
|
||||||
val timestamp: String,
|
|
||||||
val service: String
|
|
||||||
)
|
|
||||||
|
|
||||||
-8
@@ -1,8 +0,0 @@
|
|||||||
package at.mocode.shared.domain.repository
|
|
||||||
|
|
||||||
import at.mocode.shared.domain.model.PingData
|
|
||||||
import at.mocode.shared.domain.model.Resource
|
|
||||||
|
|
||||||
interface PingRepository {
|
|
||||||
suspend fun checkSystemStatus(): Resource<PingData>
|
|
||||||
}
|
|
||||||
-178
@@ -1,178 +0,0 @@
|
|||||||
package at.mocode.shared.navigation
|
|
||||||
|
|
||||||
import at.mocode.shared.presentation.actions.AppAction
|
|
||||||
import at.mocode.shared.presentation.store.AppStore
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigation manager for handling routing and navigation logic
|
|
||||||
*/
|
|
||||||
class NavigationManager(
|
|
||||||
private val store: AppStore
|
|
||||||
) {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Current route as a flow
|
|
||||||
*/
|
|
||||||
val currentRoute: Flow<String> = store.state.map { it.navigation.currentRoute }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigation history as a flow
|
|
||||||
*/
|
|
||||||
val navigationHistory: Flow<List<String>> = store.state.map { it.navigation.history }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Can go back flag as a flow
|
|
||||||
*/
|
|
||||||
val canGoBack: Flow<Boolean> = store.state.map { it.navigation.canGoBack }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to a specific route
|
|
||||||
*/
|
|
||||||
fun navigateTo(route: String) {
|
|
||||||
store.dispatch(AppAction.Navigation.NavigateTo(route))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate back to the previous route
|
|
||||||
*/
|
|
||||||
fun navigateBack() {
|
|
||||||
store.dispatch(AppAction.Navigation.NavigateBack)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace current route without adding to history
|
|
||||||
*/
|
|
||||||
fun replaceRoute(route: String) {
|
|
||||||
store.dispatch(AppAction.Navigation.UpdateHistory(route))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear navigation history and navigate to the route
|
|
||||||
*/
|
|
||||||
fun navigateAndClearHistory(route: String) {
|
|
||||||
// First clear by replacing with the new route
|
|
||||||
store.dispatch(AppAction.Navigation.UpdateHistory(route))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current route value (non-reactive)
|
|
||||||
*/
|
|
||||||
fun getCurrentRoute(): String = store.state.value.navigation.currentRoute
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if we can navigate back
|
|
||||||
*/
|
|
||||||
fun canNavigateBack(): Boolean = store.state.value.navigation.canGoBack
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route definitions for the application
|
|
||||||
*/
|
|
||||||
object Routes {
|
|
||||||
const val HOME = "/"
|
|
||||||
const val LOGIN = "/login"
|
|
||||||
const val DASHBOARD = "/dashboard"
|
|
||||||
const val PROFILE = "/profile"
|
|
||||||
const val SETTINGS = "/settings"
|
|
||||||
const val PING = "/ping"
|
|
||||||
|
|
||||||
// Auth-related routes
|
|
||||||
object Auth {
|
|
||||||
const val LOGIN = "/auth/login"
|
|
||||||
const val LOGOUT = "/auth/logout"
|
|
||||||
const val REGISTER = "/auth/register"
|
|
||||||
const val FORGOT_PASSWORD = "/auth/forgot-password"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin routes
|
|
||||||
object Admin {
|
|
||||||
const val DASHBOARD = "/admin/dashboard"
|
|
||||||
const val USERS = "/admin/users"
|
|
||||||
const val SETTINGS = "/admin/settings"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Feature routes
|
|
||||||
object Features {
|
|
||||||
const val PING = "/features/ping"
|
|
||||||
const val REPORTS = "/features/reports"
|
|
||||||
const val NOTIFICATIONS = "/features/notifications"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route validation and utilities
|
|
||||||
*/
|
|
||||||
object RouteUtils {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a route requires authentication
|
|
||||||
*/
|
|
||||||
fun requiresAuth(route: String): Boolean {
|
|
||||||
return when {
|
|
||||||
route.startsWith("/auth/") && route != Routes.Auth.LOGIN -> false
|
|
||||||
route == Routes.HOME -> false
|
|
||||||
route == Routes.LOGIN -> false
|
|
||||||
else -> true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a route is for admin only
|
|
||||||
*/
|
|
||||||
fun requiresAdmin(route: String): Boolean {
|
|
||||||
return route.startsWith("/admin/")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default route for authenticated users
|
|
||||||
*/
|
|
||||||
fun getDefaultAuthenticatedRoute(): String = Routes.DASHBOARD
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the default route for unauthenticated users
|
|
||||||
*/
|
|
||||||
fun getDefaultUnauthenticatedRoute(): String = Routes.LOGIN
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate route format
|
|
||||||
*/
|
|
||||||
fun isValidRoute(route: String): Boolean {
|
|
||||||
return route.startsWith("/") && route.isNotBlank()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse route parameters (simple implementation)
|
|
||||||
*/
|
|
||||||
fun parseRouteParams(route: String): Map<String, String> {
|
|
||||||
val params = mutableMapOf<String, String>()
|
|
||||||
|
|
||||||
// Simple query parameter parsing
|
|
||||||
if (route.contains("?")) {
|
|
||||||
val parts = route.split("?")
|
|
||||||
if (parts.size == 2) {
|
|
||||||
val queryParams = parts[1].split("&")
|
|
||||||
queryParams.forEach { param ->
|
|
||||||
val keyValue = param.split("=")
|
|
||||||
if (keyValue.size == 2) {
|
|
||||||
params[keyValue[0]] = keyValue[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get clean route without parameters
|
|
||||||
*/
|
|
||||||
fun getCleanRoute(route: String): String {
|
|
||||||
return if (route.contains("?")) {
|
|
||||||
route.split("?")[0]
|
|
||||||
} else {
|
|
||||||
route
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-75
@@ -1,75 +0,0 @@
|
|||||||
package at.mocode.shared.navigation
|
|
||||||
|
|
||||||
import at.mocode.shared.presentation.state.NavigationState
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface für das Persistieren von Navigation State
|
|
||||||
*/
|
|
||||||
interface NavigationPersistence {
|
|
||||||
suspend fun saveNavigationState(state: NavigationState)
|
|
||||||
fun getNavigationState(): Flow<NavigationState?>
|
|
||||||
suspend fun clearNavigationState()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default implementation ohne echte Persistierung (In-Memory)
|
|
||||||
* Platform-spezifische Implementierungen können echte Persistierung bereitstellen
|
|
||||||
*/
|
|
||||||
class DefaultNavigationPersistence : NavigationPersistence {
|
|
||||||
private var currentState: NavigationState? = null
|
|
||||||
|
|
||||||
override suspend fun saveNavigationState(state: NavigationState) {
|
|
||||||
currentState = state
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getNavigationState(): Flow<NavigationState?> {
|
|
||||||
return flowOf(currentState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun clearNavigationState() {
|
|
||||||
currentState = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigation History Manager mit Persistierung
|
|
||||||
*/
|
|
||||||
class NavigationHistoryManager(
|
|
||||||
private val persistence: NavigationPersistence
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
private const val MAX_HISTORY_SIZE = 50
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun saveRoute(route: String, history: List<String>) {
|
|
||||||
val state = NavigationState(
|
|
||||||
currentRoute = route,
|
|
||||||
history = history.takeLast(MAX_HISTORY_SIZE),
|
|
||||||
canGoBack = history.isNotEmpty()
|
|
||||||
)
|
|
||||||
persistence.saveNavigationState(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getPersistedState() = persistence.getNavigationState()
|
|
||||||
|
|
||||||
suspend fun clear() = persistence.clearNavigationState()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Optimiert die History für bessere Performance
|
|
||||||
*/
|
|
||||||
private fun optimizeHistory(history: List<String>): List<String> {
|
|
||||||
// Entfernt Duplikate in Folge und behält nur die letzten N Einträge
|
|
||||||
return history
|
|
||||||
.fold(emptyList<String>()) { acc, route ->
|
|
||||||
if (acc.lastOrNull() != route) acc + route else acc
|
|
||||||
}
|
|
||||||
.takeLast(MAX_HISTORY_SIZE)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun addToHistory(newRoute: String, currentHistory: List<String>) {
|
|
||||||
val optimizedHistory = optimizeHistory(currentHistory + newRoute)
|
|
||||||
saveRoute(newRoute, optimizedHistory.dropLast(1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package at.mocode.shared.network
|
|
||||||
|
|
||||||
import io.ktor.client.*
|
|
||||||
import io.ktor.client.plugins.contentnegotiation.*
|
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
object HttpClientConfig {
|
|
||||||
|
|
||||||
fun createClient(
|
|
||||||
baseUrl: String = "http://localhost:8080"
|
|
||||||
): HttpClient = HttpClient {
|
|
||||||
|
|
||||||
// Content negotiation with JSON (based on PingApiClient pattern)
|
|
||||||
install(ContentNegotiation) {
|
|
||||||
json(Json {
|
|
||||||
prettyPrint = true
|
|
||||||
isLenient = true
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createClientWithBaseUrl(baseUrl: String): HttpClient {
|
|
||||||
return createClient(baseUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
package at.mocode.shared.network
|
|
||||||
|
|
||||||
import at.mocode.shared.domain.model.ApiError
|
|
||||||
import io.ktor.client.network.sockets.*
|
|
||||||
import io.ktor.client.plugins.*
|
|
||||||
import kotlinx.io.IOException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom exceptions for network operations
|
|
||||||
*/
|
|
||||||
sealed class NetworkException(
|
|
||||||
message: String,
|
|
||||||
cause: Throwable? = null,
|
|
||||||
val apiError: ApiError
|
|
||||||
) : Exception(message, cause) {
|
|
||||||
|
|
||||||
class ConnectionException(
|
|
||||||
message: String = "Connection failed",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "CONNECTION_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf("type" to "network_connectivity")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
class TimeoutException(
|
|
||||||
message: String = "Request timed out",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "TIMEOUT_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf("type" to "request_timeout")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
class ServerException(
|
|
||||||
statusCode: Int,
|
|
||||||
message: String = "Server error",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "SERVER_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf(
|
|
||||||
"type" to "server_error",
|
|
||||||
"status_code" to statusCode.toString()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
class ClientException(
|
|
||||||
statusCode: Int,
|
|
||||||
message: String = "Client error",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "CLIENT_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf(
|
|
||||||
"type" to "client_error",
|
|
||||||
"status_code" to statusCode.toString()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
class AuthenticationException(
|
|
||||||
message: String = "Authentication failed",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "AUTHENTICATION_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf("type" to "authentication_failure")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
class AuthorizationException(
|
|
||||||
message: String = "Authorization failed",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "AUTHORIZATION_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf("type" to "authorization_failure")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
class UnknownException(
|
|
||||||
message: String = "Unknown error occurred",
|
|
||||||
cause: Throwable? = null
|
|
||||||
) : NetworkException(
|
|
||||||
message = message,
|
|
||||||
cause = cause,
|
|
||||||
apiError = ApiError(
|
|
||||||
code = "UNKNOWN_ERROR",
|
|
||||||
message = message,
|
|
||||||
details = mapOf("type" to "unknown_error")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extension function to convert various exceptions to NetworkException
|
|
||||||
*/
|
|
||||||
fun Throwable.toNetworkException(): NetworkException {
|
|
||||||
return when (this) {
|
|
||||||
is ConnectTimeoutException -> NetworkException.TimeoutException(
|
|
||||||
message = "Connection timeout: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
|
|
||||||
is SocketTimeoutException -> NetworkException.TimeoutException(
|
|
||||||
message = "Socket timeout: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
|
|
||||||
is ResponseException -> when (this.response.status.value) {
|
|
||||||
401 -> NetworkException.AuthenticationException(
|
|
||||||
message = "Authentication required",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
|
|
||||||
403 -> NetworkException.AuthorizationException(
|
|
||||||
message = "Access forbidden",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
|
|
||||||
in 400..499 -> NetworkException.ClientException(
|
|
||||||
statusCode = this.response.status.value,
|
|
||||||
message = "Client error: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
|
|
||||||
in 500..599 -> NetworkException.ServerException(
|
|
||||||
statusCode = this.response.status.value,
|
|
||||||
message = "Server error: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> NetworkException.UnknownException(
|
|
||||||
message = "HTTP error: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is IOException -> NetworkException.ConnectionException(
|
|
||||||
message = "Network connection failed: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
|
|
||||||
is NetworkException -> this
|
|
||||||
else -> NetworkException.UnknownException(
|
|
||||||
message = "Unexpected error: ${this.message}",
|
|
||||||
cause = this
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package at.mocode.shared.network
|
|
||||||
|
|
||||||
import at.mocode.shared.domain.model.ApiError
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
// Using platform-agnostic timestamp handling
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple timestamp provider for multiplatform compatibility
|
|
||||||
*/
|
|
||||||
expect fun currentTimeMillis(): Long
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Network utilities for handling retry logic and resilience
|
|
||||||
*/
|
|
||||||
object NetworkUtils {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retry configuration for network operations
|
|
||||||
*/
|
|
||||||
data class RetryConfig(
|
|
||||||
val maxAttempts: Int = 3,
|
|
||||||
val initialDelayMs: Long = 1000L,
|
|
||||||
val maxDelayMs: Long = 10000L,
|
|
||||||
val backoffMultiplier: Double = 2.0,
|
|
||||||
val retryableExceptions: Set<String> = setOf(
|
|
||||||
"CONNECTION_ERROR",
|
|
||||||
"TIMEOUT_ERROR",
|
|
||||||
"SERVER_ERROR"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute operation with retry logic
|
|
||||||
*/
|
|
||||||
suspend fun <T> withRetry(
|
|
||||||
config: RetryConfig = RetryConfig(),
|
|
||||||
operation: suspend () -> RepositoryResult<T>
|
|
||||||
): RepositoryResult<T> {
|
|
||||||
var lastError: ApiError? = null
|
|
||||||
var currentDelay = config.initialDelayMs
|
|
||||||
|
|
||||||
repeat(config.maxAttempts) { attempt ->
|
|
||||||
try {
|
|
||||||
val result = operation()
|
|
||||||
|
|
||||||
// Return success immediately
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the error is retryable
|
|
||||||
val error = result.getErrorOrNull()
|
|
||||||
if (error != null && shouldRetry(error, config)) {
|
|
||||||
lastError = error
|
|
||||||
|
|
||||||
// Don't delay on the last attempt
|
|
||||||
if (attempt < config.maxAttempts - 1) {
|
|
||||||
delay(currentDelay)
|
|
||||||
currentDelay = minOf(
|
|
||||||
(currentDelay * config.backoffMultiplier).toLong(),
|
|
||||||
config.maxDelayMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Non-retryable error, return immediately
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
val networkException = e.toNetworkException()
|
|
||||||
lastError = networkException.apiError
|
|
||||||
|
|
||||||
if (shouldRetry(networkException.apiError, config)) {
|
|
||||||
if (attempt < config.maxAttempts - 1) {
|
|
||||||
delay(currentDelay)
|
|
||||||
currentDelay = minOf(
|
|
||||||
(currentDelay * config.backoffMultiplier).toLong(),
|
|
||||||
config.maxDelayMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return RepositoryResult.Error(networkException.apiError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// All attempts exhausted, return last error
|
|
||||||
return RepositoryResult.Error(
|
|
||||||
lastError ?: ApiError(
|
|
||||||
code = "MAX_RETRIES_EXCEEDED",
|
|
||||||
message = "Maximum retry attempts exceeded"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an error should trigger a retry
|
|
||||||
*/
|
|
||||||
private fun shouldRetry(error: ApiError, config: RetryConfig): Boolean {
|
|
||||||
return config.retryableExceptions.contains(error.code)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Network connectivity checker (simplified for shared module)
|
|
||||||
*/
|
|
||||||
object ConnectivityChecker {
|
|
||||||
private var isOnline: Boolean = true
|
|
||||||
private var lastCheckMillis: Long = 0L
|
|
||||||
|
|
||||||
fun setOnlineStatus(online: Boolean) {
|
|
||||||
isOnline = online
|
|
||||||
lastCheckMillis = currentTimeMillis()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isOnline(): Boolean = isOnline
|
|
||||||
|
|
||||||
fun getLastCheckMillis(): Long = lastCheckMillis
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple connectivity test by attempting a lightweight operation
|
|
||||||
*/
|
|
||||||
suspend fun checkConnectivity(testOperation: suspend () -> Boolean): Boolean {
|
|
||||||
return try {
|
|
||||||
val result = testOperation()
|
|
||||||
setOnlineStatus(result)
|
|
||||||
result
|
|
||||||
} catch (_: Exception) {
|
|
||||||
setOnlineStatus(false)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Circuit breaker pattern for network operations
|
|
||||||
*/
|
|
||||||
class CircuitBreaker(
|
|
||||||
private val failureThreshold: Int = 5,
|
|
||||||
private val recoveryTimeoutMs: Long = 60000L,
|
|
||||||
private val successThreshold: Int = 3
|
|
||||||
) {
|
|
||||||
private enum class State { CLOSED, OPEN, HALF_OPEN }
|
|
||||||
|
|
||||||
private var state = State.CLOSED
|
|
||||||
private var failureCount = 0
|
|
||||||
private var successCount = 0
|
|
||||||
private var lastFailureTime = 0L
|
|
||||||
|
|
||||||
suspend fun <T> execute(operation: suspend () -> RepositoryResult<T>): RepositoryResult<T> {
|
|
||||||
when (state) {
|
|
||||||
State.OPEN -> {
|
|
||||||
if (currentTimeMillis() - lastFailureTime >= recoveryTimeoutMs) {
|
|
||||||
state = State.HALF_OPEN
|
|
||||||
successCount = 0
|
|
||||||
} else {
|
|
||||||
return RepositoryResult.Error(
|
|
||||||
ApiError(
|
|
||||||
code = "CIRCUIT_BREAKER_OPEN",
|
|
||||||
message = "Circuit breaker is open, requests blocked"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
State.HALF_OPEN -> {
|
|
||||||
// Allow limited requests to test recovery
|
|
||||||
}
|
|
||||||
|
|
||||||
State.CLOSED -> {
|
|
||||||
// Normal operation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return try {
|
|
||||||
val result = operation()
|
|
||||||
|
|
||||||
if (result.isSuccess()) {
|
|
||||||
onSuccess()
|
|
||||||
} else {
|
|
||||||
onFailure()
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
} catch (e: Exception) {
|
|
||||||
onFailure()
|
|
||||||
val networkException = e.toNetworkException()
|
|
||||||
RepositoryResult.Error(networkException.apiError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onSuccess() {
|
|
||||||
failureCount = 0
|
|
||||||
|
|
||||||
when (state) {
|
|
||||||
State.HALF_OPEN -> {
|
|
||||||
successCount++
|
|
||||||
if (successCount >= successThreshold) {
|
|
||||||
state = State.CLOSED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
state = State.CLOSED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onFailure() {
|
|
||||||
failureCount++
|
|
||||||
lastFailureTime = currentTimeMillis()
|
|
||||||
|
|
||||||
if (failureCount >= failureThreshold) {
|
|
||||||
state = State.OPEN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getState(): String = state.name
|
|
||||||
fun getFailureCount(): Int = failureCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package at.mocode.shared.network
|
|
||||||
|
|
||||||
import at.mocode.shared.domain.model.ApiError
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Einheitlicher Ergebnis-Typ für Repository-/Netzwerkoperationen.
|
|
||||||
*/
|
|
||||||
sealed class RepositoryResult<out T> {
|
|
||||||
data class Success<T>(val value: T) : RepositoryResult<T>()
|
|
||||||
data class Error(val apiError: ApiError) : RepositoryResult<Nothing>()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> RepositoryResult<T>.isSuccess(): Boolean = this is RepositoryResult.Success
|
|
||||||
|
|
||||||
fun <T> RepositoryResult<T>.getErrorOrNull(): ApiError? = when (this) {
|
|
||||||
is RepositoryResult.Success -> null
|
|
||||||
is RepositoryResult.Error -> this.apiError
|
|
||||||
}
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
package at.mocode.shared.presentation.actions
|
|
||||||
|
|
||||||
import at.mocode.shared.presentation.state.Notification
|
|
||||||
import at.mocode.frontend.core.domain.models.User
|
|
||||||
import at.mocode.frontend.core.domain.models.AuthToken
|
|
||||||
|
|
||||||
sealed class AppAction {
|
|
||||||
// Auth Actions
|
|
||||||
sealed class Auth : AppAction() {
|
|
||||||
data class LoginStart(val username: String, val password: String) : Auth()
|
|
||||||
data class LoginSuccess(val user: User, val token: AuthToken) : Auth()
|
|
||||||
data class LoginFailure(val error: String) : Auth()
|
|
||||||
object Logout : Auth()
|
|
||||||
data class RefreshToken(val newToken: AuthToken) : Auth()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation Actions
|
|
||||||
sealed class Navigation : AppAction() {
|
|
||||||
data class NavigateTo(val route: String) : Navigation()
|
|
||||||
object NavigateBack : Navigation()
|
|
||||||
data class UpdateHistory(val route: String) : Navigation()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI Actions
|
|
||||||
sealed class UI : AppAction() {
|
|
||||||
object ToggleDarkMode : UI()
|
|
||||||
data class SetLoading(val isLoading: Boolean) : UI()
|
|
||||||
data class ShowNotification(val notification: Notification) : UI()
|
|
||||||
data class DismissNotification(val id: String) : UI()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network Actions
|
|
||||||
sealed class Network : AppAction() {
|
|
||||||
data class SetOnlineStatus(val isOnline: Boolean) : Network()
|
|
||||||
data class UpdateLastSync(val timestamp: String) : Network()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package at.mocode.shared.presentation.state
|
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import at.mocode.frontend.core.domain.models.User
|
|
||||||
import at.mocode.frontend.core.domain.models.AuthToken
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AppState(
|
|
||||||
val auth: AuthState = AuthState(),
|
|
||||||
val navigation: NavigationState = NavigationState(),
|
|
||||||
val ui: UiState = UiState(),
|
|
||||||
val network: NetworkState = NetworkState()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class AuthState(
|
|
||||||
val isAuthenticated: Boolean = false,
|
|
||||||
val user: User? = null,
|
|
||||||
val token: AuthToken? = null,
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val error: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class NavigationState(
|
|
||||||
val currentRoute: String = "/",
|
|
||||||
val history: List<String> = emptyList(),
|
|
||||||
val canGoBack: Boolean = false
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class UiState(
|
|
||||||
val isDarkMode: Boolean = false,
|
|
||||||
val isLoading: Boolean = false,
|
|
||||||
val notifications: List<Notification> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class NetworkState(
|
|
||||||
val isOnline: Boolean = true,
|
|
||||||
val lastSync: String? = null
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class Notification(
|
|
||||||
val id: String,
|
|
||||||
val title: String,
|
|
||||||
val message: String,
|
|
||||||
val type: NotificationType = NotificationType.INFO,
|
|
||||||
val timestamp: String
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class NotificationType {
|
|
||||||
INFO, SUCCESS, WARNING, ERROR
|
|
||||||
}
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
package at.mocode.shared.presentation.store
|
|
||||||
|
|
||||||
import at.mocode.shared.presentation.state.AppState
|
|
||||||
import at.mocode.shared.presentation.actions.AppAction
|
|
||||||
import at.mocode.shared.presentation.state.AuthState
|
|
||||||
import at.mocode.shared.presentation.state.NavigationState
|
|
||||||
import at.mocode.shared.presentation.state.NetworkState
|
|
||||||
import at.mocode.shared.presentation.state.UiState
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
import kotlinx.coroutines.flow.*
|
|
||||||
|
|
||||||
class AppStore(
|
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.Main
|
|
||||||
) {
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
|
|
||||||
private val _state = MutableStateFlow(AppState())
|
|
||||||
|
|
||||||
val state: StateFlow<AppState> = _state.asStateFlow()
|
|
||||||
|
|
||||||
fun dispatch(action: AppAction) {
|
|
||||||
scope.launch {
|
|
||||||
val currentState = _state.value
|
|
||||||
val newState = reduce(currentState, action)
|
|
||||||
_state.value = newState
|
|
||||||
|
|
||||||
// Handle side effects
|
|
||||||
handleSideEffect(action, newState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reduce(currentState: AppState, action: AppAction): AppState {
|
|
||||||
return when (action) {
|
|
||||||
is AppAction.Auth -> currentState.copy(
|
|
||||||
auth = reduceAuth(currentState.auth, action)
|
|
||||||
)
|
|
||||||
|
|
||||||
is AppAction.Navigation -> currentState.copy(
|
|
||||||
navigation = reduceNavigation(currentState.navigation, action)
|
|
||||||
)
|
|
||||||
|
|
||||||
is AppAction.UI -> currentState.copy(
|
|
||||||
ui = reduceUI(currentState.ui, action)
|
|
||||||
)
|
|
||||||
|
|
||||||
is AppAction.Network -> currentState.copy(
|
|
||||||
network = reduceNetwork(currentState.network, action)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reduceAuth(currentAuth: AuthState, action: AppAction.Auth): AuthState {
|
|
||||||
return when (action) {
|
|
||||||
is AppAction.Auth.LoginStart -> currentAuth.copy(
|
|
||||||
isLoading = true,
|
|
||||||
error = null
|
|
||||||
)
|
|
||||||
|
|
||||||
is AppAction.Auth.LoginSuccess -> currentAuth.copy(
|
|
||||||
isAuthenticated = true,
|
|
||||||
user = action.user,
|
|
||||||
token = action.token,
|
|
||||||
isLoading = false,
|
|
||||||
error = null
|
|
||||||
)
|
|
||||||
|
|
||||||
is AppAction.Auth.LoginFailure -> currentAuth.copy(
|
|
||||||
isAuthenticated = false,
|
|
||||||
user = null,
|
|
||||||
token = null,
|
|
||||||
isLoading = false,
|
|
||||||
error = action.error
|
|
||||||
)
|
|
||||||
|
|
||||||
is AppAction.Auth.Logout -> AuthState()
|
|
||||||
is AppAction.Auth.RefreshToken -> currentAuth.copy(
|
|
||||||
token = action.newToken
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reduceNavigation(currentNav: NavigationState, action: AppAction.Navigation): NavigationState {
|
|
||||||
return when (action) {
|
|
||||||
is AppAction.Navigation.NavigateTo -> currentNav.copy(
|
|
||||||
currentRoute = action.route,
|
|
||||||
history = currentNav.history + currentNav.currentRoute,
|
|
||||||
canGoBack = true
|
|
||||||
)
|
|
||||||
|
|
||||||
is AppAction.Navigation.NavigateBack -> {
|
|
||||||
val newHistory = currentNav.history.dropLast(1)
|
|
||||||
currentNav.copy(
|
|
||||||
currentRoute = newHistory.lastOrNull() ?: "/",
|
|
||||||
history = newHistory,
|
|
||||||
canGoBack = newHistory.isNotEmpty()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is AppAction.Navigation.UpdateHistory -> currentNav.copy(
|
|
||||||
currentRoute = action.route
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reduceUI(currentUI: UiState, action: AppAction.UI): UiState {
|
|
||||||
return when (action) {
|
|
||||||
is AppAction.UI.ToggleDarkMode -> currentUI.copy(
|
|
||||||
isDarkMode = !currentUI.isDarkMode
|
|
||||||
)
|
|
||||||
|
|
||||||
is AppAction.UI.SetLoading -> currentUI.copy(
|
|
||||||
isLoading = action.isLoading
|
|
||||||
)
|
|
||||||
|
|
||||||
is AppAction.UI.ShowNotification -> currentUI.copy(
|
|
||||||
notifications = currentUI.notifications + action.notification
|
|
||||||
)
|
|
||||||
|
|
||||||
is AppAction.UI.DismissNotification -> currentUI.copy(
|
|
||||||
notifications = currentUI.notifications.filter { it.id != action.id }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun reduceNetwork(currentNetwork: NetworkState, action: AppAction.Network): NetworkState {
|
|
||||||
return when (action) {
|
|
||||||
is AppAction.Network.SetOnlineStatus -> currentNetwork.copy(
|
|
||||||
isOnline = action.isOnline
|
|
||||||
)
|
|
||||||
|
|
||||||
is AppAction.Network.UpdateLastSync -> currentNetwork.copy(
|
|
||||||
lastSync = action.timestamp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun handleSideEffect(action: AppAction, newState: AppState) {
|
|
||||||
when (action) {
|
|
||||||
is AppAction.Auth.LoginSuccess -> {
|
|
||||||
// Auto-save token to local storage
|
|
||||||
// TODO: Implement storage
|
|
||||||
}
|
|
||||||
|
|
||||||
is AppAction.Auth.Logout -> {
|
|
||||||
// Clear local storage
|
|
||||||
// TODO: Implement storage cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> { /* No side effects */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cleanup() {
|
|
||||||
scope.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package at.mocode.shared.network
|
|
||||||
|
|
||||||
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
package at.mocode.shared.network
|
|
||||||
|
|
||||||
import kotlin.js.Date
|
|
||||||
|
|
||||||
actual fun currentTimeMillis(): Long = Date().getTime().toLong()
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package at.mocode.shared.network
|
|
||||||
|
|
||||||
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Multi-stage Dockerfile for Meldestelle API Gateway
|
# Multi-stage Dockerfile for Meldestelle API Gateway
|
||||||
# Features: Security hardening, monitoring support, optimal caching, BuildKit cache mounts
|
# Features: Security hardening, monitoring support, optimal caching, BuildKit cache mounts
|
||||||
# Version: 2.2.2 - Optimized for Monorepo (Fixed frontend paths after refactoring)
|
# Version: 2.6.0 - Reliable Monorepo Build
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|
||||||
# === CENTRALIZED BUILD ARGUMENTS ===
|
# === CENTRALIZED BUILD ARGUMENTS ===
|
||||||
# HINWEIS: gradle:X.Y-jdkZ-alpine Images existieren nicht für alle Gradle/JDK-Kombinationen.
|
ARG GRADLE_VERSION=9.4.1
|
||||||
# Wir verwenden eclipse-temurin als Builder-Basis und das Projekt-eigene ./gradlew-Wrapper.
|
ARG JAVA_VERSION=25
|
||||||
ARG JAVA_VERSION=21
|
|
||||||
ARG BUILD_DATE
|
ARG BUILD_DATE
|
||||||
ARG VERSION
|
ARG VERSION=1.0.0-SNAPSHOT
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Build Stage
|
# Build Stage
|
||||||
@@ -19,9 +18,9 @@ FROM eclipse-temurin:${JAVA_VERSION}-jdk-alpine AS builder
|
|||||||
ARG VERSION
|
ARG VERSION
|
||||||
ARG BUILD_DATE
|
ARG BUILD_DATE
|
||||||
|
|
||||||
LABEL stage=builder
|
LABEL stage=builder \
|
||||||
LABEL service="api-gateway"
|
service="api-gateway" \
|
||||||
LABEL maintainer="Meldestelle Development Team"
|
maintainer="Meldestelle Development Team"
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
@@ -35,62 +34,21 @@ ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
|
|||||||
-XX:MaxMetaspaceSize=512m"
|
-XX:MaxMetaspaceSize=512m"
|
||||||
ENV GRADLE_USER_HOME=/root/.gradle
|
ENV GRADLE_USER_HOME=/root/.gradle
|
||||||
|
|
||||||
# Copy gradle wrapper and configuration files
|
# 1. Copy full project structure for a reliable monorepo build
|
||||||
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
|
# .dockerignore should be used to exclude unnecessary files (IDE, logs, etc.)
|
||||||
COPY gradle/ gradle/
|
COPY . .
|
||||||
|
|
||||||
RUN chmod +x gradlew
|
RUN chmod +x gradlew
|
||||||
|
|
||||||
# Copy platform and core dependencies
|
# 2. Build the service
|
||||||
COPY platform/ platform/
|
RUN --mount=type=cache,target=/root/.gradle/caches \
|
||||||
COPY core/ core/
|
--mount=type=cache,target=/root/.gradle/wrapper \
|
||||||
|
./gradlew :backend:infrastructure:gateway:bootJar --no-daemon --info
|
||||||
|
|
||||||
# Copy backend directories
|
# 3. Extract layers
|
||||||
COPY backend/infrastructure/ backend/infrastructure/
|
WORKDIR /builder
|
||||||
COPY backend/services/ backend/services/
|
RUN cp /workspace/backend/infrastructure/gateway/build/libs/*.jar app.jar && \
|
||||||
COPY contracts/ contracts/
|
java -Djarmode=layertools -jar app.jar extract
|
||||||
|
|
||||||
# Create dummy frontend directories to satisfy settings.gradle.kts include paths
|
|
||||||
# This prevents Gradle from failing configuration phase without copying actual frontend code
|
|
||||||
RUN mkdir -p \
|
|
||||||
frontend/core/auth \
|
|
||||||
frontend/core/domain \
|
|
||||||
frontend/core/design-system \
|
|
||||||
frontend/core/navigation \
|
|
||||||
frontend/core/network \
|
|
||||||
frontend/core/local-db \
|
|
||||||
frontend/core/sync \
|
|
||||||
frontend/shared \
|
|
||||||
frontend/shells/meldestelle-portal \
|
|
||||||
frontend/shells/meldestelle-desktop \
|
|
||||||
frontend/features/ping-feature \
|
|
||||||
frontend/features/nennung-feature \
|
|
||||||
frontend/features/zns-import-feature \
|
|
||||||
frontend/features/billing-feature \
|
|
||||||
frontend/features/pferde-feature \
|
|
||||||
frontend/features/verein-feature \
|
|
||||||
frontend/features/veranstaltung-feature \
|
|
||||||
frontend/features/veranstalter-feature \
|
|
||||||
frontend/features/profile-feature \
|
|
||||||
frontend/features/reiter-feature \
|
|
||||||
frontend/features/turnier-feature \
|
|
||||||
docs
|
|
||||||
|
|
||||||
# Copy root build configuration
|
|
||||||
COPY build.gradle.kts ./
|
|
||||||
|
|
||||||
# Download and cache dependencies
|
|
||||||
RUN --mount=type=cache,id=gradle-cache-gateway,target=/root/.gradle/caches \
|
|
||||||
--mount=type=cache,id=gradle-wrapper-gateway,target=/root/.gradle/wrapper \
|
|
||||||
./gradlew :backend:infrastructure:gateway:dependencies --info
|
|
||||||
# Build the application
|
|
||||||
RUN --mount=type=cache,id=gradle-cache-gateway,target=/root/.gradle/caches \
|
|
||||||
--mount=type=cache,id=gradle-wrapper-gateway,target=/root/.gradle/wrapper \
|
|
||||||
./gradlew :backend:infrastructure:gateway:bootJar --info
|
|
||||||
|
|
||||||
# Extract JAR layers
|
|
||||||
RUN mkdir -p build/dependency && \
|
|
||||||
(cd build/dependency; java -Djarmode=layertools -jar /workspace/backend/infrastructure/gateway/build/libs/*.jar extract)
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Runtime Stage
|
# Runtime Stage
|
||||||
@@ -101,19 +59,15 @@ ARG BUILD_DATE
|
|||||||
ARG VERSION
|
ARG VERSION
|
||||||
ARG JAVA_VERSION
|
ARG JAVA_VERSION
|
||||||
|
|
||||||
ENV JAVA_VERSION=${JAVA_VERSION} \
|
|
||||||
VERSION=${VERSION} \
|
|
||||||
BUILD_DATE=${BUILD_DATE}
|
|
||||||
|
|
||||||
LABEL service="api-gateway" \
|
LABEL service="api-gateway" \
|
||||||
version="${VERSION}" \
|
version="${VERSION}" \
|
||||||
description="Spring Cloud Gateway for Meldestelle microservices architecture" \
|
description="Microservice for API Gateway and Routing" \
|
||||||
maintainer="Meldestelle Development Team" \
|
maintainer="Meldestelle Development Team" \
|
||||||
org.opencontainers.image.title="Meldestelle API Gateway" \
|
java.version="${JAVA_VERSION}" \
|
||||||
org.opencontainers.image.created="${BUILD_DATE}"
|
build.date="${BUILD_DATE}"
|
||||||
|
|
||||||
ARG APP_USER=gateway
|
ARG APP_USER=appuser
|
||||||
ARG APP_GROUP=gateway
|
ARG APP_GROUP=appgroup
|
||||||
ARG APP_UID=1001
|
ARG APP_UID=1001
|
||||||
ARG APP_GID=1001
|
ARG APP_GID=1001
|
||||||
|
|
||||||
@@ -121,21 +75,18 @@ WORKDIR /app
|
|||||||
|
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk upgrade && \
|
apk upgrade && \
|
||||||
apk add --no-cache \
|
apk add --no-cache curl tzdata tini && \
|
||||||
curl \
|
rm -rf /var/cache/apk/* && addgroup -g ${APP_GID} -S ${APP_GROUP} && \
|
||||||
tzdata \
|
|
||||||
tini && \
|
|
||||||
rm -rf /var/cache/apk/* && \
|
|
||||||
addgroup -g ${APP_GID} -S ${APP_GROUP} && \
|
|
||||||
adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh && \
|
adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh && \
|
||||||
mkdir -p /app/logs /app/tmp /app/config && \
|
mkdir -p /app/logs /app/tmp /app/config && \
|
||||||
chown -R ${APP_USER}:${APP_GROUP} /app && \
|
chown -R ${APP_USER}:${APP_GROUP} /app && \
|
||||||
chmod -R 750 /app
|
chmod -R 750 /app
|
||||||
|
|
||||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /workspace/build/dependency/dependencies/ ./
|
# Copy Spring Boot layers
|
||||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /workspace/build/dependency/spring-boot-loader/ ./
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./
|
||||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /workspace/build/dependency/snapshot-dependencies/ ./
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./
|
||||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /workspace/build/dependency/application/ ./
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./
|
||||||
|
|
||||||
USER ${APP_USER}
|
USER ${APP_USER}
|
||||||
|
|
||||||
@@ -144,7 +95,7 @@ EXPOSE 8081 5005
|
|||||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
|
||||||
CMD curl -fsS --max-time 2 http://localhost:8081/actuator/health/readiness || exit 1
|
CMD curl -fsS --max-time 2 http://localhost:8081/actuator/health/readiness || exit 1
|
||||||
|
|
||||||
ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 \
|
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
|
||||||
-XX:+UseG1GC \
|
-XX:+UseG1GC \
|
||||||
-XX:+UseStringDeduplication \
|
-XX:+UseStringDeduplication \
|
||||||
-XX:+UseContainerSupport \
|
-XX:+UseContainerSupport \
|
||||||
@@ -158,25 +109,19 @@ ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 \
|
|||||||
-Dfile.encoding=UTF-8 \
|
-Dfile.encoding=UTF-8 \
|
||||||
-Duser.timezone=Europe/Vienna \
|
-Duser.timezone=Europe/Vienna \
|
||||||
-Dspring.backgroundpreinitializer.ignore=true \
|
-Dspring.backgroundpreinitializer.ignore=true \
|
||||||
-Dmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus,gateway \
|
-Dmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus \
|
||||||
-Dmanagement.endpoint.health.show-details=always \
|
-Dmanagement.endpoint.health.show-details=always \
|
||||||
-Dmanagement.prometheus.metrics.export.enabled=true"
|
-Dmanagement.prometheus.metrics.export.enabled=true"
|
||||||
|
|
||||||
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
|
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
|
||||||
SERVER_PORT=8081 \
|
SERVER_PORT=8081 \
|
||||||
LOGGING_LEVEL_ROOT=INFO \
|
LOGGING_LEVEL_ROOT=INFO
|
||||||
LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_CLOUD_GATEWAY=DEBUG
|
|
||||||
|
|
||||||
ENTRYPOINT ["tini", "--", "sh", "-c", "\
|
ENTRYPOINT ["tini", "--", "sh", "-c", "\
|
||||||
echo 'Starting API Gateway with Java ${JAVA_VERSION}...'; \
|
echo 'Starting API Gateway with Java ${JAVA_VERSION}...'; \
|
||||||
echo 'Active Spring profiles: '${SPRING_PROFILES_ACTIVE:-not-set}; \
|
|
||||||
echo 'Gateway port: ${SERVER_PORT}'; \
|
|
||||||
MEMORY_LIMIT=$(cat /sys/fs/cgroup/memory.max 2>/dev/null || cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null || echo 'unlimited'); \
|
|
||||||
echo \"Container memory limit: $MEMORY_LIMIT\"; \
|
|
||||||
if [ \"${DEBUG:-false}\" = \"true\" ]; then \
|
if [ \"${DEBUG:-false}\" = \"true\" ]; then \
|
||||||
echo 'DEBUG mode enabled - remote debugging available on port 5005'; \
|
echo 'DEBUG mode enabled'; \
|
||||||
exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.launch.JarLauncher; \
|
exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.launch.JarLauncher; \
|
||||||
else \
|
else \
|
||||||
echo 'Starting API Gateway in production mode'; \
|
|
||||||
exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher; \
|
exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher; \
|
||||||
fi"]
|
fi"]
|
||||||
|
|||||||
+19
-14
@@ -1,25 +1,30 @@
|
|||||||
package at.mocode.infrastructure.gateway
|
package at.mocode.infrastructure.gateway
|
||||||
|
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.getBean
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
|
import org.springframework.context.event.EventListener
|
||||||
import org.springframework.core.env.Environment
|
import org.springframework.core.env.Environment
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
class GatewayApplication
|
class GatewayApplication(private val env: Environment) {
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger(GatewayApplication::class.java)
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent::class)
|
||||||
|
fun onApplicationReady() {
|
||||||
|
val springPort = env.getProperty("server.port", "8081")
|
||||||
|
val appName = env.getProperty("spring.application.name", "gateway")
|
||||||
|
|
||||||
|
log.info("----------------------------------------------------------")
|
||||||
|
log.info("Application '{}' is running!", appName)
|
||||||
|
log.info("Spring Management Port: {}", springPort)
|
||||||
|
log.info("Profiles: {}", env.activeProfiles.joinToString(", "))
|
||||||
|
log.info("----------------------------------------------------------")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
val context = runApplication<GatewayApplication>(*args)
|
runApplication<GatewayApplication>(*args)
|
||||||
val logger = LoggerFactory.getLogger(GatewayApplication::class.java)
|
|
||||||
val env = context.getBean<Environment>()
|
|
||||||
val port = env.getProperty("server.port") ?: "8081"
|
|
||||||
|
|
||||||
logger.info("""
|
|
||||||
----------------------------------------------------------
|
|
||||||
Application 'Gateway' is running!
|
|
||||||
Port: $port
|
|
||||||
Profiles: ${env.activeProfiles.joinToString(", ").ifEmpty { "default" }}
|
|
||||||
----------------------------------------------------------
|
|
||||||
""".trimIndent())
|
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-7
@@ -1,6 +1,5 @@
|
|||||||
package at.mocode.infrastructure.gateway.config
|
package at.mocode.infrastructure.gateway.config
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value
|
|
||||||
import org.springframework.cloud.gateway.route.RouteLocator
|
import org.springframework.cloud.gateway.route.RouteLocator
|
||||||
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder
|
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder
|
||||||
import org.springframework.cloud.gateway.route.builder.filters
|
import org.springframework.cloud.gateway.route.builder.filters
|
||||||
@@ -9,10 +8,7 @@ import org.springframework.context.annotation.Bean
|
|||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
class GatewayConfig(
|
class GatewayConfig {
|
||||||
@Value("\${ping.service.url:http://localhost:8082}") private val pingServiceUrl: String,
|
|
||||||
@Value("\${zns.import.service.url:http://localhost:8095}") private val znsImportServiceUrl: String
|
|
||||||
) {
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun customRouteLocator(builder: RouteLocatorBuilder): RouteLocator {
|
fun customRouteLocator(builder: RouteLocatorBuilder): RouteLocator {
|
||||||
@@ -26,11 +22,31 @@ class GatewayConfig(
|
|||||||
it.fallbackUri = java.net.URI.create("forward:/fallback/ping")
|
it.fallbackUri = java.net.URI.create("forward:/fallback/ping")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
uri(pingServiceUrl)
|
uri("lb://ping-service")
|
||||||
|
}
|
||||||
|
route(id = "masterdata-service") {
|
||||||
|
path("/api/v1/masterdata/**")
|
||||||
|
uri("lb://masterdata-service")
|
||||||
|
}
|
||||||
|
route(id = "events-service") {
|
||||||
|
path("/api/v1/events/**")
|
||||||
|
uri("lb://events-service")
|
||||||
}
|
}
|
||||||
route(id = "zns-import-service") {
|
route(id = "zns-import-service") {
|
||||||
path("/api/v1/import/zns/**", "/api/v1/import/zns")
|
path("/api/v1/import/zns/**", "/api/v1/import/zns")
|
||||||
uri(znsImportServiceUrl)
|
uri("lb://zns-import-service")
|
||||||
|
}
|
||||||
|
route(id = "results-service") {
|
||||||
|
path("/api/v1/results/**")
|
||||||
|
uri("lb://results-service")
|
||||||
|
}
|
||||||
|
route(id = "series-service") {
|
||||||
|
path("/api/v1/series/**")
|
||||||
|
uri("lb://series-service")
|
||||||
|
}
|
||||||
|
route(id = "billing-service") {
|
||||||
|
path("/api/v1/billing/**")
|
||||||
|
uri("lb://billing-service")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-10
@@ -11,9 +11,8 @@ import org.springframework.security.authentication.AbstractAuthenticationToken
|
|||||||
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
|
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity
|
||||||
import org.springframework.security.config.web.server.ServerHttpSecurity
|
import org.springframework.security.config.web.server.ServerHttpSecurity
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
import org.springframework.security.oauth2.jwt.Jwt
|
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator
|
||||||
import org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder
|
import org.springframework.security.oauth2.jwt.*
|
||||||
import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder
|
|
||||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
||||||
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter
|
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter
|
||||||
import org.springframework.security.web.server.SecurityWebFilterChain
|
import org.springframework.security.web.server.SecurityWebFilterChain
|
||||||
@@ -38,7 +37,6 @@ class SecurityConfig(
|
|||||||
.authorizeExchange { exchanges ->
|
.authorizeExchange { exchanges ->
|
||||||
exchanges
|
exchanges
|
||||||
.pathMatchers(*securityProperties.publicPaths.toTypedArray()).permitAll()
|
.pathMatchers(*securityProperties.publicPaths.toTypedArray()).permitAll()
|
||||||
.pathMatchers("/api/ping/**").permitAll() // TEMPORAER fuer Debugging
|
|
||||||
.pathMatchers("/api/v1/import/zns", "/api/v1/import/zns/**").permitAll() // TEMPORAER fuer Debugging
|
.pathMatchers("/api/v1/import/zns", "/api/v1/import/zns/**").permitAll() // TEMPORAER fuer Debugging
|
||||||
.anyExchange().authenticated()
|
.anyExchange().authenticated()
|
||||||
}
|
}
|
||||||
@@ -67,16 +65,28 @@ class SecurityConfig(
|
|||||||
if (delegate == null) {
|
if (delegate == null) {
|
||||||
if (jwkSetUri.isBlank()) {
|
if (jwkSetUri.isBlank()) {
|
||||||
logger.error("JWK Set URI is missing – all authenticated requests will be rejected.")
|
logger.error("JWK Set URI is missing – all authenticated requests will be rejected.")
|
||||||
return Mono.error(org.springframework.security.oauth2.jwt.BadJwtException("Identity Provider not configured"))
|
return Mono.error(BadJwtException("Identity Provider not configured"))
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
logger.info("Attempting to initialize JWT Decoder with URI: {}", jwkSetUri)
|
logger.info("Attempting to initialize JWT Decoder with URI: {}", jwkSetUri)
|
||||||
delegate = NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build()
|
// Wir deaktivieren die Issuer-Validierung, da Keycloak intern "keycloak:8080"
|
||||||
logger.info("JWT Decoder successfully initialized.")
|
// und extern "localhost:8180" verwendet, was zu Mismatches führt.
|
||||||
|
val nimbusDecoder = NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri).build()
|
||||||
|
nimbusDecoder.setJwtValidator(JwtValidators.createDefault()) // Standard-Validierung (ohne Issuer-Zwang falls nicht explizit konfiguriert)
|
||||||
|
|
||||||
|
// Da createDefault() den Issuer-Check einbaut, wenn spring.security.oauth2.resourceserver.jwt.issuer-uri gesetzt ist,
|
||||||
|
// nutzen wir einen Custom Validator der den Issuer ignoriert oder flexibel ist.
|
||||||
|
val withAudience = DelegatingOAuth2TokenValidator<Jwt>(
|
||||||
|
JwtTimestampValidator(),
|
||||||
|
// Hier koennte man weitere Validatoren hinzufuegen, aber wir lassen den Issuer weg
|
||||||
|
)
|
||||||
|
nimbusDecoder.setJwtValidator(withAudience)
|
||||||
|
|
||||||
|
delegate = nimbusDecoder
|
||||||
|
logger.info("JWT Decoder successfully initialized (Issuer check disabled for environment flexibility).")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn("Could not initialize JWT Decoder: {}", e.message)
|
logger.warn("Could not initialize JWT Decoder: {}", e.message)
|
||||||
// Throw BadJwtException so Spring Security returns 401, not 500 or passthrough
|
return Mono.error(BadJwtException("Identity Provider unavailable: ${e.message}"))
|
||||||
return Mono.error(org.springframework.security.oauth2.jwt.BadJwtException("Identity Provider unavailable: ${e.message}"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,7 +117,7 @@ class SecurityConfig(
|
|||||||
val configuration = CorsConfiguration().apply {
|
val configuration = CorsConfiguration().apply {
|
||||||
allowedOriginPatterns = securityProperties.cors.allowedOriginPatterns.toList()
|
allowedOriginPatterns = securityProperties.cors.allowedOriginPatterns.toList()
|
||||||
allowedMethods = securityProperties.cors.allowedMethods.toList()
|
allowedMethods = securityProperties.cors.allowedMethods.toList()
|
||||||
allowedHeaders = securityProperties.cors.allowedHeaders.toList()
|
allowedHeaders = listOf("*") // Alles erlauben fuer Postman/Frontend
|
||||||
exposedHeaders = securityProperties.cors.exposedHeaders.toList()
|
exposedHeaders = securityProperties.cors.exposedHeaders.toList()
|
||||||
allowCredentials = securityProperties.cors.allowCredentials
|
allowCredentials = securityProperties.cors.allowCredentials
|
||||||
maxAge = securityProperties.cors.maxAge.seconds
|
maxAge = securityProperties.cors.maxAge.seconds
|
||||||
|
|||||||
@@ -20,14 +20,18 @@ spring:
|
|||||||
host: ${SPRING_CLOUD_CONSUL_HOST:localhost}
|
host: ${SPRING_CLOUD_CONSUL_HOST:localhost}
|
||||||
port: ${SPRING_CLOUD_CONSUL_PORT:8500}
|
port: ${SPRING_CLOUD_CONSUL_PORT:8500}
|
||||||
discovery:
|
discovery:
|
||||||
|
enabled: true
|
||||||
register: true
|
register: true
|
||||||
|
prefer-ip-address: true
|
||||||
|
health-check-path: /actuator/health
|
||||||
|
health-check-interval: 10s
|
||||||
|
health-check-port: 8081
|
||||||
|
instance-id: ${spring.application.name}:${server.port}:${random.uuid}
|
||||||
service-name: ${spring.application.name}
|
service-name: ${spring.application.name}
|
||||||
# Bei lokalem Start (Gradle) wollen wir nicht die Docker-IP registrieren, sondern localhost oder die Host-IP.
|
|
||||||
# Aber für den Anfang reicht es, wenn wir Consul finden.
|
|
||||||
|
|
||||||
gateway:
|
gateway:
|
||||||
httpclient: { }
|
httpclient: { }
|
||||||
# Routen sind in GatewayConfig.kt definiert
|
# Routen sind in GatewayConfig.kt via Service-Discovery (lb://) definiert
|
||||||
|
|
||||||
# --- SECURITY (OAuth2 Resource Server) ---
|
# --- SECURITY (OAuth2 Resource Server) ---
|
||||||
security:
|
security:
|
||||||
@@ -40,6 +44,27 @@ spring:
|
|||||||
issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://localhost:8180/realms/meldestelle}
|
issuer-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI:http://localhost:8180/realms/meldestelle}
|
||||||
jwk-set-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:8180/realms/meldestelle/protocol/openid-connect/certs}
|
jwk-set-uri: ${SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:8180/realms/meldestelle/protocol/openid-connect/certs}
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
security:
|
||||||
|
cors:
|
||||||
|
allowed-origin-patterns:
|
||||||
|
- "http://localhost:*"
|
||||||
|
- "https://*.meldestelle.at"
|
||||||
|
- "https://*.mo-code.at"
|
||||||
|
- "https://*.postman.co"
|
||||||
|
- "postman://*"
|
||||||
|
allowed-methods:
|
||||||
|
- "GET"
|
||||||
|
- "POST"
|
||||||
|
- "PUT"
|
||||||
|
- "DELETE"
|
||||||
|
- "OPTIONS"
|
||||||
|
- "PATCH"
|
||||||
|
allowed-headers:
|
||||||
|
- "*"
|
||||||
|
allow-credentials: true
|
||||||
|
max-age: 3600s
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
web:
|
web:
|
||||||
@@ -62,9 +87,3 @@ management:
|
|||||||
# Lokal: Zipkin auf Port 9411. In Docker via ENV MANAGEMENT_ZIPKIN_TRACING_ENDPOINT überschrieben.
|
# Lokal: Zipkin auf Port 9411. In Docker via ENV MANAGEMENT_ZIPKIN_TRACING_ENDPOINT überschrieben.
|
||||||
endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans}
|
endpoint: ${MANAGEMENT_ZIPKIN_TRACING_ENDPOINT:http://localhost:9411/api/v2/spans}
|
||||||
|
|
||||||
# --- Custom Service URLs ---
|
|
||||||
# Default: Localhost (für Entwicklung ohne Docker)
|
|
||||||
# Im Docker-Compose überschreiben wir das mit dem Service-Namen
|
|
||||||
ping:
|
|
||||||
service:
|
|
||||||
url: ${PING_SERVICE_URL:http://localhost:8082}
|
|
||||||
|
|||||||
+95
-1
@@ -8,7 +8,7 @@
|
|||||||
"variable": [
|
"variable": [
|
||||||
{
|
{
|
||||||
"key": "baseUrl",
|
"key": "baseUrl",
|
||||||
"value": "http://localhost:8080",
|
"value": "http://localhost:8081",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -221,6 +221,100 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Connectivity Context (Ping Service)",
|
||||||
|
"item": [
|
||||||
|
{
|
||||||
|
"name": "Simple Ping",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/ping/simple",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "ping", "simple"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Health Check",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/ping/health",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "ping", "health"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Public Info",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/ping/public",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "ping", "public"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Enhanced Ping (Resilience)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/ping/enhanced",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "ping", "enhanced"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sync Delta Diagnostic",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/ping/sync?lastSyncTimestamp=0",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "ping", "sync"],
|
||||||
|
"query": [
|
||||||
|
{
|
||||||
|
"key": "lastSyncTimestamp",
|
||||||
|
"value": "0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Secure Ping (Login Required)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Authorization",
|
||||||
|
"value": "Bearer {{authToken}}"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"url": {
|
||||||
|
"raw": "{{baseUrl}}/api/ping/secure",
|
||||||
|
"host": ["{{baseUrl}}"],
|
||||||
|
"path": ["api", "ping", "secure"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"response": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Master Data Context",
|
"name": "Master Data Context",
|
||||||
"item": [
|
"item": [
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ dependencies {
|
|||||||
|
|
||||||
// Web (for CORS config)
|
// Web (for CORS config)
|
||||||
implementation(libs.spring.web)
|
implementation(libs.spring.web)
|
||||||
|
implementation(libs.spring.boot.starter.web)
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation(projects.platform.platformTesting)
|
testImplementation(projects.platform.platformTesting)
|
||||||
|
|||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package at.mocode.infrastructure.security
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain
|
||||||
|
import jakarta.servlet.http.HttpServletRequest
|
||||||
|
import jakarta.servlet.http.HttpServletResponse
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter zur Authentifizierung von Desktop-Clients via Security Key.
|
||||||
|
* Dieser Filter ist für die Offline-First-Synchronisation gedacht.
|
||||||
|
*
|
||||||
|
* Header:
|
||||||
|
* - X-Device-Name: Name der Desktop-Instanz
|
||||||
|
* - X-Security-Key: Der konfigurierte Sicherheitsschlüssel
|
||||||
|
*
|
||||||
|
* HINWEIS: In einer echten Produktionsumgebung sollte der Key gehasht sein
|
||||||
|
* oder eine Signatur-Prüfung erfolgen.
|
||||||
|
*/
|
||||||
|
class DeviceSecurityFilter : OncePerRequestFilter() {
|
||||||
|
|
||||||
|
override fun doFilterInternal(
|
||||||
|
request: HttpServletRequest,
|
||||||
|
response: HttpServletResponse,
|
||||||
|
filterChain: FilterChain
|
||||||
|
) {
|
||||||
|
val deviceName = request.getHeader("X-Device-Name")
|
||||||
|
val securityKey = request.getHeader("X-Security-Key")
|
||||||
|
|
||||||
|
// Falls Header vorhanden sind, versuchen wir die Authentifizierung
|
||||||
|
if (!deviceName.isNullOrBlank() && !securityKey.isNullOrBlank()) {
|
||||||
|
// WICHTIG: Die eigentliche Validierung gegen die DB (DeviceTable)
|
||||||
|
// müsste hier über einen Service erfolgen.
|
||||||
|
// Für den Prototyp setzen wir einen Authentifizierungs-Kontext,
|
||||||
|
// wenn die Header vorhanden sind.
|
||||||
|
|
||||||
|
val auth = UsernamePasswordAuthenticationToken(
|
||||||
|
deviceName,
|
||||||
|
null,
|
||||||
|
listOf(SimpleGrantedAuthority("ROLE_DEVICE"))
|
||||||
|
)
|
||||||
|
SecurityContextHolder.getContext().authentication = auth
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
-6
@@ -6,8 +6,17 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
|
|||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy
|
import org.springframework.security.config.http.SessionCreationPolicy
|
||||||
|
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtTimestampValidator
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
|
||||||
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
|
||||||
import org.springframework.security.web.SecurityFilterChain
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
|
||||||
|
import org.springframework.web.cors.CorsConfiguration
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -18,17 +27,18 @@ class GlobalSecurityConfig {
|
|||||||
fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||||
http
|
http
|
||||||
.csrf { it.disable() } // CSRF nicht nötig für Stateless REST APIs
|
.csrf { it.disable() } // CSRF nicht nötig für Stateless REST APIs
|
||||||
// WICHTIG: CORS explizit deaktivieren!
|
// WICHTIG: CORS wieder aktivieren für Plan-B (Direktzugriff ohne Gateway möglich)
|
||||||
// Das API-Gateway kümmert sich um CORS. Die Microservices dürfen KEINE
|
.cors { it.configurationSource(corsConfigurationSource()) }
|
||||||
// Access-Control-Allow-Origin Header setzen, sonst haben wir doppelte Header beim Client.
|
|
||||||
.cors { it.disable() }
|
|
||||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||||
|
.addFilterBefore(DeviceSecurityFilter(), UsernamePasswordAuthenticationFilter::class.java)
|
||||||
.authorizeHttpRequests { auth ->
|
.authorizeHttpRequests { auth ->
|
||||||
// Explizite Freigaben (Health, Info, Public Endpoints)
|
// Explizite Freigaben (Health, Information, Public-Endpoints)
|
||||||
auth.requestMatchers("/actuator/**").permitAll()
|
auth.requestMatchers("/actuator/**").permitAll()
|
||||||
|
auth.requestMatchers("/api/v1/devices/register").permitAll() // Onboarding erlauben
|
||||||
|
auth.requestMatchers("/api/mail/nennung").permitAll() // Plan-B Nennungen erlauben
|
||||||
|
auth.requestMatchers("/api/mail/nennungen").authenticated() // Liste schützen
|
||||||
auth.requestMatchers("/ping/public").permitAll()
|
auth.requestMatchers("/ping/public").permitAll()
|
||||||
auth.requestMatchers("/ping/simple").permitAll()
|
auth.requestMatchers("/ping/simple").permitAll()
|
||||||
auth.requestMatchers("/ping/enhanced").permitAll()
|
|
||||||
auth.requestMatchers("/ping/health").permitAll()
|
auth.requestMatchers("/ping/health").permitAll()
|
||||||
auth.requestMatchers("/error").permitAll()
|
auth.requestMatchers("/error").permitAll()
|
||||||
|
|
||||||
@@ -38,16 +48,48 @@ class GlobalSecurityConfig {
|
|||||||
.oauth2ResourceServer { oauth2 ->
|
.oauth2ResourceServer { oauth2 ->
|
||||||
oauth2.jwt { jwt ->
|
oauth2.jwt { jwt ->
|
||||||
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
|
||||||
|
// Auch hier den Issuer-Check entspannen, da der Service intern validiert
|
||||||
|
jwt.decoder(jwtDecoder())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return http.build()
|
return http.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun jwtDecoder(): JwtDecoder {
|
||||||
|
// 1. Suche in System-Properties (Spring injects these)
|
||||||
|
// 2. Suche in Environment Variables
|
||||||
|
// 3. Fallback auf localhost (IDE-Start) oder keycloak (Docker-Start)
|
||||||
|
val jwkSetUri = System.getProperty("spring.security.oauth2.resourceserver.jwt.jwk-set-uri")
|
||||||
|
?: System.getenv("SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI")
|
||||||
|
?: "http://localhost:8180/realms/meldestelle/protocol/openid-connect/certs"
|
||||||
|
|
||||||
|
val decoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build()
|
||||||
|
val validator = DelegatingOAuth2TokenValidator<Jwt>(JwtTimestampValidator())
|
||||||
|
decoder.setJwtValidator(validator)
|
||||||
|
return decoder
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
|
fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
|
||||||
val converter = JwtAuthenticationConverter()
|
val converter = JwtAuthenticationConverter()
|
||||||
converter.setJwtGrantedAuthoritiesConverter(KeycloakRoleConverter())
|
converter.setJwtGrantedAuthoritiesConverter(KeycloakRoleConverter())
|
||||||
return converter
|
return converter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun corsConfigurationSource(): CorsConfigurationSource {
|
||||||
|
val configuration = CorsConfiguration()
|
||||||
|
configuration.allowedOrigins = listOf("*")
|
||||||
|
configuration.allowedOriginPatterns = listOf("*")
|
||||||
|
configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD")
|
||||||
|
configuration.allowedHeaders = listOf("*")
|
||||||
|
configuration.exposedHeaders = listOf("*")
|
||||||
|
configuration.maxAge = 3600L
|
||||||
|
configuration.allowCredentials = false
|
||||||
|
val source = UrlBasedCorsConfigurationSource()
|
||||||
|
source.registerCorsConfiguration("/**", configuration)
|
||||||
|
return source
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package at.mocode.zns.importer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Modus des ZNS-Imports.
|
||||||
|
*
|
||||||
|
* [FULL] - Alle Dateien (Vereine, Reiter, Pferde, Funktionäre) werden importiert.
|
||||||
|
* [LIGHT] - Nur Stammdaten (Vereine, Reiter) werden importiert (Performance-Optimiert).
|
||||||
|
*/
|
||||||
|
enum class ZnsImportMode {
|
||||||
|
FULL,
|
||||||
|
LIGHT
|
||||||
|
}
|
||||||
+159
-29
@@ -19,10 +19,10 @@ import java.util.zip.ZipInputStream
|
|||||||
* Domänenobjekte über die jeweiligen Repositories (Upsert-Logik).
|
* Domänenobjekte über die jeweiligen Repositories (Upsert-Logik).
|
||||||
*
|
*
|
||||||
* Die Verarbeitungsreihenfolge ist fix:
|
* Die Verarbeitungsreihenfolge ist fix:
|
||||||
* 1. VEREIN01.DAT → Verein (via VereinRepository)
|
* 1. VEREIN01.DAT → Verein (via VereinRepository)
|
||||||
* 2. LIZENZ01.DAT → Reiter (via ReiterRepository)
|
* 2. LIZENZ01.DAT → Reiter (via ReiterRepository)
|
||||||
* 3. PFERDE01.DAT → Pferd (via HorseRepository)
|
* 3. PFERDE01.DAT → Pferd (via HorseRepository)
|
||||||
* 4. RICHT01.DAT → Funktionaer (via FunktionaerRepository)
|
* 4. RICHT01.DAT → Funktionär (via FunktionaerRepository)
|
||||||
*
|
*
|
||||||
* Dieser Service hat **keine** Spring-Abhängigkeit und kann daher sowohl
|
* Dieser Service hat **keine** Spring-Abhängigkeit und kann daher sowohl
|
||||||
* im Backend (REST-Upload) als auch in der Compose Desktop App (Offline-Import)
|
* im Backend (REST-Upload) als auch in der Compose Desktop App (Offline-Import)
|
||||||
@@ -47,14 +47,15 @@ class ZnsImportService(
|
|||||||
companion object {
|
companion object {
|
||||||
private val CP850 = Charset.forName("Cp850")
|
private val CP850 = Charset.forName("Cp850")
|
||||||
|
|
||||||
private const val FILE_VEREIN = "VEREIN01.DAT"
|
private const val FILE_VEREIN = "VEREIN"
|
||||||
private const val FILE_LIZENZ = "LIZENZ01.DAT"
|
private const val FILE_LIZENZ = "LIZENZ"
|
||||||
private const val FILE_PFERDE = "PFERDE01.DAT"
|
private const val FILE_PFERDE = "PFERDE"
|
||||||
private const val FILE_RICHT = "RICHT01.DAT"
|
private const val FILE_RICHT = "RICHT"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extrahiert die relevanten Dateien aus dem ZIP-Archiv.
|
* Extrahiert die relevanten Dateien aus dem ZIP-Archiv.
|
||||||
|
* Optimiert: Nutzt BufferedReader für zeilenweises Einlesen, ohne das gesamte File in den RAM zu laden.
|
||||||
*/
|
*/
|
||||||
fun extrahiereDateien(zipInputStream: InputStream): Map<String, List<String>> {
|
fun extrahiereDateien(zipInputStream: InputStream): Map<String, List<String>> {
|
||||||
val dateien = mutableMapOf<String, List<String>>()
|
val dateien = mutableMapOf<String, List<String>>()
|
||||||
@@ -64,47 +65,168 @@ class ZnsImportService(
|
|||||||
while (entry != null) {
|
while (entry != null) {
|
||||||
val fileName = entry.name.uppercase().substringAfterLast("/")
|
val fileName = entry.name.uppercase().substringAfterLast("/")
|
||||||
|
|
||||||
if (fileName in setOf(FILE_VEREIN, FILE_LIZENZ, FILE_PFERDE, FILE_RICHT)) {
|
// Toleranter Check: Erkennt VEREIN01.DAT, VEREIN.DAT, etc.
|
||||||
val outputStream = java.io.ByteArrayOutputStream()
|
val targetKey = when {
|
||||||
val buffer = ByteArray(4096)
|
fileName.startsWith(FILE_VEREIN) -> FILE_VEREIN
|
||||||
var len: Int
|
fileName.startsWith(FILE_LIZENZ) -> FILE_LIZENZ
|
||||||
while (zip.read(buffer).also { len = it } > 0) {
|
fileName.startsWith(FILE_PFERDE) -> FILE_PFERDE
|
||||||
outputStream.write(buffer, 0, len)
|
fileName.startsWith(FILE_RICHT) -> FILE_RICHT
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetKey != null && fileName.endsWith(".DAT")) {
|
||||||
|
// Wir lesen den Stream direkt zeilenweise mit dem korrekten Encoding
|
||||||
|
val lines = mutableListOf<String>()
|
||||||
|
val reader = zip.bufferedReader(CP850)
|
||||||
|
|
||||||
|
// WICHTIG: Wir dürfen den Reader NICHT schließen (use), da sonst der ZipInputStream geschlossen wird!
|
||||||
|
var line = reader.readLine()
|
||||||
|
while (line != null) {
|
||||||
|
if (line.isNotBlank()) {
|
||||||
|
lines.add(line)
|
||||||
|
}
|
||||||
|
line = reader.readLine()
|
||||||
}
|
}
|
||||||
val content = outputStream.toString(CP850)
|
println("[DEBUG_LOG] Datei $fileName extrahiert als $targetKey: ${lines.size} Zeilen")
|
||||||
val lines = content.split(Regex("\\r?\\n|\\r")).filter { it.isNotBlank() }
|
dateien[targetKey] = lines
|
||||||
dateien[fileName] = lines
|
|
||||||
}
|
}
|
||||||
zip.closeEntry()
|
zip.closeEntry()
|
||||||
entry = zip.nextEntry
|
entry = zip.nextEntry
|
||||||
}
|
}
|
||||||
} finally {
|
} catch (e: Exception) {
|
||||||
// Wir schließen den ZipInputStream NICHT mit use,
|
println("[DEBUG_LOG] Fehler beim Extrahieren der ZIP (eventuell keine ZIP-Datei?): ${e.message}")
|
||||||
// um den zugrunde liegenden zipInputStream nicht vorzeitig zu schließen.
|
|
||||||
// Falls der Aufrufer den Stream schließen will, soll er das tun.
|
|
||||||
// Aber wir müssen sicherstellen, dass wir alle Entries gelesen haben.
|
|
||||||
}
|
}
|
||||||
return dateien
|
return dateien
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importiert ZNS-Daten aus einem Stream. Erkennt automatisch, ob es eine ZIP oder eine DAT ist.
|
||||||
|
*/
|
||||||
|
suspend fun importiereStream(
|
||||||
|
inputStream: InputStream,
|
||||||
|
fileName: String,
|
||||||
|
mode: ZnsImportMode = ZnsImportMode.FULL
|
||||||
|
): ZnsImportResult {
|
||||||
|
val upperName = fileName.uppercase()
|
||||||
|
return if (upperName.endsWith(".ZIP")) {
|
||||||
|
importiereZip(inputStream, mode)
|
||||||
|
} else if (upperName.endsWith(".DAT")) {
|
||||||
|
importiereEinzelDatei(inputStream, upperName, mode)
|
||||||
|
} else {
|
||||||
|
ZnsImportResult(fehler = listOf("Dateiformat nicht unterstützt: $fileName"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun importiereEinzelDatei(
|
||||||
|
inputStream: InputStream,
|
||||||
|
fileName: String,
|
||||||
|
mode: ZnsImportMode
|
||||||
|
): ZnsImportResult {
|
||||||
|
println("[DEBUG_LOG] Importiere Einzeldatei: $fileName")
|
||||||
|
val lines = inputStream.bufferedReader(CP850).readLines().filter { it.isNotBlank() }
|
||||||
|
println("[DEBUG_LOG] Einzeldatei $fileName hat ${lines.size} Zeilen")
|
||||||
|
|
||||||
|
val fehler = mutableListOf<String>()
|
||||||
|
val warnungen = mutableListOf<String>()
|
||||||
|
|
||||||
|
var vereineImportiert = 0
|
||||||
|
var vereineAktualisiert = 0
|
||||||
|
var reiterImportiert = 0
|
||||||
|
var reiterAktualisiert = 0
|
||||||
|
var pferdeImportiert = 0
|
||||||
|
var pferdeAktualisiert = 0
|
||||||
|
var richterImportiert = 0
|
||||||
|
var richterAktualisiert = 0
|
||||||
|
|
||||||
|
when {
|
||||||
|
fileName.startsWith(FILE_VEREIN) -> {
|
||||||
|
val (n, u) = importiereVereine(lines, fehler)
|
||||||
|
vereineImportiert = n
|
||||||
|
vereineAktualisiert = u
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName.startsWith(FILE_LIZENZ) -> {
|
||||||
|
val (n, u) = importiereReiter(lines, fehler, warnungen)
|
||||||
|
reiterImportiert = n
|
||||||
|
reiterAktualisiert = u
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName.startsWith(FILE_PFERDE) -> {
|
||||||
|
if (mode == ZnsImportMode.FULL) {
|
||||||
|
val (n, u) = importierePferde(lines, fehler)
|
||||||
|
pferdeImportiert = n
|
||||||
|
pferdeAktualisiert = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName.startsWith(FILE_RICHT) -> {
|
||||||
|
if (mode == ZnsImportMode.FULL) {
|
||||||
|
val (n, u) = importiereFunktionaere(lines, fehler, warnungen)
|
||||||
|
richterImportiert = n
|
||||||
|
richterAktualisiert = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> fehler.add("Unbekannte DAT-Datei: $fileName")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ZnsImportResult(
|
||||||
|
vereineImportiert = vereineImportiert,
|
||||||
|
vereineAktualisiert = vereineAktualisiert,
|
||||||
|
reiterImportiert = reiterImportiert,
|
||||||
|
reiterAktualisiert = reiterAktualisiert,
|
||||||
|
pferdeImportiert = pferdeImportiert,
|
||||||
|
pferdeAktualisiert = pferdeAktualisiert,
|
||||||
|
richterImportiert = richterImportiert,
|
||||||
|
richterAktualisiert = richterAktualisiert,
|
||||||
|
fehler = fehler,
|
||||||
|
warnungen = warnungen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Importiert eine ZNS-ZIP-Datei aus einem [InputStream].
|
* Importiert eine ZNS-ZIP-Datei aus einem [InputStream].
|
||||||
*
|
*
|
||||||
* @param zipInputStream Der InputStream der ZIP-Datei.
|
* @param zipInputStream Der InputStream der ZIP-Datei.
|
||||||
|
* @param mode Der [ZnsImportMode] (Standard: [ZnsImportMode.FULL]).
|
||||||
* @return [ZnsImportResult] mit Statistiken und eventuellen Fehlern.
|
* @return [ZnsImportResult] mit Statistiken und eventuellen Fehlern.
|
||||||
*/
|
*/
|
||||||
suspend fun importiereZip(zipInputStream: InputStream): ZnsImportResult {
|
suspend fun importiereZip(
|
||||||
|
zipInputStream: InputStream,
|
||||||
|
mode: ZnsImportMode = ZnsImportMode.FULL
|
||||||
|
): ZnsImportResult {
|
||||||
val dateien = extrahiereDateien(zipInputStream)
|
val dateien = extrahiereDateien(zipInputStream)
|
||||||
// println("[DEBUG_LOG] Gefundene Dateien: ${dateien.keys}")
|
println("[DEBUG_LOG] Gefundene Dateien im ZIP: ${dateien.keys}")
|
||||||
// dateien.forEach { (name, lines) -> println("[DEBUG_LOG] Datei $name hat ${lines.size} Zeilen") }
|
dateien.forEach { (name, lines) -> println("[DEBUG_LOG] Datei $name hat ${lines.size} Zeilen") }
|
||||||
|
|
||||||
val fehler = mutableListOf<String>()
|
val fehler = mutableListOf<String>()
|
||||||
val warnungen = mutableListOf<String>()
|
val warnungen = mutableListOf<String>()
|
||||||
|
|
||||||
val (vereineNeu, vereineUpd) = importiereVereine(dateien[FILE_VEREIN] ?: emptyList(), fehler)
|
val (vereineNeu, vereineUpd) = importiereVereine(dateien[FILE_VEREIN] ?: emptyList(), fehler)
|
||||||
val (reiterNeu, reiterUpd) = importiereReiter(dateien[FILE_LIZENZ] ?: emptyList(), fehler, warnungen)
|
val (reiterNeu, reiterUpd) = importiereReiter(dateien[FILE_LIZENZ] ?: emptyList(), fehler, warnungen)
|
||||||
val (pferdeNeu, pferdeUpd) = importierePferde(dateien[FILE_PFERDE] ?: emptyList(), fehler)
|
|
||||||
val (richterNeu, richterUpd) = importiereFunktionaere(dateien[FILE_RICHT] ?: emptyList(), fehler, warnungen)
|
var pferdeNeu = 0
|
||||||
|
var pferdeUpd = 0
|
||||||
|
var richterNeu = 0
|
||||||
|
var richterUpd = 0
|
||||||
|
|
||||||
|
if (mode == ZnsImportMode.FULL) {
|
||||||
|
val (pNeu, pUpd) = importierePferde(dateien[FILE_PFERDE] ?: emptyList(), fehler)
|
||||||
|
pferdeNeu = pNeu
|
||||||
|
pferdeUpd = pUpd
|
||||||
|
|
||||||
|
val (rNeu, rUpd) = importiereFunktionaere(dateien[FILE_RICHT] ?: emptyList(), fehler, warnungen)
|
||||||
|
richterNeu = rNeu
|
||||||
|
richterUpd = rUpd
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zusätzliche Warnung wenn Dateien fehlen
|
||||||
|
if (dateien[FILE_VEREIN] == null) warnungen.add("Vereinsdaten (VEREIN*.DAT) nicht gefunden.")
|
||||||
|
if (dateien[FILE_LIZENZ] == null) warnungen.add("Reiter/Lizenzdaten (LIZENZ*.DAT) nicht gefunden.")
|
||||||
|
if (mode == ZnsImportMode.FULL) {
|
||||||
|
if (dateien[FILE_PFERDE] == null) warnungen.add("Pferdedaten (PFERDE*.DAT) nicht gefunden.")
|
||||||
|
if (dateien[FILE_RICHT] == null) warnungen.add("Funktionärsdaten (RICHT*.DAT) nicht gefunden.")
|
||||||
|
}
|
||||||
|
|
||||||
return ZnsImportResult(
|
return ZnsImportResult(
|
||||||
vereineImportiert = vereineNeu,
|
vereineImportiert = vereineNeu,
|
||||||
@@ -132,7 +254,11 @@ class ZnsImportService(
|
|||||||
var aktualisiert = 0
|
var aktualisiert = 0
|
||||||
zeilen.forEachIndexed { index, zeile ->
|
zeilen.forEachIndexed { index, zeile ->
|
||||||
runCatching {
|
runCatching {
|
||||||
val verein = ZnsVereinParser.parse(zeile) ?: return@forEachIndexed
|
val verein = ZnsVereinParser.parse(zeile)
|
||||||
|
if (verein == null) {
|
||||||
|
if (index < 5) println("[DEBUG_LOG] Parser lieferte null für Zeile ${index + 1}: '$zeile'")
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
val vorhanden = vereinRepository.findByVereinsNummer(verein.vereinsNummer)
|
val vorhanden = vereinRepository.findByVereinsNummer(verein.vereinsNummer)
|
||||||
if (vorhanden == null) {
|
if (vorhanden == null) {
|
||||||
vereinRepository.save(verein)
|
vereinRepository.save(verein)
|
||||||
@@ -167,7 +293,11 @@ class ZnsImportService(
|
|||||||
var aktualisiert = 0
|
var aktualisiert = 0
|
||||||
zeilen.forEachIndexed { index, zeile ->
|
zeilen.forEachIndexed { index, zeile ->
|
||||||
runCatching {
|
runCatching {
|
||||||
val parsed = ZnsReiterParser.parse(zeile) ?: return@forEachIndexed
|
val parsed = ZnsReiterParser.parse(zeile)
|
||||||
|
if (parsed == null) {
|
||||||
|
if (index < 5) println("[DEBUG_LOG] Reiter-Parser lieferte null für Zeile ${index + 1}: '$zeile'")
|
||||||
|
return@forEachIndexed
|
||||||
|
}
|
||||||
|
|
||||||
// Relationen auflösen
|
// Relationen auflösen
|
||||||
val verein = parsed.vereinsName?.let { vereinRepository.findByExactName(it) }
|
val verein = parsed.vereinsName?.let { vereinRepository.findByExactName(it) }
|
||||||
|
|||||||
+48
@@ -306,4 +306,52 @@ class ZnsImportServiceTest {
|
|||||||
assertThat(result.gesamtAktualisiert).isEqualTo(0)
|
assertThat(result.gesamtAktualisiert).isEqualTo(0)
|
||||||
assertThat(result.fehler).isEmpty()
|
assertThat(result.fehler).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `importiereZip - Funktionaer mit mehrfachen Qualifikationen`() = runTest {
|
||||||
|
// Zeile mit vielen Qualifikationen (Satznummer X014346)
|
||||||
|
val qualifikationen = "DM,DPF,GAR-SP,SPF,SS*,RD,RS"
|
||||||
|
val zeile = "X014346Schubert Renate $qualifikationen"
|
||||||
|
val zip = buildZip("RICHT01.DAT" to zeile)
|
||||||
|
|
||||||
|
coEvery { funktionaerRepository.findBySatz("X", 14346) } returns null
|
||||||
|
coEvery { funktionaerRepository.save(any()) } answers { firstArg<Funktionaer>() }
|
||||||
|
coEvery { reiterRepository.findByName(any(), any()) } returns emptyList()
|
||||||
|
|
||||||
|
val result = service.importiereZip(zip)
|
||||||
|
|
||||||
|
assertThat(result.richterImportiert).isEqualTo(1)
|
||||||
|
coVerify {
|
||||||
|
funktionaerRepository.save(match { f ->
|
||||||
|
f.qualifikationen.size == 7 &&
|
||||||
|
f.qualifikationen.containsAll(listOf("DM", "DPF", "GAR-SP", "SPF", "SS*", "RD", "RS"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `importiereZip - Funktionaer Update Strategie (Delete+Insert)`() = runTest {
|
||||||
|
val zeile = funktionaerZeile(typ = "X", satznummer = "123456", name = "Geaendert Name")
|
||||||
|
val zip = buildZip("RICHT01.DAT" to zeile)
|
||||||
|
|
||||||
|
val existing = Funktionaer(
|
||||||
|
funktionaerId = kotlin.uuid.Uuid.random(),
|
||||||
|
satzId = "X",
|
||||||
|
satzNummer = 123456,
|
||||||
|
name = "Alt Name"
|
||||||
|
)
|
||||||
|
|
||||||
|
coEvery { funktionaerRepository.findBySatz("X", 123456) } returns existing
|
||||||
|
coEvery { funktionaerRepository.save(any()) } answers { firstArg<Funktionaer>() }
|
||||||
|
coEvery { reiterRepository.findByName(any(), any()) } returns emptyList()
|
||||||
|
|
||||||
|
val result = service.importiereZip(zip)
|
||||||
|
|
||||||
|
assertThat(result.richterAktualisiert).isEqualTo(1)
|
||||||
|
coVerify {
|
||||||
|
funktionaerRepository.save(match { f ->
|
||||||
|
f.funktionaerId == existing.funktionaerId && f.name == "Geaendert Name"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
@file:OptIn(ExperimentalWasmDsl::class)
|
||||||
|
|
||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
|
alias(libs.plugins.kotlinSerialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvm()
|
||||||
|
wasmJs {
|
||||||
|
browser()
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
implementation(projects.core.coreDomain)
|
||||||
|
implementation(projects.core.coreUtils)
|
||||||
|
implementation(libs.kotlinx.datetime)
|
||||||
|
}
|
||||||
|
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
jvmTest.dependencies {
|
||||||
|
implementation(projects.platform.platformTesting)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
@file:OptIn(ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.billing.domain.model
|
||||||
|
|
||||||
|
import at.mocode.core.domain.serialization.InstantSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repräsentiert das Kassa-Konto eines Teilnehmers (Reiter oder Besitzer).
|
||||||
|
* Ein Konto wird pro Veranstaltung/Turnier geführt, kann aber veranstaltungsübergreifend aggregiert werden.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TeilnehmerKonto constructor(
|
||||||
|
val kontoId: Uuid = Uuid.random(),
|
||||||
|
val veranstaltungId: Uuid,
|
||||||
|
val personId: Uuid, // Referenz auf Reiter oder Besitzer
|
||||||
|
val personName: String,
|
||||||
|
val saldoCent: Long = 0L, // Aktueller Kontostand in Cent
|
||||||
|
val bemerkungen: String? = null,
|
||||||
|
@Serializable(with = InstantSerializer::class)
|
||||||
|
val updatedAt: Instant = Clock.System.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein einzelner Buchungsvorgang (Zahlung, Gutschrift, Gebühr).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Buchung constructor(
|
||||||
|
val buchungId: Uuid = Uuid.random(),
|
||||||
|
val kontoId: Uuid,
|
||||||
|
val betragCent: Long, // Positiv für Gutschrift/Zahlung, Negativ für Gebühr/Soll
|
||||||
|
val typ: BuchungsTyp,
|
||||||
|
val verwendungszweck: String,
|
||||||
|
@Serializable(with = InstantSerializer::class)
|
||||||
|
val gebuchtAm: Instant = Clock.System.now(),
|
||||||
|
val storniertBuchungId: Uuid? = null // Referenz auf die ursprüngliche Buchung, falls dies ein Storno ist
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repräsentiert einen Kassa-Tagesabschluss.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Tagesabschluss(
|
||||||
|
val tagesabschlussId: Uuid = Uuid.random(),
|
||||||
|
val veranstaltungId: Uuid,
|
||||||
|
val abgeschlossenAm: Instant = Clock.System.now(),
|
||||||
|
val abgeschlossenVon: String,
|
||||||
|
val summeBarCent: Long,
|
||||||
|
val summeKarteCent: Long,
|
||||||
|
val summeGutschriftCent: Long,
|
||||||
|
val anzahlBuchungen: Int,
|
||||||
|
val bemerkungen: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class BuchungsTyp {
|
||||||
|
NENNGEBUEHR,
|
||||||
|
NENNGELD,
|
||||||
|
NACHNENNGEBUEHR,
|
||||||
|
STARTGEBUEHR,
|
||||||
|
BOXENGEBUEHR,
|
||||||
|
SPORTFOERDERBEITRAG,
|
||||||
|
ZAHLUNG_BAR,
|
||||||
|
ZAHLUNG_KARTE,
|
||||||
|
GUTSCHRIFT,
|
||||||
|
STORNIERUNG
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
@file:OptIn(ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.billing.domain.repository
|
||||||
|
|
||||||
|
import at.mocode.billing.domain.model.Buchung
|
||||||
|
import at.mocode.billing.domain.model.Tagesabschluss
|
||||||
|
import at.mocode.billing.domain.model.TeilnehmerKonto
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository für den Zugriff auf Teilnehmer-Konten.
|
||||||
|
*/
|
||||||
|
interface TeilnehmerKontoRepository {
|
||||||
|
fun findByVeranstaltungAndPerson(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto?
|
||||||
|
fun findById(kontoId: Uuid): TeilnehmerKonto?
|
||||||
|
fun findByVeranstaltung(veranstaltungId: Uuid): List<TeilnehmerKonto>
|
||||||
|
fun findOffenePosten(veranstaltungId: Uuid): List<TeilnehmerKonto>
|
||||||
|
fun save(konto: TeilnehmerKonto): TeilnehmerKonto
|
||||||
|
fun updateSaldo(kontoId: Uuid, saldoCent: Long): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository für den Zugriff auf Buchungen.
|
||||||
|
*/
|
||||||
|
interface BuchungRepository {
|
||||||
|
fun findByKonto(kontoId: Uuid): List<Buchung>
|
||||||
|
fun findById(buchungId: Uuid): Buchung?
|
||||||
|
fun findByVeranstaltungAndZeitraum(
|
||||||
|
veranstaltungId: Uuid,
|
||||||
|
von: Instant,
|
||||||
|
bis: Instant
|
||||||
|
): List<Buchung>
|
||||||
|
fun save(buchung: Buchung): Buchung
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository für den Zugriff auf Tagesabschlüsse.
|
||||||
|
*/
|
||||||
|
interface TagesabschlussRepository {
|
||||||
|
fun findByVeranstaltung(veranstaltungId: Uuid): List<Tagesabschluss>
|
||||||
|
fun save(abschluss: Tagesabschluss): Tagesabschluss
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# ===================================================================
|
||||||
|
# Multi-stage Dockerfile for Meldestelle Billing Service
|
||||||
|
# Features: Security hardening, monitoring support, optimal caching, BuildKit cache mounts
|
||||||
|
# Version: 2.6.0 - Reliable Monorepo Build
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
# === CENTRALIZED BUILD ARGUMENTS ===
|
||||||
|
ARG GRADLE_VERSION=9.4.1
|
||||||
|
ARG JAVA_VERSION=25
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG VERSION=1.0.0-SNAPSHOT
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Build Stage
|
||||||
|
# ===================================================================
|
||||||
|
FROM eclipse-temurin:${JAVA_VERSION}-jdk-alpine AS builder
|
||||||
|
|
||||||
|
ARG VERSION
|
||||||
|
ARG BUILD_DATE
|
||||||
|
|
||||||
|
LABEL stage=builder \
|
||||||
|
service="billing-service" \
|
||||||
|
maintainer="Meldestelle Development Team"
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Gradle optimizations
|
||||||
|
ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
|
||||||
|
-Dorg.gradle.daemon=false \
|
||||||
|
-Dorg.gradle.parallel=true \
|
||||||
|
-Dorg.gradle.workers.max=2 \
|
||||||
|
-Dorg.gradle.jvmargs=-Xmx2g \
|
||||||
|
-XX:+UseParallelGC \
|
||||||
|
-XX:MaxMetaspaceSize=512m"
|
||||||
|
ENV GRADLE_USER_HOME=/root/.gradle
|
||||||
|
|
||||||
|
# 1. Copy full project structure for a reliable monorepo build
|
||||||
|
# .dockerignore should be used to exclude unnecessary files (IDE, logs, etc.)
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN chmod +x gradlew
|
||||||
|
|
||||||
|
# 2. Build the service
|
||||||
|
RUN --mount=type=cache,target=/root/.gradle/caches \
|
||||||
|
--mount=type=cache,target=/root/.gradle/wrapper \
|
||||||
|
./gradlew :backend:services:billing:billing-service:bootJar --no-daemon --info
|
||||||
|
|
||||||
|
# 3. Extract layers
|
||||||
|
WORKDIR /builder
|
||||||
|
RUN cp /workspace/backend/services/billing/billing-service/build/libs/*.jar app.jar && \
|
||||||
|
java -Djarmode=layertools -jar app.jar extract
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Runtime Stage
|
||||||
|
# ===================================================================
|
||||||
|
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime
|
||||||
|
|
||||||
|
ARG BUILD_DATE
|
||||||
|
ARG VERSION
|
||||||
|
ARG JAVA_VERSION
|
||||||
|
|
||||||
|
LABEL service="billing-service" \
|
||||||
|
version="${VERSION}" \
|
||||||
|
description="Microservice for Billing and Payments" \
|
||||||
|
maintainer="Meldestelle Development Team" \
|
||||||
|
java.version="${JAVA_VERSION}" \
|
||||||
|
build.date="${BUILD_DATE}"
|
||||||
|
|
||||||
|
ARG APP_USER=appuser
|
||||||
|
ARG APP_GROUP=appgroup
|
||||||
|
ARG APP_UID=1001
|
||||||
|
ARG APP_GID=1001
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk update && \
|
||||||
|
apk upgrade && \
|
||||||
|
apk add --no-cache curl tzdata tini && \
|
||||||
|
rm -rf /var/cache/apk/* && addgroup -g ${APP_GID} -S ${APP_GROUP} && \
|
||||||
|
adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh && \
|
||||||
|
mkdir -p /app/logs /app/tmp /app/config && \
|
||||||
|
chown -R ${APP_USER}:${APP_GROUP} /app && \
|
||||||
|
chmod -R 750 /app
|
||||||
|
|
||||||
|
# Copy Spring Boot layers
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./
|
||||||
|
|
||||||
|
USER ${APP_USER}
|
||||||
|
|
||||||
|
EXPOSE 8087 5005
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -fsS --max-time 2 http://localhost:8087/actuator/health/readiness || exit 1
|
||||||
|
|
||||||
|
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
|
||||||
|
-XX:+UseG1GC \
|
||||||
|
-XX:+UseStringDeduplication \
|
||||||
|
-XX:+UseContainerSupport \
|
||||||
|
-XX:G1HeapRegionSize=16m \
|
||||||
|
-XX:G1ReservePercent=25 \
|
||||||
|
-XX:InitiatingHeapOccupancyPercent=30 \
|
||||||
|
-XX:+AlwaysPreTouch \
|
||||||
|
-XX:+DisableExplicitGC \
|
||||||
|
-Djava.security.egd=file:/dev/./urandom \
|
||||||
|
-Djava.awt.headless=true \
|
||||||
|
-Dfile.encoding=UTF-8 \
|
||||||
|
-Duser.timezone=Europe/Vienna \
|
||||||
|
-Dspring.backgroundpreinitializer.ignore=true \
|
||||||
|
-Dmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus \
|
||||||
|
-Dmanagement.endpoint.health.show-details=always \
|
||||||
|
-Dmanagement.prometheus.metrics.export.enabled=true"
|
||||||
|
|
||||||
|
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
|
||||||
|
SERVER_PORT=8087 \
|
||||||
|
LOGGING_LEVEL_ROOT=INFO
|
||||||
|
|
||||||
|
ENTRYPOINT ["tini", "--", "sh", "-c", "\
|
||||||
|
echo 'Starting Billing Service with Java ${JAVA_VERSION}...'; \
|
||||||
|
if [ \"${DEBUG:-false}\" = \"true\" ]; then \
|
||||||
|
echo 'DEBUG mode enabled'; \
|
||||||
|
exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.launch.JarLauncher; \
|
||||||
|
else \
|
||||||
|
exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher; \
|
||||||
|
fi"]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlinJvm)
|
||||||
|
alias(libs.plugins.spring.boot)
|
||||||
|
alias(libs.plugins.spring.dependencyManagement)
|
||||||
|
alias(libs.plugins.kotlinSpring)
|
||||||
|
}
|
||||||
|
|
||||||
|
springBoot {
|
||||||
|
mainClass.set("at.mocode.billing.service.BillingServiceApplicationKt")
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Interne Module
|
||||||
|
implementation(projects.platform.platformDependencies)
|
||||||
|
implementation(projects.core.coreUtils)
|
||||||
|
implementation(projects.core.coreDomain)
|
||||||
|
implementation(projects.backend.services.billing.billingDomain)
|
||||||
|
|
||||||
|
// Spring Boot Starters
|
||||||
|
implementation(libs.spring.boot.starter.web)
|
||||||
|
implementation(libs.spring.boot.starter.validation)
|
||||||
|
implementation(libs.spring.boot.starter.actuator)
|
||||||
|
implementation(libs.jackson.module.kotlin)
|
||||||
|
implementation(libs.openpdf)
|
||||||
|
implementation(libs.spring.cloud.starter.consul.discovery)
|
||||||
|
implementation(libs.micrometer.tracing.bridge.brave)
|
||||||
|
implementation(libs.zipkin.reporter.brave)
|
||||||
|
implementation(libs.zipkin.sender.okhttp3)
|
||||||
|
|
||||||
|
// Datenbank-Abhängigkeiten
|
||||||
|
implementation(libs.exposed.core)
|
||||||
|
implementation(libs.exposed.dao)
|
||||||
|
implementation(libs.exposed.jdbc)
|
||||||
|
implementation(libs.exposed.kotlin.datetime)
|
||||||
|
implementation(libs.hikari.cp)
|
||||||
|
runtimeOnly(libs.postgresql.driver)
|
||||||
|
testRuntimeOnly(libs.h2.driver)
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation(projects.platform.platformTesting)
|
||||||
|
testImplementation(libs.spring.boot.starter.test)
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
@file:OptIn(ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.billing.api.rest
|
||||||
|
|
||||||
|
import at.mocode.billing.domain.model.Buchung
|
||||||
|
import at.mocode.billing.domain.model.BuchungsTyp
|
||||||
|
import at.mocode.billing.domain.model.TeilnehmerKonto
|
||||||
|
import at.mocode.billing.service.PdfService
|
||||||
|
import at.mocode.billing.service.TeilnehmerKontoService
|
||||||
|
import at.mocode.core.domain.serialization.InstantSerializer
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import jakarta.validation.constraints.NotBlank
|
||||||
|
import jakarta.validation.constraints.NotNull
|
||||||
|
import org.springframework.http.HttpHeaders
|
||||||
|
import org.springframework.http.MediaType
|
||||||
|
import org.springframework.http.ResponseEntity
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/billing")
|
||||||
|
class BillingController(
|
||||||
|
private val kontoService: TeilnehmerKontoService,
|
||||||
|
private val pdfService: PdfService
|
||||||
|
) {
|
||||||
|
|
||||||
|
data class KontoDto(
|
||||||
|
val kontoId: String,
|
||||||
|
val veranstaltungId: String,
|
||||||
|
val personId: String,
|
||||||
|
val personName: String,
|
||||||
|
val saldoCent: Long,
|
||||||
|
val bemerkungen: String?,
|
||||||
|
@Serializable(with = InstantSerializer::class)
|
||||||
|
val updatedAt: Instant
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BuchungDto(
|
||||||
|
val buchungId: String,
|
||||||
|
val kontoId: String,
|
||||||
|
val betragCent: Long,
|
||||||
|
val typ: BuchungsTyp,
|
||||||
|
val verwendungszweck: String,
|
||||||
|
@Serializable(with = InstantSerializer::class)
|
||||||
|
val gebuchtAm: Instant
|
||||||
|
)
|
||||||
|
|
||||||
|
data class CreateKontoRequest(
|
||||||
|
@field:NotNull val veranstaltungId: String,
|
||||||
|
@field:NotNull val personId: String,
|
||||||
|
@field:NotBlank val personName: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BuchungRequest(
|
||||||
|
@field:NotNull val betragCent: Long,
|
||||||
|
@field:NotNull val typ: BuchungsTyp,
|
||||||
|
@field:NotBlank val verwendungszweck: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@GetMapping("/konten/{kontoId}")
|
||||||
|
fun getKonto(@PathVariable kontoId: String): ResponseEntity<KontoDto> {
|
||||||
|
val uuid = try { Uuid.parse(kontoId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||||
|
val konto = kontoService.getKontoById(uuid) ?: return ResponseEntity.notFound().build()
|
||||||
|
return ResponseEntity.ok(konto.toDto())
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/konten")
|
||||||
|
fun getKontoByVeranstaltungUndPerson(
|
||||||
|
@RequestParam veranstaltungId: String,
|
||||||
|
@RequestParam personId: String
|
||||||
|
): ResponseEntity<KontoDto> {
|
||||||
|
val vUuid = try { Uuid.parse(veranstaltungId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||||
|
val pUuid = try { Uuid.parse(personId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||||
|
|
||||||
|
val konto = kontoService.getOrCreateKonto(vUuid, pUuid, "Unbekannt") // Name wird bei getOrCreate ggf. ignoriert wenn existiert
|
||||||
|
return ResponseEntity.ok(konto.toDto())
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/konten")
|
||||||
|
fun createKonto(@Valid @RequestBody request: CreateKontoRequest): ResponseEntity<KontoDto> {
|
||||||
|
val vUuid = try { Uuid.parse(request.veranstaltungId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||||
|
val pUuid = try { Uuid.parse(request.personId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||||
|
|
||||||
|
val konto = kontoService.getOrCreateKonto(vUuid, pUuid, request.personName)
|
||||||
|
return ResponseEntity.ok(konto.toDto())
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/konten/{kontoId}/buchungen")
|
||||||
|
fun getBuchungen(@PathVariable kontoId: String): ResponseEntity<List<BuchungDto>> {
|
||||||
|
val uuid = try { Uuid.parse(kontoId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||||
|
val buchungen = kontoService.getBuchungsHistorie(uuid)
|
||||||
|
return ResponseEntity.ok(buchungen.map { it.toDto() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/konten/{kontoId}/buchungen")
|
||||||
|
fun addBuchung(
|
||||||
|
@PathVariable kontoId: String,
|
||||||
|
@Valid @RequestBody request: BuchungRequest
|
||||||
|
): ResponseEntity<KontoDto> {
|
||||||
|
val uuid = try { Uuid.parse(kontoId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||||
|
val konto = kontoService.buche(
|
||||||
|
kontoId = uuid,
|
||||||
|
betragCent = request.betragCent,
|
||||||
|
typ = request.typ,
|
||||||
|
zweck = request.verwendungszweck
|
||||||
|
)
|
||||||
|
return ResponseEntity.ok(konto.toDto())
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/konten/{kontoId}/rechnung", produces = [MediaType.APPLICATION_PDF_VALUE])
|
||||||
|
fun downloadRechnung(@PathVariable kontoId: String): ResponseEntity<ByteArray> {
|
||||||
|
val uuid = try { Uuid.parse(kontoId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||||
|
val konto = kontoService.getKontoById(uuid) ?: return ResponseEntity.notFound().build()
|
||||||
|
|
||||||
|
val pdf = pdfService.generateRechnung(konto)
|
||||||
|
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"rechnung_${konto.personName.replace(" ", "_")}.pdf\"")
|
||||||
|
.contentType(MediaType.APPLICATION_PDF)
|
||||||
|
.body(pdf)
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/veranstaltungen/{veranstaltungId}/offene-posten")
|
||||||
|
fun getOffenePosten(@PathVariable veranstaltungId: String): ResponseEntity<List<KontoDto>> {
|
||||||
|
val uuid = try { Uuid.parse(veranstaltungId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||||
|
val konten = kontoService.getOffenePosten(uuid)
|
||||||
|
return ResponseEntity.ok(konten.map { it.toDto() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/veranstaltungen/{veranstaltungId}/konten")
|
||||||
|
fun getKontenFuerVeranstaltung(@PathVariable veranstaltungId: String): ResponseEntity<List<KontoDto>> {
|
||||||
|
val uuid = try { Uuid.parse(veranstaltungId) } catch (_: Exception) { return ResponseEntity.badRequest().build() }
|
||||||
|
val konten = kontoService.getKontenFuerVeranstaltung(uuid)
|
||||||
|
return ResponseEntity.ok(konten.map { it.toDto() })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TeilnehmerKonto.toDto() = KontoDto(
|
||||||
|
kontoId = kontoId.toString(),
|
||||||
|
veranstaltungId = veranstaltungId.toString(),
|
||||||
|
personId = personId.toString(),
|
||||||
|
personName = personName,
|
||||||
|
saldoCent = saldoCent,
|
||||||
|
bemerkungen = bemerkungen,
|
||||||
|
updatedAt = updatedAt
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun Buchung.toDto() = BuchungDto(
|
||||||
|
buchungId = buchungId.toString(),
|
||||||
|
kontoId = kontoId.toString(),
|
||||||
|
betragCent = betragCent,
|
||||||
|
typ = typ,
|
||||||
|
verwendungszweck = verwendungszweck,
|
||||||
|
gebuchtAm = gebuchtAm
|
||||||
|
)
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
@file:OptIn(ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.billing.service
|
||||||
|
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||||
|
import org.springframework.boot.runApplication
|
||||||
|
import org.springframework.cloud.client.discovery.EnableDiscoveryClient
|
||||||
|
import org.springframework.context.event.EventListener
|
||||||
|
import org.springframework.core.env.Environment
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
|
||||||
|
@EnableDiscoveryClient
|
||||||
|
@SpringBootApplication
|
||||||
|
class BillingServiceApplication(private val env: Environment) {
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger(BillingServiceApplication::class.java)
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent::class)
|
||||||
|
fun onApplicationReady() {
|
||||||
|
val springPort = env.getProperty("server.port", "8087")
|
||||||
|
val appName = env.getProperty("spring.application.name", "billing-service")
|
||||||
|
|
||||||
|
log.info("----------------------------------------------------------")
|
||||||
|
log.info("Application '{}' is running!", appName)
|
||||||
|
log.info("Spring Management Port: {}", springPort)
|
||||||
|
log.info("Profiles: {}", env.activeProfiles.joinToString(", "))
|
||||||
|
log.info("----------------------------------------------------------")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun main(args: Array<String>) {
|
||||||
|
runApplication<BillingServiceApplication>(*args)
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
@file:OptIn(ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.billing.service
|
||||||
|
|
||||||
|
import at.mocode.billing.domain.model.TeilnehmerKonto
|
||||||
|
import at.mocode.billing.domain.repository.BuchungRepository
|
||||||
|
import com.lowagie.text.*
|
||||||
|
import com.lowagie.text.pdf.PdfPCell
|
||||||
|
import com.lowagie.text.pdf.PdfPTable
|
||||||
|
import com.lowagie.text.pdf.PdfWriter
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import java.awt.Color
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.text.NumberFormat
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class PdfService(
|
||||||
|
private val buchungRepository: BuchungRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun generateRechnung(konto: TeilnehmerKonto): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val document = Document(PageSize.A4)
|
||||||
|
PdfWriter.getInstance(document, out)
|
||||||
|
|
||||||
|
document.open()
|
||||||
|
|
||||||
|
// Header
|
||||||
|
val titleFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 18f)
|
||||||
|
val header = Paragraph("Rechnung / Kontoauszug", titleFont)
|
||||||
|
header.alignment = Element.ALIGN_CENTER
|
||||||
|
header.spacingAfter = 20f
|
||||||
|
document.add(header)
|
||||||
|
|
||||||
|
// Teilnehmer Info
|
||||||
|
val infoFont = FontFactory.getFont(FontFactory.HELVETICA, 12f)
|
||||||
|
document.add(Paragraph("Teilnehmer: ${konto.personName}", infoFont))
|
||||||
|
document.add(Paragraph("Datum: ${java.time.LocalDate.now()}", infoFont))
|
||||||
|
document.add(Paragraph("Konto-ID: ${konto.kontoId}", infoFont))
|
||||||
|
document.add(Paragraph("Veranstaltung: ${konto.veranstaltungId}", infoFont))
|
||||||
|
document.add(Paragraph(" ", infoFont))
|
||||||
|
|
||||||
|
// Tabelle
|
||||||
|
val table = PdfPTable(4)
|
||||||
|
table.widthPercentage = 100f
|
||||||
|
table.setWidths(floatArrayOf(2f, 4f, 2f, 2f))
|
||||||
|
|
||||||
|
val headFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 11f)
|
||||||
|
|
||||||
|
fun addCell(text: String, font: Font = headFont, bgColor: Color? = Color.LIGHT_GRAY) {
|
||||||
|
val cell = PdfPCell(Phrase(text, font))
|
||||||
|
if (bgColor != null) cell.backgroundColor = bgColor
|
||||||
|
cell.setPadding(5f)
|
||||||
|
table.addCell(cell)
|
||||||
|
}
|
||||||
|
|
||||||
|
addCell("Datum")
|
||||||
|
addCell("Zweck")
|
||||||
|
addCell("Typ")
|
||||||
|
addCell("Betrag")
|
||||||
|
|
||||||
|
val buchungen = buchungRepository.findByKonto(konto.kontoId)
|
||||||
|
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.GERMANY)
|
||||||
|
val bodyFont = FontFactory.getFont(FontFactory.HELVETICA, 10f)
|
||||||
|
|
||||||
|
buchungen.forEach { b ->
|
||||||
|
addCell(b.gebuchtAm.toString().substring(0, 10), bodyFont, null)
|
||||||
|
addCell(b.verwendungszweck, bodyFont, null)
|
||||||
|
addCell(b.typ.name, bodyFont, null)
|
||||||
|
val betragStr = currencyFormat.format(b.betragCent / 100.0)
|
||||||
|
addCell(betragStr, bodyFont, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.add(table)
|
||||||
|
|
||||||
|
// Saldo
|
||||||
|
val saldoFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 14f)
|
||||||
|
val saldoPara = Paragraph(" ", saldoFont)
|
||||||
|
saldoPara.spacingBefore = 20f
|
||||||
|
document.add(saldoPara)
|
||||||
|
|
||||||
|
val saldoText = "Gesamtsaldo: ${currencyFormat.format(konto.saldoCent / 100.0)}"
|
||||||
|
val finalSaldo = Paragraph(saldoText, saldoFont)
|
||||||
|
finalSaldo.alignment = Element.ALIGN_RIGHT
|
||||||
|
document.add(finalSaldo)
|
||||||
|
|
||||||
|
document.close()
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
@file:OptIn(ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.billing.service
|
||||||
|
|
||||||
|
import at.mocode.billing.domain.model.BuchungsTyp
|
||||||
|
import at.mocode.billing.domain.model.Tagesabschluss
|
||||||
|
import at.mocode.billing.domain.repository.BuchungRepository
|
||||||
|
import at.mocode.billing.domain.repository.TagesabschlussRepository
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class TagesabschlussService(
|
||||||
|
private val buchungRepository: BuchungRepository,
|
||||||
|
private val tagesabschlussRepository: TagesabschlussRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erstellt einen Tagesabschluss für die angegebene Veranstaltung und den Zeitraum.
|
||||||
|
* Standardmäßig wird der Zeitraum von "heute 00:00" bis "jetzt" genommen,
|
||||||
|
* wenn keine Zeiten angegeben sind.
|
||||||
|
*/
|
||||||
|
fun erstelleAbschluss(
|
||||||
|
veranstaltungId: Uuid,
|
||||||
|
von: Instant,
|
||||||
|
bis: Instant,
|
||||||
|
abgeschlossenVon: String,
|
||||||
|
bemerkungen: String? = null
|
||||||
|
): Tagesabschluss {
|
||||||
|
return transaction {
|
||||||
|
val buchungen = buchungRepository.findByVeranstaltungAndZeitraum(veranstaltungId, von, bis)
|
||||||
|
|
||||||
|
val summeBar = buchungen
|
||||||
|
.filter { it.typ == BuchungsTyp.ZAHLUNG_BAR }
|
||||||
|
.sumOf { it.betragCent }
|
||||||
|
|
||||||
|
val summeKarte = buchungen
|
||||||
|
.filter { it.typ == BuchungsTyp.ZAHLUNG_KARTE }
|
||||||
|
.sumOf { it.betragCent }
|
||||||
|
|
||||||
|
val summeGutschrift = buchungen
|
||||||
|
.filter { it.typ == BuchungsTyp.GUTSCHRIFT }
|
||||||
|
.sumOf { it.betragCent }
|
||||||
|
|
||||||
|
val abschluss = Tagesabschluss(
|
||||||
|
veranstaltungId = veranstaltungId,
|
||||||
|
abgeschlossenVon = abgeschlossenVon,
|
||||||
|
summeBarCent = summeBar,
|
||||||
|
summeKarteCent = summeKarte,
|
||||||
|
summeGutschriftCent = summeGutschrift,
|
||||||
|
anzahlBuchungen = buchungen.size,
|
||||||
|
bemerkungen = bemerkungen
|
||||||
|
)
|
||||||
|
|
||||||
|
tagesabschlussRepository.save(abschluss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAbschluesse(veranstaltungId: Uuid): List<Tagesabschluss> {
|
||||||
|
return transaction {
|
||||||
|
tagesabschlussRepository.findByVeranstaltung(veranstaltungId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
@file:OptIn(ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.billing.service
|
||||||
|
|
||||||
|
import at.mocode.billing.domain.model.Buchung
|
||||||
|
import at.mocode.billing.domain.model.BuchungsTyp
|
||||||
|
import at.mocode.billing.domain.model.TeilnehmerKonto
|
||||||
|
import at.mocode.billing.domain.repository.BuchungRepository
|
||||||
|
import at.mocode.billing.domain.repository.TeilnehmerKontoRepository
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class TeilnehmerKontoService(
|
||||||
|
private val kontoRepository: TeilnehmerKontoRepository,
|
||||||
|
private val buchungRepository: BuchungRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun getOrCreateKonto(veranstaltungId: Uuid, personId: Uuid, personName: String): TeilnehmerKonto {
|
||||||
|
return transaction {
|
||||||
|
kontoRepository.findByVeranstaltungAndPerson(veranstaltungId, personId)
|
||||||
|
?: kontoRepository.save(
|
||||||
|
TeilnehmerKonto(
|
||||||
|
veranstaltungId = veranstaltungId,
|
||||||
|
personId = personId,
|
||||||
|
personName = personName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getKontoById(kontoId: Uuid): TeilnehmerKonto? {
|
||||||
|
return transaction {
|
||||||
|
kontoRepository.findById(kontoId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getKonto(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto? {
|
||||||
|
return transaction {
|
||||||
|
kontoRepository.findByVeranstaltungAndPerson(veranstaltungId, personId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getBuchungsHistorie(kontoId: Uuid): List<Buchung> {
|
||||||
|
return transaction {
|
||||||
|
buchungRepository.findByKonto(kontoId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buche(kontoId: Uuid, betragCent: Long, typ: BuchungsTyp, zweck: String): TeilnehmerKonto {
|
||||||
|
return transaction {
|
||||||
|
val konto = kontoRepository.findById(kontoId) ?: throw IllegalArgumentException("Konto nicht gefunden: $kontoId")
|
||||||
|
|
||||||
|
// Validierung: Bestimmte Typen sind immer "Soll" (negativ), andere "Haben" (positiv/Zahlung)
|
||||||
|
val validierterBetrag = when (typ) {
|
||||||
|
BuchungsTyp.NENNGELD,
|
||||||
|
BuchungsTyp.NENNGEBUEHR,
|
||||||
|
BuchungsTyp.NACHNENNGEBUEHR,
|
||||||
|
BuchungsTyp.STARTGEBUEHR,
|
||||||
|
BuchungsTyp.SPORTFOERDERBEITRAG,
|
||||||
|
BuchungsTyp.BOXENGEBUEHR -> if (betragCent > 0) -betragCent else betragCent
|
||||||
|
|
||||||
|
BuchungsTyp.ZAHLUNG_BAR,
|
||||||
|
BuchungsTyp.ZAHLUNG_KARTE,
|
||||||
|
BuchungsTyp.GUTSCHRIFT -> if (betragCent < 0) -betragCent else betragCent
|
||||||
|
|
||||||
|
BuchungsTyp.STORNIERUNG -> betragCent // Storno kann beides sein (Gegenbuchung)
|
||||||
|
}
|
||||||
|
|
||||||
|
val buchung = Buchung(
|
||||||
|
kontoId = kontoId,
|
||||||
|
betragCent = validierterBetrag,
|
||||||
|
typ = typ,
|
||||||
|
verwendungszweck = zweck
|
||||||
|
)
|
||||||
|
|
||||||
|
buchungRepository.save(buchung)
|
||||||
|
val neuerSaldo = konto.saldoCent + validierterBetrag
|
||||||
|
kontoRepository.updateSaldo(kontoId, neuerSaldo)
|
||||||
|
|
||||||
|
kontoRepository.findById(kontoId)!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getKontenFuerVeranstaltung(veranstaltungId: Uuid): List<TeilnehmerKonto> {
|
||||||
|
return transaction {
|
||||||
|
kontoRepository.findByVeranstaltung(veranstaltungId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getOffenePosten(veranstaltungId: Uuid): List<TeilnehmerKonto> {
|
||||||
|
return transaction {
|
||||||
|
kontoRepository.findOffenePosten(veranstaltungId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storniert eine existierende Buchung durch eine Gegenbuchung.
|
||||||
|
*/
|
||||||
|
fun storniereBuchung(buchungId: Uuid, grund: String): TeilnehmerKonto {
|
||||||
|
return transaction {
|
||||||
|
val ursprung = buchungRepository.findById(buchungId)
|
||||||
|
?: throw IllegalArgumentException("Buchung nicht gefunden: $buchungId")
|
||||||
|
|
||||||
|
if (ursprung.typ == BuchungsTyp.STORNIERUNG) {
|
||||||
|
throw IllegalArgumentException("Ein Storno kann nicht erneut storniert werden.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val konto = kontoRepository.findById(ursprung.kontoId)!!
|
||||||
|
|
||||||
|
// Gegenbuchung erstellen (Betrag umkehren)
|
||||||
|
val stornoBuchung = Buchung(
|
||||||
|
kontoId = ursprung.kontoId,
|
||||||
|
betragCent = -ursprung.betragCent,
|
||||||
|
typ = BuchungsTyp.STORNIERUNG,
|
||||||
|
verwendungszweck = "Storno von ${ursprung.buchungId}: $grund",
|
||||||
|
storniertBuchungId = ursprung.buchungId
|
||||||
|
)
|
||||||
|
|
||||||
|
buchungRepository.save(stornoBuchung)
|
||||||
|
|
||||||
|
val neuerSaldo = konto.saldoCent - ursprung.betragCent
|
||||||
|
kontoRepository.updateSaldo(konto.kontoId, neuerSaldo)
|
||||||
|
|
||||||
|
kontoRepository.findById(konto.kontoId)!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package at.mocode.billing.service.config
|
||||||
|
|
||||||
|
import at.mocode.billing.service.persistence.BuchungTable
|
||||||
|
import at.mocode.billing.service.persistence.TagesabschlussTable
|
||||||
|
import at.mocode.billing.service.persistence.TeilnehmerKontoTable
|
||||||
|
import jakarta.annotation.PostConstruct
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.Database
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
class BillingDatabaseConfiguration(
|
||||||
|
@Value("\${spring.datasource.url:}") private val jdbcUrl: String,
|
||||||
|
@Value("\${spring.datasource.username:}") private val username: String,
|
||||||
|
@Value("\${spring.datasource.password:}") private val password: String
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val log = LoggerFactory.getLogger(BillingDatabaseConfiguration::class.java)
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
fun initializeDatabase() {
|
||||||
|
if (jdbcUrl.isBlank()) {
|
||||||
|
log.warn("No spring.datasource.url provided. Skipping Billing database initialization.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.info("Initializing database schema for Billing Service...")
|
||||||
|
try {
|
||||||
|
Database.connect(jdbcUrl, user = username, password = password)
|
||||||
|
transaction {
|
||||||
|
SchemaUtils.create(
|
||||||
|
TeilnehmerKontoTable,
|
||||||
|
BuchungTable,
|
||||||
|
TagesabschlussTable
|
||||||
|
)
|
||||||
|
}
|
||||||
|
log.info("Billing database schema initialized successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.error("Failed to initialize billing database schema", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
@file:OptIn(ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.billing.service.persistence
|
||||||
|
|
||||||
|
import org.jetbrains.exposed.v1.core.Table
|
||||||
|
import org.jetbrains.exposed.v1.datetime.CurrentTimestamp
|
||||||
|
import org.jetbrains.exposed.v1.datetime.timestamp
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposed-Tabellendefinition für das Teilnehmer-Konto.
|
||||||
|
*/
|
||||||
|
object TeilnehmerKontoTable : Table("teilnehmer_konten") {
|
||||||
|
val id = uuid("konto_id")
|
||||||
|
val veranstaltungId = uuid("veranstaltung_id")
|
||||||
|
val personId = uuid("person_id")
|
||||||
|
val personName = varchar("person_name", 200)
|
||||||
|
val saldoCent = long("saldo_cent").default(0L)
|
||||||
|
val bemerkungen = text("bemerkungen").nullable()
|
||||||
|
|
||||||
|
val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp)
|
||||||
|
val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp)
|
||||||
|
|
||||||
|
override val primaryKey = PrimaryKey(id)
|
||||||
|
|
||||||
|
init {
|
||||||
|
index("idx_konto_veranstaltung_person", isUnique = true, veranstaltungId, personId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposed-Tabellendefinition für Buchungen.
|
||||||
|
*/
|
||||||
|
object BuchungTable : Table("buchungen") {
|
||||||
|
val id = uuid("buchung_id")
|
||||||
|
val kontoId = uuid("konto_id")
|
||||||
|
val betragCent = long("betrag_cent")
|
||||||
|
val typ = varchar("typ", 50)
|
||||||
|
val verwendungszweck = varchar("verwendungszweck", 500)
|
||||||
|
val gebuchtAm = timestamp("gebucht_am").defaultExpression(CurrentTimestamp)
|
||||||
|
val storniertBuchungId = uuid("storniert_buchung_id").nullable()
|
||||||
|
|
||||||
|
override val primaryKey = PrimaryKey(id)
|
||||||
|
|
||||||
|
init {
|
||||||
|
index("idx_buchung_konto", isUnique = false, kontoId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposed-Tabellendefinition für Tagesabschlüsse.
|
||||||
|
*/
|
||||||
|
object TagesabschlussTable : Table("tagesabschluesse") {
|
||||||
|
val id = uuid("tagesabschluss_id")
|
||||||
|
val veranstaltungId = uuid("veranstaltung_id")
|
||||||
|
val abgeschlossenAm = timestamp("abgeschlossen_am").defaultExpression(CurrentTimestamp)
|
||||||
|
val abgeschlossenVon = varchar("abgeschlossen_von", 200)
|
||||||
|
val summeBarCent = long("summe_bar_cent")
|
||||||
|
val summeKarteCent = long("summe_karte_cent")
|
||||||
|
val summeGutschriftCent = long("summe_gutschrift_cent")
|
||||||
|
val anzahlBuchungen = integer("anzahl_buchungen")
|
||||||
|
val bemerkungen = text("bemerkungen").nullable()
|
||||||
|
|
||||||
|
override val primaryKey = PrimaryKey(id)
|
||||||
|
|
||||||
|
init {
|
||||||
|
index("idx_tagesabschluss_veranstaltung", isUnique = false, veranstaltungId)
|
||||||
|
}
|
||||||
|
}
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
@file:OptIn(ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.billing.service.persistence
|
||||||
|
|
||||||
|
import at.mocode.billing.domain.model.Buchung
|
||||||
|
import at.mocode.billing.domain.model.BuchungsTyp
|
||||||
|
import at.mocode.billing.domain.model.Tagesabschluss
|
||||||
|
import at.mocode.billing.domain.model.TeilnehmerKonto
|
||||||
|
import at.mocode.billing.domain.repository.BuchungRepository
|
||||||
|
import at.mocode.billing.domain.repository.TagesabschlussRepository
|
||||||
|
import at.mocode.billing.domain.repository.TeilnehmerKontoRepository
|
||||||
|
import org.jetbrains.exposed.v1.core.*
|
||||||
|
import org.jetbrains.exposed.v1.datetime.CurrentTimestamp
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class ExposedTeilnehmerKontoRepository : TeilnehmerKontoRepository {
|
||||||
|
|
||||||
|
override fun findByVeranstaltungAndPerson(veranstaltungId: Uuid, personId: Uuid): TeilnehmerKonto? {
|
||||||
|
return TeilnehmerKontoTable
|
||||||
|
.selectAll()
|
||||||
|
.where { (TeilnehmerKontoTable.veranstaltungId eq veranstaltungId) and (TeilnehmerKontoTable.personId eq personId) }
|
||||||
|
.singleOrNull()
|
||||||
|
?.toModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findById(kontoId: Uuid): TeilnehmerKonto? {
|
||||||
|
return TeilnehmerKontoTable
|
||||||
|
.selectAll()
|
||||||
|
.where { TeilnehmerKontoTable.id eq kontoId }
|
||||||
|
.singleOrNull()
|
||||||
|
?.toModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByVeranstaltung(veranstaltungId: Uuid): List<TeilnehmerKonto> {
|
||||||
|
return TeilnehmerKontoTable
|
||||||
|
.selectAll()
|
||||||
|
.where { TeilnehmerKontoTable.veranstaltungId eq veranstaltungId }
|
||||||
|
.map { it.toModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findOffenePosten(veranstaltungId: Uuid): List<TeilnehmerKonto> {
|
||||||
|
return TeilnehmerKontoTable
|
||||||
|
.selectAll()
|
||||||
|
.where { (TeilnehmerKontoTable.veranstaltungId eq veranstaltungId) and (TeilnehmerKontoTable.saldoCent less 0) }
|
||||||
|
.map { it.toModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(konto: TeilnehmerKonto): TeilnehmerKonto {
|
||||||
|
val existing = findById(konto.kontoId)
|
||||||
|
if (existing == null) {
|
||||||
|
TeilnehmerKontoTable.insert {
|
||||||
|
it[id] = konto.kontoId
|
||||||
|
it[veranstaltungId] = konto.veranstaltungId
|
||||||
|
it[personId] = konto.personId
|
||||||
|
it[personName] = konto.personName
|
||||||
|
it[saldoCent] = konto.saldoCent
|
||||||
|
it[bemerkungen] = konto.bemerkungen
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TeilnehmerKontoTable.update({ TeilnehmerKontoTable.id eq konto.kontoId }) {
|
||||||
|
it[personName] = konto.personName
|
||||||
|
it[saldoCent] = konto.saldoCent
|
||||||
|
it[bemerkungen] = konto.bemerkungen
|
||||||
|
it[updatedAt] = CurrentTimestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findById(konto.kontoId)!!
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateSaldo(kontoId: Uuid, saldoCent: Long): Long {
|
||||||
|
TeilnehmerKontoTable.update({ TeilnehmerKontoTable.id eq kontoId }) {
|
||||||
|
it[this.saldoCent] = saldoCent
|
||||||
|
it[updatedAt] = CurrentTimestamp
|
||||||
|
}
|
||||||
|
return saldoCent
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ResultRow.toModel() = TeilnehmerKonto(
|
||||||
|
kontoId = this[TeilnehmerKontoTable.id],
|
||||||
|
veranstaltungId = this[TeilnehmerKontoTable.veranstaltungId],
|
||||||
|
personId = this[TeilnehmerKontoTable.personId],
|
||||||
|
personName = this[TeilnehmerKontoTable.personName],
|
||||||
|
saldoCent = this[TeilnehmerKontoTable.saldoCent],
|
||||||
|
bemerkungen = this[TeilnehmerKontoTable.bemerkungen],
|
||||||
|
updatedAt = this[TeilnehmerKontoTable.updatedAt]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class ExposedBuchungRepository : BuchungRepository {
|
||||||
|
|
||||||
|
override fun findByKonto(kontoId: Uuid): List<Buchung> {
|
||||||
|
return BuchungTable
|
||||||
|
.selectAll()
|
||||||
|
.where { BuchungTable.kontoId eq kontoId }
|
||||||
|
.map { it.toModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findById(buchungId: Uuid): Buchung? {
|
||||||
|
return BuchungTable
|
||||||
|
.selectAll()
|
||||||
|
.where { BuchungTable.id eq buchungId }
|
||||||
|
.singleOrNull()
|
||||||
|
?.toModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun findByVeranstaltungAndZeitraum(
|
||||||
|
veranstaltungId: Uuid,
|
||||||
|
von: Instant,
|
||||||
|
bis: Instant
|
||||||
|
): List<Buchung> {
|
||||||
|
// Da Buchungen über Konten verknüpft sind, müssen wir einen Join machen oder über die Konten der Veranstaltung filtern
|
||||||
|
return Join(BuchungTable, TeilnehmerKontoTable, JoinType.INNER, BuchungTable.kontoId, TeilnehmerKontoTable.id)
|
||||||
|
.selectAll()
|
||||||
|
.where {
|
||||||
|
(TeilnehmerKontoTable.veranstaltungId eq veranstaltungId) and
|
||||||
|
(BuchungTable.gebuchtAm.between(von, bis))
|
||||||
|
}
|
||||||
|
.map { it.toModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(buchung: Buchung): Buchung {
|
||||||
|
BuchungTable.insert {
|
||||||
|
it[id] = buchung.buchungId
|
||||||
|
it[kontoId] = buchung.kontoId
|
||||||
|
it[betragCent] = buchung.betragCent
|
||||||
|
it[typ] = buchung.typ.name
|
||||||
|
it[verwendungszweck] = buchung.verwendungszweck
|
||||||
|
it[gebuchtAm] = buchung.gebuchtAm
|
||||||
|
it[storniertBuchungId] = buchung.storniertBuchungId
|
||||||
|
}
|
||||||
|
return buchung
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ResultRow.toModel() = Buchung(
|
||||||
|
buchungId = this[BuchungTable.id],
|
||||||
|
kontoId = this[BuchungTable.kontoId],
|
||||||
|
betragCent = this[BuchungTable.betragCent],
|
||||||
|
typ = BuchungsTyp.valueOf(this[BuchungTable.typ]),
|
||||||
|
verwendungszweck = this[BuchungTable.verwendungszweck],
|
||||||
|
gebuchtAm = this[BuchungTable.gebuchtAm],
|
||||||
|
storniertBuchungId = this[BuchungTable.storniertBuchungId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class ExposedTagesabschlussRepository : TagesabschlussRepository {
|
||||||
|
|
||||||
|
override fun findByVeranstaltung(veranstaltungId: Uuid): List<Tagesabschluss> {
|
||||||
|
return TagesabschlussTable
|
||||||
|
.selectAll()
|
||||||
|
.where { TagesabschlussTable.veranstaltungId eq veranstaltungId }
|
||||||
|
.map { it.toModel() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(abschluss: Tagesabschluss): Tagesabschluss {
|
||||||
|
TagesabschlussTable.insert {
|
||||||
|
it[id] = abschluss.tagesabschlussId
|
||||||
|
it[veranstaltungId] = abschluss.veranstaltungId
|
||||||
|
it[abgeschlossenAm] = abschluss.abgeschlossenAm
|
||||||
|
it[abgeschlossenVon] = abschluss.abgeschlossenVon
|
||||||
|
it[summeBarCent] = abschluss.summeBarCent
|
||||||
|
it[summeKarteCent] = abschluss.summeKarteCent
|
||||||
|
it[summeGutschriftCent] = abschluss.summeGutschriftCent
|
||||||
|
it[anzahlBuchungen] = abschluss.anzahlBuchungen
|
||||||
|
it[bemerkungen] = abschluss.bemerkungen
|
||||||
|
}
|
||||||
|
return abschluss
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ResultRow.toModel() = Tagesabschluss(
|
||||||
|
tagesabschlussId = this[TagesabschlussTable.id],
|
||||||
|
veranstaltungId = this[TagesabschlussTable.veranstaltungId],
|
||||||
|
abgeschlossenAm = this[TagesabschlussTable.abgeschlossenAm],
|
||||||
|
abgeschlossenVon = this[TagesabschlussTable.abgeschlossenVon],
|
||||||
|
summeBarCent = this[TagesabschlussTable.summeBarCent],
|
||||||
|
summeKarteCent = this[TagesabschlussTable.summeKarteCent],
|
||||||
|
summeGutschriftCent = this[TagesabschlussTable.summeGutschriftCent],
|
||||||
|
anzahlBuchungen = this[TagesabschlussTable.anzahlBuchungen],
|
||||||
|
bemerkungen = this[TagesabschlussTable.bemerkungen]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: billing-service
|
||||||
|
|
||||||
|
datasource:
|
||||||
|
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/pg-meldestelle-db}
|
||||||
|
username: ${SPRING_DATASOURCE_USERNAME:pg-user}
|
||||||
|
password: ${SPRING_DATASOURCE_PASSWORD:pg-password}
|
||||||
|
driver-class-name: org.postgresql.Driver
|
||||||
|
|
||||||
|
cloud:
|
||||||
|
consul:
|
||||||
|
host: ${SPRING_CLOUD_CONSUL_HOST:localhost}
|
||||||
|
port: ${SPRING_CLOUD_CONSUL_PORT:8500}
|
||||||
|
discovery:
|
||||||
|
enabled: true
|
||||||
|
register: true
|
||||||
|
prefer-ip-address: true
|
||||||
|
health-check-path: /actuator/health
|
||||||
|
health-check-interval: 10s
|
||||||
|
# health-check-port: 8089
|
||||||
|
instance-id: ${spring.application.name}:${server.port}:${random.uuid}
|
||||||
|
service-name: ${spring.application.name}
|
||||||
|
port: ${billing.http.port:8089}
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 8089
|
||||||
|
|
||||||
|
billing:
|
||||||
|
http:
|
||||||
|
port: 8089 # Ktor API Port (Haupt-Einstiegspunkt für REST-Anfragen)
|
||||||
|
address: 0.0.0.0 # Öffentlich erreichbar innerhalb des Netzwerks / Containers
|
||||||
|
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: health,info,metrics,prometheus
|
||||||
|
endpoint:
|
||||||
|
health:
|
||||||
|
show-details: always
|
||||||
|
probes:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
# at.mocode.billing: DEBUG
|
||||||
|
pattern:
|
||||||
|
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"
|
||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Billing SCS API
|
||||||
|
description: >
|
||||||
|
API für den Billing-Bounded-Context (Kassa, Abrechnung, Teilnehmerkonten)
|
||||||
|
version: 1.0.0
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8089
|
||||||
|
description: Lokaler Entwicklungs-Server
|
||||||
|
paths:
|
||||||
|
/api/billing/konten:
|
||||||
|
get:
|
||||||
|
summary: Teilnehmerkonto suchen
|
||||||
|
description: Sucht ein Konto basierend auf Veranstaltungs-ID und Personen-ID. Erstellt das Konto, falls es nicht existiert.
|
||||||
|
parameters:
|
||||||
|
- name: veranstaltungId
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
- name: personId
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Teilnehmerkonto
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/KontoDto'
|
||||||
|
'400':
|
||||||
|
description: Ungültige UUID-Formate
|
||||||
|
post:
|
||||||
|
summary: Teilnehmerkonto erstellen oder abrufen
|
||||||
|
description: Erstellt ein neues Teilnehmerkonto für eine Veranstaltung und eine Person.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateKontoRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Teilnehmerkonto (neu erstellt oder bestehend)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/KontoDto'
|
||||||
|
'400':
|
||||||
|
description: Validierungsfehler
|
||||||
|
/api/billing/konten/{kontoId}:
|
||||||
|
get:
|
||||||
|
summary: Teilnehmerkonto nach ID abrufen
|
||||||
|
parameters:
|
||||||
|
- name: kontoId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Details zum Teilnehmerkonto
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/KontoDto'
|
||||||
|
'404':
|
||||||
|
description: Konto nicht gefunden
|
||||||
|
'400':
|
||||||
|
description: Ungültige Konto-ID
|
||||||
|
/api/billing/konten/{kontoId}/buchungen:
|
||||||
|
get:
|
||||||
|
summary: Buchungshistorie abrufen
|
||||||
|
description: Liefert alle Buchungen für ein bestimmtes Teilnehmerkonto.
|
||||||
|
parameters:
|
||||||
|
- name: kontoId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Liste von Buchungen
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/BuchungDto'
|
||||||
|
'400':
|
||||||
|
description: Ungültige Konto-ID
|
||||||
|
post:
|
||||||
|
summary: Buchung hinzufügen
|
||||||
|
description: Führt eine neue Buchung auf dem Teilnehmerkonto durch und aktualisiert den Saldo.
|
||||||
|
parameters:
|
||||||
|
- name: kontoId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/BuchungRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Aktualisiertes Teilnehmerkonto nach der Buchung
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/KontoDto'
|
||||||
|
'400':
|
||||||
|
description: Validierungsfehler oder ungültige Konto-ID
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
KontoDto:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
kontoId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
veranstaltungId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
personId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
personName:
|
||||||
|
type: string
|
||||||
|
saldoCent:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: Aktueller Saldo in Cent
|
||||||
|
bemerkungen:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
updatedAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: Zeitpunkt der letzten Aktualisierung
|
||||||
|
BuchungDto:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
buchungId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
kontoId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
betragCent:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
description: Betrag in Cent (positiv für Gutschriften, negativ für Belastungen)
|
||||||
|
typ:
|
||||||
|
$ref: '#/components/schemas/BuchungsTyp'
|
||||||
|
verwendungszweck:
|
||||||
|
type: string
|
||||||
|
gebuchtAm:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
CreateKontoRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- veranstaltungId
|
||||||
|
- personId
|
||||||
|
- personName
|
||||||
|
properties:
|
||||||
|
veranstaltungId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
personId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
personName:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
BuchungRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- betragCent
|
||||||
|
- typ
|
||||||
|
- verwendungszweck
|
||||||
|
properties:
|
||||||
|
betragCent:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
typ:
|
||||||
|
$ref: '#/components/schemas/BuchungsTyp'
|
||||||
|
verwendungszweck:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
BuchungsTyp:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- NENNGEBUEHR
|
||||||
|
- KOPPELGEBUEHR
|
||||||
|
- NACHNENNGEBUEHR
|
||||||
|
- STARTGEBUEHR
|
||||||
|
- EINZAHLUNG
|
||||||
|
- AUSZAHLUNG
|
||||||
|
- SONSTIGES
|
||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
@file:OptIn(ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.billing.service
|
||||||
|
|
||||||
|
import at.mocode.billing.domain.model.BuchungsTyp
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNotNull
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.context.ActiveProfiles
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
class TagesabschlussServiceTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var kontoService: TeilnehmerKontoService
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var tagesabschlussService: TagesabschlussService
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Tagesabschluss aggregiert Buchungen korrekt`() {
|
||||||
|
val vId = Uuid.random()
|
||||||
|
val k1 = kontoService.getOrCreateKonto(vId, Uuid.random(), "Reiter A")
|
||||||
|
val k2 = kontoService.getOrCreateKonto(vId, Uuid.random(), "Reiter B")
|
||||||
|
|
||||||
|
val jetzt = Clock.System.now()
|
||||||
|
val von = jetzt - 1.hours
|
||||||
|
val bis = jetzt + 1.hours
|
||||||
|
|
||||||
|
// Buchungen erstellen
|
||||||
|
kontoService.buche(k1.kontoId, 5000L, BuchungsTyp.ZAHLUNG_BAR, "Barzahlung 1")
|
||||||
|
kontoService.buche(k2.kontoId, 3000L, BuchungsTyp.ZAHLUNG_BAR, "Barzahlung 2")
|
||||||
|
kontoService.buche(k1.kontoId, 2500L, BuchungsTyp.ZAHLUNG_KARTE, "Kartenzahlung")
|
||||||
|
kontoService.buche(k2.kontoId, 1000L, BuchungsTyp.GUTSCHRIFT, "Gutschrift")
|
||||||
|
|
||||||
|
// Gebühren (sollten nicht in den Zahlungs-Summen auftauchen)
|
||||||
|
kontoService.buche(k1.kontoId, 1500L, BuchungsTyp.NENNGEBUEHR, "Gebühr")
|
||||||
|
|
||||||
|
// Abschluss erstellen
|
||||||
|
val abschluss = tagesabschlussService.erstelleAbschluss(
|
||||||
|
veranstaltungId = vId,
|
||||||
|
von = von,
|
||||||
|
bis = bis,
|
||||||
|
abgeschlossenVon = "Admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNotNull(abschluss)
|
||||||
|
assertEquals(8000L, abschluss.summeBarCent)
|
||||||
|
assertEquals(2500L, abschluss.summeKarteCent)
|
||||||
|
assertEquals(1000L, abschluss.summeGutschriftCent)
|
||||||
|
assertEquals(5, abschluss.anzahlBuchungen) // 2x Bar + 1x Karte + 1x Gutschrift + 1x Gebühr
|
||||||
|
}
|
||||||
|
}
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
@file:OptIn(ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.billing.service
|
||||||
|
|
||||||
|
import at.mocode.billing.domain.model.BuchungsTyp
|
||||||
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest
|
||||||
|
import org.springframework.test.context.ActiveProfiles
|
||||||
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
class TeilnehmerKontoServiceTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
lateinit var service: TeilnehmerKontoService
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Konto erstellen und buchen`() {
|
||||||
|
val veranstaltungId = Uuid.random()
|
||||||
|
val personId = Uuid.random()
|
||||||
|
val personName = "Max Mustermann"
|
||||||
|
|
||||||
|
// 1. Konto erstellen
|
||||||
|
val konto = service.getOrCreateKonto(veranstaltungId, personId, personName)
|
||||||
|
assertNotNull(konto)
|
||||||
|
assertEquals(personName, konto.personName)
|
||||||
|
assertEquals(0L, konto.saldoCent)
|
||||||
|
|
||||||
|
// 2. Buchung durchführen
|
||||||
|
val updatedKonto = service.buche(
|
||||||
|
kontoId = konto.kontoId,
|
||||||
|
betragCent = 1500L,
|
||||||
|
typ = BuchungsTyp.NENNGEBUEHR,
|
||||||
|
zweck = "Nennung Bewerb 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(-1500L, updatedKonto.saldoCent)
|
||||||
|
|
||||||
|
// 3. Buchungshistorie prüfen
|
||||||
|
val buchungen = service.getBuchungsHistorie(konto.kontoId)
|
||||||
|
assertEquals(1, buchungen.size)
|
||||||
|
assertEquals(-1500L, buchungen[0].betragCent)
|
||||||
|
assertEquals("Nennung Bewerb 1", buchungen[0].verwendungszweck)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Mehrere Buchungen summieren sich korrekt`() {
|
||||||
|
val vId = Uuid.random()
|
||||||
|
val pId = Uuid.random()
|
||||||
|
val konto = service.getOrCreateKonto(vId, pId, "Susi Sorglos")
|
||||||
|
|
||||||
|
service.buche(konto.kontoId, 2000L, BuchungsTyp.STARTGEBUEHR, "Startgeld")
|
||||||
|
val finalKonto = service.buche(konto.kontoId, 500L, BuchungsTyp.STORNIERUNG, "Storno")
|
||||||
|
|
||||||
|
assertEquals(-1500L, finalKonto.saldoCent)
|
||||||
|
|
||||||
|
val historian = service.getBuchungsHistorie(konto.kontoId)
|
||||||
|
assertEquals(2, historian.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Buchung stornieren`() {
|
||||||
|
val veranstaltungId = Uuid.random()
|
||||||
|
val personId = Uuid.random()
|
||||||
|
val konto = service.getOrCreateKonto(veranstaltungId, personId, "Storno Test")
|
||||||
|
|
||||||
|
// 1. Ursprüngliche Buchung
|
||||||
|
val gebuchtKonto = service.buche(
|
||||||
|
kontoId = konto.kontoId,
|
||||||
|
betragCent = 2500L,
|
||||||
|
typ = BuchungsTyp.BOXENGEBUEHR,
|
||||||
|
zweck = "Boxenmiete"
|
||||||
|
)
|
||||||
|
assertEquals(-2500L, gebuchtKonto.saldoCent)
|
||||||
|
|
||||||
|
val buchung = service.getBuchungsHistorie(konto.kontoId).first()
|
||||||
|
|
||||||
|
// 2. Stornieren
|
||||||
|
val storniertKonto = service.storniereBuchung(buchung.buchungId, "Falsche Box")
|
||||||
|
assertEquals(0L, storniertKonto.saldoCent)
|
||||||
|
|
||||||
|
// 3. Historie prüfen
|
||||||
|
val buchungen = service.getBuchungsHistorie(konto.kontoId)
|
||||||
|
assertEquals(2, buchungen.size)
|
||||||
|
assertTrue(buchungen.any { it.typ == BuchungsTyp.STORNIERUNG })
|
||||||
|
val storno = buchungen.find { it.typ == BuchungsTyp.STORNIERUNG }!!
|
||||||
|
assertEquals(2500L, storno.betragCent)
|
||||||
|
assertEquals(buchung.buchungId, storno.storniertBuchungId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: billing-service-test
|
||||||
|
cloud:
|
||||||
|
consul:
|
||||||
|
enabled: false
|
||||||
|
discovery:
|
||||||
|
enabled: false
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:mem:billing_test;DB_CLOSE_DELAY=-1
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
username: sa
|
||||||
|
password: ""
|
||||||
|
h2:
|
||||||
|
console:
|
||||||
|
enabled: true
|
||||||
@@ -1,23 +1,30 @@
|
|||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Multi-stage Dockerfile for Meldestelle Entries Service
|
# Multi-stage Dockerfile for Meldestelle Entries Service
|
||||||
|
# Features: Security hardening, monitoring support, optimal caching, BuildKit cache mounts
|
||||||
|
# Version: 2.6.0 - Reliable Monorepo Build
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|
||||||
# === CENTRALIZED BUILD ARGUMENTS ===
|
# === CENTRALIZED BUILD ARGUMENTS ===
|
||||||
ARG GRADLE_VERSION
|
ARG GRADLE_VERSION=9.4.1
|
||||||
ARG JAVA_VERSION
|
ARG JAVA_VERSION=25
|
||||||
ARG BUILD_DATE
|
ARG BUILD_DATE
|
||||||
ARG VERSION
|
ARG VERSION=1.0.0-SNAPSHOT
|
||||||
|
|
||||||
FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION}-alpine AS builder
|
# ===================================================================
|
||||||
|
# Build Stage
|
||||||
|
# ===================================================================
|
||||||
|
FROM eclipse-temurin:${JAVA_VERSION}-jdk-alpine AS builder
|
||||||
|
|
||||||
|
ARG VERSION
|
||||||
|
ARG BUILD_DATE
|
||||||
|
|
||||||
LABEL stage=builder \
|
LABEL stage=builder \
|
||||||
service=entries-service \
|
service="entries-service" \
|
||||||
maintainer="Meldestelle Development Team" \
|
maintainer="Meldestelle Development Team"
|
||||||
version="${VERSION}" \
|
|
||||||
build.date="${BUILD_DATE}"
|
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# Gradle optimizations
|
||||||
ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
|
ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
|
||||||
-Dorg.gradle.daemon=false \
|
-Dorg.gradle.daemon=false \
|
||||||
-Dorg.gradle.parallel=true \
|
-Dorg.gradle.parallel=true \
|
||||||
@@ -25,33 +32,26 @@ ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
|
|||||||
-Dorg.gradle.jvmargs=-Xmx2g \
|
-Dorg.gradle.jvmargs=-Xmx2g \
|
||||||
-XX:+UseParallelGC \
|
-XX:+UseParallelGC \
|
||||||
-XX:MaxMetaspaceSize=512m"
|
-XX:MaxMetaspaceSize=512m"
|
||||||
|
ENV GRADLE_USER_HOME=/root/.gradle
|
||||||
|
|
||||||
ENV GRADLE_USER_HOME=/home/gradle/.gradle
|
# 1. Copy full project structure for a reliable monorepo build
|
||||||
|
# .dockerignore should be used to exclude unnecessary files (IDE, logs, etc.)
|
||||||
|
COPY . .
|
||||||
|
|
||||||
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
|
|
||||||
COPY gradle/ gradle/
|
|
||||||
RUN chmod +x gradlew
|
RUN chmod +x gradlew
|
||||||
COPY platform/ platform/
|
|
||||||
COPY frontend/ frontend/
|
|
||||||
COPY core/ core/
|
|
||||||
COPY backend/ backend/
|
|
||||||
COPY docs/ docs/
|
|
||||||
COPY entries-service/build.gradle.kts ./
|
|
||||||
|
|
||||||
# Copy entries modules
|
# 2. Build the service
|
||||||
COPY backend/services/entries/entries-api/ backend/services/entries/entries-api/
|
RUN --mount=type=cache,target=/root/.gradle/caches \
|
||||||
COPY backend/services/entries/entries-service/ backend/services/entries/entries-service/
|
--mount=type=cache,target=/root/.gradle/wrapper \
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
|
|
||||||
--mount=type=cache,target=/home/gradle/.gradle/wrapper \
|
|
||||||
./gradlew :backend:services:entries:entries-service:dependencies --no-daemon --info
|
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/home/gradle/.gradle/caches \
|
|
||||||
--mount=type=cache,target=/home/gradle/.gradle/wrapper \
|
|
||||||
./gradlew :backend:services:entries:entries-service:bootJar --no-daemon --info
|
./gradlew :backend:services:entries:entries-service:bootJar --no-daemon --info
|
||||||
|
|
||||||
|
# 3. Extract layers
|
||||||
|
WORKDIR /builder
|
||||||
|
RUN cp /workspace/backend/services/entries/entries-service/build/libs/*.jar app.jar && \
|
||||||
|
java -Djarmode=layertools -jar app.jar extract
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# Runtime stage
|
# Runtime Stage
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime
|
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime
|
||||||
|
|
||||||
@@ -59,10 +59,6 @@ ARG BUILD_DATE
|
|||||||
ARG VERSION
|
ARG VERSION
|
||||||
ARG JAVA_VERSION
|
ARG JAVA_VERSION
|
||||||
|
|
||||||
ENV JAVA_VERSION=${JAVA_VERSION} \
|
|
||||||
VERSION=${VERSION} \
|
|
||||||
BUILD_DATE=${BUILD_DATE}
|
|
||||||
|
|
||||||
LABEL service="entries-service" \
|
LABEL service="entries-service" \
|
||||||
version="${VERSION}" \
|
version="${VERSION}" \
|
||||||
description="Microservice for Entries Management" \
|
description="Microservice for Entries Management" \
|
||||||
@@ -80,15 +76,17 @@ WORKDIR /app
|
|||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk upgrade && \
|
apk upgrade && \
|
||||||
apk add --no-cache curl tzdata tini && \
|
apk add --no-cache curl tzdata tini && \
|
||||||
rm -rf /var/cache/apk/* && \
|
rm -rf /var/cache/apk/* && addgroup -g ${APP_GID} -S ${APP_GROUP} && \
|
||||||
addgroup -g ${APP_GID} -S ${APP_GROUP} && \
|
|
||||||
adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh && \
|
adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh && \
|
||||||
mkdir -p /app/logs /app/tmp /app/config && \
|
mkdir -p /app/logs /app/tmp /app/config && \
|
||||||
chown -R ${APP_USER}:${APP_GROUP} /app && \
|
chown -R ${APP_USER}:${APP_GROUP} /app && \
|
||||||
chmod -R 750 /app
|
chmod -R 750 /app
|
||||||
|
|
||||||
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} \
|
# Copy Spring Boot layers
|
||||||
/workspace/backend/services/entries/entries-service/build/libs/*.jar app.jar
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./
|
||||||
|
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./
|
||||||
|
|
||||||
USER ${APP_USER}
|
USER ${APP_USER}
|
||||||
|
|
||||||
@@ -115,17 +113,15 @@ ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
|
|||||||
-Dmanagement.endpoint.health.show-details=always \
|
-Dmanagement.endpoint.health.show-details=always \
|
||||||
-Dmanagement.prometheus.metrics.export.enabled=true"
|
-Dmanagement.prometheus.metrics.export.enabled=true"
|
||||||
|
|
||||||
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS
|
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
|
||||||
ENV SERVER_PORT=8083
|
SERVER_PORT=8083 \
|
||||||
ENV LOGGING_LEVEL_ROOT=INFO
|
LOGGING_LEVEL_ROOT=INFO
|
||||||
|
|
||||||
ENTRYPOINT ["tini", "--", "sh", "-c", "\
|
ENTRYPOINT ["tini", "--", "sh", "-c", "\
|
||||||
echo 'Starting Entries Service with Java ${JAVA_VERSION}...'; \
|
echo 'Starting Entries Service with Java ${JAVA_VERSION}...'; \
|
||||||
echo 'Service port: ${SERVER_PORT}'; \
|
|
||||||
if [ \"${DEBUG:-false}\" = \"true\" ]; then \
|
if [ \"${DEBUG:-false}\" = \"true\" ]; then \
|
||||||
echo 'DEBUG mode enabled'; \
|
echo 'DEBUG mode enabled'; \
|
||||||
exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar; \
|
exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.launch.JarLauncher; \
|
||||||
else \
|
else \
|
||||||
echo 'Starting Entries Service in production mode'; \
|
exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher; \
|
||||||
exec java ${JAVA_OPTS} -jar app.jar; \
|
|
||||||
fi"]
|
fi"]
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
@file:OptIn(ExperimentalWasmDsl::class)
|
||||||
|
|
||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.kotlinSerialization)
|
alias(libs.plugins.kotlinSerialization)
|
||||||
@@ -7,38 +11,27 @@ group = "at.mocode"
|
|||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
// Toolchain is now handled centrally in the root build.gradle.kts
|
|
||||||
|
|
||||||
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
|
|
||||||
|
|
||||||
// JVM target for backend usage
|
|
||||||
jvm()
|
jvm()
|
||||||
|
|
||||||
// JS target for frontend usage (Compose/Browser)
|
wasmJs {
|
||||||
js {
|
|
||||||
browser()
|
browser()
|
||||||
// no need for binaries.executable() in a library
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional Wasm target for browser clients
|
|
||||||
if (enableWasm) {
|
|
||||||
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
|
|
||||||
wasmJs {
|
|
||||||
browser()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain {
|
commonMain.dependencies {
|
||||||
dependencies {
|
implementation(projects.core.coreDomain)
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(projects.core.coreUtils)
|
||||||
implementation(projects.core.coreDomain)
|
implementation(libs.kotlinx.datetime)
|
||||||
}
|
implementation(libs.kotlinx.serialization.json)
|
||||||
}
|
}
|
||||||
commonTest {
|
|
||||||
dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
implementation(kotlin("test"))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jvmTest.dependencies {
|
||||||
|
implementation(projects.platform.platformTesting)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -78,7 +78,8 @@ data class NennungEinreichenRequest(
|
|||||||
val zahlerId: Uuid? = null,
|
val zahlerId: Uuid? = null,
|
||||||
val startwunsch: StartwunschE = StartwunschE.KEIN_WUNSCH,
|
val startwunsch: StartwunschE = StartwunschE.KEIN_WUNSCH,
|
||||||
val istNachnennung: Boolean = false,
|
val istNachnennung: Boolean = false,
|
||||||
val bemerkungen: String? = null
|
val bemerkungen: String? = null,
|
||||||
|
val email: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,26 +1,39 @@
|
|||||||
|
@file:OptIn(ExperimentalWasmDsl::class)
|
||||||
|
|
||||||
|
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.kotlinSerialization)
|
alias(libs.plugins.kotlinSerialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvm()
|
jvm()
|
||||||
|
|
||||||
|
wasmJs {
|
||||||
|
browser()
|
||||||
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain {
|
all {
|
||||||
kotlin.srcDir("src/main/kotlin")
|
languageSettings.optIn("kotlin.uuid.ExperimentalUuidApi")
|
||||||
dependencies {
|
|
||||||
implementation(projects.core.coreDomain)
|
|
||||||
implementation(projects.core.coreUtils)
|
|
||||||
implementation(projects.backend.services.masterdata.masterdataDomain)
|
|
||||||
implementation(libs.kotlinx.datetime)
|
|
||||||
implementation(libs.kotlinx.serialization.json)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
commonTest {
|
|
||||||
kotlin.srcDir("src/test/kotlin")
|
commonMain.dependencies {
|
||||||
dependencies {
|
implementation(projects.core.coreDomain)
|
||||||
implementation(kotlin("test"))
|
implementation(projects.core.coreUtils)
|
||||||
implementation(projects.platform.platformTesting)
|
implementation(projects.backend.services.masterdata.masterdataDomain)
|
||||||
}
|
implementation(libs.kotlinx.datetime)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonTest.dependencies {
|
||||||
|
implementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
jvmTest.dependencies {
|
||||||
|
implementation(projects.platform.platformTesting)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-7
@@ -3,6 +3,7 @@
|
|||||||
package at.mocode.entries.domain.model
|
package at.mocode.entries.domain.model
|
||||||
|
|
||||||
import at.mocode.core.domain.model.AbteilungsTeilungsTypE
|
import at.mocode.core.domain.model.AbteilungsTeilungsTypE
|
||||||
|
import at.mocode.core.domain.model.BesichtigungsTypE
|
||||||
import at.mocode.core.domain.serialization.InstantSerializer
|
import at.mocode.core.domain.serialization.InstantSerializer
|
||||||
import at.mocode.core.domain.serialization.UuidSerializer
|
import at.mocode.core.domain.serialization.UuidSerializer
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
@@ -55,6 +56,9 @@ data class Abteilung(
|
|||||||
// Zeitplanung
|
// Zeitplanung
|
||||||
var startzeit: String? = null,
|
var startzeit: String? = null,
|
||||||
|
|
||||||
|
/** Besichtigungstyp für diese Abteilung (optional, wenn abweichend von Standard). */
|
||||||
|
var besichtigungsTyp: BesichtigungsTypE? = null,
|
||||||
|
|
||||||
// Verwaltung
|
// Verwaltung
|
||||||
var bemerkungen: String? = null,
|
var bemerkungen: String? = null,
|
||||||
|
|
||||||
@@ -81,22 +85,31 @@ data class Abteilung(
|
|||||||
* Validiert die Abteilung auf Überschreitung des maximalen Starter-Limits (§ 39 Abs. 2).
|
* Validiert die Abteilung auf Überschreitung des maximalen Starter-Limits (§ 39 Abs. 2).
|
||||||
* Gibt Warnungen zurück (kein harter Fehler – Override-Event möglich, ADR-0016).
|
* Gibt Warnungen zurück (kein harter Fehler – Override-Event möglich, ADR-0016).
|
||||||
*/
|
*/
|
||||||
fun validateStarterLimit(): List<String> {
|
fun validateStarterLimit(): List<AbteilungsWarnung> {
|
||||||
val warnings = mutableListOf<String>()
|
val warnings = mutableListOf<AbteilungsWarnung>()
|
||||||
|
|
||||||
// Maximale Abteilungsgröße nach Teilung: > 80 Starter → erneute Teilung verpflichtend (§ 39 Abs. 2)
|
// Maximale Abteilungsgröße nach Teilung: > 80 Starter → erneute Teilung verpflichtend (§ 39 Abs. 2)
|
||||||
if (starterAnzahl > 80) {
|
if (starterAnzahl > 80) {
|
||||||
warnings.add(
|
warnings.add(
|
||||||
"WARN_ABTEILUNG_PFLICHT_TEILUNG_UEBERSCHRITTEN: ${getDisplayName()}, " +
|
AbteilungsWarnung(
|
||||||
"Starter: $starterAnzahl > 80. Erneute Teilung verpflichtend (§ 39 Abs. 2). " +
|
code = AbteilungsWarnungCodeE.WARN_ABTEILUNG_ZU_GROSS,
|
||||||
"Override möglich (TBA-Entscheidung)."
|
bewerbId = bewerbId,
|
||||||
|
abteilungId = abteilungId,
|
||||||
|
nachricht = "WARN_ABTEILUNG_ZU_GROSS: ${getDisplayName()}, Starter: $starterAnzahl > 80. Erneute Teilung verpflichtend.",
|
||||||
|
oetoParagraph = "§ 39 Abs. 2"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxStarter > 0 && starterAnzahl > maxStarter) {
|
if (maxStarter > 0 && starterAnzahl > maxStarter) {
|
||||||
warnings.add(
|
warnings.add(
|
||||||
"WARN_ABTEILUNG_MAX_STARTER_UEBERSCHRITTEN: ${getDisplayName()}, " +
|
AbteilungsWarnung(
|
||||||
"Starter: $starterAnzahl > Limit $maxStarter. Override möglich (TBA-Entscheidung)."
|
code = AbteilungsWarnungCodeE.WARN_ABTEILUNG_MAX_UEBERSCHRITTEN,
|
||||||
|
bewerbId = bewerbId,
|
||||||
|
abteilungId = abteilungId,
|
||||||
|
nachricht = "WARN_ABTEILUNG_MAX_UEBERSCHRITTEN: ${getDisplayName()}, Starter: $starterAnzahl > Limit $maxStarter.",
|
||||||
|
oetoParagraph = "Hausregel / Ausschreibung"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.entries.domain.model
|
||||||
|
|
||||||
|
import at.mocode.core.domain.serialization.InstantSerializer
|
||||||
|
import at.mocode.core.domain.serialization.UuidSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Instant
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value Object für eine Abteilungs-Warnung (ÖTO § 39).
|
||||||
|
*
|
||||||
|
* Eine Warnung wird ausgegeben, wenn Schwellenwerte überschritten werden oder
|
||||||
|
* strukturelle Teilungen fehlen. Alle Warnungen sind overridebar (ADR-0007).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AbteilungsWarnung(
|
||||||
|
val code: AbteilungsWarnungCodeE,
|
||||||
|
@Serializable(with = UuidSerializer::class)
|
||||||
|
val bewerbId: Uuid,
|
||||||
|
@Serializable(with = UuidSerializer::class)
|
||||||
|
val abteilungId: Uuid? = null,
|
||||||
|
val nachricht: String,
|
||||||
|
val oetoParagraph: String,
|
||||||
|
val istOverridebar: Boolean = true,
|
||||||
|
@Serializable(with = InstantSerializer::class)
|
||||||
|
val timestamp: Instant = Clock.System.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maschinenlesbare Codes für Abteilungs-Warnungen.
|
||||||
|
*/
|
||||||
|
enum class AbteilungsWarnungCodeE {
|
||||||
|
/** Starterzahl > Pflicht-Schwellenwert (§ 39 Abs. 2) */
|
||||||
|
WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN,
|
||||||
|
/** Starterzahl > Kann-Schwellenwert, keine Teilung konfiguriert (§ 39 Abs. 2) */
|
||||||
|
WARN_KANN_TEILUNG_EMPFOHLEN,
|
||||||
|
/** Abteilung nach Teilung > 80 Starter (§ 39 Abs. 2) */
|
||||||
|
WARN_ABTEILUNG_ZU_GROSS,
|
||||||
|
/** Starter > konfiguriertes maxStarter-Limit */
|
||||||
|
WARN_ABTEILUNG_MAX_UEBERSCHRITTEN,
|
||||||
|
/** Vorgeschriebene Abteilungs-Struktur nicht vorhanden */
|
||||||
|
WARN_STRUKTURELLE_TEILUNG_FEHLT,
|
||||||
|
/** Abteilungs-Struktur vorhanden, aber Teilnehmerkreis falsch/unvollständig */
|
||||||
|
WARN_STRUKTURELLE_TEILUNG_UNVOLLSTAENDIG,
|
||||||
|
/** Mehrere Bewerbe zur gleichen Zeit am gleichen Platz */
|
||||||
|
WARN_ZEITPLAN_PLATZ_KONFLIKT,
|
||||||
|
/** Richter hat zeitgleiche Einsätze in verschiedenen Bewerben */
|
||||||
|
WARN_ZEITPLAN_RICHTER_KONFLIKT
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event, das gespeichert wird, wenn ein TBA eine Warnung überschreibt.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AbteilungsWarnungOverrideEvent(
|
||||||
|
@Serializable(with = UuidSerializer::class)
|
||||||
|
val overrideId: Uuid = Uuid.random(),
|
||||||
|
val warnungCode: AbteilungsWarnungCodeE,
|
||||||
|
@Serializable(with = UuidSerializer::class)
|
||||||
|
val bewerbId: Uuid,
|
||||||
|
@Serializable(with = UuidSerializer::class)
|
||||||
|
val abteilungId: Uuid? = null,
|
||||||
|
val begruendung: String,
|
||||||
|
@Serializable(with = UuidSerializer::class)
|
||||||
|
val tbaUserId: Uuid,
|
||||||
|
@Serializable(with = InstantSerializer::class)
|
||||||
|
val timestamp: Instant = Clock.System.now()
|
||||||
|
)
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||||
|
|
||||||
|
package at.mocode.entries.domain.model
|
||||||
|
|
||||||
|
import at.mocode.core.domain.model.BesichtigungsTypE
|
||||||
|
import at.mocode.core.domain.serialization.UuidSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repräsentiert einen Zeitblock für die Parcoursbesichtigung.
|
||||||
|
* Kann mit mehreren Abteilungen oder Bewerben verknüpft sein.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class BesichtigungsBlock(
|
||||||
|
@Serializable(with = UuidSerializer::class)
|
||||||
|
val besichtigungsBlockId: Uuid = Uuid.random(),
|
||||||
|
|
||||||
|
/** Typ der Besichtigung (zu Fuß / zu Pferd). */
|
||||||
|
val typ: BesichtigungsTypE = BesichtigungsTypE.ZU_FUSS,
|
||||||
|
|
||||||
|
/** Geplante Dauer in Minuten. */
|
||||||
|
val dauerMinuten: Int = 15,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste der verknüpften Abteilungs-IDs.
|
||||||
|
* Eine Besichtigung kann für mehrere Abteilungen gleichzeitig stattfinden.
|
||||||
|
*/
|
||||||
|
val abteilungIds: List<@Serializable(with = UuidSerializer::class) Uuid> = emptyList(),
|
||||||
|
|
||||||
|
/** Optionaler Puffer nach der Besichtigung bis zum ersten Start (Standard: 5 Min gemäß ÖTO). */
|
||||||
|
val pufferMinuten: Int = 5
|
||||||
|
)
|
||||||
+134
-8
@@ -97,6 +97,10 @@ data class Bewerb(
|
|||||||
var reitdauerMinuten: Int? = null,
|
var reitdauerMinuten: Int? = null,
|
||||||
var umbauMinuten: Int? = null,
|
var umbauMinuten: Int? = null,
|
||||||
var besichtigungMinuten: Int? = null,
|
var besichtigungMinuten: Int? = null,
|
||||||
|
|
||||||
|
/** Konfiguration für Pausen während der Prüfung. */
|
||||||
|
var pausenKonfiguration: PausenKonfiguration? = null,
|
||||||
|
|
||||||
var stechenGeplant: Boolean = false,
|
var stechenGeplant: Boolean = false,
|
||||||
|
|
||||||
// Finanzen
|
// Finanzen
|
||||||
@@ -159,15 +163,18 @@ data class Bewerb(
|
|||||||
*
|
*
|
||||||
* @param aktuelleStarterAnzahl Aktuelle Anzahl der Nennungen für diesen Bewerb.
|
* @param aktuelleStarterAnzahl Aktuelle Anzahl der Nennungen für diesen Bewerb.
|
||||||
*/
|
*/
|
||||||
fun validateAbteilungsSchwellenwerte(aktuelleStarterAnzahl: Int): List<String> {
|
fun validateAbteilungsSchwellenwerte(aktuelleStarterAnzahl: Int): List<AbteilungsWarnung> {
|
||||||
val warnings = mutableListOf<String>()
|
val warnings = mutableListOf<AbteilungsWarnung>()
|
||||||
|
|
||||||
val pflichtSchwellenwert = getPflichtTeilungsSchwellenwert()
|
val pflichtSchwellenwert = getPflichtTeilungsSchwellenwert()
|
||||||
if (pflichtSchwellenwert != null && aktuelleStarterAnzahl > pflichtSchwellenwert) {
|
if (pflichtSchwellenwert != null && aktuelleStarterAnzahl > pflichtSchwellenwert) {
|
||||||
warnings.add(
|
warnings.add(
|
||||||
"WARN_ABTEILUNG_PFLICHT_TEILUNG_UEBERSCHRITTEN: Bewerb ${getDisplayName()}, " +
|
AbteilungsWarnung(
|
||||||
"Prüfungstyp $pruefungsTyp, Starter: $aktuelleStarterAnzahl > Schwellenwert $pflichtSchwellenwert. " +
|
code = AbteilungsWarnungCodeE.WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN,
|
||||||
"Empfehlung: Teilung nach ${teilungsTyp.name}. Override möglich (TBA-Entscheidung)."
|
bewerbId = bewerbId,
|
||||||
|
nachricht = "WARN_PFLICHT_TEILUNG_UEBERSCHRITTEN: Bewerb ${getDisplayName()}, Prüfungstyp $pruefungsTyp, Starter: $aktuelleStarterAnzahl > Schwellenwert $pflichtSchwellenwert. Empfehlung: Teilung nach ${teilungsTyp.name}.",
|
||||||
|
oetoParagraph = "§ 39 Abs. 2"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,17 +183,136 @@ data class Bewerb(
|
|||||||
teilungsTyp == AbteilungsTeilungsTypE.KEINE
|
teilungsTyp == AbteilungsTeilungsTypE.KEINE
|
||||||
) {
|
) {
|
||||||
warnings.add(
|
warnings.add(
|
||||||
"WARN_ABTEILUNG_KANN_TEILUNG_EMPFOHLEN: Bewerb ${getDisplayName()}, " +
|
AbteilungsWarnung(
|
||||||
"Prüfungstyp $pruefungsTyp, Starter: $aktuelleStarterAnzahl > $kannSchwellenwert. " +
|
code = AbteilungsWarnungCodeE.WARN_KANN_TEILUNG_EMPFOHLEN,
|
||||||
"Kann-Teilung empfohlen (§ 39 Abs. 2)."
|
bewerbId = bewerbId,
|
||||||
|
nachricht = "WARN_KANN_TEILUNG_EMPFOHLEN: Bewerb ${getDisplayName()}, Prüfungstyp $pruefungsTyp, Starter: $aktuelleStarterAnzahl > $kannSchwellenwert. Kann-Teilung empfohlen.",
|
||||||
|
oetoParagraph = "§ 39 Abs. 2"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return warnings
|
return warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert die strukturelle Teilung des Bewerbs gemäß ÖTO.
|
||||||
|
* Prüft, ob die vorgeschriebenen Abteilungen (z.B. nach Lizenz oder Pferdealter) vorhanden sind.
|
||||||
|
*/
|
||||||
|
fun validateStrukturellesTeilung(abteilungen: List<Abteilung>): List<AbteilungsWarnung> {
|
||||||
|
val warnings = mutableListOf<AbteilungsWarnung>()
|
||||||
|
|
||||||
|
// 1. CSN Stilspringen bis 95 cm (§ 200 Abs. 5.3)
|
||||||
|
if (sparte == SparteE.SPRINGEN && pruefungsTyp == PruefungsTypE.STIL_SPRINGEN && (hoeheCm ?: 0) <= 95) {
|
||||||
|
val hatOhneLizenz = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("ohne Lizenz", ignoreCase = true) == true }
|
||||||
|
val hatR1 = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("R1", ignoreCase = true) == true }
|
||||||
|
|
||||||
|
if (!hatOhneLizenz || !hatR1) {
|
||||||
|
val fehlend = mutableListOf<String>()
|
||||||
|
if (!hatOhneLizenz) fehlend.add("ohne Lizenz")
|
||||||
|
if (!hatR1) fehlend.add("R1")
|
||||||
|
|
||||||
|
warnings.add(
|
||||||
|
AbteilungsWarnung(
|
||||||
|
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
|
||||||
|
bewerbId = bewerbId,
|
||||||
|
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: Stilspringen bis 95 cm erfordert getrennte Abteilungen für: ${fehlend.joinToString(", ")}.",
|
||||||
|
oetoParagraph = "ÖTO B-Teil § 200 Abs. 5.3"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Springpferdeprüfung 95-110 cm / Dressurpferdeprüfung Kl. A (§ 200 Abs. 6 / § 100 Abs. 5)
|
||||||
|
val isSpringpferdeA = sparte == SparteE.SPRINGEN && pruefungsTyp == PruefungsTypE.SPRINGPFERDE && (hoeheCm ?: 0) in 95..110
|
||||||
|
val isDressurpferdeA = sparte == SparteE.DRESSUR && pruefungsTyp == PruefungsTypE.DRESSURPFERDE && aufgabe?.contains("A", ignoreCase = true) == true
|
||||||
|
|
||||||
|
if (isSpringpferdeA || isDressurpferdeA) {
|
||||||
|
val hat4Jaehrig = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("4-jährig", ignoreCase = true) == true }
|
||||||
|
val hat56Jaehrig = abteilungen.any {
|
||||||
|
it.teilnehmerkreisBeschreibung?.contains("5-jährig", ignoreCase = true) == true ||
|
||||||
|
it.teilnehmerkreisBeschreibung?.contains("6-jährig", ignoreCase = true) == true ||
|
||||||
|
it.teilnehmerkreisBeschreibung?.contains("5-6-jährig", ignoreCase = true) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hat4Jaehrig || !hat56Jaehrig) {
|
||||||
|
val fehlend = mutableListOf<String>()
|
||||||
|
if (!hat4Jaehrig) fehlend.add("4-jährige")
|
||||||
|
if (!hat56Jaehrig) fehlend.add("5-6-jährige")
|
||||||
|
|
||||||
|
warnings.add(
|
||||||
|
AbteilungsWarnung(
|
||||||
|
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
|
||||||
|
bewerbId = bewerbId,
|
||||||
|
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: Pferdeprüfung Kl. A erfordert Trennung nach Alter: ${fehlend.joinToString(", ")}.",
|
||||||
|
oetoParagraph = if (isSpringpferdeA) "ÖTO B-Teil § 200 Abs. 6" else "ÖTO B-Teil § 100 Abs. 5"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. CSN-C-NEU (§ 231)
|
||||||
|
if (turnierkategorie == TurnierkategorieE.C_NEU && sparte == SparteE.SPRINGEN) {
|
||||||
|
if ((hoeheCm ?: 0) <= 95) {
|
||||||
|
val hatOhneLizenz = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("ohne Lizenz", ignoreCase = true) == true }
|
||||||
|
val hatMitLizenz = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("mit Lizenz", ignoreCase = true) == true }
|
||||||
|
if (!hatOhneLizenz || !hatMitLizenz) {
|
||||||
|
warnings.add(AbteilungsWarnung(
|
||||||
|
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
|
||||||
|
bewerbId = bewerbId,
|
||||||
|
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: CSN-C-NEU bis 95 cm erfordert Abt. ohne Lizenz und Abt. mit Lizenz.",
|
||||||
|
oetoParagraph = "ÖTO B-Teil § 231"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else if ((hoeheCm ?: 0) >= 100) {
|
||||||
|
val hatR1 = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("R1", ignoreCase = true) == true }
|
||||||
|
val hatR2Plus = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("R2", ignoreCase = true) == true }
|
||||||
|
if (!hatR1 || !hatR2Plus) {
|
||||||
|
warnings.add(AbteilungsWarnung(
|
||||||
|
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
|
||||||
|
bewerbId = bewerbId,
|
||||||
|
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: CSN-C-NEU ab 100 cm erfordert Abt. R1 und Abt. R2+.",
|
||||||
|
oetoParagraph = "ÖTO B-Teil § 231"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Caprilli (§ 803 Abs. 2)
|
||||||
|
if (pruefungsTyp == PruefungsTypE.CAPRILLI) {
|
||||||
|
val hatLizenzfrei = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("lizenzfrei", ignoreCase = true) == true }
|
||||||
|
val hatRD1Plus = abteilungen.any { it.teilnehmerkreisBeschreibung?.contains("RD1", ignoreCase = true) == true || it.teilnehmerkreisBeschreibung?.contains("R1", ignoreCase = true) == true }
|
||||||
|
|
||||||
|
if (!hatLizenzfrei || !hatRD1Plus) {
|
||||||
|
warnings.add(AbteilungsWarnung(
|
||||||
|
code = AbteilungsWarnungCodeE.WARN_STRUKTURELLE_TEILUNG_FEHLT,
|
||||||
|
bewerbId = bewerbId,
|
||||||
|
nachricht = "WARN_STRUKTURELLE_TEILUNG_FEHLT: Caprilli-Prüfung erfordert Abt. lizenzfrei und Abt. RD1+.",
|
||||||
|
oetoParagraph = "ÖTO B-Teil § 803 Abs. 2"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
|
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
|
||||||
*/
|
*/
|
||||||
fun withUpdatedTimestamp(): Bewerb = this.copy(updatedAt = Clock.System.now())
|
fun withUpdatedTimestamp(): Bewerb = this.copy(updatedAt = Clock.System.now())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konfiguration für automatische Pausen nach einer bestimmten Anzahl von Startern.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PausenKonfiguration(
|
||||||
|
/** Pause alle X Starter (0 = keine automatischen Pausen). */
|
||||||
|
val starterIntervall: Int = 0,
|
||||||
|
|
||||||
|
/** Dauer der Pause in Minuten. */
|
||||||
|
val dauerMinuten: Int = 10,
|
||||||
|
|
||||||
|
/** Optionale Bezeichnung (z.B. "Platzpflege"). */
|
||||||
|
val bezeichnung: String? = null
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user