Compare commits
6 Commits
beb20e0cf7
...
6b690232ff
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b690232ff | |||
| 309834d90c | |||
| 8b44edda90 | |||
| 255343145d | |||
| 5baa971b46 | |||
| e65384768f |
@@ -20,6 +20,7 @@ DOCKER_GRADLE_VERSION=9.3.1
|
|||||||
DOCKER_JAVA_VERSION=25
|
DOCKER_JAVA_VERSION=25
|
||||||
DOCKER_NODE_VERSION=24.12.0
|
DOCKER_NODE_VERSION=24.12.0
|
||||||
DOCKER_NGINX_VERSION=1.28.0-alpine
|
DOCKER_NGINX_VERSION=1.28.0-alpine
|
||||||
|
DOCKER_CADDY_VERSION=2.11-alpine
|
||||||
|
|
||||||
# JVM Power Flags (Lokal leer lassen, da Intel/AMD Architektur)
|
# JVM Power Flags (Lokal leer lassen, da Intel/AMD Architektur)
|
||||||
JVM_OPTS_ARM64=
|
JVM_OPTS_ARM64=
|
||||||
@@ -96,6 +97,7 @@ CONSUL_IMAGE=hashicorp/consul:1.22.1
|
|||||||
CONSUL_PORT=8500:8500
|
CONSUL_PORT=8500:8500
|
||||||
CONSUL_UDP_PORT=8600:8600/udp
|
CONSUL_UDP_PORT=8600:8600/udp
|
||||||
CONSUL_HOST=consul
|
CONSUL_HOST=consul
|
||||||
|
CONSUL_HTTP_PORT=8500
|
||||||
SPRING_CLOUD_CONSUL_HOST=consul
|
SPRING_CLOUD_CONSUL_HOST=consul
|
||||||
SPRING_CLOUD_CONSUL_PORT=8500
|
SPRING_CLOUD_CONSUL_PORT=8500
|
||||||
SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME=api-gateway
|
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_HOST=smtp.world4you.com
|
||||||
MAIL_SMTP_PORT=587
|
MAIL_SMTP_PORT=587
|
||||||
MAIL_SMTP_USER=online-nennen@mo-code.at
|
MAIL_SMTP_USER=online-nennen@mo-code.at
|
||||||
MAIL_SMTP_PASSWORD=secret
|
MAIL_SMTP_PASSWORD=Mogi#2reiten
|
||||||
MAIL_SMTP_AUTH=true
|
MAIL_SMTP_AUTH=true
|
||||||
MAIL_SMTP_STARTTLS=true
|
MAIL_SMTP_STARTTLS=true
|
||||||
|
|
||||||
|
SPRING_MAIL_HOST=localhost
|
||||||
|
SPRING_MAIL_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-SERVICE ---
|
||||||
MASTERDATA_PORT=8086:8086
|
MASTERDATA_PORT=8086:8086
|
||||||
MASTERDATA_DEBUG_PORT=5007:5007
|
MASTERDATA_DEBUG_PORT=5007:5007
|
||||||
@@ -237,7 +250,7 @@ SERIES_CONSUL_PREFER_IP=true
|
|||||||
|
|
||||||
# --- WEB-APP ---
|
# --- WEB-APP ---
|
||||||
CADDY_VERSION=2.11-alpine
|
CADDY_VERSION=2.11-alpine
|
||||||
WEB_APP_PORT=4000:4000
|
WEB_APP_PORT=8080:80
|
||||||
WEB_BUILD_PROFILE=dev
|
WEB_BUILD_PROFILE=dev
|
||||||
# Lokal: http://localhost:8081 | Produktion: http://10.0.0.50:8081
|
# Lokal: http://localhost:8081 | Produktion: http://10.0.0.50:8081
|
||||||
WEB_APP_API_URL=http://localhost:8081
|
WEB_APP_API_URL=http://localhost:8081
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: backend/services/ping/Dockerfile
|
dockerfile: backend/services/ping/Dockerfile
|
||||||
image: ping-service
|
image: ping-service
|
||||||
|
- service: mail-service
|
||||||
|
context: .
|
||||||
|
dockerfile: backend/services/mail/Dockerfile
|
||||||
|
image: mail-service
|
||||||
- service: web-app
|
- service: web-app
|
||||||
context: .
|
context: .
|
||||||
dockerfile: config/docker/caddy/web-app/Dockerfile
|
dockerfile: config/docker/caddy/web-app/Dockerfile
|
||||||
|
|||||||
+3
-1
@@ -10,9 +10,9 @@ import jakarta.mail.Session
|
|||||||
import jakarta.mail.internet.InternetAddress
|
import jakarta.mail.internet.InternetAddress
|
||||||
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
|
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import org.springframework.transaction.annotation.Transactional
|
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
import org.springframework.beans.factory.annotation.Value
|
import org.springframework.beans.factory.annotation.Value
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent
|
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||||
import org.springframework.context.event.EventListener
|
import org.springframework.context.event.EventListener
|
||||||
import org.springframework.mail.SimpleMailMessage
|
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.EnableScheduling
|
||||||
import org.springframework.scheduling.annotation.Scheduled
|
import org.springframework.scheduling.annotation.Scheduled
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.uuid.ExperimentalUuidApi
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
@@ -27,6 +28,7 @@ import kotlin.uuid.Uuid
|
|||||||
@OptIn(ExperimentalUuidApi::class)
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
@Service
|
@Service
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
|
@ConditionalOnProperty(value = ["mail.polling.enabled"], havingValue = "true", matchIfMissing = false)
|
||||||
class MailPollingService(
|
class MailPollingService(
|
||||||
private val mailSender: JavaMailSender,
|
private val mailSender: JavaMailSender,
|
||||||
private val nennungRepository: NennungRepository,
|
private val nennungRepository: NennungRepository,
|
||||||
|
|||||||
+36
-18
@@ -71,19 +71,43 @@ class MailController(
|
|||||||
nennungRepository.save(entity)
|
nennungRepository.save(entity)
|
||||||
logger.info("Nennung ${entity.id} in Datenbank persistiert.")
|
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 {
|
try {
|
||||||
val message = SimpleMailMessage()
|
val message = SimpleMailMessage()
|
||||||
|
|
||||||
// Dynamische Absenderadresse mit Plus-Addressing (z.B. online-nennen+26128@mo-code.at)
|
// PLAN B Fallback: Kein Plus-Addressing, da World4You es nicht unterstützt
|
||||||
val dynamicFrom = try {
|
// Wir verwenden als Absender einfach die Basis-Adresse
|
||||||
val (user, domain) = baseMailAddress.split("@")
|
message.from = baseMailAddress
|
||||||
"$user+${request.turnierNr}@$domain"
|
|
||||||
} catch (_: Exception) {
|
|
||||||
baseMailAddress
|
|
||||||
}
|
|
||||||
|
|
||||||
message.from = dynamicFrom
|
|
||||||
message.setTo(request.email)
|
message.setTo(request.email)
|
||||||
message.subject = "Bestätigung: Ihre Online-Nennung für Turnier ${request.turnierNr}"
|
message.subject = "Bestätigung: Ihre Online-Nennung für Turnier ${request.turnierNr}"
|
||||||
message.text = """
|
message.text = """
|
||||||
@@ -133,14 +157,8 @@ class MailController(
|
|||||||
@RequestParam nachname: String
|
@RequestParam nachname: String
|
||||||
) {
|
) {
|
||||||
val message = SimpleMailMessage()
|
val message = SimpleMailMessage()
|
||||||
val dynamicFrom = try {
|
// PLAN B Fallback: Kein Plus-Addressing
|
||||||
val (user, domain) = baseMailAddress.split("@")
|
message.from = baseMailAddress
|
||||||
"$user+$turnierNr@$domain"
|
|
||||||
} catch (_: Exception) {
|
|
||||||
baseMailAddress
|
|
||||||
}
|
|
||||||
|
|
||||||
message.from = dynamicFrom
|
|
||||||
message.setTo(email)
|
message.setTo(email)
|
||||||
message.subject = "Bestätigung: Nennung für Turnier $turnierNr manuell übernommen"
|
message.subject = "Bestätigung: Nennung für Turnier $turnierNr manuell übernommen"
|
||||||
message.text = """
|
message.text = """
|
||||||
|
|||||||
+34
@@ -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).")
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -5,9 +5,9 @@ package at.mocode.mail.service.persistence
|
|||||||
import org.jetbrains.exposed.v1.core.eq
|
import org.jetbrains.exposed.v1.core.eq
|
||||||
import org.jetbrains.exposed.v1.jdbc.insert
|
import org.jetbrains.exposed.v1.jdbc.insert
|
||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import kotlin.uuid.ExperimentalUuidApi
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
import kotlin.uuid.Uuid
|
import kotlin.uuid.Uuid
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ spring:
|
|||||||
host: ${SPRING_MAIL_HOST:smtp.world4you.com}
|
host: ${SPRING_MAIL_HOST:smtp.world4you.com}
|
||||||
port: ${SPRING_MAIL_PORT:587}
|
port: ${SPRING_MAIL_PORT:587}
|
||||||
username: ${SPRING_MAIL_USERNAME:online-nennen@mo-code.at}
|
username: ${SPRING_MAIL_USERNAME:online-nennen@mo-code.at}
|
||||||
password: ${SPRING_MAIL_PASSWORD:}
|
password: ${SPRING_MAIL_PASSWORD:Mogi#2reiten}
|
||||||
properties:
|
properties:
|
||||||
mail:
|
mail:
|
||||||
smtp:
|
smtp:
|
||||||
@@ -26,9 +26,10 @@ spring:
|
|||||||
consul:
|
consul:
|
||||||
host: ${SPRING_CLOUD_CONSUL_HOST:localhost}
|
host: ${SPRING_CLOUD_CONSUL_HOST:localhost}
|
||||||
port: ${SPRING_CLOUD_CONSUL_PORT:8500}
|
port: ${SPRING_CLOUD_CONSUL_PORT:8500}
|
||||||
|
enabled: ${SPRING_CLOUD_CONSUL_ENABLED:false}
|
||||||
discovery:
|
discovery:
|
||||||
enabled: true
|
enabled: ${SPRING_CLOUD_CONSUL_DISCOVERY_ENABLED:false}
|
||||||
register: true
|
register: ${SPRING_CLOUD_CONSUL_DISCOVERY_REGISTER:false}
|
||||||
prefer-ip-address: true
|
prefer-ip-address: true
|
||||||
health-check-path: /actuator/health
|
health-check-path: /actuator/health
|
||||||
health-check-interval: 10s
|
health-check-interval: 10s
|
||||||
@@ -43,4 +44,14 @@ management:
|
|||||||
endpoints:
|
endpoints:
|
||||||
web:
|
web:
|
||||||
exposure:
|
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
|
metrics
|
||||||
}
|
}
|
||||||
|
|
||||||
:4000 {
|
:80 {
|
||||||
root * /usr/share/caddy
|
root * /usr/share/caddy
|
||||||
log {
|
log {
|
||||||
output stdout
|
output stdout
|
||||||
@@ -17,14 +17,34 @@
|
|||||||
|
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
|
|
||||||
handle /api/* {
|
# Reverse Proxy: Plan-B leitet nur /api/mail an den Mail-Service weiter (kein API-Gateway nötig)
|
||||||
reverse_proxy api-gateway:8081
|
handle /api/mail/* {
|
||||||
|
reverse_proxy mail-service:8085
|
||||||
}
|
}
|
||||||
|
|
||||||
handle /health {
|
handle /health {
|
||||||
respond "healthy" 200
|
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 {
|
handle {
|
||||||
try_files {path} /index.html
|
try_files {path} /index.html
|
||||||
file_server
|
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
|
COPY config/docker/caddy/web-app/config.json /usr/share/caddy/config.json.tmpl
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# Copy Pre-built Static Assets from Host
|
# Copy Pre-built Static Assets from Host (WasmJs)
|
||||||
# NOTE: You must run `./gradlew :frontend:shells:meldestelle-portal:jsBrowserDistribution -Pproduction=true` locally first!
|
# NOTE: You must run `./gradlew :frontend:shells:meldestelle-web:wasmJsBrowserDistribution -Pproduction=true` locally first!
|
||||||
COPY frontend/shells/meldestelle-portal/build/dist/js/productionExecutable/ /usr/share/caddy/
|
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
|
# index.html wird als Template abgelegt; der Entrypoint erzeugt daraus zur Laufzeit die finale index.html
|
||||||
RUN mv /usr/share/caddy/index.html /usr/share/caddy/index.html.tmpl
|
RUN mv /usr/share/caddy/index.html /usr/share/caddy/index.html.tmpl
|
||||||
|
|
||||||
@@ -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
|
# Using the shared asset from existing config structure
|
||||||
COPY config/docker/nginx/web-app/favicon.svg /usr/share/caddy/favicon.svg
|
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 \
|
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"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"apiBaseUrl": "${API_BASE_URL}",
|
"apiBaseUrl": "${API_BASE_URL}",
|
||||||
|
"mailServiceUrl": "${MAIL_SERVICE_URL}",
|
||||||
"keycloakUrl": "${KEYCLOAK_URL}"
|
"keycloakUrl": "${KEYCLOAK_URL}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
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.
|
# 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.tmpl \
|
||||||
> /usr/share/caddy/index.html
|
> /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.tmpl \
|
||||||
> /usr/share/caddy/config.json
|
> /usr/share/caddy/config.json
|
||||||
|
|
||||||
|
|||||||
+37
-37
@@ -6,43 +6,43 @@ services:
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
# --- WEB-APP ---
|
# --- WEB-APP ---
|
||||||
web-app:
|
# web-app:
|
||||||
image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/web-app:${DOCKER_TAG:-latest}"
|
# image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/web-app:${DOCKER_TAG:-latest}"
|
||||||
build:
|
# build:
|
||||||
context: . # Wichtig: Root Context für Monorepo Zugriff
|
# context: . # Wichtig: Root Context für Monorepo Zugriff
|
||||||
dockerfile: config/docker/caddy/web-app/Dockerfile
|
# dockerfile: config/docker/caddy/web-app/Dockerfile
|
||||||
args:
|
# args:
|
||||||
# Frontend spezifisch:
|
# # Frontend spezifisch:
|
||||||
CADDY_VERSION: "${DOCKER_CADDY_VERSION:-2.11-alpine}"
|
# CADDY_VERSION: "${DOCKER_CADDY_VERSION:-2.11-alpine}"
|
||||||
# Metadaten:
|
# # Metadaten:
|
||||||
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
|
# VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
|
||||||
BUILD_DATE: "${DOCKER_BUILD_DATE}"
|
# BUILD_DATE: "${DOCKER_BUILD_DATE}"
|
||||||
labels:
|
# labels:
|
||||||
- "org.opencontainers.image.created=${DOCKER_BUILD_DATE}"
|
# - "org.opencontainers.image.created=${DOCKER_BUILD_DATE}"
|
||||||
container_name: "${PROJECT_NAME:-meldestelle}-web-app"
|
# container_name: "${PROJECT_NAME:-meldestelle}-web-app"
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
ports:
|
# ports:
|
||||||
- "${WEB_APP_PORT:-4000:4000}"
|
# - "${WEB_APP_PORT:-4000:4000}"
|
||||||
environment:
|
# environment:
|
||||||
# Runtime Configuration — via envsubst in entrypoint.sh in config.json & index.html injiziert.
|
# # Runtime Configuration — via envsubst in entrypoint.sh in config.json & index.html injiziert.
|
||||||
# Muss die öffentlich erreichbare URL sein (Browser-Zugriff!), NICHT localhost.
|
# # Muss die öffentlich erreichbare URL sein (Browser-Zugriff!), NICHT localhost.
|
||||||
API_BASE_URL: "${WEB_APP_API_URL:-http://localhost:8081}"
|
# API_BASE_URL: "${WEB_APP_API_URL:-http://localhost:8081}"
|
||||||
# Keycloak Public URL (muss vom Browser aus erreichbar sein)
|
# # Keycloak Public URL (muss vom Browser aus erreichbar sein)
|
||||||
KEYCLOAK_URL: "${WEB_APP_KEYCLOAK_URL:-http://localhost:8180}"
|
# KEYCLOAK_URL: "${WEB_APP_KEYCLOAK_URL:-http://localhost:8180}"
|
||||||
depends_on:
|
# depends_on:
|
||||||
api-gateway:
|
# api-gateway:
|
||||||
condition: "service_started"
|
# condition: "service_started"
|
||||||
healthcheck:
|
# healthcheck:
|
||||||
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:4000/" ]
|
# test: [ "CMD", "wget", "--spider", "-q", "http://localhost:4000/" ]
|
||||||
interval: 20s
|
# interval: 20s
|
||||||
timeout: 5s
|
# timeout: 5s
|
||||||
retries: 5
|
# retries: 5
|
||||||
start_period: 20s
|
# start_period: 20s
|
||||||
networks:
|
# networks:
|
||||||
meldestelle-network:
|
# meldestelle-network:
|
||||||
aliases:
|
# aliases:
|
||||||
- "web-app"
|
# - "web-app"
|
||||||
profiles: [ "gui", "all" ]
|
# profiles: [ "gui", "all" ]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
meldestelle-network:
|
meldestelle-network:
|
||||||
|
|||||||
@@ -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).
|
||||||
+1
-1
@@ -13,7 +13,7 @@ actual object PlatformConfig {
|
|||||||
actual fun resolveMailServiceUrl(): String {
|
actual fun resolveMailServiceUrl(): String {
|
||||||
val env = System.getenv("MAIL_SERVICE_URL")?.trim().orEmpty()
|
val env = System.getenv("MAIL_SERVICE_URL")?.trim().orEmpty()
|
||||||
if (env.isNotEmpty()) return env.removeSuffix("/")
|
if (env.isNotEmpty()) return env.removeSuffix("/")
|
||||||
return "http://localhost:8083"
|
return "http://localhost:8092"
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun resolveKeycloakUrl(): String {
|
actual fun resolveKeycloakUrl(): String {
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ actual object PlatformConfig {
|
|||||||
actual fun resolveMailServiceUrl(): String {
|
actual fun resolveMailServiceUrl(): String {
|
||||||
val fromGlobal = getGlobalMailServiceUrl()
|
val fromGlobal = getGlobalMailServiceUrl()
|
||||||
if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/")
|
if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/")
|
||||||
return "http://localhost:8085"
|
return "http://localhost:8092"
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun resolveKeycloakUrl(): String {
|
actual fun resolveKeycloakUrl(): String {
|
||||||
|
|||||||
+1
-1
@@ -11,5 +11,5 @@ import org.koin.dsl.module
|
|||||||
val nennungFeatureModule = module {
|
val nennungFeatureModule = module {
|
||||||
single<NennungRemoteRepository> { NennungRemoteRepository(get<HttpClient>(named("apiClient"))) }
|
single<NennungRemoteRepository> { NennungRemoteRepository(get<HttpClient>(named("apiClient"))) }
|
||||||
viewModel { NennungViewModel() }
|
viewModel { NennungViewModel() }
|
||||||
viewModel { OnlineNennungViewModel(get(named("apiClient"))) }
|
viewModel { OnlineNennungViewModel(get()) }
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -1,9 +1,8 @@
|
|||||||
package at.mocode.frontend.features.nennung.presentation
|
package at.mocode.frontend.features.nennung.presentation
|
||||||
|
|
||||||
import at.mocode.frontend.features.nennung.domain.*
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.*
|
||||||
import io.ktor.client.call.*
|
import io.ktor.client.call.*
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
@@ -50,6 +49,18 @@ class NennungViewModel : ViewModel(), KoinComponent {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isOnlineLoading = true) }
|
_uiState.update { it.copy(isOnlineLoading = true) }
|
||||||
try {
|
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 dtos: List<NennungDto> = apiClient.get("/api/mail/nennungen").body()
|
||||||
val mapped = dtos.map { dto ->
|
val mapped = dtos.map { dto ->
|
||||||
OnlineNennung(
|
OnlineNennung(
|
||||||
|
|||||||
+59
-242
@@ -1,12 +1,8 @@
|
|||||||
package at.mocode.frontend.features.nennung.presentation.web
|
package at.mocode.frontend.features.nennung.presentation.web
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -17,7 +13,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import at.mocode.frontend.core.designsystem.theme.AppColors
|
import at.mocode.frontend.core.designsystem.theme.AppColors
|
||||||
import at.mocode.frontend.features.nennung.domain.Bewerb
|
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(
|
data class NennungPayload(
|
||||||
val vorname: String,
|
val vorname: String,
|
||||||
@@ -39,272 +35,93 @@ fun OnlineNennungFormular(
|
|||||||
) {
|
) {
|
||||||
var vorname by remember { mutableStateOf("") }
|
var vorname by remember { mutableStateOf("") }
|
||||||
var nachname 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 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 isEmailValid = email.contains("@") && email.contains(".")
|
||||||
val canSubmit = vorname.isNotBlank() &&
|
val canSubmit = vorname.isNotBlank() && nachname.isNotBlank() && isEmailValid
|
||||||
nachname.isNotBlank() &&
|
|
||||||
pferdName.isNotBlank() &&
|
|
||||||
isEmailValid &&
|
|
||||||
ausgewaehlteBewerbe.isNotEmpty() &&
|
|
||||||
dsgvoAkzeptiert
|
|
||||||
|
|
||||||
// Clean-White Layout: Hintergrund hellgrau, Formular in weißen Cards
|
Box(
|
||||||
Box(modifier = Modifier.fillMaxSize().background(Color(0xFFF8F9FA))) {
|
modifier = Modifier.fillMaxSize().background(Color(0xFFF8F9FA)),
|
||||||
LazyColumn(
|
contentAlignment = Alignment.Center
|
||||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
) {
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
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)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
item {
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
Text(
|
Text(
|
||||||
text = "Turnier Online-Nennung",
|
text = "Hallo Du! 👋",
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
fontWeight = FontWeight.ExtraBold,
|
fontWeight = FontWeight.ExtraBold,
|
||||||
color = Color(0xFF2D3436)
|
color = AppColors.Primary
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Turnier-Nr: $turnierNr",
|
text = "Lass uns Plan-B testen. Turnier: $turnierNr",
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color.Gray,
|
color = Color.Gray
|
||||||
modifier = Modifier.padding(bottom = 24.dp)
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
// --- REITER CARD ---
|
OutlinedTextField(
|
||||||
item {
|
value = vorname,
|
||||||
FormCard("Persönliche Daten (Reiter)") {
|
onValueChange = { vorname = it },
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
label = { Text("Vorname") },
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
singleLine = true,
|
||||||
ModernTextField(vorname, { vorname = it }, "Vorname *", Modifier.weight(1f))
|
modifier = Modifier.fillMaxWidth()
|
||||||
ModernTextField(nachname, { nachname = it }, "Nachname *", Modifier.weight(1f))
|
)
|
||||||
}
|
|
||||||
|
|
||||||
Text("Lizenzklasse", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
|
OutlinedTextField(
|
||||||
DropdownSelector(lizenz, lizenzen) { lizenz = it }
|
value = nachname,
|
||||||
}
|
onValueChange = { nachname = it },
|
||||||
}
|
label = { Text("Nachname") },
|
||||||
}
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
// --- PFERD CARD ---
|
OutlinedTextField(
|
||||||
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,
|
value = email,
|
||||||
onValueChange = { email = it },
|
onValueChange = { email = it },
|
||||||
label = "E-Mail Adresse *",
|
label = { Text("E-Mail Adresse") },
|
||||||
isError = email.isNotBlank() && !isEmailValid
|
singleLine = true,
|
||||||
|
isError = email.isNotEmpty() && !isEmailValid,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
ModernTextField(telefon, { telefon = it }, "Telefonnummer (optional)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- BEWERBE CARD ---
|
Spacer(Modifier.height(8.dp))
|
||||||
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(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
onNennenAbgeschickt(
|
// Wir füllen den Rest mit Dummy-Daten für den Test
|
||||||
NennungPayload(
|
val payload = NennungPayload(
|
||||||
vorname, nachname, lizenz, pferdName, pferdAlter,
|
vorname = vorname,
|
||||||
email, telefon, ausgewaehlteBewerbe.toList(), bemerkungen
|
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,
|
enabled = canSubmit,
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
modifier = Modifier.fillMaxWidth().height(50.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),
|
shape = RoundedCornerShape(12.dp),
|
||||||
isError = isError,
|
colors = ButtonDefaults.buttonColors(containerColor = AppColors.Primary)
|
||||||
singleLine = true,
|
) {
|
||||||
colors = OutlinedTextFieldDefaults.colors(
|
Text("Jetzt schicken!", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||||
focusedBorderColor = AppColors.Primary,
|
|
||||||
unfocusedBorderColor = Color(0xFFE0E0E0),
|
|
||||||
errorBorderColor = Color.Red
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
TextButton(onClick = onBack) {
|
||||||
fun DropdownSelector(current: String, options: List<String>, onSelect: (String) -> Unit) {
|
Text("Zurück", color = Color.Gray)
|
||||||
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,
|
|
||||||
color = Color.Gray
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-45
@@ -2,31 +2,12 @@ package at.mocode.frontend.features.nennung.presentation.web
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.ktor.client.*
|
import at.mocode.frontend.features.nennung.domain.NennungRemoteRepository
|
||||||
import io.ktor.client.request.*
|
|
||||||
import io.ktor.http.*
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
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(
|
data class OnlineNennungUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@@ -35,7 +16,7 @@ data class OnlineNennungUiState(
|
|||||||
)
|
)
|
||||||
|
|
||||||
class OnlineNennungViewModel(
|
class OnlineNennungViewModel(
|
||||||
private val httpClient: HttpClient
|
private val nennungRepository: NennungRemoteRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(OnlineNennungUiState())
|
private val _uiState = MutableStateFlow(OnlineNennungUiState())
|
||||||
@@ -44,31 +25,11 @@ class OnlineNennungViewModel(
|
|||||||
fun sendeNennung(turnierNr: String, payload: NennungPayload) {
|
fun sendeNennung(turnierNr: String, payload: NennungPayload) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
try {
|
val result = nennungRepository.sendeNennung(turnierNr, payload)
|
||||||
val dto = NennungDto(
|
if (result.isSuccess) {
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiState.update { it.copy(isLoading = false, isSuccess = true) }
|
_uiState.update { it.copy(isLoading = false, isSuccess = true) }
|
||||||
} catch (e: Exception) {
|
} else {
|
||||||
_uiState.update { it.copy(isLoading = false, error = "Fehler beim Senden: ${e.message}") }
|
_uiState.update { it.copy(isLoading = false, error = "Fehler beim Senden: ${result.exceptionOrNull()?.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -73,7 +73,8 @@ dev.port.offset=0
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Setze enableWasm=true, um die Web-App zu bauen oder Web-spezifische
|
# Setze enableWasm=true, um die Web-App zu bauen oder Web-spezifische
|
||||||
# Module zu testen. Default=false spart massiv Zeit beim Desktop-Build.
|
# 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)
|
# Dokka Gradle plugin V2 mode (with helpers for V1 compatibility)
|
||||||
# See https://kotl.in/dokka-gradle-migration
|
# See https://kotl.in/dokka-gradle-migration
|
||||||
|
|||||||
@@ -38,6 +38,6 @@ dependencies {
|
|||||||
implementation(projects.frontend.core.localDb)
|
implementation(projects.frontend.core.localDb)
|
||||||
implementation(projects.frontend.core.sync)
|
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)
|
// implementation(projects.frontend.shells.meldestelleWeb) // WASM-only modules cannot be tested with ArchUnit (JVM-only)
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -11,9 +11,9 @@ class FrontendArchitectureTest {
|
|||||||
|
|
||||||
@ArchTest
|
@ArchTest
|
||||||
fun `feature modules should not depend on each other`(importedClasses: JavaClasses) {
|
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()
|
slices()
|
||||||
.matching("at.mocode.(*).feature..")
|
.matching("at.mocode.frontend.features.(*)..")
|
||||||
.should().notDependOnEachOther()
|
.should().notDependOnEachOther()
|
||||||
.check(importedClasses)
|
.check(importedClasses)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,10 @@ include(":frontend:features:billing-feature")
|
|||||||
include(":frontend:features:device-initialization")
|
include(":frontend:features:device-initialization")
|
||||||
|
|
||||||
// --- SHELLS ---
|
// --- SHELLS ---
|
||||||
|
val enableDesktop = providers.gradleProperty("enableDesktop").getOrElse("true").toBoolean()
|
||||||
|
if (enableDesktop) {
|
||||||
include(":frontend:shells:meldestelle-desktop")
|
include(":frontend:shells:meldestelle-desktop")
|
||||||
|
}
|
||||||
include(":frontend:shells:meldestelle-web")
|
include(":frontend:shells:meldestelle-web")
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user