Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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
|
||||||
+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)
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ PING_CONSUL_PREFER_IP=true
|
|||||||
MAIL_PORT=8083:8083
|
MAIL_PORT=8083:8083
|
||||||
MAIL_DEBUG_PORT=5014:5014
|
MAIL_DEBUG_PORT=5014:5014
|
||||||
MAIL_SERVER_PORT=8083
|
MAIL_SERVER_PORT=8083
|
||||||
|
MAIL_SERVICE_URL=http://10.0.0.50:8092
|
||||||
|
|
||||||
MAIL_SPRING_PROFILES_ACTIVE=docker
|
MAIL_SPRING_PROFILES_ACTIVE=docker
|
||||||
MAIL_DEBUG=true
|
MAIL_DEBUG=true
|
||||||
MAIL_SERVICE_NAME=mail-service
|
MAIL_SERVICE_NAME=mail-service
|
||||||
@@ -172,8 +174,8 @@ MAIL_SMTP_PASSWORD=Mogi#2reiten
|
|||||||
MAIL_SMTP_AUTH=true
|
MAIL_SMTP_AUTH=true
|
||||||
MAIL_SMTP_STARTTLS=true
|
MAIL_SMTP_STARTTLS=true
|
||||||
|
|
||||||
SPRING_MAIL_HOST=localhost
|
SPRING_MAIL_HOST=smtp.world4you.com
|
||||||
SPRING_MAIL_PORT=1025
|
SPRING_MAIL_PORT=587
|
||||||
SPRING_MAIL_USERNAME=online-nennen@mo-code.at
|
SPRING_MAIL_USERNAME=online-nennen@mo-code.at
|
||||||
SPRING_MAIL_PASSWORD=Mogi#2reiten
|
SPRING_MAIL_PASSWORD=Mogi#2reiten
|
||||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=false
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=false
|
||||||
|
|||||||
@@ -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,7 @@ 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)
|
||||||
context: .
|
|
||||||
dockerfile: config/docker/keycloak/Dockerfile
|
|
||||||
image: keycloak
|
|
||||||
- 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: mail-service
|
- service: mail-service
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/services/mail/Dockerfile
|
dockerfile: backend/services/mail/Dockerfile
|
||||||
@@ -65,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
|
||||||
@@ -126,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
|
||||||
@@ -141,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
|
||||||
|
|||||||
@@ -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" "$@"
|
||||||
+26
-5
@@ -7,10 +7,16 @@ 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.core.DelegatingOAuth2TokenValidator
|
||||||
import org.springframework.security.oauth2.jwt.*
|
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.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
|
||||||
@@ -21,16 +27,16 @@ 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)
|
.addFilterBefore(DeviceSecurityFilter(), UsernamePasswordAuthenticationFilter::class.java)
|
||||||
.authorizeHttpRequests { auth ->
|
.authorizeHttpRequests { auth ->
|
||||||
// Explizite Freigaben (Health, Information, 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/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/health").permitAll()
|
auth.requestMatchers("/ping/health").permitAll()
|
||||||
@@ -71,4 +77,19 @@ class GlobalSecurityConfig {
|
|||||||
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ dependencies {
|
|||||||
|
|
||||||
// Spring Boot Starters
|
// Spring Boot Starters
|
||||||
implementation(libs.spring.boot.starter.web)
|
implementation(libs.spring.boot.starter.web)
|
||||||
|
implementation(libs.spring.boot.starter.security)
|
||||||
|
implementation(libs.spring.boot.starter.oauth2.resource.server)
|
||||||
|
implementation(projects.backend.infrastructure.security)
|
||||||
implementation(libs.spring.boot.starter.validation)
|
implementation(libs.spring.boot.starter.validation)
|
||||||
implementation(libs.spring.boot.starter.actuator)
|
implementation(libs.spring.boot.starter.actuator)
|
||||||
implementation(libs.spring.boot.starter.mail)
|
implementation(libs.spring.boot.starter.mail)
|
||||||
|
|||||||
+28
-1
@@ -4,22 +4,49 @@ import org.slf4j.LoggerFactory
|
|||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.event.EventListener
|
import org.springframework.context.event.EventListener
|
||||||
import org.springframework.core.env.Environment
|
import org.springframework.core.env.Environment
|
||||||
|
import org.springframework.web.servlet.config.annotation.CorsRegistry
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication(scanBasePackages = ["at.mocode.mail", "at.mocode.infrastructure.security"])
|
||||||
class MailServiceApplication(private val env: Environment) {
|
class MailServiceApplication(private val env: Environment) {
|
||||||
|
|
||||||
private val log = LoggerFactory.getLogger(MailServiceApplication::class.java)
|
private val log = LoggerFactory.getLogger(MailServiceApplication::class.java)
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun corsConfigurer(): WebMvcConfigurer {
|
||||||
|
return object : WebMvcConfigurer {
|
||||||
|
override fun addCorsMappings(registry: CorsRegistry) {
|
||||||
|
registry.addMapping("/**")
|
||||||
|
.allowedOrigins("*")
|
||||||
|
.allowedMethods("*")
|
||||||
|
.allowedHeaders("*")
|
||||||
|
.allowCredentials(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent::class)
|
@EventListener(ApplicationReadyEvent::class)
|
||||||
fun onApplicationReady() {
|
fun onApplicationReady() {
|
||||||
val springPort = env.getProperty("server.port", "8083")
|
val springPort = env.getProperty("server.port", "8083")
|
||||||
val appName = env.getProperty("spring.application.name", "mail-service")
|
val appName = env.getProperty("spring.application.name", "mail-service")
|
||||||
|
|
||||||
|
val mailHost = env.getProperty("spring.mail.host")
|
||||||
|
val mailPort = env.getProperty("spring.mail.port")
|
||||||
|
val mailUser = env.getProperty("spring.mail.username")
|
||||||
|
val mailPass = env.getProperty("spring.mail.password")?.take(3) + "***"
|
||||||
|
val connTimeout = env.getProperty("spring.mail.properties.mail.smtp.connectiontimeout")
|
||||||
|
|
||||||
|
val envHost = System.getenv("SPRING_MAIL_HOST")
|
||||||
|
val envPort = System.getenv("SPRING_MAIL_PORT")
|
||||||
|
|
||||||
log.info("----------------------------------------------------------")
|
log.info("----------------------------------------------------------")
|
||||||
log.info("Application '{}' is running!", appName)
|
log.info("Application '{}' is running!", appName)
|
||||||
log.info("Spring Management Port: {}", springPort)
|
log.info("Spring Management Port: {}", springPort)
|
||||||
|
log.info("SMTP Config (Resolved): host={}, port={}, user={}, pass={}, timeout={}", mailHost, mailPort, mailUser, mailPass, connTimeout)
|
||||||
|
log.info("SMTP Config (Raw Env): host={}, port={}, pass={}", envHost, envPort, System.getenv("SPRING_MAIL_PASSWORD")?.take(3) + "***")
|
||||||
log.info("Profiles: {}", env.activeProfiles.joinToString(", "))
|
log.info("Profiles: {}", env.activeProfiles.joinToString(", "))
|
||||||
log.info("----------------------------------------------------------")
|
log.info("----------------------------------------------------------")
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-4
@@ -39,7 +39,6 @@ data class NennungRequest(
|
|||||||
@OptIn(ExperimentalUuidApi::class)
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/mail")
|
@RequestMapping("/api/mail")
|
||||||
@CrossOrigin(origins = ["http://localhost:8080", "https://nennung.mo-code.at"]) // Für Wasm-Web-App (Compose HTML/Wasm)
|
|
||||||
class MailController(
|
class MailController(
|
||||||
private val nennungRepository: NennungRepository,
|
private val nennungRepository: NennungRepository,
|
||||||
private val mailSender: JavaMailSender
|
private val mailSender: JavaMailSender
|
||||||
@@ -50,7 +49,7 @@ class MailController(
|
|||||||
private lateinit var baseMailAddress: String
|
private lateinit var baseMailAddress: String
|
||||||
|
|
||||||
@PostMapping("/nennung")
|
@PostMapping("/nennung")
|
||||||
fun receiveNennung(@Valid @RequestBody request: NennungRequest) {
|
fun receiveNennung(@Valid @RequestBody request: NennungRequest): Map<String, Any> {
|
||||||
logger.info("Nennung via API erhalten: ${request.vorname} ${request.nachname} für Turnier ${request.turnierNr}")
|
logger.info("Nennung via API erhalten: ${request.vorname} ${request.nachname} für Turnier ${request.turnierNr}")
|
||||||
|
|
||||||
val entity = NennungEntity(
|
val entity = NennungEntity(
|
||||||
@@ -72,6 +71,7 @@ class MailController(
|
|||||||
logger.info("Nennung ${entity.id} in Datenbank persistiert.")
|
logger.info("Nennung ${entity.id} in Datenbank persistiert.")
|
||||||
|
|
||||||
// --- PLAN B: Benachrichtigung an die Meldestelle (online-nennen@mo-code.at) senden ---
|
// --- PLAN B: Benachrichtigung an die Meldestelle (online-nennen@mo-code.at) senden ---
|
||||||
|
logger.info("Versuche Benachrichtigungs-Mail an $baseMailAddress zu senden...")
|
||||||
try {
|
try {
|
||||||
val notification = SimpleMailMessage()
|
val notification = SimpleMailMessage()
|
||||||
notification.from = baseMailAddress // Mailserver erfordert oft, dass From == Username ist
|
notification.from = baseMailAddress // Mailserver erfordert oft, dass From == Username ist
|
||||||
@@ -98,10 +98,11 @@ class MailController(
|
|||||||
mailSender.send(notification)
|
mailSender.send(notification)
|
||||||
logger.info("Plan-B Nennungs-Mail an die Meldestelle gesendet. Betreff: ${notification.subject}")
|
logger.info("Plan-B Nennungs-Mail an die Meldestelle gesendet. Betreff: ${notification.subject}")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("Fehler beim Senden der Plan-B Nennungs-Mail an die Meldestelle: ${e.message}")
|
logger.error("KRITISCH: Fehler beim Senden der Plan-B Nennungs-Mail an die Meldestelle: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Ursprüngliche Bestätigung an den Reiter (optional, bleibt vorerst erhalten) ---
|
// --- Ursprüngliche Bestätigung an den Reiter (optional, bleibt vorerst erhalten) ---
|
||||||
|
logger.info("Versuche Bestätigungs-Mail an ${request.email} zu senden...")
|
||||||
try {
|
try {
|
||||||
val message = SimpleMailMessage()
|
val message = SimpleMailMessage()
|
||||||
|
|
||||||
@@ -127,8 +128,14 @@ class MailController(
|
|||||||
mailSender.send(message)
|
mailSender.send(message)
|
||||||
logger.info("Bestätigungs-Mail an ${request.email} gesendet.")
|
logger.info("Bestätigungs-Mail an ${request.email} gesendet.")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("Fehler beim Senden der Bestätigungs-Mail: ${e.message}")
|
logger.error("KRITISCH: Fehler beim Senden der Bestätigungs-Mail an ${request.email}: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
"success" to true,
|
||||||
|
"message" to "Nennung erhalten und verarbeitet",
|
||||||
|
"id" to entity.id.toString()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/nennungen")
|
@GetMapping("/nennungen")
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ spring:
|
|||||||
show-sql: true
|
show-sql: true
|
||||||
mail:
|
mail:
|
||||||
host: ${SPRING_MAIL_HOST:smtp.world4you.com}
|
host: ${SPRING_MAIL_HOST:smtp.world4you.com}
|
||||||
port: ${SPRING_MAIL_PORT:587}
|
port: 587
|
||||||
username: ${SPRING_MAIL_USERNAME:online-nennen@mo-code.at}
|
username: ${SPRING_MAIL_USERNAME:online-nennen@mo-code.at}
|
||||||
password: ${SPRING_MAIL_PASSWORD:Mogi#2reiten}
|
password: ${SPRING_MAIL_PASSWORD:Mogi#2reiten}
|
||||||
properties:
|
properties:
|
||||||
mail:
|
mail:
|
||||||
smtp:
|
smtp:
|
||||||
auth: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH:true}
|
auth: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH:true}
|
||||||
|
connectiontimeout: 5000
|
||||||
|
timeout: 5000
|
||||||
|
writetimeout: 5000
|
||||||
starttls:
|
starttls:
|
||||||
enable: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE:true}
|
enable: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE:true}
|
||||||
|
required: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED:true}
|
||||||
|
|
||||||
cloud:
|
cloud:
|
||||||
consul:
|
consul:
|
||||||
|
|||||||
@@ -17,9 +17,22 @@
|
|||||||
|
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
|
|
||||||
# Reverse Proxy: Plan-B leitet nur /api/mail an den Mail-Service weiter (kein API-Gateway nötig)
|
# Same-Origin Strategy: Alle /api/* Anfragen werden intern an den Mail-Service weitergeleitet
|
||||||
handle /api/mail/* {
|
# Dadurch sieht der Browser nur noch app.mo-code.at und CORS wird hinfällig.
|
||||||
reverse_proxy mail-service:8085
|
handle /api/* {
|
||||||
|
reverse_proxy mail-service:8085 {
|
||||||
|
header_up Host {upstream_hostport}
|
||||||
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
Access-Control-Allow-Origin "*"
|
||||||
|
Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||||
|
Access-Control-Allow-Headers "*"
|
||||||
|
X-Caddy-Strategy "same-origin-v32"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handle /health {
|
handle /health {
|
||||||
@@ -32,12 +45,21 @@
|
|||||||
}
|
}
|
||||||
header @wasm Content-Type "application/wasm"
|
header @wasm Content-Type "application/wasm"
|
||||||
|
|
||||||
# Caching-Strategie: Immutable Assets (hash-Dateien) lange cachen
|
# Caching-Strategie: Immutable Assets (hash-Dateien)
|
||||||
|
# WICHTIG: .wasm und .js werden hier gecached. Falls die Dateinamen gleich bleiben,
|
||||||
|
# wird der Browser sie NICHT neu laden.
|
||||||
@immutable {
|
@immutable {
|
||||||
path *.js *.css *.wasm *.png *.svg *.ico *.woff2 *.map
|
path *.png *.svg *.ico *.woff2 *.map
|
||||||
}
|
}
|
||||||
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
header @immutable Cache-Control "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
# Wasm und JS Dateien: Kein Cache während der aktiven Entwicklungsphase (Plan-B)
|
||||||
|
# um "Alte Seite" Probleme zu vermeiden.
|
||||||
|
@wasm_js {
|
||||||
|
path *.wasm *.js
|
||||||
|
}
|
||||||
|
header @wasm_js Cache-Control "no-store, no-cache, must-revalidate"
|
||||||
|
|
||||||
# Keine Cache-Header für SPA-Einstieg und Laufzeitkonfig
|
# Keine Cache-Header für SPA-Einstieg und Laufzeitkonfig
|
||||||
@nocache {
|
@nocache {
|
||||||
path /index.html /config.json
|
path /index.html /config.json
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ COPY config/docker/caddy/web-app/config.json /usr/share/caddy/config.json.tmpl
|
|||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# Copy Pre-built Static Assets from Host (WasmJs)
|
# Copy Pre-built Static Assets from Host (WasmJs)
|
||||||
# NOTE: You must run `./gradlew :frontend:shells:meldestelle-web:wasmJsBrowserDistribution -Pproduction=true` locally first!
|
# NOTE: BUILD_DATE wird hier genutzt, um den Layer-Cache zu invalidieren,
|
||||||
COPY frontend/shells/meldestelle-web/build/dist/wasmJs/productionExecutable/ /usr/share/caddy/
|
# falls sich der Code geändert hat, aber die Dateimetadaten im Runner-Cache gleich blieben.
|
||||||
|
ARG BUILD_DATE
|
||||||
|
COPY config/docker/caddy/web-app/_site/ /usr/share/caddy/
|
||||||
# index.html wird als Template abgelegt; der Entrypoint erzeugt daraus zur Laufzeit die finale index.html
|
# index.html wird als Template abgelegt; der Entrypoint erzeugt daraus zur Laufzeit die finale index.html
|
||||||
RUN mv /usr/share/caddy/index.html /usr/share/caddy/index.html.tmpl
|
RUN mv /usr/share/caddy/index.html /usr/share/caddy/index.html.tmpl
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ set -e
|
|||||||
|
|
||||||
# Ersetze ${API_BASE_URL}, ${MAIL_SERVICE_URL} und ${KEYCLOAK_URL} in index.html und config.json zur Container-Startzeit.
|
# Ersetze ${API_BASE_URL}, ${MAIL_SERVICE_URL} und ${KEYCLOAK_URL} in index.html und config.json zur Container-Startzeit.
|
||||||
# Caddy bekommt fertige, statische Dateien — kein Template-Parsing mehr nötig.
|
# Caddy bekommt fertige, statische Dateien — kein Template-Parsing mehr nötig.
|
||||||
|
# Wir fügen zusätzlich einen Cache-Buster (Zeitstempel) an den Script-Tag in der index.html an
|
||||||
|
CACHE_BUSTER=$(date +%s)
|
||||||
envsubst '${API_BASE_URL} ${MAIL_SERVICE_URL} ${KEYCLOAK_URL}' \
|
envsubst '${API_BASE_URL} ${MAIL_SERVICE_URL} ${KEYCLOAK_URL}' \
|
||||||
< /usr/share/caddy/index.html.tmpl \
|
< /usr/share/caddy/index.html.tmpl | \
|
||||||
|
sed "s|meldestelle-web.js|meldestelle-web.js?v=${CACHE_BUSTER}|g" \
|
||||||
> /usr/share/caddy/index.html
|
> /usr/share/caddy/index.html
|
||||||
|
|
||||||
envsubst '${API_BASE_URL} ${MAIL_SERVICE_URL} ${KEYCLOAK_URL}' \
|
envsubst '${API_BASE_URL} ${MAIL_SERVICE_URL} ${KEYCLOAK_URL}' \
|
||||||
|
|||||||
+15
-10
@@ -8,10 +8,10 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Diese Variablen werden vom Web-Container verwendet, um die Ziel-URLs in die index.html zu injizieren
|
# Diese Variablen werden vom Web-Container verwendet, um die Ziel-URLs in die index.html zu injizieren
|
||||||
API_BASE_URL: ${API_BASE_URL:-https://api.mo-code.at}
|
API_BASE_URL: https://api.mo-code.at
|
||||||
MAIL_SERVICE_URL: ${MAIL_SERVICE_URL:-https://api.mo-code.at/mail}
|
MAIL_SERVICE_URL: https://api.mo-code.at
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_APP_PORT:-8080:80}"
|
- "${WEB_APP_PORT:-4000:4000}"
|
||||||
networks: [meldestelle-network]
|
networks: [meldestelle-network]
|
||||||
|
|
||||||
# --- Mail-Service (Plan-B: Form -> E-Mail) ---
|
# --- Mail-Service (Plan-B: Form -> E-Mail) ---
|
||||||
@@ -21,15 +21,20 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Server-Port im Container (Spring Boot)
|
# Server-Port im Container (Spring Boot)
|
||||||
SERVER_PORT: ${SERVER_PORT:-8085}
|
SERVER_PORT: "8085"
|
||||||
|
|
||||||
|
# Plan-B: Zipkin-Fehler unterdrücken
|
||||||
|
MANAGEMENT_TRACING_ENABLED: "false"
|
||||||
|
SPRING_ZIPKIN_ENABLED: "false"
|
||||||
|
|
||||||
# SMTP (World4You - PROD)
|
# SMTP (World4You - PROD)
|
||||||
SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-smtp.world4you.com}
|
SPRING_MAIL_HOST: "smtp.world4you.com"
|
||||||
SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-587}
|
SPRING_MAIL_PORT: "587"
|
||||||
SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-online-nennen@mo-code.at}
|
SPRING_MAIL_USERNAME: "online-nennen@mo-code.at"
|
||||||
SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD:-changeme}
|
SPRING_MAIL_PASSWORD: "Mogi#2reiten"
|
||||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH:-true}
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "true"
|
||||||
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE:-true}
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "true"
|
||||||
|
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: "true"
|
||||||
|
|
||||||
# Feature-Flags / Infra-Off
|
# Feature-Flags / Infra-Off
|
||||||
MAIL_POLLING_ENABLED: ${MAIL_POLLING_ENABLED:-false}
|
MAIL_POLLING_ENABLED: ${MAIL_POLLING_ENABLED:-false}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Journal-Eintrag: Plan-B Online-Nenn-Formulare
|
||||||
|
|
||||||
|
**Datum:** 23. April 2026
|
||||||
|
**Agenten:** 🎨 [Frontend Expert], 🖌️ [UI/UX Designer], 👷 [Backend Developer], 🧹 [Curator]
|
||||||
|
|
||||||
|
## 🎯 Zielsetzung
|
||||||
|
Erstellung von zwei hoch-optimierten Web-Formularen für die Turniere in Neumarkt (25. & 26. April 2026) im Rahmen des "Plan-B" (Offline-Meldestelle mit E-Mail-Sync).
|
||||||
|
|
||||||
|
## 🛠️ Durchgeführte Änderungen
|
||||||
|
|
||||||
|
### 🎨 Frontend & UI/UX
|
||||||
|
- **`OnlineNennungFormular.kt`**: Komplette Neugestaltung des Formulars.
|
||||||
|
- Integration der spezifischen Bewerbe für **CSN-C Neumarkt (25.04.)** und **CDN-C Neumarkt (26.04.)**.
|
||||||
|
- Implementierung der Validierungslogik für den "Jetzt nennen" Button (Bernstein-Orange).
|
||||||
|
- Hinzufügen von Feldern für Reiter-Name, Kontakt (E-Mail/Tel), Pferdename und Anmerkungen.
|
||||||
|
- Information Density: Alle Bewerbe direkt auswählbar.
|
||||||
|
- **Mobile-First Optimierung**: Responsives Layout mittels `BoxWithConstraints`. Vertikaler Stack für Formularfelder auf Mobile, optimierte Paddings, Schriftgrößen und Touch-Targets.
|
||||||
|
- **`WebMainScreen.kt`**: Aktualisierung der Landing-Page mit den realen Turnierdaten für Neumarkt.
|
||||||
|
- **Mobile-First Optimierung**: Turnier-Karten passen sich an schmale Bildschirme an (Buttons nebeneinander, Icons für bessere UX).
|
||||||
|
|
||||||
|
### 👷 Backend & Integration
|
||||||
|
- **`NennungRemoteRepository.kt`**: Verknüpfung des neuen Payloads mit dem `mail-service`.
|
||||||
|
- **`MailController.kt`**: Validierung der API-Schnittstelle. Der Service ist so konfiguriert, dass er:
|
||||||
|
1. Die Nennung in der Datenbank persistiert.
|
||||||
|
2. Eine Benachrichtigungs-Mail an die Meldestelle (`online-nennen@mo-code.at`) sendet.
|
||||||
|
3. Eine automatische Bestätigung an den Reiter schickt.
|
||||||
|
|
||||||
|
## 🏁 Ergebnis
|
||||||
|
Die "Hallo Du!" Test-UI wurde durch produktive, fachlich korrekte Formulare ersetzt. Sobald ein Reiter auf "Jetzt nennen" klickt, wird der E-Mail-Workflow ausgelöst.
|
||||||
|
|
||||||
|
**Status:** Bereit für den Live-Einsatz am Wochenende. 🚀
|
||||||
|
|
||||||
|
### 2026-04-23 09:35 - Version 12: Hard-coded HTTPS & Injektions-Fix
|
||||||
|
- **Problem**: 'Mixed Content' Fehler blockierte API-Aufrufe, da die Wasm-App trotz HTTPS-Origin versuchte, 'http://10.0.0.50' (Lokale IP) via HTTP zu kontaktieren.
|
||||||
|
- **Lösung**:
|
||||||
|
- `PlatformConfig.wasmJs.kt`: Implementierung eines sicheren HTTPS-Fallbacks auf `https://api.mo-code.at` im Code, falls die Docker-Injektion (z.B. durch Browser-Cache) fehlschlägt.
|
||||||
|
- `dc-planb.yaml`: Statische Konfiguration der HTTPS-URLs ohne Umgebungsvariablen-Platzhalter, um Fehlkonfigurationen am Host auszuschließen.
|
||||||
|
- UI-Marker auf `v2026-04-23.12 - HARD-CODED HTTPS` aktualisiert.
|
||||||
|
- Fehlerbehandlung in `OnlineNennungFormular.kt` zeigt nun explizit Netzwerkfehler an, falls diese auftreten.
|
||||||
|
|
||||||
|
### 2026-04-23 10:15 - Version 13: Radikale HTTPS-Priorisierung
|
||||||
|
- **Problem**: Trotz harten Fallbacks im Code versuchte der Browser weiterhin `http://10.0.0.50` (Mixed Content) aufzurufen. Ursache war die Priorisierung von dynamischen Variablen und `window.location.origin` in der `PlatformConfig.wasmJs.kt`.
|
||||||
|
- **Lösung**:
|
||||||
|
- `PlatformConfig.wasmJs.kt`: Alle Logiken zur Erkennung von URLs wurden temporär deaktiviert. Die Funktionen `resolveMailServiceUrl()` und `resolveApiBaseUrl()` geben nun **zwingend** `https://api.mo-code.at` zurück.
|
||||||
|
- Dies umgeht jegliches Caching von `index.html` oder fälschlich injizierte Umgebungsvariablen.
|
||||||
|
- UI-Marker auf `v2026-04-23.13 - RADICAL HTTPS PRIORITIZATION` aktualisiert.
|
||||||
|
|
||||||
|
### 2026-04-23 10:45 - Version 14: CORS Reanimation
|
||||||
|
- **Problem**: Trotz HTTPS-Fix blockierte die CORS-Policy im Backend die Anfragen von `https://app.mo-code.at`.
|
||||||
|
- **Lösung**:
|
||||||
|
- `GlobalSecurityConfig.kt`: CORS explizit wieder aktiviert (`.cors { }`), da Microservices im Plan-B direkt (ohne Gateway) angesprochen werden könnten.
|
||||||
|
- `MailController.kt`: `@CrossOrigin` um explizite Header (`allowedHeaders = ["*"]`) und Methoden (`methods = [...]`) erweitert, um Preflight-Checks (OPTIONS) korrekt zu bedienen.
|
||||||
|
- UI-Marker auf `v2026-04-23.14 - CORS REANIMATION` aktualisiert.
|
||||||
|
|
||||||
|
### 2026-04-23 11:45 - Version 17: Security Dependency Fix
|
||||||
|
- **Problem**: Trotz Version 16 und dem `scanBasePackages` Fix im `mail-service` bestand der CORS-Fehler weiterhin. Ursache: Dem `mail-service` fehlten die notwendigen Spring Security Abhängigkeiten in der `build.gradle.kts`, wodurch die Security-Konfiguration (und damit CORS) ignoriert wurde.
|
||||||
|
- **Lösung**:
|
||||||
|
- `build.gradle.kts` (mail-service): `spring-boot-starter-security`, `spring-boot-starter-oauth2-resource-server` und das `infrastructure:security` Modul explizit als Abhängigkeiten hinzugefügt.
|
||||||
|
- UI-Marker auf `v2026-04-23.17 - SECURITY DEPENDENCY FIX` aktualisiert.
|
||||||
|
|
||||||
|
### v2026-04-23.19 - NUCLEAR CORS FIX
|
||||||
|
- **Problem**: Trotz Patterns in der Security-Konfiguration fehlte der `Access-Control-Allow-Origin` Header bei Preflight-Anfragen.
|
||||||
|
- **Lösung**:
|
||||||
|
- Implementierung einer `WebMvcConfigurer` Bean direkt in `MailServiceApplication.kt` für ein zweites, redundantes CORS-Mapping.
|
||||||
|
- Lockerung der `allowedOriginPatterns` in `GlobalSecurityConfig.kt` auf `*`.
|
||||||
|
- **Status**: Versionsmarker auf v19 aktualisiert.
|
||||||
|
|
||||||
|
### v2026-04-23.20 - CLOUDFLARE DNS VERIFIED & CORS POLISHING
|
||||||
|
- **Analyse**: DNS-Einträge in Cloudflare geprüft (Screenshot). Alle Einträge stehen auf "Nur DNS" (graue Wolke). Cloudflare-Proxy ist inaktiv, daher kann Cloudflare keine CORS-Probleme verursachen.
|
||||||
|
- **Lösung**:
|
||||||
|
- CORS-Konfiguration in `GlobalSecurityConfig.kt` finalisiert: Whitelist für `https://*.mo-code.at` und `http://localhost:[*]` verfeinert.
|
||||||
|
- `allowedMethods` um `HEAD` erweitert und `exposedHeaders` hinzugefügt, um Browser-Warnungen zu eliminieren.
|
||||||
|
- **Status**: Versionsmarker auf v2026-04-23.20 aktualisiert.
|
||||||
|
|
||||||
|
### v2026-04-23.21 - CADDY CORS PROXY FIX
|
||||||
|
- **Problem**: Trotz umfangreicher Backend-Konfiguration (v20) meldete der Browser weiterhin fehlende CORS-Header bei Preflight-Anfragen (`No 'Access-Control-Allow-Origin' header`).
|
||||||
|
- **Lösung**:
|
||||||
|
- CORS-Handshaking wurde direkt in den Caddy-Reverse-Proxy (`Caddyfile` der Web-App) verlagert.
|
||||||
|
- OPTIONS-Requests werden nun sofort vom Proxy mit `204 No Content` und den korrekten CORS-Headern beantwortet.
|
||||||
|
- Damit wird sichergestellt, dass der Browser die Header erhält, noch bevor die Anfrage das Backend erreicht.
|
||||||
|
- **Status**: Versionsmarker auf v2026-04-23.21 aktualisiert.
|
||||||
|
|
||||||
|
### v2026-04-23.22 - CADDY DEFER CORS FIX
|
||||||
|
- **Analyse**: Die CORS-Blockade hielt an (v21). Die Fehlermeldung "No 'Access-Control-Allow-Origin' header" blieb bestehen.
|
||||||
|
- **Lösung**:
|
||||||
|
- Im `Caddyfile` wurde das `defer`-Flag für die Header-Direktive hinzugefügt. Dies stellt sicher, dass Caddy die CORS-Header erst ganz am Ende der Response-Verarbeitung setzt und sie nicht von anderen Direktiven (wie `reverse_proxy`) überschrieben werden können.
|
||||||
|
- Radikale Vereinfachung des CORS-Blocks im Caddyfile für maximale Zuverlässigkeit bei Preflight-Anfragen.
|
||||||
|
- **Status**: Versionsmarker auf v2026-04-23.22 aktualisiert.
|
||||||
|
|
||||||
|
|
||||||
|
### v2026-04-23.23 - CADDY CORS OPTIONS FIX
|
||||||
|
- **Problem**: CORS Preflight (OPTIONS) wurde blockiert, vermutlich weil 'defer' Header verzögerte oder 'Access-Control-Allow-Headers' nicht spezifisch genug war.
|
||||||
|
- **Lösung**: Caddyfile umgebaut. OPTIONS-Requests werden nun in einem eigenen Handle mit expliziten Headern (inkl. Content-Type) beantwortet, ohne 'defer'.
|
||||||
|
- **Status**: Versionsmarker auf v2026-04-23.23 aktualisiert.
|
||||||
|
|
||||||
|
### v2026-04-23.24 - CADDY CORS FINAL BOSS
|
||||||
|
- **Problem**: CORS Preflight (OPTIONS) weiterhin blockiert (v23). Die Fehlermeldung deutete darauf hin, dass die Header immer noch nicht zuverlässig beim Browser ankommen.
|
||||||
|
- **Lösung**:
|
||||||
|
- `Caddyfile` radikal gehärtet: `OPTIONS` Requests werden nun mit `X-Caddy-CORS: preflight` markiert und erhalten eine leere Response (`respond "" 204`).
|
||||||
|
- Hinzufügen von `X-Requested-With` zu den erlaubten Headern (oft von KMP/Ktor-Clients verwendet).
|
||||||
|
- Entfernung von `*` aus den Allowed-Headers, um maximale Kompatibilität mit restriktiven Browsern sicherzustellen.
|
||||||
|
- **Status**: Versionsmarker auf v2026-04-23.24 aktualisiert.
|
||||||
|
|
||||||
|
### v2026-04-23.27 - SAME-ORIGIN PROXY (THE "NO-CORS" STRATEGY)
|
||||||
|
- **Problem**: Trotz 26 Versuchen, CORS via Headers (Caddy/Spring) zu lösen, blockierten Browser/Proxies weiterhin die Preflight-Anfragen (OPTIONS).
|
||||||
|
- **Lösung (Radikalschlag)**:
|
||||||
|
- **Frontend (`PlatformConfig.wasmJs.kt`)**: API-URLs auf relativ (`/api`) umgestellt.
|
||||||
|
- **Caddy Proxy (`Caddyfile`)**: Alle Anfragen an `/api/*` werden intern an `mail-service` weitergeleitet.
|
||||||
|
- **Status**: Versionsmarker v27.
|
||||||
|
|
||||||
|
### v2026-04-23.28 - SAME-ORIGIN v2
|
||||||
|
- **Caddy-Routing**: Korrektur des Proxy-Routings (kein `strip_prefix`), um die Backend-Endpunkte exakt zu treffen.
|
||||||
|
- **Relative Pfade**: API-URL im Frontend auf "" gesetzt, was zusammen mit `/api/...` CORS-Prüfungen eliminiert.
|
||||||
|
- **Repository-Logs**: Zusätzliche Log-Ausgaben in `NennungRemoteRepository.kt` zur URL-Verifizierung.
|
||||||
|
|
||||||
|
### v2026-04-23.29 - BACKEND DEBUG & SUCCESS FLOW
|
||||||
|
- **Backend-Logging**: Detaillierte Log-Ausgaben im `MailController` hinzugefügt, um den SMTP-Versandprozess auf dem Host genau verfolgen zu können (Status: "Versuche zu senden...").
|
||||||
|
- **UI-Erfolgssteuerung**: Korrektur im Frontend-Flow. Der User wird nun explizit erst nach erfolgreicher API-Antwort zum Erfolgsscreen weitergeleitet.
|
||||||
|
- **Fehler-Transparenz**: Bei Sende-Fehlern wird nun ein Hinweis auf die Browser-Konsole ausgegeben, um CORS- oder Netzwerk-Details besser greifen zu können.
|
||||||
|
|
||||||
|
### v2026-04-23.32 - PROXY DEBUG
|
||||||
|
- Erweiterung des Loggings im `NennungRemoteRepository`, um API-Antworten (Status & Body) in der Konsole zu sehen.
|
||||||
|
- Erhöhung der Diagnose-Transparenz im Caddy-Proxy (v32).
|
||||||
|
- Ziel: Identifikation, warum Requests im Same-Origin Modus scheinbar still scheitern.
|
||||||
|
|
||||||
|
### v2026-04-23.34 - CALLBACK LOGGING
|
||||||
|
- **Fokus**: Behebung des stillen Scheiterns (kein UI-Umschalten nach 200 OK).
|
||||||
|
- **Änderungen**:
|
||||||
|
- Detaillierte `println`-Logs in `WebMainScreen.kt` und `OnlineNennungFormular.kt` hinzugefügt.
|
||||||
|
- Ziel: Feststellen, ob `onResult` korrekt feuert und ob der State-Wechsel in Compose registriert wird.
|
||||||
|
- **Status**: Bereit für Deployment.
|
||||||
|
|
||||||
|
### v2026-04-23.33 - JSON RESPONSE FIX
|
||||||
|
- **Analyse**: Version 32 zeigte, dass der Server mit `200 OK`, aber einem leeren Body antwortet. Das Frontend (KMP/Wasm) wartete jedoch auf eine JSON-Antwort, was zum "Hängen" im Ladezustand führte.
|
||||||
|
- **Backend-Fix**: `MailController.kt` gibt nun explizit ein JSON-Objekt `{"success": true, ...}` zurück.
|
||||||
|
- **Frontend-Härtung**: `NennungRemoteRepository.kt` wurde robuster gegenüber leeren Antwort-Bodies gestaltet.
|
||||||
|
- **Status**: Erfolgreich (Antwort 200 OK mit Body bestätigt).
|
||||||
|
|
||||||
|
|
||||||
|
## v2026-04-23.35 - SMTP Fix
|
||||||
|
- Korrektur der `dc-planb.yaml`: Hard-Coded Fallback für SMTP-Passwort und Erzwingung der AUTH/STARTTLS Flags.
|
||||||
|
- Der `mail-service` nutzt nun definitiv die World4You-Credentials statt der Spring-Defaults (localhost:1025).
|
||||||
|
- Finaler Versions-Marker v35 gesetzt.
|
||||||
|
|
||||||
|
### v2026-04-23.39 - FINAL SMTP & UI SYNC
|
||||||
|
- **Analyse**: Trotz v35-38 zeigten die Logs weiterhin `localhost` als SMTP-Host (Raw Env), was auf eine persistente Fehlkonfiguration am Host hindeutete.
|
||||||
|
- **Backend-Härtung**:
|
||||||
|
- `application.yaml`: SMTP-Werte auf Platzhalter `${SPRING_MAIL_HOST:smtp.world4you.com}` umgestellt, um Umgebungsvariablen zu priorisieren.
|
||||||
|
- `dc-planb.yaml`: Hinzufügen von `SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_REQUIRED: "true"`.
|
||||||
|
- `MailServiceApplication.kt`: Erweiterte Startup-Logs für Resolved vs. Raw Env Variablen.
|
||||||
|
- **Frontend-Härtung**:
|
||||||
|
- `WebMainScreen.kt`: Implementierung einer "Force Success" Logik. Sobald der API-Status `200 OK` (`result.isSuccess`) ist, wird der Erfolgsscreen angezeigt, unabhängig vom internen `success`-Flag im Payload.
|
||||||
|
- **Status**: Versions-Marker auf v39 aktualisiert.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -4,3 +4,4 @@ Dieses Modul enthält den gesamten Code für das Kotlin Multiplatform (KMP) Fron
|
|||||||
|
|
||||||
**Die vollständige Dokumentation befindet sich hier:**
|
**Die vollständige Dokumentation befindet sich hier:**
|
||||||
[**→ docs/06_Frontend/README.md**](../docs/06_Frontend/README.md)
|
[**→ docs/06_Frontend/README.md**](../docs/06_Frontend/README.md)
|
||||||
|
|
||||||
|
|||||||
+4
-18
@@ -7,9 +7,8 @@ package at.mocode.frontend.core.network
|
|||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
actual object PlatformConfig {
|
actual object PlatformConfig {
|
||||||
actual fun resolveMailServiceUrl(): String {
|
actual fun resolveMailServiceUrl(): String {
|
||||||
val fromGlobal = getGlobalMailServiceUrl()
|
// SAME-ORIGIN Strategy: Use root for proxying
|
||||||
if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/")
|
return ""
|
||||||
return "http://localhost:8092"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun resolveKeycloakUrl(): String {
|
actual fun resolveKeycloakUrl(): String {
|
||||||
@@ -21,21 +20,8 @@ actual object PlatformConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
actual fun resolveApiBaseUrl(): String {
|
actual fun resolveApiBaseUrl(): String {
|
||||||
// 1) Prefer a global JS variable (can be injected by index.html or nginx)
|
// SAME-ORIGIN Strategy: Use root for proxying
|
||||||
val fromGlobal = getGlobalApiBaseUrl()
|
return ""
|
||||||
if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/")
|
|
||||||
|
|
||||||
// 2) Try window location origin (same origin gateway/proxy setup)
|
|
||||||
val origin = try {
|
|
||||||
getOrigin()
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!origin.isNullOrBlank()) return origin.removeSuffix("/")
|
|
||||||
|
|
||||||
// 3) Fallback to the local gateway
|
|
||||||
return "http://localhost:8081"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+17
-2
@@ -92,12 +92,27 @@ class NennungRemoteRepository(private val client: HttpClient) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Wir senden an den mail-service (URL dynamisch aufgelöst)
|
// Wir senden an den mail-service (URL dynamisch aufgelöst)
|
||||||
client.post("$mailServiceUrl/api/mail/nennung") {
|
val fullUrl = "$mailServiceUrl/api/mail/nennung"
|
||||||
|
println("Sende Nennung an URL: $fullUrl")
|
||||||
|
|
||||||
|
val response = client.post(fullUrl) {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
setBody(request)
|
setBody(request)
|
||||||
}
|
}
|
||||||
Result.success(Unit)
|
|
||||||
|
println("Antwort erhalten: ${response.status.value}")
|
||||||
|
val responseText = try { response.body<String>() } catch (e: Exception) { "" }
|
||||||
|
println("Antwort Body: '$responseText'")
|
||||||
|
|
||||||
|
if (response.status.isSuccess()) {
|
||||||
|
Result.success(Unit)
|
||||||
|
} else {
|
||||||
|
val errorText = "Server meldet Fehler: ${response.status.value} ${response.status.description} - $responseText"
|
||||||
|
println(errorText)
|
||||||
|
Result.failure(Exception(errorText))
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
println("Ausnahme beim Senden: ${e.message}")
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+467
-100
@@ -1,14 +1,24 @@
|
|||||||
package at.mocode.frontend.features.nennung.presentation.web
|
package at.mocode.frontend.features.nennung.presentation.web
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import at.mocode.frontend.core.designsystem.theme.AppColors
|
import at.mocode.frontend.core.designsystem.theme.AppColors
|
||||||
@@ -16,112 +26,469 @@ import at.mocode.frontend.features.nennung.domain.Bewerb
|
|||||||
import at.mocode.frontend.features.nennung.domain.Sparte
|
import at.mocode.frontend.features.nennung.domain.Sparte
|
||||||
|
|
||||||
data class NennungPayload(
|
data class NennungPayload(
|
||||||
val vorname: String,
|
val vorname: String,
|
||||||
val nachname: String,
|
val nachname: String,
|
||||||
val lizenz: String,
|
val lizenz: String,
|
||||||
val pferdName: String,
|
val pferdName: String,
|
||||||
val pferdAlter: String,
|
val pferdAlter: String,
|
||||||
val email: String,
|
val email: String,
|
||||||
val telefon: String,
|
val telefon: String,
|
||||||
val bewerbe: List<Bewerb>,
|
val bewerbe: List<Bewerb>,
|
||||||
val bemerkungen: String
|
val bemerkungen: String
|
||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OnlineNennungFormular(
|
fun OnlineNennungFormular(
|
||||||
turnierNr: String,
|
turnierNr: String,
|
||||||
onNennenAbgeschickt: (NennungPayload) -> Unit,
|
onNennenAbgeschickt: (NennungPayload, (Boolean, String?) -> Unit) -> Unit,
|
||||||
onBack: () -> Unit
|
onBack: () -> Unit
|
||||||
) {
|
) {
|
||||||
var vorname by remember { mutableStateOf("") }
|
var vorname by remember { mutableStateOf("") }
|
||||||
var nachname by remember { mutableStateOf("") }
|
var nachname by remember { mutableStateOf("") }
|
||||||
var email by remember { mutableStateOf("") }
|
var telefon by remember { mutableStateOf("") }
|
||||||
|
var email by remember { mutableStateOf("") }
|
||||||
|
var pferdName by remember { mutableStateOf("") }
|
||||||
|
var bemerkungen by remember { mutableStateOf("") }
|
||||||
|
val ausgewaehlteBewerbe = remember { mutableStateListOf<Bewerb>() }
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
val isEmailValid = email.contains("@") && email.contains(".")
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
val canSubmit = vorname.isNotBlank() && nachname.isNotBlank() && isEmailValid
|
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
Box(
|
val bewerbeListe = remember(turnierNr) {
|
||||||
modifier = Modifier.fillMaxSize().background(Color(0xFFF8F9FA)),
|
if (turnierNr == "26128") {
|
||||||
contentAlignment = Alignment.Center
|
listOf(
|
||||||
) {
|
Bewerb(1, "Sa", 1, "", "Pony Stilspringprüfung (60 cm)", Sparte.SPRINGEN, "Pony"),
|
||||||
Card(
|
Bewerb(
|
||||||
modifier = Modifier.width(400.dp).padding(16.dp),
|
2,
|
||||||
shape = RoundedCornerShape(20.dp),
|
"Sa",
|
||||||
colors = CardDefaults.cardColors(containerColor = Color.White),
|
1,
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
|
"",
|
||||||
) {
|
"Einlaufspringprüfung (60 cm) - Abt. 1: liz.frei / Abt. 2: mit Lizenz",
|
||||||
Column(
|
Sparte.SPRINGEN,
|
||||||
modifier = Modifier.padding(24.dp),
|
"E"
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
Bewerb(3, "Sa", 1, "", "Pony Stilspringprüfung (70 cm)", Sparte.SPRINGEN, "Pony"),
|
||||||
) {
|
Bewerb(
|
||||||
Text(
|
4,
|
||||||
text = "Hallo Du! 👋",
|
"Sa",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
1,
|
||||||
fontWeight = FontWeight.ExtraBold,
|
"",
|
||||||
color = AppColors.Primary
|
"Einlaufspringprüfung (70 cm) - Abt. 1: liz.frei / Abt. 2: mit Lizenz",
|
||||||
)
|
Sparte.SPRINGEN,
|
||||||
Text(
|
"E"
|
||||||
text = "Lass uns Plan-B testen. Turnier: $turnierNr",
|
),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Bewerb(5, "Sa", 1, "", "Pony Stilspringprüfung (80 cm)", Sparte.SPRINGEN, "Pony"),
|
||||||
color = Color.Gray
|
Bewerb(
|
||||||
)
|
6,
|
||||||
|
"Sa",
|
||||||
OutlinedTextField(
|
1,
|
||||||
value = vorname,
|
"",
|
||||||
onValueChange = { vorname = it },
|
"Stilspringprüfung (80 cm) - Abt. 1: liz.frei / Abt. 2: R1 & 5-6j. Pf.",
|
||||||
label = { Text("Vorname") },
|
Sparte.SPRINGEN,
|
||||||
singleLine = true,
|
"E"
|
||||||
modifier = Modifier.fillMaxWidth()
|
),
|
||||||
)
|
Bewerb(7, "Sa", 1, "", "Pony Stilspringprüfung (95 cm)", Sparte.SPRINGEN, "Pony"),
|
||||||
|
Bewerb(8, "Sa", 1, "", "Springreiterbewerb liz.frei (95 cm)", Sparte.SPRINGEN, "E"),
|
||||||
OutlinedTextField(
|
Bewerb(9, "Sa", 1, "", "Standardspringprüfung (95 cm) - Abt. 1: R1 / Abt. 2: R2+", Sparte.SPRINGEN, "A1"),
|
||||||
value = nachname,
|
Bewerb(
|
||||||
onValueChange = { nachname = it },
|
10,
|
||||||
label = { Text("Nachname") },
|
"Sa",
|
||||||
singleLine = true,
|
1,
|
||||||
modifier = Modifier.fillMaxWidth()
|
"",
|
||||||
)
|
"Springpferdeprüfung (105 cm) - Abt. 1: 4j. / Abt. 2: 5-6j.",
|
||||||
|
Sparte.SPRINGEN,
|
||||||
OutlinedTextField(
|
"A"
|
||||||
value = email,
|
),
|
||||||
onValueChange = { email = it },
|
Bewerb(11, "Sa", 1, "", "Stilspringprüfung (105 cm) - Abt. 1: R1", Sparte.SPRINGEN, "A2"),
|
||||||
label = { Text("E-Mail Adresse") },
|
Bewerb(
|
||||||
singleLine = true,
|
12,
|
||||||
isError = email.isNotEmpty() && !isEmailValid,
|
"Sa",
|
||||||
modifier = Modifier.fillMaxWidth()
|
1,
|
||||||
)
|
"",
|
||||||
|
"Standardspringprüfung (105 cm) - Abt. 1: R1 / Abt. 2: R2/RS2+",
|
||||||
Spacer(Modifier.height(8.dp))
|
Sparte.SPRINGEN,
|
||||||
|
"A2"
|
||||||
Button(
|
),
|
||||||
onClick = {
|
Bewerb(13, "Sa", 1, "", "Stilspringprüfung (115 cm) - Abt. 1: R1", Sparte.SPRINGEN, "L"),
|
||||||
// Wir füllen den Rest mit Dummy-Daten für den Test
|
Bewerb(
|
||||||
val payload = NennungPayload(
|
14,
|
||||||
vorname = vorname,
|
"Sa",
|
||||||
nachname = nachname,
|
1,
|
||||||
lizenz = "Lizenzfrei",
|
"",
|
||||||
pferdName = "Test-Pferd (Plan-B)",
|
"Standardspringprüfung (115 cm) - Abt. 1: R1 / Abt. 2: R2/RS2+",
|
||||||
pferdAlter = "2020",
|
Sparte.SPRINGEN,
|
||||||
email = email,
|
"L"
|
||||||
telefon = "0123456789",
|
),
|
||||||
bewerbe = listOf(Bewerb(1, "Tag 1", 1, "08:00", "Test-Bewerb", Sparte.SPRINGEN, "A")),
|
)
|
||||||
bemerkungen = "Dies ist ein automatischer Test für Plan-B."
|
} else {
|
||||||
)
|
listOf(
|
||||||
onNennenAbgeschickt(payload)
|
Bewerb(1, "So", 1, "", "Dressurreiterprüfung Reiterpass (Aufg. R1)", Sparte.DRESSUR, "RP"),
|
||||||
},
|
Bewerb(2, "So", 1, "", "Dressurreiterprüfung Reiternadel (Aufg. R4)", Sparte.DRESSUR, "RN"),
|
||||||
enabled = canSubmit,
|
Bewerb(3, "So", 1, "", "Dressurreiterprüfung lizenzfrei (Aufg. LF1)", Sparte.DRESSUR, "LF"),
|
||||||
modifier = Modifier.fillMaxWidth().height(50.dp),
|
Bewerb(4, "So", 1, "", "Dressurreiterprüfung lizenzfrei (Aufg. LF3)", Sparte.DRESSUR, "LF"),
|
||||||
shape = RoundedCornerShape(12.dp),
|
Bewerb(5, "So", 1, "", "First Ridden", Sparte.DRESSUR, "FR"),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Primary)
|
Bewerb(6, "So", 1, "", "Führzügelklasse", Sparte.DRESSUR, "FZ"),
|
||||||
) {
|
Bewerb(7, "So", 1, "", "Pony Dressurprüfung Kl. A (Aufg. P1)", Sparte.DRESSUR, "A"),
|
||||||
Text("Jetzt schicken!", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
Bewerb(
|
||||||
}
|
8,
|
||||||
|
"So",
|
||||||
TextButton(onClick = onBack) {
|
1,
|
||||||
Text("Zurück", color = Color.Gray)
|
"",
|
||||||
}
|
"Dressurreiterprüfung Kl. A (Aufg. DRA1) - Abt. 1: R1/RD1 / Abt. 2: R2/RD2+",
|
||||||
}
|
Sparte.DRESSUR,
|
||||||
}
|
"A"
|
||||||
|
),
|
||||||
|
Bewerb(
|
||||||
|
9,
|
||||||
|
"So",
|
||||||
|
1,
|
||||||
|
"",
|
||||||
|
"Dressurprüfung Kl. A (Aufg. A5) - Abt. 1: R1/RD1 / Abt. 2: R2/RD2+",
|
||||||
|
Sparte.DRESSUR,
|
||||||
|
"A"
|
||||||
|
),
|
||||||
|
Bewerb(
|
||||||
|
13,
|
||||||
|
"So",
|
||||||
|
1,
|
||||||
|
"",
|
||||||
|
"Dressurpferdeprüfung Kl. A (Aufg. DPA1) - Abt. 1: 4j. / Abt. 2: 5-6j.",
|
||||||
|
Sparte.DRESSUR,
|
||||||
|
"DP-A"
|
||||||
|
),
|
||||||
|
Bewerb(14, "So", 1, "", "Dressurpferdprüfung Kl. L (Aufg. DPL1) - 5-6j. Pferde", Sparte.DRESSUR, "DP-L"),
|
||||||
|
Bewerb(10, "So", 1, "", "Pony Dressurprüfung Kl. L (Aufg. P6)", Sparte.DRESSUR, "L"),
|
||||||
|
Bewerb(
|
||||||
|
11,
|
||||||
|
"So",
|
||||||
|
1,
|
||||||
|
"",
|
||||||
|
"Dressurreiterprüfung Kl. L (Aufg. DRL1) - Abt. 1: R1/RD1 / Abt. 2: R2/RD2+",
|
||||||
|
Sparte.DRESSUR,
|
||||||
|
"L"
|
||||||
|
),
|
||||||
|
Bewerb(
|
||||||
|
12,
|
||||||
|
"So",
|
||||||
|
1,
|
||||||
|
"",
|
||||||
|
"Dressurprüfung Kl. L (Aufg. L3) - Abt. 1: R1/RD1 / Abt. 2: R2/RD2+",
|
||||||
|
Sparte.DRESSUR,
|
||||||
|
"L"
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isEmailValid = email.contains("@") && email.contains(".")
|
||||||
|
val canSubmit =
|
||||||
|
vorname.isNotBlank() && nachname.isNotBlank() && isEmailValid && pferdName.isNotBlank() && ausgewaehlteBewerbe.isNotEmpty()
|
||||||
|
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = Modifier.fillMaxSize().background(Color(0xFFF0F2F5)),
|
||||||
|
contentAlignment = Alignment.TopCenter
|
||||||
|
) {
|
||||||
|
val isMobile = maxWidth < 600.dp
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 800.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(if (isMobile) 4.dp else 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(if (isMobile) 0.dp else 16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = if (isMobile) 2.dp else 6.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(if (isMobile) 16.dp else 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (turnierNr == "26128") "Online-Nennung: Springturnier Neumarkt" else "Online-Nennung: Dressurturnier Neumarkt",
|
||||||
|
style = if (isMobile) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.ExtraBold,
|
||||||
|
color = AppColors.Primary
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Turnier-Nr: $turnierNr | Datum: ${if (turnierNr == "26128") "25. April 2026" else "26. April 2026"}",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = Color.Gray
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp), thickness = 1.dp, color = Color.LightGray)
|
||||||
|
|
||||||
|
Text("Reiter & Kontakt", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = vorname,
|
||||||
|
onValueChange = { vorname = it },
|
||||||
|
label = { Text("Vorname*") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = nachname,
|
||||||
|
onValueChange = { nachname = it },
|
||||||
|
label = { Text("Nachname*") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = vorname,
|
||||||
|
onValueChange = { vorname = it },
|
||||||
|
label = { Text("Vorname*") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = nachname,
|
||||||
|
onValueChange = { nachname = it },
|
||||||
|
label = { Text("Nachname*") },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = email,
|
||||||
|
onValueChange = { email = it },
|
||||||
|
label = { Text("E-Mail Adresse* (für Bestätigung)") },
|
||||||
|
isError = email.isNotEmpty() && !isEmailValid,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next),
|
||||||
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = telefon,
|
||||||
|
onValueChange = { telefon = it },
|
||||||
|
label = { Text("Telefon-Nr.") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next),
|
||||||
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text("Pferd", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = pferdName,
|
||||||
|
onValueChange = { pferdName = it },
|
||||||
|
label = { Text("Pferdename / Kopfnummer*") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Bewerbe auswählen*", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
|
||||||
|
bewerbeListe.forEach { bewerb ->
|
||||||
|
val selected = ausgewaehlteBewerbe.contains(bewerb)
|
||||||
|
val parts = bewerb.name.split(" - ", limit = 2)
|
||||||
|
val mainName = parts[0]
|
||||||
|
val abteilung = if (parts.size > 1) parts[1] else ""
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
color = if (selected) AppColors.PrimaryContainer.copy(alpha = 0.7f) else Color.Transparent,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable {
|
||||||
|
if (selected) ausgewaehlteBewerbe.remove(bewerb)
|
||||||
|
else ausgewaehlteBewerbe.add(bewerb)
|
||||||
|
}
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = selected,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
if (checked == true) ausgewaehlteBewerbe.add(bewerb)
|
||||||
|
else ausgewaehlteBewerbe.remove(bewerb)
|
||||||
|
},
|
||||||
|
colors = CheckboxDefaults.colors(checkedColor = AppColors.Primary)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"${bewerb.nr}. $mainName",
|
||||||
|
fontWeight = if (selected) FontWeight.Bold else FontWeight.SemiBold,
|
||||||
|
fontSize = if (isMobile) 14.sp else 16.sp
|
||||||
|
)
|
||||||
|
if (abteilung.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
abteilung,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontSize = if (isMobile) 11.sp else 12.sp,
|
||||||
|
color = if (selected) Color.Black.copy(alpha = 0.8f) else Color.Gray,
|
||||||
|
modifier = Modifier.padding(start = if (isMobile) 8.dp else 12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ausgewaehlteBewerbe.size > 3) {
|
||||||
|
Text(
|
||||||
|
"⚠️ Hinweis: Ein Pferd darf maximal 3x pro Tag starten.",
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("Wünsche / Anmerkungen", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = bemerkungen,
|
||||||
|
onValueChange = { bemerkungen = it },
|
||||||
|
placeholder = { Text("z.B. Startzeit-Wünsche, Stallnachbarn...") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
minLines = 3,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(onDone = {
|
||||||
|
if (canSubmit && !isLoading) {
|
||||||
|
val payload = NennungPayload(
|
||||||
|
vorname = vorname,
|
||||||
|
nachname = nachname,
|
||||||
|
lizenz = "N/A",
|
||||||
|
pferdName = pferdName,
|
||||||
|
pferdAlter = "N/A",
|
||||||
|
email = email,
|
||||||
|
telefon = telefon,
|
||||||
|
bewerbe = ausgewaehlteBewerbe.toList(),
|
||||||
|
bemerkungen = bemerkungen
|
||||||
|
)
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = null
|
||||||
|
onNennenAbgeschickt(payload) { success, error ->
|
||||||
|
println("Formular Callback erhalten: success=$success, error=$error")
|
||||||
|
if (!success) {
|
||||||
|
isLoading = false
|
||||||
|
errorMessage = "Senden fehlgeschlagen: " + (error ?: "Fehler beim Server-Aufruf. Bitte prüfen Sie die Browser-Konsole (F12) auf Netzwerk-Fehler.")
|
||||||
|
} else {
|
||||||
|
println("Formular meldet: Erfolg! (Ladezustand bleibt aktiv bis Screen-Wechsel)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (errorMessage != null) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = errorMessage!!,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
val payload = NennungPayload(
|
||||||
|
vorname = vorname,
|
||||||
|
nachname = nachname,
|
||||||
|
lizenz = "N/A",
|
||||||
|
pferdName = pferdName,
|
||||||
|
pferdAlter = "N/A",
|
||||||
|
email = email,
|
||||||
|
telefon = telefon,
|
||||||
|
bewerbe = ausgewaehlteBewerbe.toList(),
|
||||||
|
bemerkungen = bemerkungen
|
||||||
|
)
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = null
|
||||||
|
onNennenAbgeschickt(payload) { success, error ->
|
||||||
|
println("Button Callback erhalten: success=$success, error=$error")
|
||||||
|
if (!success) {
|
||||||
|
isLoading = false
|
||||||
|
errorMessage = "Senden fehlgeschlagen: " + (error ?: "Netzwerkfehler oder Server nicht erreichbar.")
|
||||||
|
} else {
|
||||||
|
println("Button meldet: Erfolg! (Ladezustand bleibt aktiv bis Screen-Wechsel)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = canSubmit && !isLoading,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(if (isMobile) 56.dp else 64.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFFFFBF00),
|
||||||
|
disabledContainerColor = Color(0xFFFFBF00).copy(alpha = 0.4f)
|
||||||
|
),
|
||||||
|
elevation = ButtonDefaults.buttonElevation(defaultElevation = 4.dp, pressedElevation = 8.dp)
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Color.Black)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = if (isLoading) "Wird gesendet..." else "Jetzt nennen",
|
||||||
|
fontWeight = FontWeight.ExtraBold,
|
||||||
|
fontSize = if (isMobile) 18.sp else 20.sp,
|
||||||
|
color = if (canSubmit && !isLoading) Color.Black else Color.DarkGray
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Mit dem Absenden akzeptiere ich die Speicherung meiner Daten für die Turnierabwicklung.\nSchutz gegen automatisierte Eingaben ist aktiv.",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.Gray,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
lineHeight = 16.sp
|
||||||
|
)
|
||||||
|
|
||||||
|
TextButton(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Abbrechen", color = Color.Gray, fontSize = 16.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+146
-39
@@ -26,6 +26,18 @@ fun WebMainScreen() {
|
|||||||
MainAppContent()
|
MainAppContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalWasmJsInterop::class)
|
||||||
|
private fun getWindowHash(): String = js("window.location.hash")
|
||||||
|
|
||||||
|
@OptIn(ExperimentalWasmJsInterop::class)
|
||||||
|
private fun setWindowHash(hash: String): Unit = js("window.location.hash = hash")
|
||||||
|
|
||||||
|
@OptIn(ExperimentalWasmJsInterop::class)
|
||||||
|
private fun onHashChange(onChanged: () -> Unit): Unit = js("window.addEventListener('hashchange', onChanged)")
|
||||||
|
|
||||||
|
@OptIn(ExperimentalWasmJsInterop::class)
|
||||||
|
private fun openInNewTab(url: String): Unit = js("window.open(url, '_blank')")
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun MainAppContent() {
|
fun MainAppContent() {
|
||||||
@@ -34,6 +46,45 @@ fun MainAppContent() {
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var currentScreen by remember { mutableStateOf<WebScreen>(WebScreen.Landing) }
|
var currentScreen by remember { mutableStateOf<WebScreen>(WebScreen.Landing) }
|
||||||
|
|
||||||
|
// Hash-basiertes Routing zur Synchronisation mit der Adressleiste
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val handleHashChange = {
|
||||||
|
val hash = getWindowHash()
|
||||||
|
println("Hash geändert: $hash")
|
||||||
|
when {
|
||||||
|
hash.startsWith("#/nennung/") -> {
|
||||||
|
val tId = hash.substringAfter("#/nennung/").toLongOrNull() ?: 26128L
|
||||||
|
currentScreen = WebScreen.Nennung(1, tId)
|
||||||
|
}
|
||||||
|
hash == "#/erfolg" -> {
|
||||||
|
// Behalte den aktuellen Erfolgsscreen bei oder wechsle zu einem leeren
|
||||||
|
if (currentScreen !is WebScreen.Erfolg) {
|
||||||
|
currentScreen = WebScreen.Erfolg("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
currentScreen = WebScreen.Landing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handleHashChange()
|
||||||
|
onHashChange { handleHashChange() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update der Adressleiste bei Screen-Wechsel
|
||||||
|
LaunchedEffect(currentScreen) {
|
||||||
|
val targetHash = when (val screen = currentScreen) {
|
||||||
|
is WebScreen.Landing -> "/"
|
||||||
|
is WebScreen.Nennung -> "/nennung/${screen.turnierId}"
|
||||||
|
is WebScreen.Erfolg -> "/erfolg"
|
||||||
|
}
|
||||||
|
val currentHash = getWindowHash()
|
||||||
|
if (currentHash != "#$targetHash") {
|
||||||
|
println("Setze neuen Hash: #$targetHash (aktuell: $currentHash)")
|
||||||
|
setWindowHash("#$targetHash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -53,19 +104,28 @@ fun MainAppContent() {
|
|||||||
},
|
},
|
||||||
onNennenClick = { vId, tId ->
|
onNennenClick = { vId, tId ->
|
||||||
currentScreen = WebScreen.Nennung(vId, tId)
|
currentScreen = WebScreen.Nennung(vId, tId)
|
||||||
|
},
|
||||||
|
onAusschreibungClick = { pdfUrl ->
|
||||||
|
openInNewTab(pdfUrl)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
is WebScreen.Nennung -> OnlineNennungFormular(
|
is WebScreen.Nennung -> OnlineNennungFormular(
|
||||||
turnierNr = screen.turnierId.toString(),
|
turnierNr = screen.turnierId.toString(),
|
||||||
onNennenAbgeschickt = { payload ->
|
onNennenAbgeschickt = { payload, onResult ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
println("Starte Senden der Nennung für ${payload.vorname} ${payload.nachname}...")
|
||||||
val result = nennungRepository.sendeNennung(screen.turnierId.toString(), payload)
|
val result = nennungRepository.sendeNennung(screen.turnierId.toString(), payload)
|
||||||
if (result.isSuccess) {
|
val success = result.isSuccess
|
||||||
|
val error = result.exceptionOrNull()?.message
|
||||||
|
|
||||||
|
println("API Result im MainScreen: success=$success, error=$error")
|
||||||
|
onResult(success, error)
|
||||||
|
|
||||||
|
// FORCE SUCCESS SCREEN on 200 OK (v39)
|
||||||
|
if (success || result.isSuccess) {
|
||||||
|
println("FORCE: Wechsle zum Erfolgsscreen für ${payload.email}")
|
||||||
currentScreen = WebScreen.Erfolg(payload.email)
|
currentScreen = WebScreen.Erfolg(payload.email)
|
||||||
} else {
|
|
||||||
// Hier könnte man eine Fehlermeldung anzeigen
|
|
||||||
println("Fehler beim Senden der Nennung: ${result.exceptionOrNull()?.message}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -77,6 +137,15 @@ fun MainAppContent() {
|
|||||||
onBack = { currentScreen = WebScreen.Landing }
|
onBack = { currentScreen = WebScreen.Landing }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dezentraler Versions-Marker in der unteren rechten Ecke
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.BottomEnd) {
|
||||||
|
Text(
|
||||||
|
text = "v2026-04-23.41 - UI NAVIGATION FIX",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = Color.LightGray.copy(alpha = 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,18 +183,19 @@ fun Erfolgsscreen(email: String, onBack: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
fun LandingPage(
|
fun LandingPage(
|
||||||
onVeranstaltungClick: (Long) -> Unit,
|
onVeranstaltungClick: (Long) -> Unit,
|
||||||
onNennenClick: (Long, Long) -> Unit
|
onNennenClick: (Long, Long) -> Unit,
|
||||||
|
onAusschreibungClick: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val veranstaltungen = remember {
|
val veranstaltungen = remember {
|
||||||
listOf(
|
listOf(
|
||||||
VeranstaltungWebModel(
|
VeranstaltungWebModel(
|
||||||
id = 1,
|
id = 1,
|
||||||
name = "CSN-B* Neumarkt",
|
name = "Turniere in Neumarkt",
|
||||||
ort = "Neumarkt am Wallersee",
|
ort = "Reitanlage Stroblmair",
|
||||||
datum = "24. - 26. April 2026",
|
datum = "25. - 26. April 2026",
|
||||||
turniere = listOf(
|
turniere = listOf(
|
||||||
TurnierWebModel(101, "Springturnier Neumarkt", "Ausschreibung_Neumarkt.pdf"),
|
TurnierWebModel(26128, "Springturnier (CSN-C NEU)", "26128.pdf"),
|
||||||
TurnierWebModel(102, "Dressurturnier Neumarkt", "Ausschreibung_Dressur.pdf")
|
TurnierWebModel(26129, "Dressurturnier (CDN-C NEU)", "26129.pdf")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -160,7 +230,8 @@ fun LandingPage(
|
|||||||
items(veranstaltungen) { veranstaltung ->
|
items(veranstaltungen) { veranstaltung ->
|
||||||
VeranstaltungsCardWeb(
|
VeranstaltungsCardWeb(
|
||||||
veranstaltung = veranstaltung,
|
veranstaltung = veranstaltung,
|
||||||
onNennenClick = { tId -> onNennenClick(veranstaltung.id, tId) }
|
onNennenClick = { tId -> onNennenClick(veranstaltung.id, tId) },
|
||||||
|
onAusschreibungClick = onAusschreibungClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +240,8 @@ fun LandingPage(
|
|||||||
@Composable
|
@Composable
|
||||||
fun VeranstaltungsCardWeb(
|
fun VeranstaltungsCardWeb(
|
||||||
veranstaltung: VeranstaltungWebModel,
|
veranstaltung: VeranstaltungWebModel,
|
||||||
onNennenClick: (Long) -> Unit
|
onNennenClick: (Long) -> Unit,
|
||||||
|
onAusschreibungClick: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -195,7 +267,8 @@ fun VeranstaltungsCardWeb(
|
|||||||
veranstaltung.turniere.forEach { turnier ->
|
veranstaltung.turniere.forEach { turnier ->
|
||||||
TurnierCardWeb(
|
TurnierCardWeb(
|
||||||
turnier = turnier,
|
turnier = turnier,
|
||||||
onNennenClick = { onNennenClick(turnier.id) }
|
onNennenClick = { onNennenClick(turnier.id) },
|
||||||
|
onAusschreibungClick = { onAusschreibungClick(turnier.pdfUrl) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,35 +278,69 @@ fun VeranstaltungsCardWeb(
|
|||||||
@Composable
|
@Composable
|
||||||
fun TurnierCardWeb(
|
fun TurnierCardWeb(
|
||||||
turnier: TurnierWebModel,
|
turnier: TurnierWebModel,
|
||||||
onNennenClick: () -> Unit
|
onNennenClick: () -> Unit,
|
||||||
|
onAusschreibungClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
OutlinedCard(
|
BoxWithConstraints {
|
||||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
val isMobile = maxWidth < 500.dp
|
||||||
colors = CardDefaults.outlinedCardColors(containerColor = AppColors.BackgroundLight)
|
|
||||||
) {
|
OutlinedCard(
|
||||||
Row(
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
modifier = Modifier.padding(12.dp),
|
colors = CardDefaults.outlinedCardColors(containerColor = AppColors.BackgroundLight)
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
if (isMobile) {
|
||||||
Text(turnier.name, fontWeight = FontWeight.Bold)
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
}
|
Text(turnier.name, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(
|
||||||
TextButton(onClick = { /* PDF öffnen Logik */ }) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Icon(Icons.Default.Description, contentDescription = null)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
Spacer(Modifier.width(4.dp))
|
) {
|
||||||
Text("Ausschreibung")
|
TextButton(
|
||||||
|
onClick = onAusschreibungClick,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Description, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Ausschreibung")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onNennenClick,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Success),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Nennen")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
Button(
|
Row(
|
||||||
onClick = onNennenClick,
|
modifier = Modifier.padding(12.dp),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Success)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Spacer(Modifier.width(4.dp))
|
Text(turnier.name, fontWeight = FontWeight.Bold)
|
||||||
Text("Online-Nennen")
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
TextButton(onClick = onAusschreibungClick) {
|
||||||
|
Icon(Icons.Default.Description, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Ausschreibung")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onNennenClick,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Success)
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Online-Nennen")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,13 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script>
|
||||||
|
// Runtime configuration injected by Docker entrypoint
|
||||||
|
window.API_BASE_URL = "${API_BASE_URL}";
|
||||||
|
window.MAIL_SERVICE_URL = "${MAIL_SERVICE_URL}";
|
||||||
|
window.KEYCLOAK_URL = "${KEYCLOAK_URL}";
|
||||||
|
console.log("App Config loaded:", { API: window.API_BASE_URL, Mail: window.MAIL_SERVICE_URL });
|
||||||
|
</script>
|
||||||
<script type="application/javascript" src="meldestelle-web.js"></script>
|
<script type="application/javascript" src="meldestelle-web.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user