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) } + } } }