diff --git a/backend/services/mail/Dockerfile b/backend/services/mail/Dockerfile
new file mode 100644
index 00000000..365408d3
--- /dev/null
+++ b/backend/services/mail/Dockerfile
@@ -0,0 +1,118 @@
+# ===================================================================
+# Multi-stage Dockerfile for Meldestelle Mail Service
+# Features: Security hardening, monitoring support, optimal caching, BuildKit cache mounts
+# ===================================================================
+
+# === CENTRALIZED BUILD ARGUMENTS ===
+ARG GRADLE_VERSION=9.4.1
+ARG JAVA_VERSION=25
+ARG BUILD_DATE
+ARG VERSION=1.0.0-SNAPSHOT
+
+# ===================================================================
+# Build Stage
+# ===================================================================
+FROM eclipse-temurin:${JAVA_VERSION}-jdk-alpine AS builder
+
+ARG VERSION
+ARG BUILD_DATE
+
+LABEL stage=builder \
+ service="mail-service" \
+ maintainer="Meldestelle Development Team"
+
+WORKDIR /workspace
+
+# Gradle optimizations
+ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
+ -Dorg.gradle.daemon=false \
+ -Dorg.gradle.parallel=true \
+ -Dorg.gradle.workers.max=2 \
+ -Dorg.gradle.jvmargs=-Xmx2g \
+ -XX:+UseParallelGC \
+ -XX:MaxMetaspaceSize=512m"
+ENV GRADLE_USER_HOME=/root/.gradle
+
+# 1. Copy full project structure for a reliable monorepo build
+COPY . .
+
+RUN chmod +x gradlew
+
+# 2. Build the service
+RUN --mount=type=cache,target=/root/.gradle/caches \
+ --mount=type=cache,target=/root/.gradle/wrapper \
+ ./gradlew :backend:services:mail:mail-service:bootJar --no-daemon --info
+
+# 3. Extract layers
+WORKDIR /builder
+RUN cp /workspace/backend/services/mail/mail-service/build/libs/*.jar app.jar && \
+ java -Djarmode=layertools -jar app.jar extract
+
+# ===================================================================
+# Runtime Stage
+# ===================================================================
+FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime
+
+ARG BUILD_DATE
+ARG VERSION
+ARG JAVA_VERSION
+
+LABEL service="mail-service" \
+ version="${VERSION}" \
+ description="Microservice for Mail and Online Entries" \
+ maintainer="Meldestelle Development Team" \
+ java.version="${JAVA_VERSION}" \
+ build.date="${BUILD_DATE}"
+
+ARG APP_USER=appuser
+ARG APP_GROUP=appgroup
+ARG APP_UID=1001
+ARG APP_GID=1001
+
+WORKDIR /app
+
+RUN apk update && \
+ apk upgrade && \
+ apk add --no-cache curl tzdata tini && \
+ rm -rf /var/cache/apk/* && \
+ addgroup -g ${APP_GID} -S ${APP_GROUP} && \
+ adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh && \
+ mkdir -p /app/logs /app/tmp /app/config && \
+ chown -R ${APP_USER}:${APP_GROUP} /app && \
+ chmod -R 750 /app
+
+# Copy Spring Boot layers
+COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./
+COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./
+COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./
+COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./
+
+USER ${APP_USER}
+
+EXPOSE 8085 5005
+
+HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
+ CMD curl -fsS --max-time 2 http://localhost:8085/actuator/health/readiness || exit 1
+
+ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
+ -XX:+UseG1GC \
+ -XX:+UseStringDeduplication \
+ -XX:+UseContainerSupport \
+ -Djava.security.egd=file:/dev/./urandom \
+ -Djava.awt.headless=true \
+ -Dfile.encoding=UTF-8 \
+ -Duser.timezone=Europe/Vienna \
+ -Dmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus"
+
+ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
+ SERVER_PORT=8085 \
+ LOGGING_LEVEL_ROOT=INFO
+
+ENTRYPOINT ["tini", "--", "sh", "-c", "\
+ echo 'Starting Mail Service with Java ${JAVA_VERSION}...'; \
+ if [ \"${DEBUG:-false}\" = \"true\" ]; then \
+ echo 'DEBUG mode enabled'; \
+ exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.launch.JarLauncher; \
+ else \
+ exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher; \
+ fi"]
diff --git a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/api/MailController.kt b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/api/MailController.kt
index c3cb2db1..c8de16ad 100644
--- a/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/api/MailController.kt
+++ b/backend/services/mail/mail-service/src/main/kotlin/at/mocode/mail/service/api/MailController.kt
@@ -2,6 +2,9 @@ package at.mocode.mail.service.api
import at.mocode.mail.service.persistence.NennungEntity
import at.mocode.mail.service.persistence.NennungRepository
+import jakarta.validation.Valid
+import jakarta.validation.constraints.Email
+import jakarta.validation.constraints.NotBlank
import org.slf4j.LoggerFactory
import org.springframework.mail.SimpleMailMessage
import org.springframework.mail.javamail.JavaMailSender
@@ -11,14 +14,23 @@ import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
data class NennungRequest(
+ @field:NotBlank(message = "Turniernummer ist erforderlich")
val turnierNr: String,
+ @field:NotBlank(message = "Vorname ist erforderlich")
val vorname: String,
+ @field:NotBlank(message = "Nachname ist erforderlich")
val nachname: String,
+ @field:NotBlank(message = "Lizenznummer ist erforderlich")
val lizenz: String,
+ @field:NotBlank(message = "Pferdename ist erforderlich")
val pferdName: String,
+ @field:NotBlank(message = "Pferdealter ist erforderlich")
val pferdAlter: String,
+ @field:Email(message = "Ungültiges Email-Format")
+ @field:NotBlank(message = "Email ist erforderlich")
val email: String,
val telefon: String?,
+ @field:NotBlank(message = "Bewerbe sind erforderlich")
val bewerbe: String,
val bemerkungen: String?
)
@@ -26,7 +38,7 @@ data class NennungRequest(
@OptIn(ExperimentalUuidApi::class)
@RestController
@RequestMapping("/api/mail")
-@CrossOrigin(origins = ["*"]) // Für Wasm-Web-App (Compose HTML/Wasm)
+@CrossOrigin(origins = ["http://localhost:8080", "https://nennung.mo-code.at"]) // Für Wasm-Web-App (Compose HTML/Wasm)
class MailController(
private val nennungRepository: NennungRepository,
private val mailSender: JavaMailSender
@@ -34,7 +46,7 @@ class MailController(
private val logger = LoggerFactory.getLogger(MailController::class.java)
@PostMapping("/nennung")
- fun receiveNennung(@RequestBody request: NennungRequest) {
+ fun receiveNennung(@Valid @RequestBody request: NennungRequest) {
logger.info("Nennung via API erhalten: ${request.vorname} ${request.nachname} für Turnier ${request.turnierNr}")
val entity = NennungEntity(
diff --git a/backend/services/mail/mail-service/src/main/resources/application.yaml b/backend/services/mail/mail-service/src/main/resources/application.yaml
index 368aad58..7ae38b8a 100644
--- a/backend/services/mail/mail-service/src/main/resources/application.yaml
+++ b/backend/services/mail/mail-service/src/main/resources/application.yaml
@@ -2,34 +2,32 @@ spring:
application:
name: mail-service
datasource:
- url: jdbc:h2:mem:maildb;DB_CLOSE_DELAY=-1
- driver-class-name: org.h2.Driver
- username: sa
- password: ""
- h2:
- console:
- enabled: true
- path: /h2-console
+ url: ${SPRING_DATASOURCE_URL:jdbc:h2:mem:maildb;DB_CLOSE_DELAY=-1}
+ driver-class-name: ${SPRING_DATASOURCE_DRIVER_CLASS_NAME:org.h2.Driver}
+ username: ${SPRING_DATASOURCE_USERNAME:sa}
+ password: ${SPRING_DATASOURCE_PASSWORD:""}
+ jpa:
+ hibernate:
+ ddl-auto: update
+ show-sql: true
mail:
- host: ${MAIL_HOST:imap.world4you.com}
- port: ${MAIL_PORT:993}
- username: ${MAIL_USERNAME:online-nennen@mo-code.at}
- password: ${MAIL_PASSWORD:}
+ host: ${SPRING_MAIL_HOST:imap.world4you.com}
+ port: ${SPRING_MAIL_PORT:993}
+ username: ${SPRING_MAIL_USERNAME:online-nennen@mo-code.at}
+ password: ${SPRING_MAIL_PASSWORD:}
properties:
mail:
store:
protocol: imaps
imaps:
- host: ${MAIL_HOST:imap.world4you.com}
- port: ${MAIL_PORT:993}
+ host: ${SPRING_MAIL_HOST:imap.world4you.com}
+ port: ${SPRING_MAIL_PORT:993}
ssl:
enable: true
smtp:
- auth: true
+ auth: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH:true}
starttls:
- enable: true
- host-smtp: ${SMTP_HOST:smtp.world4you.com}
- port-smtp: ${SMTP_PORT:587}
+ enable: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE:true}
server:
port: 8085
diff --git a/dc-backend.yaml b/dc-backend.yaml
index 3ad3e784..0d229a7a 100644
--- a/dc-backend.yaml
+++ b/dc-backend.yaml
@@ -61,6 +61,7 @@ services:
PING_SERVICE_URL: "http://ping-service:8082"
MASTERDATA_SERVICE_URL: "http://masterdata-service:8086"
EVENTS_SERVICE_URL: "http://events-service:8085"
+ MAIL_SERVICE_URL: "http://mail-service:8085"
ZNS_IMPORT_SERVICE_URL: "http://zns-import-service:8095"
RESULTS_SERVICE_URL: "http://results-service:8088"
BILLING_SERVICE_URL: "http://billing-service:8087"
@@ -76,6 +77,8 @@ services:
condition: "service_healthy"
zipkin:
condition: "service_healthy"
+ mail-service:
+ condition: "service_healthy"
healthcheck:
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8081/actuator/health/readiness" ]
@@ -540,78 +543,150 @@ services:
volumes:
- ./config/app/base-application.yaml:/workspace/config/application.yml:Z
- # --- MICROSERVICE: Scheduling Service ---
-# scheduling-service:
-# image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/scheduling-service:${DOCKER_TAG:-latest}"
-# build:
-# context: .
-# dockerfile: backend/services/scheduling/scheduling-service/Dockerfile
-# args:
-# GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
-# JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
-# VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
-# BUILD_DATE: "${DOCKER_BUILD_DATE}"
-# labels:
-# - "org.opencontainers.image.created=${DOCKER_BUILD_DATE}"
-# container_name: "${PROJECT_NAME:-meldestelle}-scheduling-service"
-# restart: unless-stopped
-# ports:
-# - "${SCHEDULING_PORT:-8084:8084}"
-# - "${SCHEDULING_DEBUG_PORT:-5013:5013}"
-# environment:
-# SPRING_PROFILES_ACTIVE: "${SCHEDULING_SPRING_PROFILES_ACTIVE:-docker}"
-# DEBUG: "${SCHEDULING_DEBUG:-true}"
-# SERVER_PORT: "${SCHEDULING_SERVER_PORT:-8084}"
-# SPRING_APPLICATION_NAME: "${SCHEDULING_SERVICE_NAME:-scheduling-service}"
-#
-# # --- KEYCLOAK ---
-# SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}"
-# SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "${KC_JWK_SET_URI:-http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}"
-#
-# # --- CONSUL ---
-# SPRING_CLOUD_CONSUL_HOST: "${CONSUL_HOST:-consul}"
-# SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}"
-# SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${SCHEDULING_SERVICE_NAME:-scheduling-service}"
-# SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${SCHEDULING_CONSUL_PREFER_IP:-true}"
-#
-# # - DATENBANK VERBINDUNG -
-# SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}"
-# SPRING_DATASOURCE_USERNAME: "${POSTGRES_USER:-pg-user}"
-# SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}"
-#
-# # --- VALKEY ---
-# SPRING_DATA_VALKEY_HOST: "${VALKEY_SERVER_HOSTNAME:-valkey}"
-# SPRING_DATA_VALKEY_PORT: "${VALKEY_SERVER_PORT:-6379}"
-#
-# # --- ZIPKIN ---
-# MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: "${ZIPKIN_ENDPOINT:-http://zipkin:9411/api/v2/spans}"
-#
-# depends_on:
-# postgres:
-# condition: "service_healthy"
-# keycloak:
-# condition: "service_healthy"
-# consul:
-# condition: "service_healthy"
-# valkey:
-# condition: "service_healthy"
-# zipkin:
-# condition: "service_healthy"
-#
-# healthcheck:
-# test: [ "CMD", "curl", "-f", "http://localhost:8084/actuator/health" ]
-# interval: 15s
-# timeout: 5s
-# retries: 5
-# start_period: 40s
-#
-# networks:
-# meldestelle-network:
-# aliases:
-# - "scheduling-service"
-# profiles: [ "backend", "all" ]
-# volumes:
-# - ./config/app/base-application.yaml:/workspace/config/application.yml:Z
+ # --- MICROSERVICE: Mail Service ---
+ mail-service:
+ image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/mail-service:${DOCKER_TAG:-latest}"
+ build:
+ context: .
+ dockerfile: backend/services/mail/Dockerfile
+ args:
+ GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
+ JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
+ VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
+ BUILD_DATE: "${DOCKER_BUILD_DATE}"
+ labels:
+ - "org.opencontainers.image.created=${DOCKER_BUILD_DATE}"
+ container_name: "${PROJECT_NAME:-meldestelle}-mail-service"
+ restart: unless-stopped
+ ports:
+ - "${MAIL_PORT:-8085:8085}"
+ - "${MAIL_DEBUG_PORT:-5014:5014}"
+ environment:
+ SPRING_PROFILES_ACTIVE: "${MAIL_SPRING_PROFILES_ACTIVE:-docker}"
+ DEBUG: "${MAIL_DEBUG:-true}"
+ SERVER_PORT: "${MAIL_SERVER_PORT:-8085}"
+
+ # --- KEYCLOAK ---
+ SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}"
+ SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "${KC_JWK_SET_URI:-http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}"
+
+ # --- CONSUL ---
+ SPRING_CLOUD_CONSUL_HOST: "${CONSUL_HOST:-consul}"
+ SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}"
+ SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${MAIL_SERVICE_NAME:-mail-service}"
+ SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${MAIL_CONSUL_PREFER_IP:-true}"
+
+ # - DATENBANK VERBINDUNG -
+ SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}"
+ SPRING_DATASOURCE_USERNAME: "${POSTGRES_USER:-pg-user}"
+ SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}"
+
+ # --- MAIL CONFIG (SMTP) ---
+ SPRING_MAIL_HOST: "${MAIL_SMTP_HOST:-smtp.mo-code.at}"
+ SPRING_MAIL_PORT: "${MAIL_SMTP_PORT:-587}"
+ SPRING_MAIL_USERNAME: "${MAIL_SMTP_USER:-online-nennen@mo-code.at}"
+ SPRING_MAIL_PASSWORD: "${MAIL_SMTP_PASSWORD:-secret}"
+ SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "true"
+ SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "true"
+
+ # --- ZIPKIN ---
+ MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: "${ZIPKIN_ENDPOINT:-http://zipkin:9411/api/v2/spans}"
+
+ depends_on:
+ postgres:
+ condition: "service_healthy"
+ consul:
+ condition: "service_healthy"
+ zipkin:
+ condition: "service_healthy"
+
+ healthcheck:
+ test: [ "CMD", "curl", "-f", "http://localhost:8085/actuator/health/readiness" ]
+ interval: 15s
+ timeout: 5s
+ retries: 5
+ start_period: 40s
+
+ networks:
+ meldestelle-network:
+ aliases:
+ - "mail-service"
+ profiles: [ "backend", "all" ]
+ volumes:
+ - ./config/app/base-application.yaml:/workspace/config/application.yml:Z
+
+ # --- MICROSERVICE: Scheduling Service ---
+ scheduling-service:
+ image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/scheduling-service:${DOCKER_TAG:-latest}"
+ build:
+ context: .
+ dockerfile: backend/services/scheduling/scheduling-service/Dockerfile
+ args:
+ GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
+ JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
+ VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
+ BUILD_DATE: "${DOCKER_BUILD_DATE}"
+ labels:
+ - "org.opencontainers.image.created=${DOCKER_BUILD_DATE}"
+ container_name: "${PROJECT_NAME:-meldestelle}-scheduling-service"
+ restart: unless-stopped
+ ports:
+ - "${SCHEDULING_PORT:-8084:8084}"
+ - "${SCHEDULING_DEBUG_PORT:-5013:5013}"
+ environment:
+ SPRING_PROFILES_ACTIVE: "${SCHEDULING_SPRING_PROFILES_ACTIVE:-docker}"
+ DEBUG: "${SCHEDULING_DEBUG:-true}"
+ SERVER_PORT: "${SCHEDULING_SERVER_PORT:-8084}"
+ SPRING_APPLICATION_NAME: "${SCHEDULING_SERVICE_NAME:-scheduling-service}"
+
+ # --- KEYCLOAK ---
+ SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}"
+ SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "${KC_JWK_SET_URI:-http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}"
+
+ # --- CONSUL ---
+ SPRING_CLOUD_CONSUL_HOST: "${CONSUL_HOST:-consul}"
+ SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}"
+ SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${SCHEDULING_SERVICE_NAME:-scheduling-service}"
+ SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${SCHEDULING_CONSUL_PREFER_IP:-true}"
+
+ # - DATENBANK VERBINDUNG -
+ SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}"
+ SPRING_DATASOURCE_USERNAME: "${POSTGRES_USER:-pg-user}"
+ SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}"
+
+ # --- VALKEY ---
+ SPRING_DATA_VALKEY_HOST: "${VALKEY_SERVER_HOSTNAME:-valkey}"
+ SPRING_DATA_VALKEY_PORT: "${VALKEY_SERVER_PORT:-6379}"
+
+ # --- ZIPKIN ---
+ MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: "${ZIPKIN_ENDPOINT:-http://zipkin:9411/api/v2/spans}"
+
+ depends_on:
+ postgres:
+ condition: "service_healthy"
+ keycloak:
+ condition: "service_healthy"
+ consul:
+ condition: "service_healthy"
+ valkey:
+ condition: "service_healthy"
+ zipkin:
+ condition: "service_healthy"
+
+ healthcheck:
+ test: [ "CMD", "curl", "-f", "http://localhost:8084/actuator/health" ]
+ interval: 15s
+ timeout: 5s
+ retries: 5
+ start_period: 40s
+
+ networks:
+ meldestelle-network:
+ aliases:
+ - "scheduling-service"
+ profiles: [ "backend", "all" ]
+ volumes:
+ - ./config/app/base-application.yaml:/workspace/config/application.yml:Z
# --- MICROSERVICE: Series Service ---
series-service:
diff --git a/docs/03_Journal/2026-04-15_Live-Gang-Vorbereitung.md b/docs/03_Journal/2026-04-15_Live-Gang-Vorbereitung.md
new file mode 100644
index 00000000..6b29826a
--- /dev/null
+++ b/docs/03_Journal/2026-04-15_Live-Gang-Vorbereitung.md
@@ -0,0 +1,39 @@
+# 🧹 Session Journal - 15. April 2026 (Live-Gang Vorbereitung)
+
+## 🏗️ Status-Check (Lead Architect)
+
+- **Phase 13 (Export & Mail-Service):** Infrastruktur und Deployment-Vorbereitungen für den Live-Gang des Online-Nennens
+ sind abgeschlossen.
+- **Ziel erreicht:** Das System kann nun auf dem Produktions-Server deployt werden.
+
+## 👷 Durchgeführte Arbeiten (DevOps & Frontend)
+
+1. **Infrastruktur (Docker):**
+ - Dockerfile für `mail-service` erstellt.
+ - `dc-backend.yaml` um den `mail-service` erweitert (inkl. Postgres-Link, Consul-Discovery und SMTP-Konfiguration).
+ - `application.yaml` im `mail-service` für dynamische Konfiguration via Environment-Variablen fit gemacht.
+2. **Frontend (Konfigurierbarkeit):**
+ - Common `PlatformConfig` erweitert um `resolveMailServiceUrl`.
+ - Implementierung für Wasm, JS und JVM hinzugefügt, um Backend-URLs zur Laufzeit steuern zu können (Wasm: via global
+ JS variables).
+ - `NennungRemoteRepository` nutzt nun die dynamisch aufgelöste Mail-Service-URL.
+ - Fehlende Projekt-Abhängigkeit (`frontend.core.network`) im `nennung-feature` ergänzt.
+3. **Sicherheit:**
+ - CORS im `MailController` auf Ziel-Domains eingeschränkt (`nennung.mo-code.at`).
+ - Bean-Validierung für `NennungRequest` (Email-Format, Pflichtfelder) implementiert.
+4. **Dokumentation:**
+ - `docs/05_Deployment/2026-04-15_Online-Nennung-Deployment.md` erstellt.
+
+## 🧐 QA-Status & Bekannte Themen
+
+- [x] **Infrastruktur-Check:** Docker-Stack ist bereit für `up -d mail-service`.
+- [x] **Frontend-URL:** Die harte Verdrahtung auf `localhost:8085` wurde durch eine flexible Runtime-Konfiguration
+ ersetzt.
+- [ ] **Mail-Versand:** Der tatsächliche Versand muss in der Ziel-Umgebung mit echten SMTP-Credentials validiert werden.
+
+## 🧹 Curator's Note
+
+- Die ROADMAP Phase 13 wurde in der Vormittags-Session bereits aktualisiert.
+- Das "Biest" ist nun technologisch "Live-ready". 🚀
+
+**Abschluss:** Online-Nennung bereit für das Neumarkt-Turnier (April 2026). 🐎
diff --git a/docs/05_Deployment/2026-04-15_Online-Nennung-Deployment.md b/docs/05_Deployment/2026-04-15_Online-Nennung-Deployment.md
new file mode 100644
index 00000000..0a70f0ed
--- /dev/null
+++ b/docs/05_Deployment/2026-04-15_Online-Nennung-Deployment.md
@@ -0,0 +1,70 @@
+# 🚀 Deployment Guide - Online-Nennung (Neumarkt 2026)
+
+Dieser Guide beschreibt den Prozess zum Deployment des "Biest" Online-Nennung Stacks auf den Produktions-Server.
+
+## 1. Voraussetzungen
+
+- Docker & Docker Compose installiert.
+- Zugriff auf den OEPS SMTP Server oder eine Alternative.
+- Domain (z.B. `nennung.mo-code.at`) zeigt auf den Server.
+
+## 2. Infrastruktur (Backend)
+
+Der Stack wird über `dc-backend.yaml` gestartet.
+
+### Umgebungsvariablen (`.env` Datei am Server)
+
+Folgende Variablen müssen gesetzt sein:
+
+```env
+# Datenbank
+POSTGRES_USER=pg-user
+POSTGRES_PASSWORD=dein-geheimes-passwort
+
+# SMTP (für Bestätigungs-Mails)
+MAIL_SMTP_HOST=smtp.mo-code.at
+MAIL_SMTP_PORT=587
+MAIL_SMTP_USER=online-nennen@mo-code.at
+MAIL_SMTP_PASSWORD=dein-smtp-passwort
+```
+
+### Starten
+
+```bash
+docker-compose -f dc-backend.yaml up -d mail-service postgres consul
+```
+
+## 3. Frontend (Wasm Web App)
+
+Die Web-App kommuniziert direkt mit dem `mail-service`.
+
+### Build
+
+```bash
+./gradlew :frontend:shells:meldestelle-web:wasmJsBrowserDistribution
+```
+
+Die Artefakte liegen in `frontend/shells/meldestelle-web/build/dist/wasmJs/productionExecutable/`.
+
+### Konfiguration (Laufzeit)
+
+In der `index.html` oder über ein vorgeschaltetes Nginx können globale Variablen gesetzt werden, um die Backend-URLs zu
+steuern:
+
+```html
+
+
+```
+
+## 4. Sicherheit & Härtung
+
+- **CORS:** Der `MailController` ist aktuell für `localhost:8080` und `nennung.mo-code.at` freigeschaltet.
+- **Reverse Proxy:** Es wird empfohlen, einen Nginx oder Traefik mit SSL (Let's Encrypt) vor den Stack zu schalten.
+- **Mail-Absender:** Die Absender-Adresse ist im `MailController` hartcodiert auf `online-nennen@mo-code.at`. Dies
+ sollte bei Bedarf angepasst werden.
+
+---
+*Dokumentiert durch den Lead Architect am 15. April 2026.*
diff --git a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.kt b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.kt
index 1accbe25..cb24ccd4 100644
--- a/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.kt
+++ b/frontend/core/network/src/commonMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.kt
@@ -1,6 +1,8 @@
package at.mocode.frontend.core.network
+
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
expect object PlatformConfig {
fun resolveApiBaseUrl(): String
+ fun resolveMailServiceUrl(): String
fun resolveKeycloakUrl(): String
}
diff --git a/frontend/core/network/src/jsMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.js.kt b/frontend/core/network/src/jsMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.js.kt
index fe5182ba..549fa104 100644
--- a/frontend/core/network/src/jsMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.js.kt
+++ b/frontend/core/network/src/jsMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.js.kt
@@ -36,6 +36,18 @@ actual object PlatformConfig {
return fallbackUrl
}
+ actual fun resolveMailServiceUrl(): String {
+ val fromGlobal = try {
+ (globalScope.MAIL_SERVICE_URL as? String)?.trim().orEmpty()
+ } catch (_: dynamic) {
+ ""
+ }
+ if (fromGlobal.isNotEmpty()) {
+ return fromGlobal.removeSuffix("/")
+ }
+ return "http://localhost:8085"
+ }
+
actual fun resolveKeycloakUrl(): String {
// 1) Prefer a global JS variable (injected by main.kt via AppConfig)
val fromGlobal = try {
diff --git a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.jvm.kt b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.jvm.kt
index 31f96f82..754aa036 100644
--- a/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.jvm.kt
+++ b/frontend/core/network/src/jvmMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.jvm.kt
@@ -10,6 +10,12 @@ actual object PlatformConfig {
return "http://localhost:8081"
}
+ actual fun resolveMailServiceUrl(): String {
+ val env = System.getenv("MAIL_SERVICE_URL")?.trim().orEmpty()
+ if (env.isNotEmpty()) return env.removeSuffix("/")
+ return "http://localhost:8085"
+ }
+
actual fun resolveKeycloakUrl(): String {
val env = System.getenv("KEYCLOAK_URL")?.trim().orEmpty()
if (env.isNotEmpty()) return env.removeSuffix("/")
diff --git a/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.wasmJs.kt b/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.wasmJs.kt
index 82c933c3..067950b6 100644
--- a/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.wasmJs.kt
+++ b/frontend/core/network/src/wasmJsMain/kotlin/at/mocode/frontend/core/network/PlatformConfig.wasmJs.kt
@@ -6,6 +6,12 @@ package at.mocode.frontend.core.network
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual object PlatformConfig {
+ actual fun resolveMailServiceUrl(): String {
+ val fromGlobal = getGlobalMailServiceUrl()
+ if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/")
+ return "http://localhost:8085"
+ }
+
actual fun resolveKeycloakUrl(): String {
val fromGlobal = getGlobalKeycloakUrl()
if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/")
@@ -47,6 +53,16 @@ private fun getGlobalApiBaseUrl(): String = js(
"""
)
+@OptIn(ExperimentalWasmJsInterop::class)
+private fun getGlobalMailServiceUrl(): String = js(
+ """
+ (function() {
+ var global = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : (typeof self !== 'undefined' ? self : {}));
+ return (global.MAIL_SERVICE_URL && typeof global.MAIL_SERVICE_URL === 'string') ? global.MAIL_SERVICE_URL : "";
+ })()
+"""
+)
+
@OptIn(ExperimentalWasmJsInterop::class)
private fun getGlobalKeycloakUrl(): String = js(
"""
diff --git a/frontend/features/nennung-feature/build.gradle.kts b/frontend/features/nennung-feature/build.gradle.kts
index 2cb3e21d..68ddb81b 100644
--- a/frontend/features/nennung-feature/build.gradle.kts
+++ b/frontend/features/nennung-feature/build.gradle.kts
@@ -41,6 +41,7 @@ kotlin {
commonMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain)
+ implementation(projects.frontend.core.network)
implementation(libs.kotlinx.datetime)
implementation(compose.foundation)
diff --git a/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/domain/NennungRemoteRepository.kt b/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/domain/NennungRemoteRepository.kt
index b9132d70..d229e65c 100644
--- a/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/domain/NennungRemoteRepository.kt
+++ b/frontend/features/nennung-feature/src/commonMain/kotlin/at/mocode/frontend/features/nennung/domain/NennungRemoteRepository.kt
@@ -1,52 +1,52 @@
package at.mocode.frontend.features.nennung.domain
+import at.mocode.frontend.core.network.PlatformConfig
import at.mocode.frontend.features.nennung.presentation.web.NennungPayload
-import io.ktor.client.HttpClient
-import io.ktor.client.request.post
-import io.ktor.client.request.setBody
-import io.ktor.http.ContentType
-import io.ktor.http.contentType
+import io.ktor.client.*
+import io.ktor.client.request.*
+import io.ktor.http.*
import kotlinx.serialization.Serializable
@Serializable
data class NennungApiRequest(
- val turnierNr: String,
- val vorname: String,
- val nachname: String,
- val lizenz: String,
- val pferdName: String,
- val pferdAlter: String,
- val email: String,
- val telefon: String?,
- val bewerbe: String,
- val bemerkungen: String?
+ val turnierNr: String,
+ val vorname: String,
+ val nachname: String,
+ val lizenz: String,
+ val pferdName: String,
+ val pferdAlter: String,
+ val email: String,
+ val telefon: String?,
+ val bewerbe: String,
+ val bemerkungen: String?
)
class NennungRemoteRepository(private val client: HttpClient) {
- suspend fun sendeNennung(turnierNr: String, payload: NennungPayload): Result {
- return try {
- val request = NennungApiRequest(
- turnierNr = turnierNr,
- vorname = payload.vorname,
- nachname = payload.nachname,
- lizenz = payload.lizenz,
- pferdName = payload.pferdName,
- pferdAlter = payload.pferdAlter,
- email = payload.email,
- telefon = payload.telefon,
- bewerbe = payload.bewerbe.joinToString(", ") { it.nr.toString() },
- bemerkungen = payload.bemerkungen
- )
+ private val mailServiceUrl = PlatformConfig.resolveMailServiceUrl()
- // Wir senden direkt an den mail-service (Port 8085)
- // In einer Prod-Umgebung würde dies über das Gateway laufen.
- client.post("http://localhost:8085/api/mail/nennung") {
- contentType(ContentType.Application.Json)
- setBody(request)
- }
- Result.success(Unit)
- } catch (e: Exception) {
- Result.failure(e)
- }
+ suspend fun sendeNennung(turnierNr: String, payload: NennungPayload): Result {
+ return try {
+ val request = NennungApiRequest(
+ turnierNr = turnierNr,
+ vorname = payload.vorname,
+ nachname = payload.nachname,
+ lizenz = payload.lizenz,
+ pferdName = payload.pferdName,
+ pferdAlter = payload.pferdAlter,
+ email = payload.email,
+ telefon = payload.telefon,
+ bewerbe = payload.bewerbe.joinToString(", ") { it.nr.toString() },
+ bemerkungen = payload.bemerkungen
+ )
+
+ // Wir senden an den mail-service (URL dynamisch aufgelöst)
+ client.post("$mailServiceUrl/api/mail/nennung") {
+ contentType(ContentType.Application.Json)
+ setBody(request)
+ }
+ Result.success(Unit)
+ } catch (e: Exception) {
+ Result.failure(e)
}
+ }
}
diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt
index 56560c9c..b8421ef9 100644
--- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt
+++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierStammdatenTab.kt
@@ -49,7 +49,8 @@ fun StammdatenTabContent(
var znsDataLoaded by remember { mutableStateOf(false) }
var znsPayloadVersion by remember { mutableStateOf(null) }
var znsImportedAt by remember { mutableStateOf(null) }
- val znsImportHistory = remember { mutableStateListOf>() } // (source, payloadVersion, ok)
+ val znsImportHistory =
+ remember { mutableStateListOf>() } // (source, payloadVersion, ok)
var typ by remember { mutableStateOf("ÖTO (National)") }
val sparten = remember { mutableStateListOf() }
@@ -63,10 +64,10 @@ fun StammdatenTabContent(
var titel by remember { mutableStateOf("") }
var subTitel by remember { mutableStateOf("") }
- // Initialisierung aus Mock-Store (StoreV2/TurnierStoreV2) falls vorhanden
+ // Initialisierung aus Mock-Store (`StoreV2/TurnierStoreV2`) falls vorhanden
LaunchedEffect(turnierId) {
- // Da wir in einem anderen Modul sind, können wir nicht direkt auf StoreV2 zugreifen
- // ohne die Abhängigkeit zu haben. In einer echten Architektur käme dies über das Repository.
+ // Da wir in einem anderen Modul sind, können wir nicht direkt auf StoreV2 zugreifen,
+ // ohne die Abhängigkeit zu haben. In einer echten Architektur kommt dies über das Repository.
// Aber für die Demo/Fakten-Präsentation im Desktop-Shell-Kontext:
try {
val clazz = Class.forName("at.mocode.desktop.v2.TurnierStoreV2")
@@ -76,37 +77,39 @@ fun StammdatenTabContent(
val idField = t!!::class.java.getDeclaredField("turnierNr")
idField.isAccessible = true
idField.get(t).toString() == turnierId.toString() ||
- t.hashCode().toLong() == turnierId // Fallback falls ID anders gemappt ist
+ t.hashCode().toLong() == turnierId // Fallback, falls die ID anders gemappt ist
}
- if (turnier != null) {
- val tClass = turnier::class.java
+ when {
+ turnier != null -> {
+ val tClass = turnier::class.java
- val nrField = tClass.getDeclaredField("turnierNr")
- nrField.isAccessible = true
- turnierNr = nrField.get(turnier).toString()
- nrConfirmed = true
+ val nrField = tClass.getDeclaredField("turnierNr")
+ nrField.isAccessible = true
+ turnierNr = nrField.get(turnier).toString()
+ nrConfirmed = true
- val titelField = tClass.getDeclaredField("titel")
- titelField.isAccessible = true
- titel = titelField.get(turnier) as String
+ val titelField = tClass.getDeclaredField("titel")
+ titelField.isAccessible = true
+ titel = titelField.get(turnier) as String
- val subField = tClass.getDeclaredField("subTitel")
- subField.isAccessible = true
- subTitel = subField.get(turnier) as String
+ val subField = tClass.getDeclaredField("subTitel")
+ subField.isAccessible = true
+ subTitel = subField.get(turnier) as String
- val katField = tClass.getDeclaredField("kategorie")
- katField.isAccessible = true
- val kats = katField.get(turnier) as? List
- kats?.let { kat.addAll(it) }
+ val katField = tClass.getDeclaredField("kategorie")
+ katField.isAccessible = true
+ val kats = katField.get(turnier) as? List
+ kats?.let { kat.addAll(it) }
- val typField = tClass.getDeclaredField("typ")
- typField.isAccessible = true
- typ = typField.get(turnier) as String
+ val typField = tClass.getDeclaredField("typ")
+ typField.isAccessible = true
+ typ = typField.get(turnier) as String
- val znsField = tClass.getDeclaredField("znsDataLoaded")
- znsField.isAccessible = true
- znsDataLoaded = znsField.get(turnier) as Boolean
+ val znsField = tClass.getDeclaredField("znsDataLoaded")
+ znsField.isAccessible = true
+ znsDataLoaded = znsField.get(turnier) as Boolean
+ }
}
} catch (_: Exception) {
// Reflection fehlgeschlagen oder Store nicht erreichbar -> Fallback auf leere Felder
@@ -118,7 +121,7 @@ fun StammdatenTabContent(
var showZnsDialog by remember { mutableStateOf(false) }
var showZnsLog by remember { mutableStateOf(false) }
- // Hilfs-States für DatePicker
+ // Hilf's-States für DatePicker
var showDatePickerVon by remember { mutableStateOf(false) }
var showDatePickerBis by remember { mutableStateOf(false) }
@@ -143,29 +146,35 @@ fun StammdatenTabContent(
singleLine = true,
enabled = !nrConfirmed
)
- if (!nrConfirmed) {
- Button(
- onClick = { showNrConfirm = true },
- enabled = turnierNr.length == 5,
- colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
- ) {
- Text("Bestätigen")
+ when {
+ !nrConfirmed -> {
+ Button(
+ onClick = { showNrConfirm = true },
+ enabled = turnierNr.length == 5,
+ colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
+ ) {
+ Text("Bestätigen")
+ }
+ }
+
+ else -> {
+ InputChip(
+ selected = true,
+ onClick = { },
+ label = { Text("Bestätigt") },
+ trailingIcon = { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
+ )
}
- } else {
- InputChip(
- selected = true,
- onClick = { },
- label = { Text("Bestätigt") },
- trailingIcon = { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
- )
}
}
- if (turnierNr.length == 5 && !nrConfirmed) {
- Text(
- "Bitte Turnier-Nummer bestätigen um fortzufahren.",
- color = MaterialTheme.colorScheme.error,
- fontSize = 11.sp
- )
+ when (turnierNr.length) {
+ 5 if !nrConfirmed -> {
+ Text(
+ "Bitte Turnier-Nummer bestätigen um fortzufahren.",
+ color = MaterialTheme.colorScheme.error,
+ fontSize = 11.sp
+ )
+ }
}
}
@@ -190,8 +199,7 @@ fun StammdatenTabContent(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = { showZnsDialog = true },
- colors = ButtonDefaults.buttonColors(containerColor = AccentBlue)
- , enabled = nrConfirmed
+ colors = ButtonDefaults.buttonColors(containerColor = AccentBlue), enabled = nrConfirmed
) {
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
@@ -254,7 +262,7 @@ fun StammdatenTabContent(
}
FormRow("Klasse:") {
- val klassenListe = listOf("C-NEU", "C", "B", "A", "L", "LM", "M", "S")
+ val klassenListe = listOf("C-NEU", "C", "B", "A")
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
klassenListe.forEach { k ->
FilterChip(
@@ -278,32 +286,37 @@ fun StammdatenTabContent(
}
}
- if (suggested.isEmpty()) {
- Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp)
- } else {
- // Gruppiere nach Sparte (CDN/CSN)
- val grouped = suggested.groupBy { if (it.startsWith("CDN")) "Dressur" else "Springen" }
- grouped.forEach { (gruppe, eintraege) ->
- Text(gruppe, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
- Spacer(Modifier.height(4.dp))
- FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- eintraege.sorted().forEach { c ->
- InputChip(
- selected = kat.contains(c),
- onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) },
- enabled = nrConfirmed,
- label = { Text(c) }
- )
+ when {
+ suggested.isEmpty() -> {
+ Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp)
+ }
+
+ else -> {
+ // Gruppiere nach Sparte (CDN/CSN)
+ val grouped = suggested.groupBy { if (it.startsWith("CDN")) "Dressur" else "Springen" }
+ grouped.forEach { (gruppe, eintraege) ->
+ Text(gruppe, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
+ Spacer(Modifier.height(4.dp))
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ eintraege.sorted().forEach { c ->
+ InputChip(
+ selected = kat.contains(c),
+ onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) },
+ enabled = nrConfirmed,
+ label = { Text(c) }
+ )
+ }
}
+ Spacer(Modifier.height(8.dp))
}
- Spacer(Modifier.height(8.dp))
}
}
}
FormRow("Zeitraum:") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
- val vonMod = if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerVon = true } else Modifier.width(160.dp)
+ val vonMod =
+ if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerVon = true } else Modifier.width(160.dp)
OutlinedTextField(
value = von,
onValueChange = {},
@@ -314,7 +327,8 @@ fun StammdatenTabContent(
trailingIcon = { Icon(Icons.Default.DateRange, null) }
)
Text("bis")
- val bisMod = if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerBis = true } else Modifier.width(160.dp)
+ val bisMod =
+ if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerBis = true } else Modifier.width(160.dp)
OutlinedTextField(
value = bis,
onValueChange = {},
@@ -325,7 +339,8 @@ fun StammdatenTabContent(
trailingIcon = { Icon(Icons.Default.DateRange, null) }
)
}
- val rangeText = if (eventVon != null && eventBis != null) "Muss zwischen $eventVon – $eventBis liegen." else "Muss innerhalb des Veranstaltungs-Zeitraums liegen."
+ val rangeText =
+ if (eventVon != null && eventBis != null) "Muss zwischen $eventVon – $eventBis liegen." else "Muss innerhalb des Veranstaltungs-Zeitraums liegen."
Text(rangeText, fontSize = 11.sp, color = Color.Gray)
}
}
@@ -335,7 +350,8 @@ fun StammdatenTabContent(
// Default-Titel-Vorschlag: [Kategorien] [Verein-Ort] [Bundesland]
val defaultTitle = remember(kat.size, veranstalterOrt, veranstalterBundesland) {
val cats = if (kat.isEmpty()) "" else kat.sorted().joinToString(" ")
- listOfNotNull(cats.ifBlank { null },
+ listOfNotNull(
+ cats.ifBlank { null },
listOfNotNull(veranstalterOrt, veranstalterBundesland).filter { it.isNotBlank() }.joinToString(" ")
.takeIf { it.isNotBlank() }
).joinToString(" ")
@@ -367,7 +383,12 @@ fun StammdatenTabContent(
supportingText = {
if (eventOrt != null && ort.isNotBlank() && ort.trim() != eventOrt.trim()) {
Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(Icons.Default.Warning, contentDescription = null, tint = Color(0xFFF59E0B), modifier = Modifier.size(14.dp))
+ Icon(
+ Icons.Default.Warning,
+ contentDescription = null,
+ tint = Color(0xFFF59E0B),
+ modifier = Modifier.size(14.dp)
+ )
Spacer(Modifier.width(4.dp))
Text("Abweichung zum Veranstaltungsort ($eventOrt) – bitte prüfen.", color = Color(0xFFF59E0B))
}
@@ -409,14 +430,26 @@ fun StammdatenTabContent(
}
// ── Footer ──────────────────────────────────────────────────────────
- Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
// Save-Enable-Matrix (kleine Checkliste)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
AssistChip(onClick = {}, label = { Text("Nr bestätigt") }, leadingIcon = {
- Icon(if (nrConfirmed) Icons.Default.Check else Icons.Default.Close, null, tint = if (nrConfirmed) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error)
+ Icon(
+ if (nrConfirmed) Icons.Default.Check else Icons.Default.Close,
+ null,
+ tint = if (nrConfirmed) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
+ )
})
AssistChip(onClick = {}, label = { Text("ZNS geladen") }, leadingIcon = {
- Icon(if (znsDataLoaded) Icons.Default.Check else Icons.Default.Close, null, tint = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error)
+ Icon(
+ if (znsDataLoaded) Icons.Default.Check else Icons.Default.Close,
+ null,
+ tint = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
+ )
})
val dateOk = remember(von, bis, eventVon, eventBis) {
try {
@@ -427,10 +460,16 @@ fun StammdatenTabContent(
val tB = if (bis.isBlank()) tV else LocalDate.parse(bis)
!tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV)
}
- } catch (_: Exception) { false }
+ } catch (_: Exception) {
+ false
+ }
}
AssistChip(onClick = {}, label = { Text("Datum gültig") }, leadingIcon = {
- Icon(if (dateOk) Icons.Default.Check else Icons.Default.Close, null, tint = if (dateOk) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error)
+ Icon(
+ if (dateOk) Icons.Default.Check else Icons.Default.Close,
+ null,
+ tint = if (dateOk) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
+ )
})
}
@@ -446,7 +485,9 @@ fun StammdatenTabContent(
val tB = if (bis.isBlank()) tV else LocalDate.parse(bis)
!tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV)
}
- } catch (_: Exception) { false }
+ } catch (_: Exception) {
+ false
+ }
base && dateValid
},
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
@@ -460,88 +501,96 @@ fun StammdatenTabContent(
}
// Dialog-Simulationen
- if (showZnsDialog) {
- AlertDialog(
- onDismissRequest = { showZnsDialog = false },
- title = { Text("ZNS Import") },
- text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") },
- confirmButton = {
- TextButton(onClick = {
- znsDataLoaded = true
- znsPayloadVersion = "v2.4"
- znsImportedAt = java.time.Instant.now().toString()
- znsImportHistory.add(Triple("Internet/USB", znsPayloadVersion!!, true))
- showZnsDialog = false
- }) { Text("Importieren") }
- },
- dismissButton = {
- TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") }
- }
- )
+ when {
+ showZnsDialog -> {
+ AlertDialog(
+ onDismissRequest = { showZnsDialog = false },
+ title = { Text("ZNS Import") },
+ text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") },
+ confirmButton = {
+ TextButton(onClick = {
+ znsDataLoaded = true
+ znsPayloadVersion = "v2.4"
+ znsImportedAt = java.time.Instant.now().toString()
+ znsImportHistory.add(Triple("Internet/USB", znsPayloadVersion!!, true))
+ showZnsDialog = false
+ }) { Text("Importieren") }
+ },
+ dismissButton = {
+ TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") }
+ }
+ )
+ }
}
- if (showNrConfirm) {
- AlertDialog(
- onDismissRequest = { showNrConfirm = false },
- title = { Text("Turnier-Nummer bestätigen?") },
- text = { Text("Die Turnier-Nr. ist nach der Bestätigung nicht mehr änderbar.") },
- confirmButton = {
- TextButton(onClick = { nrConfirmed = true; showNrConfirm = false }) { Text("Ja, bestätigen") }
- },
- dismissButton = {
- TextButton(onClick = { showNrConfirm = false }) { Text("Abbrechen") }
- }
- )
+ when {
+ showNrConfirm -> {
+ AlertDialog(
+ onDismissRequest = { showNrConfirm = false },
+ title = { Text("Turnier-Nummer bestätigen?") },
+ text = { Text("Die Turnier-Nr. ist nach der Bestätigung nicht mehr änderbar.") },
+ confirmButton = {
+ TextButton(onClick = { nrConfirmed = true; showNrConfirm = false }) { Text("Ja, bestätigen") }
+ },
+ dismissButton = {
+ TextButton(onClick = { showNrConfirm = false }) { Text("Abbrechen") }
+ }
+ )
+ }
}
- if (showZnsLog) {
- AlertDialog(
- onDismissRequest = { showZnsLog = false },
- title = { Text("ZNS Import-Log (letzte 5)") },
- text = {
- Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
- if (znsImportHistory.isEmpty()) {
- Text("Keine Einträge vorhanden.", color = Color.Gray)
- } else {
- znsImportHistory.takeLast(5).asReversed().forEach { (src, ver, ok) ->
- val c = if (ok) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
- Text("• $src – Version $ver – ${if (ok) "OK" else "Fehler"}", color = c, fontSize = 13.sp)
+ when {
+ showZnsLog -> {
+ AlertDialog(
+ onDismissRequest = { showZnsLog = false },
+ title = { Text("ZNS Import-Log (letzte 5)") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ if (znsImportHistory.isEmpty()) {
+ Text("Keine Einträge vorhanden.", color = Color.Gray)
+ } else {
+ znsImportHistory.takeLast(5).asReversed().forEach { (src, ver, ok) ->
+ val c = if (ok) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
+ Text("• $src – Version $ver – ${if (ok) "OK" else "Fehler"}", color = c, fontSize = 13.sp)
+ }
}
}
+ },
+ confirmButton = { TextButton(onClick = { showZnsLog = false }) { Text("Schließen") } }
+ )
+ }
+ }
+
+ when {
+ showDatePickerVon -> {
+ val state = rememberDatePickerState()
+ DatePickerDialog(
+ onDismissRequest = { showDatePickerVon = false },
+ confirmButton = {
+ TextButton(onClick = {
+ state.selectedDateMillis?.let {
+ von = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
+ }
+ showDatePickerVon = false
+ }) { Text("OK") }
}
- },
- confirmButton = { TextButton(onClick = { showZnsLog = false }) { Text("Schließen") } }
- )
- }
+ ) { DatePicker(state) }
+ }
- if (showDatePickerVon) {
- val state = rememberDatePickerState()
- DatePickerDialog(
- onDismissRequest = { showDatePickerVon = false },
- confirmButton = {
- TextButton(onClick = {
- state.selectedDateMillis?.let {
- von = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
- }
- showDatePickerVon = false
- }) { Text("OK") }
- }
- ) { DatePicker(state) }
- }
-
- if (showDatePickerBis) {
- val state = rememberDatePickerState()
- DatePickerDialog(
- onDismissRequest = { showDatePickerBis = false },
- confirmButton = {
- TextButton(onClick = {
- state.selectedDateMillis?.let {
- bis = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
- }
- showDatePickerBis = false
- }) { Text("OK") }
- }
- ) { DatePicker(state) }
+ showDatePickerBis -> {
+ val state = rememberDatePickerState()
+ DatePickerDialog(
+ onDismissRequest = { showDatePickerBis = false },
+ confirmButton = {
+ TextButton(onClick = {
+ state.selectedDateMillis?.let {
+ bis = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
+ }
+ showDatePickerBis = false
+ }) { Text("OK") }
+ }
+ ) { DatePicker(state) }
+ }
}
}