Compare commits
6 Commits
beb20e0cf7
...
6b690232ff
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b690232ff | |||
| 309834d90c | |||
| 8b44edda90 | |||
| 255343145d | |||
| 5baa971b46 | |||
| e65384768f |
17
.env
17
.env
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = """
|
||||
|
|
|
|||
|
|
@ -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).")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"apiBaseUrl": "${API_BASE_URL}",
|
||||
"mailServiceUrl": "${MAIL_SERVICE_URL}",
|
||||
"keycloakUrl": "${KEYCLOAK_URL}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
74
dc-gui.yaml
74
dc-gui.yaml
|
|
@ -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
48
dc-planb.yaml
Normal 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
|
||||
|
|
@ -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).
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
// ==========================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user