Compare commits

...

6 Commits

Author SHA1 Message Date
6b690232ff ### feat: füge Mail-Service-
Some checks failed
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Failing after 58s
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 5m59s
Build and Publish Docker Images / build-and-push (., backend/services/mail/Dockerfile, mail-service, mail-service) (push) Successful in 5m48s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 6m2s
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Failing after 1m52s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Successful in 1m44s
2026-04-22 23:17:19 +02:00
309834d90c ### feat: verbessere DTO-Handling und füge Feature-Flag hinzu
- **NennungViewModel:** Definiere lokales `NennungDto` zur Optimierung der Backend-Response-Verarbeitung.
- **MailPollingService:** Ergänze Bedingung für Scheduler-Aktivierung (`ConditionalOnProperty`).
- **.env & application.yaml:** Füge `MAIL_POLLING_ENABLED` als Feature-Flag hinzu.
- **Dependencies:** Refaktor Import-Reihenfolge für Konsistenz.
2026-04-22 20:27:04 +02:00
8b44edda90 ### feat: aktualisiere Netzwerk-URLs und vereinfachte Online-Nennung
- **PlatformConfig:** Passe Standard-URLs für lokale Dienste an (`8092` statt `8083/8085`).
- **OnlineNennungFormular:** Entferne zusätzliche Felder und vereinfachere Validierungslogik.
- **OnlineNennungViewModel:** Ersetze HttpClient-Logik durch Repository-Injektion.
- **DI:** Aktualisiere Dependencies für `NennungRemoteRepository`.
2026-04-22 16:22:59 +02:00
255343145d ### feat: optimiere Architektur und verbessere E-Mail-Handling
- **ArchTests:** Passe Slices-Matching für `FrontendArchitectureTest` an Package-Struktur an.
- **Mail-Service:** Füge Plan-B-Benachrichtigung für Nennungen an Meldestelle hinzu; entferne Plus-Addressing (Fallback).
- **Build:** Deaktiviere Desktop-Build standardmäßig (`enableDesktop=false`) und mache Module-Registrierung optional.
2026-04-22 16:01:55 +02:00
5baa971b46 ### docs: aktualisiere ADR 0028 für E-Mail-basiertes Routing
- Ersetze Catch-All-Ansatz durch Betreff-basiertes Routing.
- Reduziere Infrastruktur-Aufwände durch generische Zieladresse.
2026-04-22 15:18:35 +02:00
e65384768f ### feat: initialisiere Plan-B für E-Mail-basierte Online-Nennung
- **ADR 0028:** Dokumentiere MVP-Entscheidung für E-Mail-gesteuertes Nennsystem.
- **Gradle:** Aktiviere `enableWasm` für die Web-App-Generierung.
2026-04-22 15:11:00 +02:00
24 changed files with 366 additions and 387 deletions

17
.env
View File

@ -20,6 +20,7 @@ DOCKER_GRADLE_VERSION=9.3.1
DOCKER_JAVA_VERSION=25
DOCKER_NODE_VERSION=24.12.0
DOCKER_NGINX_VERSION=1.28.0-alpine
DOCKER_CADDY_VERSION=2.11-alpine
# JVM Power Flags (Lokal leer lassen, da Intel/AMD Architektur)
JVM_OPTS_ARM64=
@ -96,6 +97,7 @@ CONSUL_IMAGE=hashicorp/consul:1.22.1
CONSUL_PORT=8500:8500
CONSUL_UDP_PORT=8600:8600/udp
CONSUL_HOST=consul
CONSUL_HTTP_PORT=8500
SPRING_CLOUD_CONSUL_HOST=consul
SPRING_CLOUD_CONSUL_PORT=8500
SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME=api-gateway
@ -166,10 +168,21 @@ MAIL_CONSUL_PREFER_IP=true
MAIL_SMTP_HOST=smtp.world4you.com
MAIL_SMTP_PORT=587
MAIL_SMTP_USER=online-nennen@mo-code.at
MAIL_SMTP_PASSWORD=secret
MAIL_SMTP_PASSWORD=Mogi#2reiten
MAIL_SMTP_AUTH=true
MAIL_SMTP_STARTTLS=true
SPRING_MAIL_HOST=localhost
SPRING_MAIL_PORT=1025
SPRING_MAIL_USERNAME=online-nennen@mo-code.at
SPRING_MAIL_PASSWORD=Mogi#2reiten
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH=false
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE=false
SPRING_CLOUD_CONSUL_DISCOVERY_ENABLED=false
SPRING_CLOUD_CONSUL_ENABLED=false
MAIL_POLLING_ENABLED=false
# --- MASTERDATA-SERVICE ---
MASTERDATA_PORT=8086:8086
MASTERDATA_DEBUG_PORT=5007:5007
@ -237,7 +250,7 @@ SERIES_CONSUL_PREFER_IP=true
# --- WEB-APP ---
CADDY_VERSION=2.11-alpine
WEB_APP_PORT=4000:4000
WEB_APP_PORT=8080:80
WEB_BUILD_PROFILE=dev
# Lokal: http://localhost:8081 | Produktion: http://10.0.0.50:8081
WEB_APP_API_URL=http://localhost:8081

View File

@ -45,6 +45,10 @@ jobs:
context: .
dockerfile: backend/services/ping/Dockerfile
image: ping-service
- service: mail-service
context: .
dockerfile: backend/services/mail/Dockerfile
image: mail-service
- service: web-app
context: .
dockerfile: config/docker/caddy/web-app/Dockerfile

View File

@ -10,9 +10,9 @@ import jakarta.mail.Session
import jakarta.mail.internet.InternetAddress
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.springframework.transaction.annotation.Transactional
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.event.EventListener
import org.springframework.mail.SimpleMailMessage
@ -20,6 +20,7 @@ import org.springframework.mail.javamail.JavaMailSender
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.*
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@ -27,6 +28,7 @@ import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
@Service
@EnableScheduling
@ConditionalOnProperty(value = ["mail.polling.enabled"], havingValue = "true", matchIfMissing = false)
class MailPollingService(
private val mailSender: JavaMailSender,
private val nennungRepository: NennungRepository,

View File

@ -71,19 +71,43 @@ class MailController(
nennungRepository.save(entity)
logger.info("Nennung ${entity.id} in Datenbank persistiert.")
// Bestätigung an Reiter senden
// --- PLAN B: Benachrichtigung an die Meldestelle (online-nennen@mo-code.at) senden ---
try {
val notification = SimpleMailMessage()
notification.from = baseMailAddress // Mailserver erfordert oft, dass From == Username ist
notification.setTo(baseMailAddress) // Wir senden es an uns selbst
// WICHTIG: Die Turniernummer im Betreff für das einfache Mail-Filtering!
notification.subject = "[NENNUNG] Turnier ${request.turnierNr} - ${request.vorname} ${request.nachname}"
val textBody = buildString {
appendLine("Neue Online-Nennung eingegangen!")
appendLine("----------------------------------")
appendLine("Turnier: ${request.turnierNr}")
appendLine("Reiter: ${request.vorname} ${request.nachname}")
appendLine("Lizenz: ${request.lizenz}")
appendLine("Pferd: ${request.pferdName} (Alter: ${request.pferdAlter})")
appendLine("E-Mail: ${request.email}")
appendLine("Telefon: ${request.telefon ?: "-"}")
appendLine("Bewerbe: ${request.bewerbe}")
appendLine("Bemerkungen: ${request.bemerkungen ?: "-"}")
appendLine("----------------------------------")
appendLine("System-ID: ${entity.id}")
}
notification.text = textBody
mailSender.send(notification)
logger.info("Plan-B Nennungs-Mail an die Meldestelle gesendet. Betreff: ${notification.subject}")
} catch (e: Exception) {
logger.error("Fehler beim Senden der Plan-B Nennungs-Mail an die Meldestelle: ${e.message}")
}
// --- Ursprüngliche Bestätigung an den Reiter (optional, bleibt vorerst erhalten) ---
try {
val message = SimpleMailMessage()
// Dynamische Absenderadresse mit Plus-Addressing (z.B. online-nennen+26128@mo-code.at)
val dynamicFrom = try {
val (user, domain) = baseMailAddress.split("@")
"$user+${request.turnierNr}@$domain"
} catch (_: Exception) {
baseMailAddress
}
message.from = dynamicFrom
// PLAN B Fallback: Kein Plus-Addressing, da World4You es nicht unterstützt
// Wir verwenden als Absender einfach die Basis-Adresse
message.from = baseMailAddress
message.setTo(request.email)
message.subject = "Bestätigung: Ihre Online-Nennung für Turnier ${request.turnierNr}"
message.text = """
@ -133,14 +157,8 @@ class MailController(
@RequestParam nachname: String
) {
val message = SimpleMailMessage()
val dynamicFrom = try {
val (user, domain) = baseMailAddress.split("@")
"$user+$turnierNr@$domain"
} catch (_: Exception) {
baseMailAddress
}
message.from = dynamicFrom
// PLAN B Fallback: Kein Plus-Addressing
message.from = baseMailAddress
message.setTo(email)
message.subject = "Bestätigung: Nennung für Turnier $turnierNr manuell übernommen"
message.text = """

View File

@ -0,0 +1,34 @@
package at.mocode.mail.service.config
import at.mocode.mail.service.persistence.NennungTable
import jakarta.annotation.PostConstruct
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.slf4j.LoggerFactory
import org.springframework.context.annotation.Configuration
import javax.sql.DataSource
/**
* Wires Spring's DataSource into Exposed and ensures the schema exists.
* This replaces the implicit init that previously happened in the polling service.
*/
@Configuration
class ExposedConfiguration(
private val dataSource: DataSource,
) {
private val log = LoggerFactory.getLogger(ExposedConfiguration::class.java)
@PostConstruct
fun connectAndInitSchema() {
// Bind Exposed to Spring's DataSource
Database.connect(dataSource)
// Create required tables if missing (idempotent for H2 and typical RDBMS)
transaction {
SchemaUtils.create(NennungTable)
}
log.info("Exposed connected to DataSource and schema initialized (NennungTable).")
}
}

View File

@ -5,9 +5,9 @@ package at.mocode.mail.service.persistence
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import org.springframework.stereotype.Repository
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.springframework.transaction.annotation.Transactional
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid

View File

@ -14,7 +14,7 @@ spring:
host: ${SPRING_MAIL_HOST:smtp.world4you.com}
port: ${SPRING_MAIL_PORT:587}
username: ${SPRING_MAIL_USERNAME:online-nennen@mo-code.at}
password: ${SPRING_MAIL_PASSWORD:}
password: ${SPRING_MAIL_PASSWORD:Mogi#2reiten}
properties:
mail:
smtp:
@ -26,9 +26,10 @@ spring:
consul:
host: ${SPRING_CLOUD_CONSUL_HOST:localhost}
port: ${SPRING_CLOUD_CONSUL_PORT:8500}
enabled: ${SPRING_CLOUD_CONSUL_ENABLED:false}
discovery:
enabled: true
register: true
enabled: ${SPRING_CLOUD_CONSUL_DISCOVERY_ENABLED:false}
register: ${SPRING_CLOUD_CONSUL_DISCOVERY_REGISTER:false}
prefer-ip-address: true
health-check-path: /actuator/health
health-check-interval: 10s
@ -43,4 +44,14 @@ management:
endpoints:
web:
exposure:
include: "health,info,prometheus"
include: health,info,prometheus
endpoint:
health:
show-details: always
probes:
enabled: true
# Feature-Flags
mail:
polling:
enabled: ${MAIL_POLLING_ENABLED:false}

View File

@ -3,7 +3,7 @@
metrics
}
:4000 {
:80 {
root * /usr/share/caddy
log {
output stdout
@ -17,14 +17,34 @@
encode gzip zstd
handle /api/* {
reverse_proxy api-gateway:8081
# Reverse Proxy: Plan-B leitet nur /api/mail an den Mail-Service weiter (kein API-Gateway nötig)
handle /api/mail/* {
reverse_proxy mail-service:8085
}
handle /health {
respond "healthy" 200
}
# Korrekte MIME für .wasm sicherstellen (Caddy erkennt es i. d. R. automatisch; hier explizit)
@wasm {
path *.wasm
}
header @wasm Content-Type "application/wasm"
# Caching-Strategie: Immutable Assets (hash-Dateien) lange cachen
@immutable {
path *.js *.css *.wasm *.png *.svg *.ico *.woff2 *.map
}
header @immutable Cache-Control "public, max-age=31536000, immutable"
# Keine Cache-Header für SPA-Einstieg und Laufzeitkonfig
@nocache {
path /index.html /config.json
}
header @nocache Cache-Control "no-store"
# Static file serving mit SPA-Fallback
handle {
try_files {path} /index.html
file_server

View File

@ -31,9 +31,9 @@ COPY config/docker/caddy/web-app/entrypoint.sh /entrypoint.sh
COPY config/docker/caddy/web-app/config.json /usr/share/caddy/config.json.tmpl
RUN chmod +x /entrypoint.sh
# Copy Pre-built Static Assets from Host
# NOTE: You must run `./gradlew :frontend:shells:meldestelle-portal:jsBrowserDistribution -Pproduction=true` locally first!
COPY frontend/shells/meldestelle-portal/build/dist/js/productionExecutable/ /usr/share/caddy/
# Copy Pre-built Static Assets from Host (WasmJs)
# NOTE: You must run `./gradlew :frontend:shells:meldestelle-web:wasmJsBrowserDistribution -Pproduction=true` locally first!
COPY frontend/shells/meldestelle-web/build/dist/wasmJs/productionExecutable/ /usr/share/caddy/
# index.html wird als Template abgelegt; der Entrypoint erzeugt daraus zur Laufzeit die finale index.html
RUN mv /usr/share/caddy/index.html /usr/share/caddy/index.html.tmpl
@ -41,10 +41,10 @@ RUN mv /usr/share/caddy/index.html /usr/share/caddy/index.html.tmpl
# Using the shared asset from existing config structure
COPY config/docker/nginx/web-app/favicon.svg /usr/share/caddy/favicon.svg
EXPOSE 4000
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:4000/health || exit 1
CMD wget --no-verbose --tries=1 --spider http://localhost/health || exit 1
ENTRYPOINT ["/entrypoint.sh"]
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

View File

@ -1,4 +1,5 @@
{
"apiBaseUrl": "${API_BASE_URL}",
"mailServiceUrl": "${MAIL_SERVICE_URL}",
"keycloakUrl": "${KEYCLOAK_URL}"
}

View File

@ -1,13 +1,13 @@
#!/bin/sh
set -e
# Ersetze ${API_BASE_URL} und ${KEYCLOAK_URL} in index.html und config.json zur Container-Startzeit.
# Ersetze ${API_BASE_URL}, ${MAIL_SERVICE_URL} und ${KEYCLOAK_URL} in index.html und config.json zur Container-Startzeit.
# Caddy bekommt fertige, statische Dateien — kein Template-Parsing mehr nötig.
envsubst '${API_BASE_URL} ${KEYCLOAK_URL}' \
envsubst '${API_BASE_URL} ${MAIL_SERVICE_URL} ${KEYCLOAK_URL}' \
< /usr/share/caddy/index.html.tmpl \
> /usr/share/caddy/index.html
envsubst '${API_BASE_URL} ${KEYCLOAK_URL}' \
envsubst '${API_BASE_URL} ${MAIL_SERVICE_URL} ${KEYCLOAK_URL}' \
< /usr/share/caddy/config.json.tmpl \
> /usr/share/caddy/config.json

View File

@ -6,43 +6,43 @@ services:
# ==========================================
# --- WEB-APP ---
web-app:
image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/web-app:${DOCKER_TAG:-latest}"
build:
context: . # Wichtig: Root Context für Monorepo Zugriff
dockerfile: config/docker/caddy/web-app/Dockerfile
args:
# Frontend spezifisch:
CADDY_VERSION: "${DOCKER_CADDY_VERSION:-2.11-alpine}"
# Metadaten:
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}-web-app"
restart: unless-stopped
ports:
- "${WEB_APP_PORT:-4000:4000}"
environment:
# Runtime Configuration — via envsubst in entrypoint.sh in config.json & index.html injiziert.
# Muss die öffentlich erreichbare URL sein (Browser-Zugriff!), NICHT localhost.
API_BASE_URL: "${WEB_APP_API_URL:-http://localhost:8081}"
# Keycloak Public URL (muss vom Browser aus erreichbar sein)
KEYCLOAK_URL: "${WEB_APP_KEYCLOAK_URL:-http://localhost:8180}"
depends_on:
api-gateway:
condition: "service_started"
healthcheck:
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:4000/" ]
interval: 20s
timeout: 5s
retries: 5
start_period: 20s
networks:
meldestelle-network:
aliases:
- "web-app"
profiles: [ "gui", "all" ]
# web-app:
# image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/web-app:${DOCKER_TAG:-latest}"
# build:
# context: . # Wichtig: Root Context für Monorepo Zugriff
# dockerfile: config/docker/caddy/web-app/Dockerfile
# args:
# # Frontend spezifisch:
# CADDY_VERSION: "${DOCKER_CADDY_VERSION:-2.11-alpine}"
# # Metadaten:
# 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}-web-app"
# restart: unless-stopped
# ports:
# - "${WEB_APP_PORT:-4000:4000}"
# environment:
# # Runtime Configuration — via envsubst in entrypoint.sh in config.json & index.html injiziert.
# # Muss die öffentlich erreichbare URL sein (Browser-Zugriff!), NICHT localhost.
# API_BASE_URL: "${WEB_APP_API_URL:-http://localhost:8081}"
# # Keycloak Public URL (muss vom Browser aus erreichbar sein)
# KEYCLOAK_URL: "${WEB_APP_KEYCLOAK_URL:-http://localhost:8180}"
# depends_on:
# api-gateway:
# condition: "service_started"
# healthcheck:
# test: [ "CMD", "wget", "--spider", "-q", "http://localhost:4000/" ]
# interval: 20s
# timeout: 5s
# retries: 5
# start_period: 20s
# networks:
# meldestelle-network:
# aliases:
# - "web-app"
# profiles: [ "gui", "all" ]
networks:
meldestelle-network:

48
dc-planb.yaml Normal file
View File

@ -0,0 +1,48 @@
name: "${PROJECT_NAME:-meldestelle}"
services:
# --- Statische Web-App (WASM) ---
web-app:
image: ${REGISTRY_INTERNAL:-10.0.0.22:3000}/mocode-software/meldestelle/web-app:${DOCKER_TAG:-latest}
container_name: ${PROJECT_NAME:-meldestelle}-web-app
restart: unless-stopped
environment:
# Diese Variablen werden vom Web-Container verwendet, um die Ziel-URLs in die index.html zu injizieren
API_BASE_URL: ${API_BASE_URL:-https://api.mo-code.at}
MAIL_SERVICE_URL: ${MAIL_SERVICE_URL:-https://api.mo-code.at/mail}
ports:
- "${WEB_APP_PORT:-8080:80}"
networks: [meldestelle-network]
# --- Mail-Service (Plan-B: Form -> E-Mail) ---
mail-service:
image: ${REGISTRY_INTERNAL:-10.0.0.22:3000}/mocode-software/meldestelle/mail-service:${DOCKER_TAG:-latest}
container_name: ${PROJECT_NAME:-meldestelle}-mail-service
restart: unless-stopped
environment:
# Server-Port im Container (Spring Boot)
SERVER_PORT: ${SERVER_PORT:-8085}
# SMTP (World4You - PROD)
SPRING_MAIL_HOST: ${SPRING_MAIL_HOST:-smtp.world4you.com}
SPRING_MAIL_PORT: ${SPRING_MAIL_PORT:-587}
SPRING_MAIL_USERNAME: ${SPRING_MAIL_USERNAME:-online-nennen@mo-code.at}
SPRING_MAIL_PASSWORD: ${SPRING_MAIL_PASSWORD:-changeme}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH:-true}
SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE:-true}
# Feature-Flags / Infra-Off
MAIL_POLLING_ENABLED: ${MAIL_POLLING_ENABLED:-false}
SPRING_CLOUD_CONSUL_ENABLED: ${SPRING_CLOUD_CONSUL_ENABLED:-false}
SPRING_CLOUD_CONSUL_DISCOVERY_ENABLED: ${SPRING_CLOUD_CONSUL_DISCOVERY_ENABLED:-false}
SPRING_CLOUD_CONSUL_DISCOVERY_REGISTER: ${SPRING_CLOUD_CONSUL_DISCOVERY_REGISTER:-false}
# Datenbank: H2 In-Memory (Default in application.yaml) KEINE Postgres-Variablen setzen
ports:
- "8092:${SERVER_PORT:-8085}" # Extern 8092 beibehalten
networks: [meldestelle-network]
networks:
meldestelle-network:
driver: bridge

View File

@ -0,0 +1,35 @@
# ADR 0028: Plan-B - Fallback auf E-Mail-basierte Online-Nennung (MVP)
**Status:** Angenommen
**Datum:** 2026-04-22
**Entscheider:** Lead Architect, Product Owner
## Kontext
Ursprünglich war geplant, dass die Desktop-App vollständig integriert mit dem ZNS die Konfiguration von Turnieren ermöglicht. Daraus sollten automatisch Web-Formulare für die Online-Nennung generiert werden. Die Entwicklung dieser komplexen End-to-End-Strecke inklusive ZNS-Synchronisation und Formular-Generierung ist zeitlich in Verzug geraten. Das Kernziel die rechtzeitige Bereitstellung einer funktionierenden Online-Nennung für Teilnehmer ist gefährdet.
Gleichzeitig haben Tests ergeben, dass der aktuelle Mailserver (World4You) das sogenannte "Plus-Addressing" (z.B. `online-nennen+26128@...`) nativ nicht unterstützt (Fehler: `550 Unknown User`), weshalb ein robusterer Mechanismus für das Mail-Routing pro Turnier gefunden werden muss.
## Entscheidung
Wir setzen das Prinzip der **Graceful Degradation** an und wechseln für das Online-Nenn-System auf einen pragmatischen **MVP (Plan-B)**:
1. **Entkopplung:** Die Web-App wird vorerst nicht dynamisch aus der Desktop-App/ZNS-Datenstruktur generiert.
2. **Statische Frontend-Formulare:** Wir erstellen einfache, statische (oder semi-statische) Nenn-Formulare im Web (WasmJs) mit grundlegender clientseitiger Validierung (Pflichtfelder).
3. **E-Mail als Integrationsschicht:** Das Backend dient lediglich als "Form-to-Email-Gateway". Wenn ein Teilnehmer das Formular absendet (`POST` Request mit JSON-Payload), generiert das Backend eine strukturierte E-Mail.
4. **Betreff-basiertes Routing statt Catch-All/Plus-Addressing:** Um jegliche Infrastruktur-Änderungen beim Hoster zu vermeiden, senden wir alle Nennungen an die generische Adresse `online-nennen@mo-code.at`. Die Trennung pro Turnier erfolgt zwingend über den **Betreff der E-Mail** (z.B. `[NENNUNG] Turnier-ID: 26128`). Im Posteingang können dann einfache Filter/Regeln eingerichtet werden.
## Konsequenzen
### Positiv
* **Time-to-Market:** Die Kernanforderung (Nennungen empfangen) kann extrem schnell umgesetzt und deployt werden.
* **Stabilität:** Das System ist hochgradig ausfallsicher. Es gibt keine komplexe DB-Synchronisation, die im Live-Betrieb abbrechen könnte.
* **Kein Blocker für Desktop-App:** Das Team kann ungestört weiter an der komplexen Desktop-ZNS-Integration arbeiten, während das Nenn-Problem für die User gelöst ist.
* **Keine Infrastruktur-Aufwände:** Weder Catch-All noch Alias-Verwaltung beim Hoster nötig. Ein einziges Standard-Postfach reicht.
### Negativ
* **Manueller Aufwand im Backoffice:** Nennungen kommen vorerst als E-Mails (Text/HTML) an und müssen (bis zu einer späteren Automatisierung) manuell oder per Skript aus dem Postfach ins eigentliche System übertragen werden.
* **Kein automatisiertes Setup:** Formulare müssen bei Bedarf per Hand im Frontend-Code oder einer einfachen Konfigurationsdatei angepasst werden.
## Nächste Schritte
1. `enableWasm=true` in `gradle.properties` aktivieren (erledigt).
2. Web-App (Frontend) mit einem minimalen "Hallo Du!"-Formular implementieren, das die Turnier-ID als Parameter hält.
3. Backend-Endpoint (`POST` to Mail) implementieren und SMTP anbinden. Die Turnier-ID muss zwingend in den Mail-Betreff geschrieben werden.
4. End-to-End-Test auf dem Staging/Prod-Server (Gitea Pipeline).

View File

@ -13,7 +13,7 @@ actual object PlatformConfig {
actual fun resolveMailServiceUrl(): String {
val env = System.getenv("MAIL_SERVICE_URL")?.trim().orEmpty()
if (env.isNotEmpty()) return env.removeSuffix("/")
return "http://localhost:8083"
return "http://localhost:8092"
}
actual fun resolveKeycloakUrl(): String {

View File

@ -9,7 +9,7 @@ actual object PlatformConfig {
actual fun resolveMailServiceUrl(): String {
val fromGlobal = getGlobalMailServiceUrl()
if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/")
return "http://localhost:8085"
return "http://localhost:8092"
}
actual fun resolveKeycloakUrl(): String {

View File

@ -11,5 +11,5 @@ import org.koin.dsl.module
val nennungFeatureModule = module {
single<NennungRemoteRepository> { NennungRemoteRepository(get<HttpClient>(named("apiClient"))) }
viewModel { NennungViewModel() }
viewModel { OnlineNennungViewModel(get(named("apiClient"))) }
viewModel { OnlineNennungViewModel(get()) }
}

View File

@ -1,9 +1,8 @@
package at.mocode.frontend.features.nennung.presentation
import at.mocode.frontend.features.nennung.domain.*
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.frontend.features.nennung.presentation.web.NennungDto
import at.mocode.frontend.features.nennung.domain.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
@ -50,6 +49,18 @@ class NennungViewModel : ViewModel(), KoinComponent {
viewModelScope.launch {
_uiState.update { it.copy(isOnlineLoading = true) }
try {
// Lokales, schlankes DTO passend zur Backend-Response (MailController → NennungEntity)
data class NennungDto(
val id: String?,
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val bewerbe: String
)
val dtos: List<NennungDto> = apiClient.get("/api/mail/nennungen").body()
val mapped = dtos.map { dto ->
OnlineNennung(

View File

@ -1,12 +1,8 @@
package at.mocode.frontend.features.nennung.presentation.web
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -17,7 +13,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.features.nennung.domain.Bewerb
import at.mocode.frontend.features.nennung.domain.NennungMockData
import at.mocode.frontend.features.nennung.domain.Sparte
data class NennungPayload(
val vorname: String,
@ -39,271 +35,92 @@ fun OnlineNennungFormular(
) {
var vorname by remember { mutableStateOf("") }
var nachname by remember { mutableStateOf("") }
var lizenz by remember { mutableStateOf("Lizenzfrei") }
var pferdName by remember { mutableStateOf("") }
var pferdAlter by remember { mutableStateOf("2020") }
var email by remember { mutableStateOf("") }
var telefon by remember { mutableStateOf("") }
var bemerkungen by remember { mutableStateOf("") }
var dsgvoAkzeptiert by remember { mutableStateOf(false) }
val ausgewaehlteBewerbe = remember { mutableStateListOf<Bewerb>() }
val lizenzen = listOf("Lizenzfrei", "R1", "R2", "R3", "R4", "RS1", "RS2")
val jahre = (2000..2022).map { it.toString() }.reversed()
val isEmailValid = email.contains("@") && email.contains(".")
val canSubmit = vorname.isNotBlank() &&
nachname.isNotBlank() &&
pferdName.isNotBlank() &&
isEmailValid &&
ausgewaehlteBewerbe.isNotEmpty() &&
dsgvoAkzeptiert
val canSubmit = vorname.isNotBlank() && nachname.isNotBlank() && isEmailValid
// Clean-White Layout: Hintergrund hellgrau, Formular in weißen Cards
Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF8F9FA))) {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
Box(
modifier = Modifier.fillMaxSize().background(Color(0xFFF8F9FA)),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier.width(400.dp).padding(16.dp),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
item {
Spacer(Modifier.height(32.dp))
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Turnier Online-Nennung",
text = "Hallo Du! 👋",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFF2D3436)
color = AppColors.Primary
)
Text(
text = "Turnier-Nr: $turnierNr",
style = MaterialTheme.typography.bodyLarge,
color = Color.Gray,
modifier = Modifier.padding(bottom = 24.dp)
)
}
// --- REITER CARD ---
item {
FormCard("Persönliche Daten (Reiter)") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ModernTextField(vorname, { vorname = it }, "Vorname *", Modifier.weight(1f))
ModernTextField(nachname, { nachname = it }, "Nachname *", Modifier.weight(1f))
}
Text("Lizenzklasse", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
DropdownSelector(lizenz, lizenzen) { lizenz = it }
}
}
}
// --- PFERD CARD ---
item {
FormCard("Pferdedaten") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
ModernTextField(pferdName, { pferdName = it }, "Name oder Kopfnummer *")
Text("Geburtsjahr", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
DropdownSelector(pferdAlter, jahre) { pferdAlter = it }
}
}
}
// --- KONTAKT CARD ---
item {
FormCard("Kontakt für Rückfragen") {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
ModernTextField(
value = email,
onValueChange = { email = it },
label = "E-Mail Adresse *",
isError = email.isNotBlank() && !isEmailValid
)
ModernTextField(telefon, { telefon = it }, "Telefonnummer (optional)")
}
}
}
// --- BEWERBE CARD ---
item {
FormCard("Bewerbe & Prüfungen") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
NennungMockData.bewerbe.forEach { bewerb ->
val isSelected = ausgewaehlteBewerbe.any { it.nr == bewerb.nr }
BewerbRow(bewerb, isSelected) {
if (isSelected) {
val item = ausgewaehlteBewerbe.find { it.nr == bewerb.nr }
if (item != null) ausgewaehlteBewerbe.remove(item)
} else {
ausgewaehlteBewerbe.add(bewerb)
}
}
}
}
}
}
// --- WÜNSCHE CARD ---
item {
FormCard("Anmerkungen") {
OutlinedTextField(
value = bemerkungen,
onValueChange = { bemerkungen = it },
placeholder = { Text("Besondere Wünsche, Stallplaketten, etc.") },
modifier = Modifier.fillMaxWidth().height(120.dp),
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = AppColors.Primary,
unfocusedBorderColor = Color(0xFFE0E0E0)
)
)
}
}
// --- DSGVO & ABSCHLUSS ---
item {
Column(
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { dsgvoAkzeptiert = !dsgvoAkzeptiert }.padding(8.dp)
) {
Checkbox(checked = dsgvoAkzeptiert, onCheckedChange = { dsgvoAkzeptiert = it })
Spacer(Modifier.width(8.dp))
Text(
"Ich akzeptiere die Datenschutzbestimmungen.",
style = MaterialTheme.typography.bodyMedium
)
}
Spacer(Modifier.height(16.dp))
Button(
onClick = {
onNennenAbgeschickt(
NennungPayload(
vorname, nachname, lizenz, pferdName, pferdAlter,
email, telefon, ausgewaehlteBewerbe.toList(), bemerkungen
)
)
},
enabled = canSubmit,
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (canSubmit) Color(0xFF2ECC71) else Color(0xFFBDC3C7)
)
) {
Text("JETZT NENNEN", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
TextButton(onClick = onBack, modifier = Modifier.padding(top = 8.dp)) {
Text("Abbrechen", color = Color.Gray)
}
Spacer(Modifier.height(48.dp))
}
}
}
}
}
@Composable
fun FormCard(title: String, content: @Composable () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = AppColors.Primary,
modifier = Modifier.padding(bottom = 16.dp)
)
content()
}
}
}
@Composable
fun ModernTextField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
isError: Boolean = false
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
isError = isError,
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = AppColors.Primary,
unfocusedBorderColor = Color(0xFFE0E0E0),
errorBorderColor = Color.Red
)
)
}
@Composable
fun DropdownSelector(current: String, options: List<String>, onSelect: (String) -> Unit) {
var expanded by remember { mutableStateOf(false) }
Box {
OutlinedButton(
onClick = { expanded = true },
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.Black),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = androidx.compose.ui.graphics.SolidColor(Color(0xFFE0E0E0)))
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text(current)
Icon(Icons.Default.Info, null, modifier = Modifier.size(18.dp), tint = Color.LightGray)
}
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { opt ->
DropdownMenuItem(text = { Text(opt) }, onClick = { onSelect(opt); expanded = false })
}
}
}
}
@Composable
fun BewerbRow(bewerb: Bewerb, isSelected: Boolean, onClick: () -> Unit) {
Surface(
onClick = onClick,
shape = RoundedCornerShape(12.dp),
color = if (isSelected) Color(0xFFE8F5E9) else Color(0xFFF5F5F5),
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(12.dp)
) {
Checkbox(checked = isSelected, onCheckedChange = null)
Spacer(Modifier.width(12.dp))
Column {
Text(
"Bewerb ${bewerb.nr}: ${bewerb.name}",
fontWeight = FontWeight.Bold,
fontSize = 14.sp
)
Text(
bewerb.tag,
style = MaterialTheme.typography.bodySmall,
text = "Lass uns Plan-B testen. Turnier: $turnierNr",
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
OutlinedTextField(
value = vorname,
onValueChange = { vorname = it },
label = { Text("Vorname") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = nachname,
onValueChange = { nachname = it },
label = { Text("Nachname") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("E-Mail Adresse") },
singleLine = true,
isError = email.isNotEmpty() && !isEmailValid,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
Button(
onClick = {
// Wir füllen den Rest mit Dummy-Daten für den Test
val payload = NennungPayload(
vorname = vorname,
nachname = nachname,
lizenz = "Lizenzfrei",
pferdName = "Test-Pferd (Plan-B)",
pferdAlter = "2020",
email = email,
telefon = "0123456789",
bewerbe = listOf(Bewerb(1, "Tag 1", 1, "08:00", "Test-Bewerb", Sparte.SPRINGEN, "A")),
bemerkungen = "Dies ist ein automatischer Test für Plan-B."
)
onNennenAbgeschickt(payload)
},
enabled = canSubmit,
modifier = Modifier.fillMaxWidth().height(50.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Primary)
) {
Text("Jetzt schicken!", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
TextButton(onClick = onBack) {
Text("Zurück", color = Color.Gray)
}
}
}
}

View File

@ -2,31 +2,12 @@ package at.mocode.frontend.features.nennung.presentation.web
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.http.*
import at.mocode.frontend.features.nennung.domain.NennungRemoteRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
@Serializable
data class NennungDto(
val id: String? = null,
val turnierNr: String,
val status: String = "NEU",
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val telefon: String?,
val bewerbe: String, // Als JSON-String oder Komma-separiert
val bemerkungen: String?
)
data class OnlineNennungUiState(
val isLoading: Boolean = false,
@ -35,7 +16,7 @@ data class OnlineNennungUiState(
)
class OnlineNennungViewModel(
private val httpClient: HttpClient
private val nennungRepository: NennungRemoteRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(OnlineNennungUiState())
@ -44,31 +25,11 @@ class OnlineNennungViewModel(
fun sendeNennung(turnierNr: String, payload: NennungPayload) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
try {
val dto = NennungDto(
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 nutzen den httpClient, der via Koin injiziert wird.
// Da im Web-Frontend evtl. kein API-Gateway davor ist (oder ein anderes),
// konfigurieren wir den Pfad hier explizit.
httpClient.post("/api/mail/nennungen") {
contentType(ContentType.Application.Json)
setBody(dto)
}
val result = nennungRepository.sendeNennung(turnierNr, payload)
if (result.isSuccess) {
_uiState.update { it.copy(isLoading = false, isSuccess = true) }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = "Fehler beim Senden: ${e.message}") }
} else {
_uiState.update { it.copy(isLoading = false, error = "Fehler beim Senden: ${result.exceptionOrNull()?.message}") }
}
}
}

View File

@ -73,7 +73,8 @@ dev.port.offset=0
# ------------------------------------------------------------------
# Setze enableWasm=true, um die Web-App zu bauen oder Web-spezifische
# Module zu testen. Default=false spart massiv Zeit beim Desktop-Build.
enableWasm=false
enableWasm=true
enableDesktop=false
# Dokka Gradle plugin V2 mode (with helpers for V1 compatibility)
# See https://kotl.in/dokka-gradle-migration

View File

@ -38,6 +38,6 @@ dependencies {
implementation(projects.frontend.core.localDb)
implementation(projects.frontend.core.sync)
implementation(projects.frontend.shells.meldestelleDesktop)
// implementation(projects.frontend.shells.meldestelleDesktop) // Temporarily disabled while desktop build is disabled
// implementation(projects.frontend.shells.meldestelleWeb) // WASM-only modules cannot be tested with ArchUnit (JVM-only)
}

View File

@ -11,9 +11,9 @@ class FrontendArchitectureTest {
@ArchTest
fun `feature modules should not depend on each other`(importedClasses: JavaClasses) {
// The pattern must match the actual package structure, e.g., 'at.mocode.ping.feature'
// The pattern must match the actual package structure, e.g., 'at.mocode.frontend.features.(*)..'
slices()
.matching("at.mocode.(*).feature..")
.matching("at.mocode.frontend.features.(*)..")
.should().notDependOnEachOther()
.check(importedClasses)
}

View File

@ -160,7 +160,10 @@ include(":frontend:features:billing-feature")
include(":frontend:features:device-initialization")
// --- SHELLS ---
include(":frontend:shells:meldestelle-desktop")
val enableDesktop = providers.gradleProperty("enableDesktop").getOrElse("true").toBoolean()
if (enableDesktop) {
include(":frontend:shells:meldestelle-desktop")
}
include(":frontend:shells:meldestelle-web")
// ==========================================================================