Compare commits

...

6 Commits

Author SHA1 Message Date
a6fcb81594 feat(desktop-onboarding): neue Onboarding-UI implementiert, Backup- und Rollenmanagement hinzugefügt
Some checks failed
Desktop CI — Headless Tests & Build / Compose Desktop — Tests (headless) & Build (push) Failing after 3m10s
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Successful in 6m37s
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Successful in 5m59s
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
- Einbindung eines komplett überarbeiteten Onboarding-Screens mit validierten Eingaben für Gerätename, Sicherheitsschlüssel und Backup-Pfad.
- `SettingsManager` eingeführt zur Speicherung der Onboarding-Daten in `settings.json`.
- Navigation verbessert: Onboarding-Workflow startet, wenn Konfiguration fehlt; neues "Setup"-Icon in der Navigationsleiste hinzugefügt.
- Backend: Geräte-API und `DeviceSecurityFilter` für Authentifizierung per Sicherheitsschlüssel implementiert.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-04-15 15:49:01 +02:00
a5f5e7a24b feat(mail-service): Port-Konflikt behoben, SMTP-Konfig optimiert und dynamisches Plus-Addressing eingefügt
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-04-15 13:44:34 +02:00
d0b756694b feat(frontend): Struktur und Kommentare verfeinert, Mail-Service-Konfiguration erweitert
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-04-15 11:49:31 +02:00
8c804832d8 feat(billing): add automatic booking for Sportförderbeitrag in compliance with § 16 ÖTO
Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
2026-04-15 11:17:35 +02:00
c542094196 feat(online-nennung): integrate online nomination workflow via REST and mail service
- Enabled web-to-backend nominations with `MailController` and REST endpoint (`/api/mail/nennung`).
- Added `NennungRemoteRepository` for frontend API integration using Ktor.
- Linked `WebMainScreen` to backend API for nomination handling and confirmation display.
- Implemented automated confirmation emails for received nominations.
- Updated `MASTER_ROADMAP` to reflect progress on Phase 13 milestones.
- Improved Nennung UI, backend persistence, and QA tracking for Neumarkt tournament.
2026-04-15 10:37:12 +02:00
b4c400efea docs(agents): expand playbooks and refine agent collaboration protocols
- Added `Bounded Context Awareness` section to the Architect playbook, emphasizing adherence to SCS boundaries.
- Refined agent definitions and responsibilities, highlighting domain-driven principles and offline-first focus.
- Introduced strategic project goals in AGENTS.md, clarifying the operational scope of Meldestelle-Biest.
- Enhanced workflow and session protocols for better alignment with the MASTER_ROADMAP and DDD principles.
- Updated role descriptions to emphasize tools, technologies, and accountability.
2026-04-15 09:06:09 +02:00
49 changed files with 1912 additions and 560 deletions

View File

@ -159,6 +159,82 @@ PING_DEBUG=true
PING_SERVICE_NAME=ping-service PING_SERVICE_NAME=ping-service
PING_CONSUL_PREFER_IP=true PING_CONSUL_PREFER_IP=true
# --- MAIL-SERVICE ---
MAIL_PORT=8083:8085
MAIL_DEBUG_PORT=5014:5014
MAIL_SERVER_PORT=8085
MAIL_SPRING_PROFILES_ACTIVE=docker
MAIL_DEBUG=true
MAIL_SERVICE_NAME=mail-service
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=<DEIN_WORLD4YOU_PASSWORT>
MAIL_SMTP_AUTH=true
MAIL_SMTP_STARTTLS=true
# --- MASTERDATA-SERVICE ---
MASTERDATA_PORT=8086:8086
MASTERDATA_DEBUG_PORT=5007:5007
MASTERDATA_SERVER_PORT=8086
MASTERDATA_SPRING_PROFILES_ACTIVE=docker
MASTERDATA_DEBUG=true
MASTERDATA_SERVICE_NAME=masterdata-service
MASTERDATA_CONSUL_PREFER_IP=true
# --- EVENTS-SERVICE ---
EVENTS_PORT=8085:8085
EVENTS_DEBUG_PORT=5008:5008
EVENTS_SERVER_PORT=8085
EVENTS_SPRING_PROFILES_ACTIVE=docker
EVENTS_DEBUG=true
EVENTS_SERVICE_NAME=events-service
EVENTS_CONSUL_PREFER_IP=true
# --- ZNS-IMPORT-SERVICE ---
ZNS_IMPORT_PORT=8095:8095
ZNS_IMPORT_DEBUG_PORT=5009:5009
ZNS_IMPORT_SERVER_PORT=8095
ZNS_IMPORT_SPRING_PROFILES_ACTIVE=docker
ZNS_IMPORT_DEBUG=true
ZNS_IMPORT_SERVICE_NAME=zns-import-service
ZNS_IMPORT_CONSUL_PREFER_IP=true
# --- RESULTS-SERVICE ---
RESULTS_PORT=8088:8088
RESULTS_DEBUG_PORT=5010:5010
RESULTS_SERVER_PORT=8088
RESULTS_SPRING_PROFILES_ACTIVE=docker
RESULTS_DEBUG=true
RESULTS_SERVICE_NAME=results-service
RESULTS_CONSUL_PREFER_IP=true
# --- BILLING-SERVICE ---
BILLING_PORT=8087:8087
BILLING_DEBUG_PORT=5012:5012
BILLING_SERVER_PORT=8087
BILLING_SPRING_PROFILES_ACTIVE=docker
BILLING_DEBUG=true
BILLING_SERVICE_NAME=billing-service
BILLING_CONSUL_PREFER_IP=true
# --- SCHEDULING-SERVICE ---
SCHEDULING_PORT=8084:8084
SCHEDULING_DEBUG_PORT=5013:5013
SCHEDULING_SERVER_PORT=8084
SCHEDULING_SPRING_PROFILES_ACTIVE=docker
SCHEDULING_DEBUG=true
SCHEDULING_SERVICE_NAME=scheduling-service
SCHEDULING_CONSUL_PREFER_IP=true
# --- SERIES-SERVICE ---
SERIES_PORT=8089:8089
SERIES_DEBUG_PORT=5011:5011
SERIES_SERVER_PORT=8089
SERIES_SPRING_PROFILES_ACTIVE=docker
SERIES_DEBUG=true
# --- WEB-APP --- # --- WEB-APP ---
WEB_APP_PORT=4000:4000 WEB_APP_PORT=4000:4000
# URL für API-Zugriffe vom Browser (Public URL via Pangolin) # URL für API-Zugriffe vom Browser (Public URL via Pangolin)

View File

@ -1,35 +1,42 @@
# 🤖 Project Agents & Protocol # 🤖 Projekt Agenten & Protokoll (Meldestelle-Biest)
Dieses Dokument definiert die Zusammenarbeit zwischen dem User (Owner) und den KI-Agenten. Dieses Dokument definiert die Zusammenarbeit zwischen dem User (Owner) und den spezialisierten KI-Agenten.
Es dient als "System Prompt" für neue Chat-Sessions. Es dient als zentraler **System-Prompt-Erweiterung** für neue Chat-Sessions.
## 1. Protokoll & Badges ## 🚀 Strategische Ausrichtung
Jeder Agent muss seine Antwort mit einem Badge beginnen, um den Kontext zu setzen. Detaillierte Anweisungen finden sich in den jeweiligen Playbooks. Das Projekt **"Meldestelle-Biest"** entwickelt eine ÖTO/FEI-konforme, offline-fähige Turnier-Software.
1. **Desktop-First:** Primäres Ziel ist die Compose Desktop App (KMP). UX & Performance sind auf Profis optimiert.
2. **Offline-First:** Das System muss autark (ohne Internet) funktionieren. Sync-Logik ist Kernbestandteil.
3. **Domain-Driven:** 6 Bounded Contexts (SCS) bilden den fachlichen Rahmen.
* **🏗️ [Lead Architect]**: Strategie, Planung, Entscheidungen, Master Roadmap. ## 1. Protokoll & Rollen-Badges
Jede Agenten-Antwort **muss** mit dem entsprechenden Badge beginnen, um den Kontext und die Verantwortlichkeit zu klären.
* **🏗️ [Lead Architect]**: Hüter der **MASTER_ROADMAP**. Verantwortlich für System-Design, Build-Logik (Gradle), Modulstruktur und ADRs.
* [Playbook](docs/04_Agents/Playbooks/Architect.md) * [Playbook](docs/04_Agents/Playbooks/Architect.md)
* **🧹 [Curator]**: Dokumentation, Logs, Reports, Aufräumen. * **📜 [Rulebook Expert]**: Wächter über **ÖTO & FEI**. Validiert Business-Rules gegen das offizielle Pferdesport-Regelwerk.
* [Playbook](docs/04_Agents/Playbooks/Curator.md) * [Playbook](docs/04_Agents/Playbooks/RulebookExpert.md)
* **👷 [Backend Developer]**: Spring Boot, Kotlin, SQL, API-Design. * **👷 [Backend Developer]**: Kotlin & Spring Boot Experte. Fokus auf DDD, Persistenz (Postgres) und **Delta-Sync APIs**.
* [Playbook](docs/04_Agents/Playbooks/BackendDeveloper.md) * [Playbook](docs/04_Agents/Playbooks/BackendDeveloper.md)
* **🎨 [Frontend Expert]**: KMP, Compose, State-Management, Auth. * **🎨 [Frontend Expert]**: KMP & Compose Desktop Spezialist. Implementiert State-Management und High-Performance UI.
* [Playbook](docs/04_Agents/Playbooks/FrontendExpert.md) * [Playbook](docs/04_Agents/Playbooks/FrontendExpert.md)
* **🖌️ [UI/UX Designer]**: High-Density Design, Wireframes, Usability. * **🖌️ [UI/UX Designer]**: "Toolsmith" für High-Density Enterprise-UIs. Fokus auf Tastatur-Bedienbarkeit und Effizienz.
* [Playbook](docs/04_Agents/Playbooks/UIUXDesigner.md) * [Playbook](docs/04_Agents/Playbooks/UIUXDesigner.md)
* **🐧 [DevOps Engineer]**: Docker, CI/CD, Gradle, Security. * **🐧 [DevOps Engineer]**: Infrastruktur-Automatisierung (Docker, Gitea-Actions). Fokus auf Stabilität und lokale Dev-Umgebung.
* [Playbook](docs/04_Agents/Playbooks/DevOpsEngineer.md) * [Playbook](docs/04_Agents/Playbooks/DevOpsEngineer.md)
* **🧐 [QA Specialist]**: Test-Strategie, Edge-Cases. * **🧐 [QA Specialist]**: Test-Stratege (Shift-Left). Fokus auf Unit-, Integration- und Edge-Case-Tests (Testing Pyramid).
* [Playbook](docs/04_Agents/Playbooks/QASpecialist.md) * [Playbook](docs/04_Agents/Playbooks/QASpecialist.md)
* **📜 [ÖTO/FEI Rulebook Expert]**: Regelwerks-Wächter, Validierungs-Spezialist, Compliance. * **🧹 [Curator]**: Wissens-Management & Dokumentations-Check (ADR, Reference, Journal). Beendet jede Session.
* [Playbook](docs/04_Agents/Playbooks/RulebookExpert.md) * [Playbook](docs/04_Agents/Playbooks/Curator.md)
## 2. Workflow ## 2. Der "Biest"-Workflow
1. **Kontext:** Lies immer zuerst die `MASTER_ROADMAP` in `docs/01_Architecture/`. 1. **Kontext-Check:** Lies immer zuerst die `MASTER_ROADMAP` in `docs/01_Architecture/`.
2. **Fokus:** Bearbeite immer nur EINE Aufgabe zur Zeit. 2. **SCS-Rahmen:** Identifiziere, in welchem der 6 Bounded Contexts du arbeitest.
3. **Doku:** Jede Session endet mit einem Eintrag durch den **Curator**. 3. **Fokus:** Bearbeite immer nur EINE fachliche Aufgabe pro Session.
4. **Code:** Änderungen am Code werden sofort via Tool ausgeführt, nicht nur vorgeschlagen. 4. **Doku-as-Code:** Änderungen an Code/Architektur müssen sofort in `docs/` (ADR/Reference) reflektiert werden.
5. **Session-Abschluss:** Jede Session endet mit einem Eintrag durch den **Curator** (Journal oder Artefakt).
## 3. Projekt-Philosophie ## 3. Projekt-Philosophie
* **Startup-Mode:** Wir bauen ein echtes Produkt. Code-Qualität und Geschwindigkeit sind gleich wichtig. * **Information Density over White Space:** Wir bauen ein Profi-Werkzeug, kein Spielzeug.
* **Docs-as-Code:** Die Dokumentation ist die Single Source of Truth. * **Speed over Animation:** Reaktionsgeschwindigkeit der UI hat höchste Priorität.
* **Offline-First:** Das System muss ohne Internet funktionieren (Sync). * **Offline-Authentizität:** Lokale Daten sind die "Source of Truth" für den User; der Server ist das Backup/Sync-Target.

View File

@ -25,6 +25,7 @@ dependencies {
// Web (for CORS config) // Web (for CORS config)
implementation(libs.spring.web) implementation(libs.spring.web)
implementation(libs.spring.boot.starter.web)
// Testing // Testing
testImplementation(projects.platform.platformTesting) testImplementation(projects.platform.platformTesting)

View File

@ -0,0 +1,49 @@
package at.mocode.infrastructure.security
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.filter.OncePerRequestFilter
/**
* Filter zur Authentifizierung von Desktop-Clients via Security Key.
* Dieser Filter ist für die Offline-First-Synchronisation gedacht.
*
* Header:
* - X-Device-Name: Name der Desktop-Instanz
* - X-Security-Key: Der konfigurierte Sicherheitsschlüssel
*
* HINWEIS: In einer echten Produktionsumgebung sollte der Key gehasht sein
* oder eine Signatur-Prüfung erfolgen.
*/
class DeviceSecurityFilter : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val deviceName = request.getHeader("X-Device-Name")
val securityKey = request.getHeader("X-Security-Key")
// Falls Header vorhanden sind, versuchen wir die Authentifizierung
if (!deviceName.isNullOrBlank() && !securityKey.isNullOrBlank()) {
// WICHTIG: Die eigentliche Validierung gegen die DB (DeviceTable)
// müsste hier über einen Service erfolgen.
// Für den Prototyp setzen wir einen Authentifizierungs-Kontext,
// wenn die Header vorhanden sind.
val auth = UsernamePasswordAuthenticationToken(
deviceName,
null,
listOf(SimpleGrantedAuthority("ROLE_DEVICE"))
)
SecurityContextHolder.getContext().authentication = auth
}
filterChain.doFilter(request, response)
}
}

View File

@ -8,6 +8,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@ -23,9 +24,11 @@ class GlobalSecurityConfig {
// Access-Control-Allow-Origin Header setzen, sonst haben wir doppelte Header beim Client. // Access-Control-Allow-Origin Header setzen, sonst haben wir doppelte Header beim Client.
.cors { it.disable() } .cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.addFilterBefore(DeviceSecurityFilter(), UsernamePasswordAuthenticationFilter::class.java)
.authorizeHttpRequests { auth -> .authorizeHttpRequests { auth ->
// Explizite Freigaben (Health, Info, Public Endpoints) // Explizite Freigaben (Health, Info, Public Endpoints)
auth.requestMatchers("/actuator/**").permitAll() auth.requestMatchers("/actuator/**").permitAll()
auth.requestMatchers("/api/v1/devices/register").permitAll() // Onboarding erlauben
auth.requestMatchers("/ping/public").permitAll() auth.requestMatchers("/ping/public").permitAll()
auth.requestMatchers("/ping/simple").permitAll() auth.requestMatchers("/ping/simple").permitAll()
auth.requestMatchers("/ping/enhanced").permitAll() auth.requestMatchers("/ping/enhanced").permitAll()

View File

@ -63,6 +63,7 @@ enum class BuchungsTyp {
NACHNENNGEBUEHR, NACHNENNGEBUEHR,
STARTGEBUEHR, STARTGEBUEHR,
BOXENGEBUEHR, BOXENGEBUEHR,
SPORTFOERDERBEITRAG,
ZAHLUNG_BAR, ZAHLUNG_BAR,
ZAHLUNG_KARTE, ZAHLUNG_KARTE,
GUTSCHRIFT, GUTSCHRIFT,

View File

@ -59,6 +59,7 @@ class TeilnehmerKontoService(
BuchungsTyp.NENNGEBUEHR, BuchungsTyp.NENNGEBUEHR,
BuchungsTyp.NACHNENNGEBUEHR, BuchungsTyp.NACHNENNGEBUEHR,
BuchungsTyp.STARTGEBUEHR, BuchungsTyp.STARTGEBUEHR,
BuchungsTyp.SPORTFOERDERBEITRAG,
BuchungsTyp.BOXENGEBUEHR -> if (betragCent > 0) -betragCent else betragCent BuchungsTyp.BOXENGEBUEHR -> if (betragCent > 0) -betragCent else betragCent
BuchungsTyp.ZAHLUNG_BAR, BuchungsTyp.ZAHLUNG_BAR,

View File

@ -4,7 +4,6 @@ package at.mocode.entries.service.usecase
import at.mocode.billing.domain.model.BuchungsTyp import at.mocode.billing.domain.model.BuchungsTyp
import at.mocode.billing.service.TeilnehmerKontoService import at.mocode.billing.service.TeilnehmerKontoService
import at.mocode.entries.service.notification.MailService
import at.mocode.core.domain.model.NennStatusE import at.mocode.core.domain.model.NennStatusE
import at.mocode.entries.api.* import at.mocode.entries.api.*
import at.mocode.entries.domain.model.Nennung import at.mocode.entries.domain.model.Nennung
@ -12,6 +11,7 @@ import at.mocode.entries.domain.model.NennungsTransfer
import at.mocode.entries.domain.repository.NennungRepository import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.domain.repository.NennungsTransferRepository import at.mocode.entries.domain.repository.NennungsTransferRepository
import at.mocode.entries.service.bewerbe.BewerbRepository import at.mocode.entries.service.bewerbe.BewerbRepository
import at.mocode.entries.service.notification.MailService
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@ -109,6 +109,14 @@ class NennungUseCases(
zweck = "Nachnenngebühr Bewerb ${bewerb.bezeichnung}" zweck = "Nachnenngebühr Bewerb ${bewerb.bezeichnung}"
) )
} }
// Sportförderbeitrag buchen (1€ gemäß § 16 ÖTO)
kontoService.buche(
kontoId = konto.kontoId,
betragCent = -100, // 1,00 EUR
typ = BuchungsTyp.SPORTFOERDERBEITRAG,
zweck = "Sportförderbeitrag ÖTO (§ 16)"
)
} catch (e: Exception) { } catch (e: Exception) {
log.error("Fehler bei der automatischen Buchung für Nennung {}: {}", saved.nennungId, e.message, e) log.error("Fehler bei der automatischen Buchung für Nennung {}: {}", saved.nennungId, e.message, e)
// Wir lassen die Nennung bestehen, loggen aber den Fehler. // Wir lassen die Nennung bestehen, loggen aber den Fehler.

View File

@ -98,15 +98,15 @@ class NennungBillingIntegrationTest {
// WHEN: Nennung einreichen // WHEN: Nennung einreichen
nennungUseCases.nennungEinreichen(request) nennungUseCases.nennungEinreichen(request)
// THEN: Konto muss existieren und Saldo muss -25,00 EUR sein (Gebühr) // THEN: Konto muss existieren und Saldo muss -26,00 EUR sein (25,00 Gebühr + 1,00 Sportförderbeitrag)
val konto = kontoService.getKonto(turnierId, reiterId) val konto = kontoService.getKonto(turnierId, reiterId)
assertNotNull(konto, "Konto sollte automatisch erstellt worden sein") assertNotNull(konto, "Konto sollte automatisch erstellt worden sein")
assertEquals(-2500L, konto?.saldoCent) assertEquals(-2600L, konto?.saldoCent)
val buchungen = kontoService.getBuchungsHistorie(konto!!.kontoId) val buchungen = kontoService.getBuchungsHistorie(konto!!.kontoId)
assertEquals(1, buchungen.size) assertEquals(2, buchungen.size)
assertEquals(BuchungsTyp.NENNGELD, buchungen[0].typ) assertNotNull(buchungen.find { it.typ == BuchungsTyp.NENNGELD })
assertEquals(-2500L, buchungen[0].betragCent) assertNotNull(buchungen.find { it.typ == BuchungsTyp.SPORTFOERDERBEITRAG })
} }
@Test @Test
@ -165,13 +165,14 @@ class NennungBillingIntegrationTest {
// WHEN: Nennung einreichen // WHEN: Nennung einreichen
nennungUseCases.nennungEinreichen(request) nennungUseCases.nennungEinreichen(request)
// THEN: Saldo muss -45,00 EUR sein (-30 - 15) // THEN: Saldo muss -46,00 EUR sein (-30 - 15 - 1 Sportförderbeitrag)
val konto = kontoService.getKonto(turnierId, reiterId) val konto = kontoService.getKonto(turnierId, reiterId)
assertEquals(-4500L, konto?.saldoCent) assertEquals(-4600L, konto?.saldoCent)
val buchungen = kontoService.getBuchungsHistorie(konto!!.kontoId) val buchungen = kontoService.getBuchungsHistorie(konto!!.kontoId)
assertEquals(2, buchungen.size) assertEquals(3, buchungen.size)
// Einer muss NACHNENNGEBUEHR sein // Einer muss NACHNENNGEBUEHR sein
assertNotNull(buchungen.find { it.typ == BuchungsTyp.NACHNENNGEBUEHR }) assertNotNull(buchungen.find { it.typ == BuchungsTyp.NACHNENNGEBUEHR })
assertNotNull(buchungen.find { it.typ == BuchungsTyp.SPORTFOERDERBEITRAG })
} }
} }

View File

@ -0,0 +1,21 @@
package at.mocode.identity.domain.model
import kotlinx.datetime.Instant
import java.util.*
/**
* Repräsentiert eine registrierte Desktop-Instanz ("Gerät").
* Die Identität wird während des Onboarding-Prozesses festgelegt.
*/
data class Device(
val id: UUID = UUID.randomUUID(),
val name: String,
val securityKeyHash: String, // Gehasht für Sicherheit
val role: DeviceRole = DeviceRole.CLIENT,
val lastSyncAt: Instant? = null,
val createdAt: Instant
)
enum class DeviceRole {
MASTER, CLIENT
}

View File

@ -0,0 +1,12 @@
package at.mocode.identity.domain.repository
import at.mocode.identity.domain.model.Device
import kotlinx.datetime.Instant
import java.util.*
interface DeviceRepository {
suspend fun findById(id: UUID): Device?
suspend fun findByName(name: String): Device?
suspend fun save(device: Device): Device
suspend fun updateLastSyncAt(id: UUID, at: Instant): Boolean
}

View File

@ -0,0 +1,39 @@
package at.mocode.identity.domain.service
import at.mocode.identity.domain.model.Device
import at.mocode.identity.domain.model.DeviceRole
import at.mocode.identity.domain.repository.DeviceRepository
import java.util.*
import kotlin.time.Clock
class DeviceService(
private val deviceRepository: DeviceRepository
) {
suspend fun registerDevice(name: String, securityKeyHash: String, role: DeviceRole): Device {
val existing = deviceRepository.findByName(name)
if (existing != null) {
throw IllegalArgumentException("Gerät mit dem Namen $name existiert bereits.")
}
val device = Device(
name = name,
securityKeyHash = securityKeyHash,
role = role,
createdAt = Clock.System.now()
)
return deviceRepository.save(device)
}
suspend fun validateDeviceKey(name: String, securityKeyHash: String): Boolean {
val device = deviceRepository.findByName(name) ?: return false
return device.securityKeyHash == securityKeyHash
}
suspend fun getDeviceByName(name: String): Device? {
return deviceRepository.findByName(name)
}
suspend fun updateSyncTime(deviceId: UUID): Boolean {
return deviceRepository.updateLastSyncAt(deviceId, Clock.System.now())
}
}

View File

@ -0,0 +1,22 @@
package at.mocode.identity.infrastructure.persistence
import at.mocode.identity.domain.model.DeviceRole
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Exposed Table definition für registrierte Desktop-Geräte.
*/
object DeviceTable : Table("identity_devices") {
val id = javaUUID("id").autoGenerate()
override val primaryKey = PrimaryKey(id)
val name = varchar("name", 100).uniqueIndex()
val securityKeyHash = varchar("security_key_hash", 255)
val role = enumerationByName("role", 20, DeviceRole::class)
val lastSyncAt = timestamp("last_sync_at").nullable()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
}

View File

@ -0,0 +1,72 @@
package at.mocode.identity.infrastructure.persistence
import at.mocode.identity.domain.model.Device
import at.mocode.identity.domain.repository.DeviceRepository
import org.jetbrains.exposed.v1.core.ResultRow
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 java.util.*
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.time.toJavaInstant
class ExposedDeviceRepository : DeviceRepository {
override suspend fun findById(id: UUID): Device? = transaction {
DeviceTable.selectAll().where { DeviceTable.id eq id }
.map { rowToDevice(it) }
.singleOrNull()
}
override suspend fun findByName(name: String): Device? = transaction {
DeviceTable.selectAll().where { DeviceTable.name eq name }
.map { rowToDevice(it) }
.singleOrNull()
}
override suspend fun save(device: Device): Device = transaction {
val now = Clock.System.now()
val existing = DeviceTable.selectAll().where { DeviceTable.id eq device.id }.singleOrNull()
if (existing != null) {
DeviceTable.update({ DeviceTable.id eq device.id }) {
it[name] = device.name
it[securityKeyHash] = device.securityKeyHash
it[role] = device.role
it[lastSyncAt] = device.lastSyncAt?.toJavaInstant()
it[updatedAt] = now.toJavaInstant()
}
} else {
DeviceTable.insert {
it[id] = device.id
it[name] = device.name
it[securityKeyHash] = device.securityKeyHash
it[role] = device.role
it[lastSyncAt] = device.lastSyncAt?.toJavaInstant()
it[createdAt] = now.toJavaInstant()
it[updatedAt] = now.toJavaInstant()
}
}
device
}
override suspend fun updateLastSyncAt(id: UUID, at: Instant): Boolean = transaction {
val javaInstant = at.toJavaInstant()
DeviceTable.update({ DeviceTable.id eq id }) {
it[lastSyncAt] = javaInstant
it[updatedAt] = javaInstant
} > 0
}
private fun rowToDevice(row: ResultRow): Device = Device(
id = row[DeviceTable.id],
name = row[DeviceTable.name],
securityKeyHash = row[DeviceTable.securityKeyHash],
role = row[DeviceTable.role],
lastSyncAt = row[DeviceTable.lastSyncAt]?.let { Instant.fromEpochMilliseconds(it.toEpochMilli()) },
createdAt = Instant.fromEpochMilliseconds(row[DeviceTable.createdAt].toEpochMilli())
)
}

View File

@ -1,7 +1,10 @@
package at.mocode.identity.service.config package at.mocode.identity.service.config
import at.mocode.identity.domain.repository.DeviceRepository
import at.mocode.identity.domain.repository.ProfileRepository import at.mocode.identity.domain.repository.ProfileRepository
import at.mocode.identity.domain.service.DeviceService
import at.mocode.identity.domain.service.ProfileService import at.mocode.identity.domain.service.ProfileService
import at.mocode.identity.infrastructure.persistence.ExposedDeviceRepository
import at.mocode.identity.infrastructure.persistence.ExposedProfileRepository import at.mocode.identity.infrastructure.persistence.ExposedProfileRepository
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
@ -15,4 +18,11 @@ class IdentityConfig {
@Bean @Bean
fun profileService(profileRepository: ProfileRepository): ProfileService = fun profileService(profileRepository: ProfileRepository): ProfileService =
ProfileService(profileRepository) ProfileService(profileRepository)
@Bean
fun deviceRepository(): DeviceRepository = ExposedDeviceRepository()
@Bean
fun deviceService(deviceRepository: DeviceRepository): DeviceService =
DeviceService(deviceRepository)
} }

View File

@ -0,0 +1,33 @@
package at.mocode.identity.service.web
import at.mocode.identity.domain.model.Device
import at.mocode.identity.domain.model.DeviceRole
import at.mocode.identity.domain.service.DeviceService
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/v1/devices")
class DeviceController(
private val deviceService: DeviceService
) {
@PostMapping("/register")
suspend fun registerDevice(@RequestBody request: DeviceRegisterRequest): Device {
return deviceService.registerDevice(
name = request.name,
securityKeyHash = request.securityKeyHash,
role = request.role
)
}
@GetMapping("/{name}")
suspend fun getDevice(@PathVariable name: String): Device? {
return deviceService.getDeviceByName(name)
}
}
data class DeviceRegisterRequest(
val name: String,
val securityKeyHash: String,
val role: DeviceRole
)

View File

@ -0,0 +1,118 @@
# ===================================================================
# Multi-stage Dockerfile for Meldestelle Mail Service
# Features: Security hardening, monitoring support, optimal caching, BuildKit cache mounts
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
ARG GRADLE_VERSION=9.4.1
ARG JAVA_VERSION=25
ARG BUILD_DATE
ARG VERSION=1.0.0-SNAPSHOT
# ===================================================================
# Build Stage
# ===================================================================
FROM eclipse-temurin:${JAVA_VERSION}-jdk-alpine AS builder
ARG VERSION
ARG BUILD_DATE
LABEL stage=builder \
service="mail-service" \
maintainer="Meldestelle Development Team"
WORKDIR /workspace
# Gradle optimizations
ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
-Dorg.gradle.daemon=false \
-Dorg.gradle.parallel=true \
-Dorg.gradle.workers.max=2 \
-Dorg.gradle.jvmargs=-Xmx2g \
-XX:+UseParallelGC \
-XX:MaxMetaspaceSize=512m"
ENV GRADLE_USER_HOME=/root/.gradle
# 1. Copy full project structure for a reliable monorepo build
COPY . .
RUN chmod +x gradlew
# 2. Build the service
RUN --mount=type=cache,target=/root/.gradle/caches \
--mount=type=cache,target=/root/.gradle/wrapper \
./gradlew :backend:services:mail:mail-service:bootJar --no-daemon --info
# 3. Extract layers
WORKDIR /builder
RUN cp /workspace/backend/services/mail/mail-service/build/libs/*.jar app.jar && \
java -Djarmode=layertools -jar app.jar extract
# ===================================================================
# Runtime Stage
# ===================================================================
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime
ARG BUILD_DATE
ARG VERSION
ARG JAVA_VERSION
LABEL service="mail-service" \
version="${VERSION}" \
description="Microservice for Mail and Online Entries" \
maintainer="Meldestelle Development Team" \
java.version="${JAVA_VERSION}" \
build.date="${BUILD_DATE}"
ARG APP_USER=appuser
ARG APP_GROUP=appgroup
ARG APP_UID=1001
ARG APP_GID=1001
WORKDIR /app
RUN apk update && \
apk upgrade && \
apk add --no-cache curl tzdata tini && \
rm -rf /var/cache/apk/* && \
addgroup -g ${APP_GID} -S ${APP_GROUP} && \
adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh && \
mkdir -p /app/logs /app/tmp /app/config && \
chown -R ${APP_USER}:${APP_GROUP} /app && \
chmod -R 750 /app
# Copy Spring Boot layers
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./
USER ${APP_USER}
EXPOSE 8085 5005
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
CMD curl -fsS --max-time 2 http://localhost:8085/actuator/health/readiness || exit 1
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 \
-XX:+UseG1GC \
-XX:+UseStringDeduplication \
-XX:+UseContainerSupport \
-Djava.security.egd=file:/dev/./urandom \
-Djava.awt.headless=true \
-Dfile.encoding=UTF-8 \
-Duser.timezone=Europe/Vienna \
-Dmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus"
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
SERVER_PORT=8085 \
LOGGING_LEVEL_ROOT=INFO
ENTRYPOINT ["tini", "--", "sh", "-c", "\
echo 'Starting Mail Service with Java ${JAVA_VERSION}...'; \
if [ \"${DEBUG:-false}\" = \"true\" ]; then \
echo 'DEBUG mode enabled'; \
exec java ${JAVA_OPTS} -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.launch.JarLauncher; \
else \
exec java ${JAVA_OPTS} org.springframework.boot.loader.launch.JarLauncher; \
fi"]

View File

@ -0,0 +1,114 @@
package at.mocode.mail.service.api
import at.mocode.mail.service.persistence.NennungEntity
import at.mocode.mail.service.persistence.NennungRepository
import jakarta.validation.Valid
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.mail.SimpleMailMessage
import org.springframework.mail.javamail.JavaMailSender
import org.springframework.web.bind.annotation.*
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
data class NennungRequest(
@field:NotBlank(message = "Turniernummer ist erforderlich")
val turnierNr: String,
@field:NotBlank(message = "Vorname ist erforderlich")
val vorname: String,
@field:NotBlank(message = "Nachname ist erforderlich")
val nachname: String,
@field:NotBlank(message = "Lizenznummer ist erforderlich")
val lizenz: String,
@field:NotBlank(message = "Pferdename ist erforderlich")
val pferdName: String,
@field:NotBlank(message = "Pferdealter ist erforderlich")
val pferdAlter: String,
@field:Email(message = "Ungültiges Email-Format")
@field:NotBlank(message = "Email ist erforderlich")
val email: String,
val telefon: String?,
@field:NotBlank(message = "Bewerbe sind erforderlich")
val bewerbe: String,
val bemerkungen: String?
)
@OptIn(ExperimentalUuidApi::class)
@RestController
@RequestMapping("/api/mail")
@CrossOrigin(origins = ["http://localhost:8080", "https://nennung.mo-code.at"]) // Für Wasm-Web-App (Compose HTML/Wasm)
class MailController(
private val nennungRepository: NennungRepository,
private val mailSender: JavaMailSender
) {
private val logger = LoggerFactory.getLogger(MailController::class.java)
@Value("\${spring.mail.username}")
private lateinit var baseMailAddress: String
@PostMapping("/nennung")
fun receiveNennung(@Valid @RequestBody request: NennungRequest) {
logger.info("Nennung via API erhalten: ${request.vorname} ${request.nachname} für Turnier ${request.turnierNr}")
val entity = NennungEntity(
id = Uuid.random(),
turnierNr = request.turnierNr,
status = "API_EMPFANGEN",
vorname = request.vorname,
nachname = request.nachname,
lizenz = request.lizenz,
pferdName = request.pferdName,
pferdAlter = request.pferdAlter,
email = request.email,
telefon = request.telefon,
bewerbe = request.bewerbe,
bemerkungen = request.bemerkungen
)
nennungRepository.save(entity)
logger.info("Nennung ${entity.id} in Datenbank persistiert.")
// Bestätigung an Reiter senden
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
message.setTo(request.email)
message.subject = "Bestätigung: Ihre Online-Nennung für Turnier ${request.turnierNr}"
message.text = """
Sehr geehrte(r) ${request.vorname} ${request.nachname},
vielen Dank für Ihre Online-Nennung für das Turnier ${request.turnierNr}.
Ihre Daten:
- Pferd: ${request.pferdName}
- Bewerbe: ${request.bewerbe}
Ihre Nennung ist erfolgreich bei uns eingegangen und wird nun verarbeitet.
Mit freundlichen Grüßen,
Ihre Meldestelle
""".trimIndent()
mailSender.send(message)
logger.info("Bestätigungs-Mail an ${request.email} gesendet.")
} catch (e: Exception) {
logger.error("Fehler beim Senden der Bestätigungs-Mail: ${e.message}")
}
}
@GetMapping("/nennungen")
fun getAllNennungen(): List<NennungEntity> {
return nennungRepository.findAll()
}
}

View File

@ -2,34 +2,25 @@ spring:
application: application:
name: mail-service name: mail-service
datasource: datasource:
url: jdbc:h2:mem:maildb;DB_CLOSE_DELAY=-1 url: ${SPRING_DATASOURCE_URL:jdbc:h2:mem:maildb;DB_CLOSE_DELAY=-1}
driver-class-name: org.h2.Driver driver-class-name: ${SPRING_DATASOURCE_DRIVER_CLASS_NAME:org.h2.Driver}
username: sa username: ${SPRING_DATASOURCE_USERNAME:sa}
password: "" password: ${SPRING_DATASOURCE_PASSWORD:""}
h2: jpa:
console: hibernate:
enabled: true ddl-auto: update
path: /h2-console show-sql: true
mail: mail:
host: ${MAIL_HOST:imap.world4you.com} host: ${SPRING_MAIL_HOST:smtp.world4you.com}
port: ${MAIL_PORT:993} port: ${SPRING_MAIL_PORT:587}
username: ${MAIL_USERNAME:online-nennen@mo-code.at} username: ${SPRING_MAIL_USERNAME:online-nennen@mo-code.at}
password: ${MAIL_PASSWORD:} password: ${SPRING_MAIL_PASSWORD:}
properties: properties:
mail: mail:
store:
protocol: imaps
imaps:
host: ${MAIL_HOST:imap.world4you.com}
port: ${MAIL_PORT:993}
ssl:
enable: true
smtp: smtp:
auth: true auth: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH:true}
starttls: starttls:
enable: true enable: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE:true}
host-smtp: ${SMTP_HOST:smtp.world4you.com}
port-smtp: ${SMTP_PORT:587}
server: server:
port: 8085 port: 8085

View File

@ -61,6 +61,7 @@ services:
PING_SERVICE_URL: "http://ping-service:8082" PING_SERVICE_URL: "http://ping-service:8082"
MASTERDATA_SERVICE_URL: "http://masterdata-service:8086" MASTERDATA_SERVICE_URL: "http://masterdata-service:8086"
EVENTS_SERVICE_URL: "http://events-service:8085" EVENTS_SERVICE_URL: "http://events-service:8085"
MAIL_SERVICE_URL: "http://mail-service:8085"
ZNS_IMPORT_SERVICE_URL: "http://zns-import-service:8095" ZNS_IMPORT_SERVICE_URL: "http://zns-import-service:8095"
RESULTS_SERVICE_URL: "http://results-service:8088" RESULTS_SERVICE_URL: "http://results-service:8088"
BILLING_SERVICE_URL: "http://billing-service:8087" BILLING_SERVICE_URL: "http://billing-service:8087"
@ -76,6 +77,8 @@ services:
condition: "service_healthy" condition: "service_healthy"
zipkin: zipkin:
condition: "service_healthy" condition: "service_healthy"
mail-service:
condition: "service_healthy"
healthcheck: healthcheck:
test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8081/actuator/health/readiness" ] test: [ "CMD", "wget", "--spider", "-q", "http://localhost:8081/actuator/health/readiness" ]
@ -540,78 +543,150 @@ services:
volumes: volumes:
- ./config/app/base-application.yaml:/workspace/config/application.yml:Z - ./config/app/base-application.yaml:/workspace/config/application.yml:Z
# --- MICROSERVICE: Scheduling Service --- # --- MICROSERVICE: Mail Service ---
# scheduling-service: mail-service:
# image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/scheduling-service:${DOCKER_TAG:-latest}" image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/mail-service:${DOCKER_TAG:-latest}"
# build: build:
# context: . context: .
# dockerfile: backend/services/scheduling/scheduling-service/Dockerfile dockerfile: backend/services/mail/Dockerfile
# args: args:
# GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}" GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
# JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}" JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
# 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}-scheduling-service" container_name: "${PROJECT_NAME:-meldestelle}-mail-service"
# restart: unless-stopped restart: unless-stopped
# ports: ports:
# - "${SCHEDULING_PORT:-8084:8084}" - "${MAIL_PORT:-8083:8085}"
# - "${SCHEDULING_DEBUG_PORT:-5013:5013}" - "${MAIL_DEBUG_PORT:-5014:5014}"
# environment: environment:
# SPRING_PROFILES_ACTIVE: "${SCHEDULING_SPRING_PROFILES_ACTIVE:-docker}" SPRING_PROFILES_ACTIVE: "${MAIL_SPRING_PROFILES_ACTIVE:-docker}"
# DEBUG: "${SCHEDULING_DEBUG:-true}" DEBUG: "${MAIL_DEBUG:-true}"
# SERVER_PORT: "${SCHEDULING_SERVER_PORT:-8084}" SERVER_PORT: "${MAIL_SERVER_PORT:-8085}"
# SPRING_APPLICATION_NAME: "${SCHEDULING_SERVICE_NAME:-scheduling-service}"
# # --- KEYCLOAK ---
# # --- KEYCLOAK --- SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}"
# SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}" SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "${KC_JWK_SET_URI:-http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}"
# SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "${KC_JWK_SET_URI:-http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}"
# # --- CONSUL ---
# # --- CONSUL --- SPRING_CLOUD_CONSUL_HOST: "${CONSUL_HOST:-consul}"
# SPRING_CLOUD_CONSUL_HOST: "${CONSUL_HOST:-consul}" SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}"
# SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}" SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${MAIL_SERVICE_NAME:-mail-service}"
# SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${SCHEDULING_SERVICE_NAME:-scheduling-service}" SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${MAIL_CONSUL_PREFER_IP:-true}"
# SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${SCHEDULING_CONSUL_PREFER_IP:-true}"
# # - DATENBANK VERBINDUNG -
# # - DATENBANK VERBINDUNG - SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}"
# SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}" SPRING_DATASOURCE_USERNAME: "${POSTGRES_USER:-pg-user}"
# SPRING_DATASOURCE_USERNAME: "${POSTGRES_USER:-pg-user}" SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}"
# SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}"
# # --- MAIL CONFIG (SMTP) ---
# # --- VALKEY --- SPRING_MAIL_HOST: "${MAIL_SMTP_HOST:-smtp.mo-code.at}"
# SPRING_DATA_VALKEY_HOST: "${VALKEY_SERVER_HOSTNAME:-valkey}" SPRING_MAIL_PORT: "${MAIL_SMTP_PORT:-587}"
# SPRING_DATA_VALKEY_PORT: "${VALKEY_SERVER_PORT:-6379}" SPRING_MAIL_USERNAME: "${MAIL_SMTP_USER:-online-nennen@mo-code.at}"
# SPRING_MAIL_PASSWORD: "${MAIL_SMTP_PASSWORD:-secret}"
# # --- ZIPKIN --- SPRING_MAIL_PROPERTIES_MAIL_SMTP_AUTH: "true"
# MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: "${ZIPKIN_ENDPOINT:-http://zipkin:9411/api/v2/spans}" SPRING_MAIL_PROPERTIES_MAIL_SMTP_STARTTLS_ENABLE: "true"
#
# depends_on: # --- ZIPKIN ---
# postgres: MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: "${ZIPKIN_ENDPOINT:-http://zipkin:9411/api/v2/spans}"
# condition: "service_healthy"
# keycloak: depends_on:
# condition: "service_healthy" postgres:
# consul: condition: "service_healthy"
# condition: "service_healthy" consul:
# valkey: condition: "service_healthy"
# condition: "service_healthy" zipkin:
# zipkin: condition: "service_healthy"
# condition: "service_healthy"
# healthcheck:
# healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:8085/actuator/health/readiness" ]
# test: [ "CMD", "curl", "-f", "http://localhost:8084/actuator/health" ] interval: 15s
# interval: 15s timeout: 5s
# timeout: 5s retries: 5
# retries: 5 start_period: 40s
# start_period: 40s
# networks:
# networks: meldestelle-network:
# meldestelle-network: aliases:
# aliases: - "mail-service"
# - "scheduling-service" profiles: [ "backend", "all" ]
# profiles: [ "backend", "all" ] volumes:
# volumes: - ./config/app/base-application.yaml:/workspace/config/application.yml:Z
# - ./config/app/base-application.yaml:/workspace/config/application.yml:Z
# --- MICROSERVICE: Scheduling Service ---
scheduling-service:
image: "${DOCKER_REGISTRY:-git.mo-code.at/mo-code}/scheduling-service:${DOCKER_TAG:-latest}"
build:
context: .
dockerfile: backend/services/scheduling/scheduling-service/Dockerfile
args:
GRADLE_VERSION: "${DOCKER_GRADLE_VERSION:-9.4.1}"
JAVA_VERSION: "${DOCKER_JAVA_VERSION:-25}"
VERSION: "${DOCKER_VERSION:-1.0.0-SNAPSHOT}"
BUILD_DATE: "${DOCKER_BUILD_DATE}"
labels:
- "org.opencontainers.image.created=${DOCKER_BUILD_DATE}"
container_name: "${PROJECT_NAME:-meldestelle}-scheduling-service"
restart: unless-stopped
ports:
- "${SCHEDULING_PORT:-8084:8084}"
- "${SCHEDULING_DEBUG_PORT:-5013:5013}"
environment:
SPRING_PROFILES_ACTIVE: "${SCHEDULING_SPRING_PROFILES_ACTIVE:-docker}"
DEBUG: "${SCHEDULING_DEBUG:-true}"
SERVER_PORT: "${SCHEDULING_SERVER_PORT:-8084}"
SPRING_APPLICATION_NAME: "${SCHEDULING_SERVICE_NAME:-scheduling-service}"
# --- KEYCLOAK ---
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}"
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "${KC_JWK_SET_URI:-http://keycloak:8080/realms/meldestelle/protocol/openid-connect/certs}"
# --- CONSUL ---
SPRING_CLOUD_CONSUL_HOST: "${CONSUL_HOST:-consul}"
SPRING_CLOUD_CONSUL_PORT: "${CONSUL_HTTP_PORT:-8500}"
SPRING_CLOUD_CONSUL_DISCOVERY_SERVICE_NAME: "${SCHEDULING_SERVICE_NAME:-scheduling-service}"
SPRING_CLOUD_CONSUL_DISCOVERY_PREFER_IP_ADDRESS: "${SCHEDULING_CONSUL_PREFER_IP:-true}"
# - DATENBANK VERBINDUNG -
SPRING_DATASOURCE_URL: "${POSTGRES_DB_URL:-jdbc:postgresql://postgres:5432/pg-meldestelle-db}"
SPRING_DATASOURCE_USERNAME: "${POSTGRES_USER:-pg-user}"
SPRING_DATASOURCE_PASSWORD: "${POSTGRES_PASSWORD:-pg-password}"
# --- VALKEY ---
SPRING_DATA_VALKEY_HOST: "${VALKEY_SERVER_HOSTNAME:-valkey}"
SPRING_DATA_VALKEY_PORT: "${VALKEY_SERVER_PORT:-6379}"
# --- ZIPKIN ---
MANAGEMENT_ZIPKIN_TRACING_ENDPOINT: "${ZIPKIN_ENDPOINT:-http://zipkin:9411/api/v2/spans}"
depends_on:
postgres:
condition: "service_healthy"
keycloak:
condition: "service_healthy"
consul:
condition: "service_healthy"
valkey:
condition: "service_healthy"
zipkin:
condition: "service_healthy"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8084/actuator/health" ]
interval: 15s
timeout: 5s
retries: 5
start_period: 40s
networks:
meldestelle-network:
aliases:
- "scheduling-service"
profiles: [ "backend", "all" ]
volumes:
- ./config/app/base-application.yaml:/workspace/config/application.yml:Z
# --- MICROSERVICE: Series Service --- # --- MICROSERVICE: Series Service ---
series-service: series-service:
@ -635,7 +710,7 @@ services:
SPRING_PROFILES_ACTIVE: "${SERIES_SPRING_PROFILES_ACTIVE:-docker}" SPRING_PROFILES_ACTIVE: "${SERIES_SPRING_PROFILES_ACTIVE:-docker}"
DEBUG: "${SERIES_DEBUG:-true}" DEBUG: "${SERIES_DEBUG:-true}"
SERVER_PORT: "${SERIES_SERVER_PORT:-8089}" SERVER_PORT: "${SERIES_SERVER_PORT:-8089}"
SPRING_APPLICATION_NAME: "series-service" SPRING_APPLICATION_NAME: "${SERIES_SERVICE_NAME:-series-service}"
# --- KEYCLOAK --- # --- KEYCLOAK ---
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}" SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "${KC_ISSUER_URI:-http://keycloak:8080/realms/meldestelle}"

View File

@ -279,6 +279,7 @@ und über definierte Schnittstellen kommunizieren.
### PHASE 13: Export & ZNS-Rückmeldung ### PHASE 13: Export & ZNS-Rückmeldung
*Ziel: Finalisierung der Turnier-Daten und Rückübermittlung an den OEPS.* *Ziel: Finalisierung der Turnier-Daten und Rückübermittlung an den OEPS.*
* [x] **Mail-Service Integration:** Online-Nennungen via REST/Mail empfangen und persistieren. ✓ (April 2026)
* [ ] **XML-Export:** Vollständiger B-Satz Export (inkl. Ergebnisse und Platzierungen). * [ ] **XML-Export:** Vollständiger B-Satz Export (inkl. Ergebnisse und Platzierungen).
* [ ] **ZNS-Portal:** Upload-Integration in das OEPS-ZNS. * [ ] **ZNS-Portal:** Upload-Integration in das OEPS-ZNS.
* [ ] **Archivierung:** Langzeit-Archivierung abgeschlossener Turniere. * [ ] **Archivierung:** Langzeit-Archivierung abgeschlossener Turniere.
@ -354,3 +355,26 @@ und über definierte Schnittstellen kommunizieren.
* [ ] **End-to-End Test:** Online-Nennung (Web) -> E-Mail -> Desktop-Verarbeitung. * [ ] **End-to-End Test:** Online-Nennung (Web) -> E-Mail -> Desktop-Verarbeitung.
--- ---
### PHASE 5: Desktop-Zentrale & Synchronisation 🔵 IN ARBEIT
*Ziel: Ein einsatzbereiter Desktop-Client für das Neumarkt-Turnier.*
#### 🎨 Agent: Frontend Expert
* [x] **Onboarding UI:** Implementierung des Onboarding-Screens (Name, Key, Backup, Rolle, Sync, Drucker) mit
validierten Eingaben.
* [x] **Navigation:** Navigations-Rail mit Hover-Tooltips und dedizierten Icons für "Setup" und "Sync".
* [x] **Settings:** Persistente Speicherung der Onboarding-Daten in `settings.json`.
#### 👷 Agent: Backend Developer
* [x] **Device Management:** Domain-Modell (`Device`), Tabelle (`identity_devices`) und Repository zur
Geräteverwaltung implementiert.
* [x] **Security Key Auth:** Implementierung des `DeviceSecurityFilter` zur Authentifizierung via `X-Security-Key`
Header.
* [x] **Onboarding API:** REST-Endpunkte zur Registrierung und Abfrage von Desktop-Instanzen erstellt.
#### 🧹 Agent: Curator
* [x] **Dokumentation:** Erstellung der Architektur-Doku für das Onboarding-Backend.

View File

@ -0,0 +1,68 @@
# Onboarding-Backend & Desktop-Identität
Dieses Dokument beschreibt die Backend-Infrastruktur für die Identifizierung und Authentifizierung von
Desktop-Clients ("Meldestelle-Biest").
## 🚀 Übersicht
Im Gegensatz zur Web-App (die via Keycloak/JWT authentifiziert) nutzen die Desktop-Instanzen für die
Offline-Synchronisation eine Identität, die während des **Onboarding-Prozesses** lokal vergeben und am Server
registriert wird.
## 🛡️ Authentifizierungs-Mechanismus
Die Authentifizierung erfolgt über zwei HTTP-Header, die bei jedem Request vom Desktop-Client mitgesendet werden müssen:
| Header | Beschreibung | Beispiel |
|:-----------------|:---------------------------------------------------|:-------------------|
| `X-Device-Name` | Der beim Onboarding vergebene Gerätename | `Meldestelle-PC-1` |
| `X-Security-Key` | Der beim Onboarding vergebene Sicherheitsschlüssel | `secret-key-123` |
### DeviceSecurityFilter
Ein Custom-Security-Filter (`DeviceSecurityFilter`) im Backend extrahiert diese Header und setzt einen Spring Security
Kontext mit der Authority `ROLE_DEVICE`.
## 🛰️ API-Endpunkte (Identity Service)
### 1. Gerät registrieren
Wird beim Abschluss des Onboarding-Screens aufgerufen.
- **URL:** `POST /api/v1/devices/register`
- **Body:**
```json
{
"name": "Meldestelle-PC-1",
"securityKeyHash": "...",
"role": "MASTER"
}
```
- **Hinweis:** Dieser Endpunkt ist `permitAll()`, um die Erstregistrierung zu ermöglichen.
### 2. Gerät abrufen
- **URL:** `GET /api/v1/devices/{name}`
- **Auth:** Erfordert `ROLE_DEVICE` oder `JWT`.
## 💾 Datenmodell (Exposed)
Die Tabelle `identity_devices` speichert die registrierten Instanzen:
- `id`: Eindeutige UUID.
- `name`: Gerätename (eindeutig).
- `security_key_hash`: Der Sicherheitsschlüssel (gehasht).
- `role`: `MASTER` oder `CLIENT`.
- `last_sync_at`: Zeitstempel der letzten erfolgreichen Synchronisation.
## 🛠️ Local Test-Setup
Für lokale Tests mit `curl`:
```bash
curl -X GET http://localhost:8081/api/v1/devices/Meldestelle-PC-1 \
-H "X-Device-Name: Meldestelle-PC-1" \
-H "X-Security-Key: secret-key-123"
```

View File

@ -0,0 +1,35 @@
# 🧹 Session Journal - 15. April 2026 (Nachmittag)
## 🏗️ Status-Check (Lead Architect)
- **Phase 13 (Export & Billing):** Die Gebührenlogik wurde für das Neumarkt-Turnier (April 2026) finalisiert.
- **ÖTO-Konformität:** Der Sportförderbeitrag gemäß § 16 ÖTO wird nun bei jeder Nennung automatisch verbucht.
## 👷 Durchgeführte Arbeiten (Backend)
1. **Billing (billing-domain & service):**
- `BuchungsTyp.SPORTFOERDERBEITRAG` zum Enum hinzugefügt.
- `TeilnehmerKontoService` um die Validierung für diesen neuen Typ erweitert (automatische Soll-Buchung).
2. **Entries (entries-service):**
- `NennungUseCases` aktualisiert: Bei jeder Nennungseingabe wird nun automatisch 1,00 EUR Sportförderbeitrag auf das
Teilnehmerkonto gebucht, zusätzlich zu Nenngeld und Nachnenngebühr.
3. **ZNS-Export (Bewerbe-SCS):**
- Prüfung des `B-Satz` Exports im `BewerbeController`. Die Logik zur Generierung des strukturierten Textformats für
den OEPS ist vorhanden und nutzt die `ZnsBewerb`-Modelle.
## 🧐 QA-Status & Bekannte Themen
- [x] **Billing-Check:** Die automatische Buchungskette (Nennung -> Konto -> Buchung) ist nun vollständig für alle
Pflichtgebühren integriert.
- [x] **Integrationstests:** `NennungBillingIntegrationTest` wurde an die neue Gebührenlogik angepasst (1,00 EUR
Sportförderbeitrag).
- [ ] **Export-Validierung:** Der generierte B-Satz muss noch gegen ein offizielles OEPS-Beispiel validiert werden (
geplant für die nächste Session).
## 🧹 Curator's Note
- Die ROADMAP Phase 13 wurde in der Vormittags-Session bereits aktualisiert.
- Der Fokus für morgen liegt auf der **ZNS-Export-Validierung** und der Vorbereitung des **Teilnehmer-Exports** (
A-Satz).
**Abschluss:** Das Billing-System ist "ÖTO-ready" für Neumarkt. 🐎🏦

View File

@ -0,0 +1,47 @@
# 🧹 Session Journal - 15. April 2026 (Desktop UX & Onboarding)
## 🏗️ Status-Check (Lead Architect)
- **Workflow-Fokus:** Abkehr vom verfrühten Deployment hin zur ehrlichen "Workflow-First" Entwicklung der Desktop-App.
- **Identität & Sicherheit:** Die App verfügt nun über ein robustes Onboarding-System für die lokale Identität und
Sicherheit.
- **UX-Optimierung:** Die Navigation wurde um Hover-Tooltips erweitert, um die Bedienbarkeit ohne Textlabels in der
NavRail zu gewährleisten.
## 👷 Durchgeführte Arbeiten (Frontend & UX)
1. **Onboarding & Setup ("Geburtsurkunde"):**
- Komplette Neugestaltung des `OnboardingScreen` (v2).
- Erfassung von Gerätename, Sicherheitsschlüssel (Shared Secret) und Datenbank-Sicherungspfad.
- Integration von interaktiven Auswahl-Dialogen: `JFileChooser` für Pfade und `PrintServiceLookup` für installierte
Drucker.
- Einführung des `SettingsManager` zur persistenten Speicherung der Einstellungen in `settings.json`.
- Implementierung des `OnboardingValidator` zur Sicherstellung valider Pflichtangaben (Name, Key, Backup-Pfad).
2. **Navigation & Layout:**
- Erweiterung der `DesktopNavRail` um ein dediziertes "Setup"-Icon (`AppRegistration`) am unteren Ende.
- Auslagerung des Ping-Service ("Sync") als eigenständiges Icon (`WifiTethering`).
- Implementierung von **Hover-Tooltips** für alle Navigations-Items (`NavRailItem`) unter Verwendung von Material3
`TooltipBox`.
- Tooltips sind rechtsbündig (`TooltipAnchorPosition.Right`) positioniert und zeigen den Namen des Moduls ("Admin", "
Vereine", "Mails", "Sync", "Setup").
3. **Code-Qualität & Refactoring:**
- Bereinigung veralteter Onboarding-Screens und Konsolidierung auf das v2-Datenmodell.
- Integration von `@Preview`-Blöcken direkt in den Screen-Komponenten zur IDE-gestützten Entwicklung.
- Erfolgreiche Kompilierung des `meldestelle-desktop` Moduls nach Behebung von Typ-Konflikten.
## 🧐 QA-Status & Bekannte Themen
- [x] **Onboarding-Workflow:** App erzwingt Setup bei fehlender Konfiguration.
- [x] **Drucker-Anbindung:** Systemdrucker werden korrekt gelistet.
- [x] **Tooltip-UX:** Hover-Effekt in der Navigationsleiste ist aktiv und informativ.
- [ ] **E2E-Integration:** Die Anbindung des `NennungsEingangScreen` an den echten `mail-service` (Server-Daten abholen)
ist der nächste logische Schritt.
## 🧹 Curator's Note
- Die Strategie hat sich von "Live-Gang" zurück auf "Ehrliches Desktop-Fundament" verschoben.
- Das "Biest" hat jetzt einen Namen und einen Platz für seine Backups. 💾
**Abschluss:** Onboarding und Basis-Navigation sind "Enterprise-Ready". 🚀

View File

@ -0,0 +1,43 @@
# 🧹 Session Journal - 15. April 2026 (Live-Gang Vorbereitung)
## 🏗️ Status-Check (Lead Architect)
- **Phase 13 (Export & Mail-Service):** Infrastruktur und Deployment-Vorbereitungen für den Live-Gang des Online-Nennens
sind abgeschlossen.
- **Ziel erreicht:** Das System kann nun auf dem Produktions-Server deployt werden.
## 👷 Durchgeführte Arbeiten (DevOps & Frontend)
1. **Infrastruktur (Docker & Mail):**
- Dockerfile für `mail-service` erstellt.
- `dc-backend.yaml` um den `mail-service` erweitert (inkl. Postgres-Link, Consul-Discovery und SMTP-Konfiguration).
- Port-Kollision zwischen `events-service` und `mail-service` behoben (`mail-service` Host-Port auf 8083).
- `.env` und `.env.example` (SSoT) umfassend für alle Microservices (Standard-Ports, Debug, SMTP) vervollständigt.
- `application.yaml` im `mail-service` auf SMTP-Versand (World4You Standard) optimiert und IMAP-Reste entfernt.
- **Plus-Addressing:** Backend-Logik im `MailController` implementiert, um Mails dynamisch als
`online-nennen+[TurnierNr]@mo-code.at` zu versenden.
2. **Frontend (Konfigurierbarkeit):**
- Common `PlatformConfig` erweitert um `resolveMailServiceUrl`.
- Implementierung für Wasm, JS und JVM hinzugefügt, um Backend-URLs zur Laufzeit steuern zu können (Wasm: via global
JS variables).
- `NennungRemoteRepository` nutzt nun die dynamisch aufgelöste Mail-Service-URL.
- Fehlende Projekt-Abhängigkeit (`frontend.core.network`) im `nennung-feature` ergänzt.
3. **Sicherheit:**
- CORS im `MailController` auf Ziel-Domains eingeschränkt (`nennung.mo-code.at`).
- Bean-Validierung für `NennungRequest` (Email-Format, Pflichtfelder) implementiert.
4. **Dokumentation:**
- `docs/05_Deployment/2026-04-15_Online-Nennung-Deployment.md` erstellt.
## 🧐 QA-Status & Bekannte Themen
- [x] **Infrastruktur-Check:** Docker-Stack ist bereit für `up -d mail-service`.
- [x] **Frontend-URL:** Die harte Verdrahtung auf `localhost:8085` wurde durch eine flexible Runtime-Konfiguration
ersetzt.
- [ ] **Mail-Versand:** Der tatsächliche Versand muss in der Ziel-Umgebung mit echten SMTP-Credentials validiert werden.
## 🧹 Curator's Note
- Die ROADMAP Phase 13 wurde in der Vormittags-Session bereits aktualisiert.
- Das "Biest" ist nun technologisch "Live-ready". 🚀
**Abschluss:** Online-Nennung bereit für das Neumarkt-Turnier (April 2026). 🐎

View File

@ -0,0 +1,29 @@
# 🧹 Session Journal - 15. April 2026
## 🏗️ Status-Check (Lead Architect)
- **Phase 13 (Export & Mail-Service):** Signifikanter Fortschritt. Die Online-Nennung (Web -> Backend) ist nun funktional integriert.
- **Deadline-Fokus:** Neumarkt-Turnier (24. April 2026). Das System ist bereit für die ersten Online-Nennungen über die Web-Plattform.
## 👷 Durchgeführte Arbeiten (Backend & Frontend)
1. **Backend (mail-service):**
- `MailController` implementiert (`/api/mail/nennung`).
- REST-Endpunkt zur direkten Aufnahme von Web-Nennungen (Bypass für Polling-Latenz).
- Automatische Bestätigungs-Mails an Reiter via Spring Mail.
- Nennungen werden direkt in der Nennungs-Tabelle persistiert.
2. **Frontend (nennung-feature):**
- `NennungRemoteRepository` (KMP) für Ktor-API-Calls erstellt.
- Ktor-Client Abhängigkeiten und Kotlin-Serialization integriert.
3. **Frontend (meldestelle-web):**
- `WebMainScreen` mit dem Remote-Repository verknüpft.
- Echte Datenübertragung statt bloßer Konsolenausgabe.
- Erfolgsscreen nach erfolgreichem API-Call.
## 🧐 QA-Status & Bekannte Themen
- [ ] **DI-Check:** Die Koin-Registrierung des `HttpClient` im `nennung-feature` zeigt in der IDE Typ-Inferenz-Probleme (wahrscheinlich KMP/Compose Compiler Sync-Thema). Muss beim Build final validiert werden.
- [ ] **CORS:** Im `MailController` auf `*` gesetzt für den Wasm-Prototyp. In Prod auf Domain einschränken.
## 🧹 Curator's Note
- Die `MASTER_ROADMAP` wurde aktualisiert.
- Der Fokus für die nächste Session liegt auf dem **Billing-Check** (Gebühren-Validierung für Neumarkt) und dem ersten **Probelauf des ZNS-Exports**.
**Abschluss:** Das "Biest" ist nun "online-fähig" für Neumarkt. 🚀

View File

@ -31,6 +31,7 @@ Deine Aufgaben:
5. Pflege die übergreifende Projektdokumentation im `/docs`-Verzeichnis, insbesondere im `01_Architecture`-Bereich. 5. Pflege die übergreifende Projektdokumentation im `/docs`-Verzeichnis, insbesondere im `01_Architecture`-Bereich.
6. **Handover:** Stelle Architekturentscheidungen nicht nur als Text, sondern auch als Diagramm (Mermaid/PlantUML) bereit. 6. **Handover:** Stelle Architekturentscheidungen nicht nur als Text, sondern auch als Diagramm (Mermaid/PlantUML) bereit.
7. Erstelle und pflege die MASTER ROADMAP. Du bist der "Hüter des Plans". Du delegierst Aufgaben an die spezialisierten Agenten (Backend, Frontend, DevOps, QA), führst sie aber nicht selbst aus, es sei denn, es betrifft direkt die Architektur oder das Build-System. 7. Erstelle und pflege die MASTER ROADMAP. Du bist der "Hüter des Plans". Du delegierst Aufgaben an die spezialisierten Agenten (Backend, Frontend, DevOps, QA), führst sie aber nicht selbst aus, es sei denn, es betrifft direkt die Architektur oder das Build-System.
8. **Bounded Context Awareness:** Stelle sicher, dass Änderungen immer einem der 6 SCS (Self-Contained Systems) zugeordnet sind und die Grenzen gewahrt bleiben.
Don't: Don't:
- Implementiere keine Business-Logik in Backend-Services (→ Backend Developer). - Implementiere keine Business-Logik in Backend-Services (→ Backend Developer).

View File

@ -0,0 +1,70 @@
# 🚀 Deployment Guide - Online-Nennung (Neumarkt 2026)
Dieser Guide beschreibt den Prozess zum Deployment des "Biest" Online-Nennung Stacks auf den Produktions-Server.
## 1. Voraussetzungen
- Docker & Docker Compose installiert.
- Zugriff auf den OEPS SMTP Server oder eine Alternative.
- Domain (z.B. `nennung.mo-code.at`) zeigt auf den Server.
## 2. Infrastruktur (Backend)
Der Stack wird über `dc-backend.yaml` gestartet.
### Umgebungsvariablen (`.env` Datei am Server)
Folgende Variablen müssen gesetzt sein:
```env
# Datenbank
POSTGRES_USER=pg-user
POSTGRES_PASSWORD=dein-geheimes-passwort
# SMTP (für Bestätigungs-Mails)
MAIL_SMTP_HOST=smtp.mo-code.at
MAIL_SMTP_PORT=587
MAIL_SMTP_USER=online-nennen@mo-code.at
MAIL_SMTP_PASSWORD=dein-smtp-passwort
```
### Starten
```bash
docker-compose -f dc-backend.yaml up -d mail-service postgres consul
```
## 3. Frontend (Wasm Web App)
Die Web-App kommuniziert direkt mit dem `mail-service`.
### Build
```bash
./gradlew :frontend:shells:meldestelle-web:wasmJsBrowserDistribution
```
Die Artefakte liegen in `frontend/shells/meldestelle-web/build/dist/wasmJs/productionExecutable/`.
### Konfiguration (Laufzeit)
In der `index.html` oder über ein vorgeschaltetes Nginx können globale Variablen gesetzt werden, um die Backend-URLs zu
steuern:
```html
<script>
window.MAIL_SERVICE_URL = "https://nennung.mo-code.at/api/mail";
window.API_BASE_URL = "https://nennung.mo-code.at/api";
</script>
```
## 4. Sicherheit & Härtung
- **CORS:** Der `MailController` ist aktuell für `localhost:8080` und `nennung.mo-code.at` freigeschaltet.
- **Reverse Proxy:** Es wird empfohlen, einen Nginx oder Traefik mit SSL (Let's Encrypt) vor den Stack zu schalten.
- **Mail-Absender:** Die Absender-Adresse ist im `MailController` hartcodiert auf `online-nennen@mo-code.at`. Dies
sollte bei Bedarf angepasst werden.
---
*Dokumentiert durch den Lead Architect am 15. April 2026.*

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@ -1,6 +1,8 @@
package at.mocode.frontend.core.network package at.mocode.frontend.core.network
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
expect object PlatformConfig { expect object PlatformConfig {
fun resolveApiBaseUrl(): String fun resolveApiBaseUrl(): String
fun resolveMailServiceUrl(): String
fun resolveKeycloakUrl(): String fun resolveKeycloakUrl(): String
} }

View File

@ -36,6 +36,18 @@ actual object PlatformConfig {
return fallbackUrl return fallbackUrl
} }
actual fun resolveMailServiceUrl(): String {
val fromGlobal = try {
(globalScope.MAIL_SERVICE_URL as? String)?.trim().orEmpty()
} catch (_: dynamic) {
""
}
if (fromGlobal.isNotEmpty()) {
return fromGlobal.removeSuffix("/")
}
return "http://localhost:8085"
}
actual fun resolveKeycloakUrl(): String { actual fun resolveKeycloakUrl(): String {
// 1) Prefer a global JS variable (injected by main.kt via AppConfig) // 1) Prefer a global JS variable (injected by main.kt via AppConfig)
val fromGlobal = try { val fromGlobal = try {

View File

@ -10,6 +10,12 @@ actual object PlatformConfig {
return "http://localhost:8081" return "http://localhost:8081"
} }
actual fun resolveMailServiceUrl(): String {
val env = System.getenv("MAIL_SERVICE_URL")?.trim().orEmpty()
if (env.isNotEmpty()) return env.removeSuffix("/")
return "http://localhost:8085"
}
actual fun resolveKeycloakUrl(): String { actual fun resolveKeycloakUrl(): String {
val env = System.getenv("KEYCLOAK_URL")?.trim().orEmpty() val env = System.getenv("KEYCLOAK_URL")?.trim().orEmpty()
if (env.isNotEmpty()) return env.removeSuffix("/") if (env.isNotEmpty()) return env.removeSuffix("/")

View File

@ -6,6 +6,12 @@ package at.mocode.frontend.core.network
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING") @Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
actual object PlatformConfig { actual object PlatformConfig {
actual fun resolveMailServiceUrl(): String {
val fromGlobal = getGlobalMailServiceUrl()
if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/")
return "http://localhost:8085"
}
actual fun resolveKeycloakUrl(): String { actual fun resolveKeycloakUrl(): String {
val fromGlobal = getGlobalKeycloakUrl() val fromGlobal = getGlobalKeycloakUrl()
if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/") if (fromGlobal.isNotEmpty()) return fromGlobal.removeSuffix("/")
@ -22,7 +28,7 @@ actual object PlatformConfig {
// 2) Try window location origin (same origin gateway/proxy setup) // 2) Try window location origin (same origin gateway/proxy setup)
val origin = try { val origin = try {
getOrigin() getOrigin()
} catch (e: Throwable) { } catch (_: Throwable) {
null null
} }
@ -47,6 +53,16 @@ private fun getGlobalApiBaseUrl(): String = js(
""" """
) )
@OptIn(ExperimentalWasmJsInterop::class)
private fun getGlobalMailServiceUrl(): String = js(
"""
(function() {
var global = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : (typeof self !== 'undefined' ? self : {}));
return (global.MAIL_SERVICE_URL && typeof global.MAIL_SERVICE_URL === 'string') ? global.MAIL_SERVICE_URL : "";
})()
"""
)
@OptIn(ExperimentalWasmJsInterop::class) @OptIn(ExperimentalWasmJsInterop::class)
private fun getGlobalKeycloakUrl(): String = js( private fun getGlobalKeycloakUrl(): String = js(
""" """

View File

@ -10,6 +10,7 @@ plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
} }
group = "at.mocode.clients" group = "at.mocode.clients"
@ -40,6 +41,7 @@ kotlin {
commonMain.dependencies { commonMain.dependencies {
implementation(projects.frontend.core.designSystem) implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain) implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.network)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(compose.foundation) implementation(compose.foundation)
@ -54,6 +56,8 @@ kotlin {
implementation(libs.koin.core) implementation(libs.koin.core)
implementation(libs.koin.compose) implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel) implementation(libs.koin.compose.viewmodel)
implementation(libs.bundles.ktor.client.common)
} }
jvmMain.dependencies { jvmMain.dependencies {

View File

@ -1,9 +1,12 @@
package at.mocode.frontend.features.nennung.di package at.mocode.frontend.features.nennung.di
import at.mocode.frontend.features.nennung.domain.NennungRemoteRepository
import at.mocode.frontend.features.nennung.presentation.NennungViewModel import at.mocode.frontend.features.nennung.presentation.NennungViewModel
import io.ktor.client.HttpClient
import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module import org.koin.dsl.module
val nennungFeatureModule = module { val nennungFeatureModule = module {
single<NennungRemoteRepository> { NennungRemoteRepository(get<HttpClient>()) }
viewModel { NennungViewModel() } viewModel { NennungViewModel() }
} }

View File

@ -0,0 +1,52 @@
package at.mocode.frontend.features.nennung.domain
import at.mocode.frontend.core.network.PlatformConfig
import at.mocode.frontend.features.nennung.presentation.web.NennungPayload
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
@Serializable
data class NennungApiRequest(
val turnierNr: String,
val vorname: String,
val nachname: String,
val lizenz: String,
val pferdName: String,
val pferdAlter: String,
val email: String,
val telefon: String?,
val bewerbe: String,
val bemerkungen: String?
)
class NennungRemoteRepository(private val client: HttpClient) {
private val mailServiceUrl = PlatformConfig.resolveMailServiceUrl()
suspend fun sendeNennung(turnierNr: String, payload: NennungPayload): Result<Unit> {
return try {
val request = NennungApiRequest(
turnierNr = turnierNr,
vorname = payload.vorname,
nachname = payload.nachname,
lizenz = payload.lizenz,
pferdName = payload.pferdName,
pferdAlter = payload.pferdAlter,
email = payload.email,
telefon = payload.telefon,
bewerbe = payload.bewerbe.joinToString(", ") { it.nr.toString() },
bemerkungen = payload.bemerkungen
)
// Wir senden an den mail-service (URL dynamisch aufgelöst)
client.post("$mailServiceUrl/api/mail/nennung") {
contentType(ContentType.Application.Json)
setBody(request)
}
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@ -761,7 +761,8 @@ private fun VerkaufTabInhalt(artikel: List<VerkaufArtikel>, onMengeChanged: (Ver
IconButton(onClick = { onMengeChanged(art, -1) }, modifier = Modifier.size(20.dp)) { IconButton(onClick = { onMengeChanged(art, -1) }, modifier = Modifier.size(20.dp)) {
Icon(Icons.Default.Remove, contentDescription = "", modifier = Modifier.size(12.dp)) Icon(Icons.Default.Remove, contentDescription = "", modifier = Modifier.size(12.dp))
} }
Text(art.buchungstext, Text(
art.buchungstext,
fontSize = 10.sp, fontSize = 10.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
maxLines = 1, maxLines = 1,

View File

@ -49,7 +49,8 @@ fun StammdatenTabContent(
var znsDataLoaded by remember { mutableStateOf(false) } var znsDataLoaded by remember { mutableStateOf(false) }
var znsPayloadVersion by remember { mutableStateOf<String?>(null) } var znsPayloadVersion by remember { mutableStateOf<String?>(null) }
var znsImportedAt by remember { mutableStateOf<String?>(null) } var znsImportedAt by remember { mutableStateOf<String?>(null) }
val znsImportHistory = remember { mutableStateListOf<Triple<String, String, Boolean>>() } // (source, payloadVersion, ok) val znsImportHistory =
remember { mutableStateListOf<Triple<String, String, Boolean>>() } // (source, payloadVersion, ok)
var typ by remember { mutableStateOf("ÖTO (National)") } var typ by remember { mutableStateOf("ÖTO (National)") }
val sparten = remember { mutableStateListOf<String>() } val sparten = remember { mutableStateListOf<String>() }
@ -63,10 +64,10 @@ fun StammdatenTabContent(
var titel by remember { mutableStateOf("") } var titel by remember { mutableStateOf("") }
var subTitel by remember { mutableStateOf("") } var subTitel by remember { mutableStateOf("") }
// Initialisierung aus Mock-Store (StoreV2/TurnierStoreV2) falls vorhanden // Initialisierung aus Mock-Store (`StoreV2/TurnierStoreV2`) falls vorhanden
LaunchedEffect(turnierId) { LaunchedEffect(turnierId) {
// Da wir in einem anderen Modul sind, können wir nicht direkt auf StoreV2 zugreifen // Da wir in einem anderen Modul sind, können wir nicht direkt auf StoreV2 zugreifen,
// ohne die Abhängigkeit zu haben. In einer echten Architektur käme dies über das Repository. // ohne die Abhängigkeit zu haben. In einer echten Architektur kommt dies über das Repository.
// Aber für die Demo/Fakten-Präsentation im Desktop-Shell-Kontext: // Aber für die Demo/Fakten-Präsentation im Desktop-Shell-Kontext:
try { try {
val clazz = Class.forName("at.mocode.desktop.v2.TurnierStoreV2") val clazz = Class.forName("at.mocode.desktop.v2.TurnierStoreV2")
@ -76,37 +77,39 @@ fun StammdatenTabContent(
val idField = t!!::class.java.getDeclaredField("turnierNr") val idField = t!!::class.java.getDeclaredField("turnierNr")
idField.isAccessible = true idField.isAccessible = true
idField.get(t).toString() == turnierId.toString() || idField.get(t).toString() == turnierId.toString() ||
t.hashCode().toLong() == turnierId // Fallback falls ID anders gemappt ist t.hashCode().toLong() == turnierId // Fallback, falls die ID anders gemappt ist
} }
if (turnier != null) { when {
val tClass = turnier::class.java turnier != null -> {
val tClass = turnier::class.java
val nrField = tClass.getDeclaredField("turnierNr") val nrField = tClass.getDeclaredField("turnierNr")
nrField.isAccessible = true nrField.isAccessible = true
turnierNr = nrField.get(turnier).toString() turnierNr = nrField.get(turnier).toString()
nrConfirmed = true nrConfirmed = true
val titelField = tClass.getDeclaredField("titel") val titelField = tClass.getDeclaredField("titel")
titelField.isAccessible = true titelField.isAccessible = true
titel = titelField.get(turnier) as String titel = titelField.get(turnier) as String
val subField = tClass.getDeclaredField("subTitel") val subField = tClass.getDeclaredField("subTitel")
subField.isAccessible = true subField.isAccessible = true
subTitel = subField.get(turnier) as String subTitel = subField.get(turnier) as String
val katField = tClass.getDeclaredField("kategorie") val katField = tClass.getDeclaredField("kategorie")
katField.isAccessible = true katField.isAccessible = true
val kats = katField.get(turnier) as? List<String> val kats = katField.get(turnier) as? List<String>
kats?.let { kat.addAll(it) } kats?.let { kat.addAll(it) }
val typField = tClass.getDeclaredField("typ") val typField = tClass.getDeclaredField("typ")
typField.isAccessible = true typField.isAccessible = true
typ = typField.get(turnier) as String typ = typField.get(turnier) as String
val znsField = tClass.getDeclaredField("znsDataLoaded") val znsField = tClass.getDeclaredField("znsDataLoaded")
znsField.isAccessible = true znsField.isAccessible = true
znsDataLoaded = znsField.get(turnier) as Boolean znsDataLoaded = znsField.get(turnier) as Boolean
}
} }
} catch (_: Exception) { } catch (_: Exception) {
// Reflection fehlgeschlagen oder Store nicht erreichbar -> Fallback auf leere Felder // Reflection fehlgeschlagen oder Store nicht erreichbar -> Fallback auf leere Felder
@ -118,7 +121,7 @@ fun StammdatenTabContent(
var showZnsDialog by remember { mutableStateOf(false) } var showZnsDialog by remember { mutableStateOf(false) }
var showZnsLog by remember { mutableStateOf(false) } var showZnsLog by remember { mutableStateOf(false) }
// Hilfs-States für DatePicker // Hilf's-States für DatePicker
var showDatePickerVon by remember { mutableStateOf(false) } var showDatePickerVon by remember { mutableStateOf(false) }
var showDatePickerBis by remember { mutableStateOf(false) } var showDatePickerBis by remember { mutableStateOf(false) }
@ -143,29 +146,35 @@ fun StammdatenTabContent(
singleLine = true, singleLine = true,
enabled = !nrConfirmed enabled = !nrConfirmed
) )
if (!nrConfirmed) { when {
Button( !nrConfirmed -> {
onClick = { showNrConfirm = true }, Button(
enabled = turnierNr.length == 5, onClick = { showNrConfirm = true },
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue) enabled = turnierNr.length == 5,
) { colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue)
Text("Bestätigen") ) {
Text("Bestätigen")
}
}
else -> {
InputChip(
selected = true,
onClick = { },
label = { Text("Bestätigt") },
trailingIcon = { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
)
} }
} else {
InputChip(
selected = true,
onClick = { },
label = { Text("Bestätigt") },
trailingIcon = { Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
)
} }
} }
if (turnierNr.length == 5 && !nrConfirmed) { when (turnierNr.length) {
Text( 5 if !nrConfirmed -> {
"Bitte Turnier-Nummer bestätigen um fortzufahren.", Text(
color = MaterialTheme.colorScheme.error, "Bitte Turnier-Nummer bestätigen um fortzufahren.",
fontSize = 11.sp color = MaterialTheme.colorScheme.error,
) fontSize = 11.sp
)
}
} }
} }
@ -190,8 +199,7 @@ fun StammdatenTabContent(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Button( Button(
onClick = { showZnsDialog = true }, onClick = { showZnsDialog = true },
colors = ButtonDefaults.buttonColors(containerColor = AccentBlue) colors = ButtonDefaults.buttonColors(containerColor = AccentBlue), enabled = nrConfirmed
, enabled = nrConfirmed
) { ) {
Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp)) Icon(Icons.Default.CloudDownload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
@ -254,7 +262,7 @@ fun StammdatenTabContent(
} }
FormRow("Klasse:") { FormRow("Klasse:") {
val klassenListe = listOf("C-NEU", "C", "B", "A", "L", "LM", "M", "S") val klassenListe = listOf("C-NEU", "C", "B", "A")
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
klassenListe.forEach { k -> klassenListe.forEach { k ->
FilterChip( FilterChip(
@ -278,32 +286,37 @@ fun StammdatenTabContent(
} }
} }
if (suggested.isEmpty()) { when {
Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp) suggested.isEmpty() -> {
} else { Text("Bitte Sparte und Klasse wählen", color = Color.Gray, fontSize = 13.sp)
// Gruppiere nach Sparte (CDN/CSN) }
val grouped = suggested.groupBy { if (it.startsWith("CDN")) "Dressur" else "Springen" }
grouped.forEach { (gruppe, eintraege) -> else -> {
Text(gruppe, fontWeight = FontWeight.SemiBold, color = PrimaryBlue) // Gruppiere nach Sparte (CDN/CSN)
Spacer(Modifier.height(4.dp)) val grouped = suggested.groupBy { if (it.startsWith("CDN")) "Dressur" else "Springen" }
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { grouped.forEach { (gruppe, eintraege) ->
eintraege.sorted().forEach { c -> Text(gruppe, fontWeight = FontWeight.SemiBold, color = PrimaryBlue)
InputChip( Spacer(Modifier.height(4.dp))
selected = kat.contains(c), FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) }, eintraege.sorted().forEach { c ->
enabled = nrConfirmed, InputChip(
label = { Text(c) } selected = kat.contains(c),
) onClick = { if (kat.contains(c)) kat.remove(c) else kat.add(c) },
enabled = nrConfirmed,
label = { Text(c) }
)
}
} }
Spacer(Modifier.height(8.dp))
} }
Spacer(Modifier.height(8.dp))
} }
} }
} }
FormRow("Zeitraum:") { FormRow("Zeitraum:") {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
val vonMod = if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerVon = true } else Modifier.width(160.dp) val vonMod =
if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerVon = true } else Modifier.width(160.dp)
OutlinedTextField( OutlinedTextField(
value = von, value = von,
onValueChange = {}, onValueChange = {},
@ -314,7 +327,8 @@ fun StammdatenTabContent(
trailingIcon = { Icon(Icons.Default.DateRange, null) } trailingIcon = { Icon(Icons.Default.DateRange, null) }
) )
Text("bis") Text("bis")
val bisMod = if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerBis = true } else Modifier.width(160.dp) val bisMod =
if (nrConfirmed) Modifier.width(160.dp).clickable { showDatePickerBis = true } else Modifier.width(160.dp)
OutlinedTextField( OutlinedTextField(
value = bis, value = bis,
onValueChange = {}, onValueChange = {},
@ -325,7 +339,8 @@ fun StammdatenTabContent(
trailingIcon = { Icon(Icons.Default.DateRange, null) } trailingIcon = { Icon(Icons.Default.DateRange, null) }
) )
} }
val rangeText = if (eventVon != null && eventBis != null) "Muss zwischen $eventVon $eventBis liegen." else "Muss innerhalb des Veranstaltungs-Zeitraums liegen." val rangeText =
if (eventVon != null && eventBis != null) "Muss zwischen $eventVon $eventBis liegen." else "Muss innerhalb des Veranstaltungs-Zeitraums liegen."
Text(rangeText, fontSize = 11.sp, color = Color.Gray) Text(rangeText, fontSize = 11.sp, color = Color.Gray)
} }
} }
@ -335,7 +350,8 @@ fun StammdatenTabContent(
// Default-Titel-Vorschlag: [Kategorien] [Verein-Ort] [Bundesland] // Default-Titel-Vorschlag: [Kategorien] [Verein-Ort] [Bundesland]
val defaultTitle = remember(kat.size, veranstalterOrt, veranstalterBundesland) { val defaultTitle = remember(kat.size, veranstalterOrt, veranstalterBundesland) {
val cats = if (kat.isEmpty()) "" else kat.sorted().joinToString(" ") val cats = if (kat.isEmpty()) "" else kat.sorted().joinToString(" ")
listOfNotNull(cats.ifBlank { null }, listOfNotNull(
cats.ifBlank { null },
listOfNotNull(veranstalterOrt, veranstalterBundesland).filter { it.isNotBlank() }.joinToString(" ") listOfNotNull(veranstalterOrt, veranstalterBundesland).filter { it.isNotBlank() }.joinToString(" ")
.takeIf { it.isNotBlank() } .takeIf { it.isNotBlank() }
).joinToString(" ") ).joinToString(" ")
@ -367,7 +383,12 @@ fun StammdatenTabContent(
supportingText = { supportingText = {
if (eventOrt != null && ort.isNotBlank() && ort.trim() != eventOrt.trim()) { if (eventOrt != null && ort.isNotBlank() && ort.trim() != eventOrt.trim()) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Warning, contentDescription = null, tint = Color(0xFFF59E0B), modifier = Modifier.size(14.dp)) Icon(
Icons.Default.Warning,
contentDescription = null,
tint = Color(0xFFF59E0B),
modifier = Modifier.size(14.dp)
)
Spacer(Modifier.width(4.dp)) Spacer(Modifier.width(4.dp))
Text("Abweichung zum Veranstaltungsort ($eventOrt) bitte prüfen.", color = Color(0xFFF59E0B)) Text("Abweichung zum Veranstaltungsort ($eventOrt) bitte prüfen.", color = Color(0xFFF59E0B))
} }
@ -409,14 +430,26 @@ fun StammdatenTabContent(
} }
// ── Footer ────────────────────────────────────────────────────────── // ── Footer ──────────────────────────────────────────────────────────
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Save-Enable-Matrix (kleine Checkliste) // Save-Enable-Matrix (kleine Checkliste)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
AssistChip(onClick = {}, label = { Text("Nr bestätigt") }, leadingIcon = { AssistChip(onClick = {}, label = { Text("Nr bestätigt") }, leadingIcon = {
Icon(if (nrConfirmed) Icons.Default.Check else Icons.Default.Close, null, tint = if (nrConfirmed) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error) Icon(
if (nrConfirmed) Icons.Default.Check else Icons.Default.Close,
null,
tint = if (nrConfirmed) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
)
}) })
AssistChip(onClick = {}, label = { Text("ZNS geladen") }, leadingIcon = { AssistChip(onClick = {}, label = { Text("ZNS geladen") }, leadingIcon = {
Icon(if (znsDataLoaded) Icons.Default.Check else Icons.Default.Close, null, tint = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error) Icon(
if (znsDataLoaded) Icons.Default.Check else Icons.Default.Close,
null,
tint = if (znsDataLoaded) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
)
}) })
val dateOk = remember(von, bis, eventVon, eventBis) { val dateOk = remember(von, bis, eventVon, eventBis) {
try { try {
@ -427,10 +460,16 @@ fun StammdatenTabContent(
val tB = if (bis.isBlank()) tV else LocalDate.parse(bis) val tB = if (bis.isBlank()) tV else LocalDate.parse(bis)
!tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV) !tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV)
} }
} catch (_: Exception) { false } } catch (_: Exception) {
false
}
} }
AssistChip(onClick = {}, label = { Text("Datum gültig") }, leadingIcon = { AssistChip(onClick = {}, label = { Text("Datum gültig") }, leadingIcon = {
Icon(if (dateOk) Icons.Default.Check else Icons.Default.Close, null, tint = if (dateOk) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error) Icon(
if (dateOk) Icons.Default.Check else Icons.Default.Close,
null,
tint = if (dateOk) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
)
}) })
} }
@ -446,7 +485,9 @@ fun StammdatenTabContent(
val tB = if (bis.isBlank()) tV else LocalDate.parse(bis) val tB = if (bis.isBlank()) tV else LocalDate.parse(bis)
!tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV) !tV.isBefore(evV) && !tB.isAfter(evB) && !tB.isBefore(tV)
} }
} catch (_: Exception) { false } } catch (_: Exception) {
false
}
base && dateValid base && dateValid
}, },
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue), colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
@ -460,88 +501,96 @@ fun StammdatenTabContent(
} }
// Dialog-Simulationen // Dialog-Simulationen
if (showZnsDialog) { when {
AlertDialog( showZnsDialog -> {
onDismissRequest = { showZnsDialog = false }, AlertDialog(
title = { Text("ZNS Import") }, onDismissRequest = { showZnsDialog = false },
text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") }, title = { Text("ZNS Import") },
confirmButton = { text = { Text("Simuliere ZNS-Stammdaten Import für Turnier #$turnierNr...") },
TextButton(onClick = { confirmButton = {
znsDataLoaded = true TextButton(onClick = {
znsPayloadVersion = "v2.4" znsDataLoaded = true
znsImportedAt = java.time.Instant.now().toString() znsPayloadVersion = "v2.4"
znsImportHistory.add(Triple("Internet/USB", znsPayloadVersion!!, true)) znsImportedAt = java.time.Instant.now().toString()
showZnsDialog = false znsImportHistory.add(Triple("Internet/USB", znsPayloadVersion!!, true))
}) { Text("Importieren") } showZnsDialog = false
}, }) { Text("Importieren") }
dismissButton = { },
TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") } dismissButton = {
} TextButton(onClick = { showZnsDialog = false }) { Text("Abbrechen") }
) }
)
}
} }
if (showNrConfirm) { when {
AlertDialog( showNrConfirm -> {
onDismissRequest = { showNrConfirm = false }, AlertDialog(
title = { Text("Turnier-Nummer bestätigen?") }, onDismissRequest = { showNrConfirm = false },
text = { Text("Die Turnier-Nr. ist nach der Bestätigung nicht mehr änderbar.") }, title = { Text("Turnier-Nummer bestätigen?") },
confirmButton = { text = { Text("Die Turnier-Nr. ist nach der Bestätigung nicht mehr änderbar.") },
TextButton(onClick = { nrConfirmed = true; showNrConfirm = false }) { Text("Ja, bestätigen") } confirmButton = {
}, TextButton(onClick = { nrConfirmed = true; showNrConfirm = false }) { Text("Ja, bestätigen") }
dismissButton = { },
TextButton(onClick = { showNrConfirm = false }) { Text("Abbrechen") } dismissButton = {
} TextButton(onClick = { showNrConfirm = false }) { Text("Abbrechen") }
) }
)
}
} }
if (showZnsLog) { when {
AlertDialog( showZnsLog -> {
onDismissRequest = { showZnsLog = false }, AlertDialog(
title = { Text("ZNS Import-Log (letzte 5)") }, onDismissRequest = { showZnsLog = false },
text = { title = { Text("ZNS Import-Log (letzte 5)") },
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { text = {
if (znsImportHistory.isEmpty()) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Keine Einträge vorhanden.", color = Color.Gray) if (znsImportHistory.isEmpty()) {
} else { Text("Keine Einträge vorhanden.", color = Color.Gray)
znsImportHistory.takeLast(5).asReversed().forEach { (src, ver, ok) -> } else {
val c = if (ok) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error znsImportHistory.takeLast(5).asReversed().forEach { (src, ver, ok) ->
Text("$src Version $ver ${if (ok) "OK" else "Fehler"}", color = c, fontSize = 13.sp) val c = if (ok) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error
Text("$src Version $ver ${if (ok) "OK" else "Fehler"}", color = c, fontSize = 13.sp)
}
} }
} }
},
confirmButton = { TextButton(onClick = { showZnsLog = false }) { Text("Schließen") } }
)
}
}
when {
showDatePickerVon -> {
val state = rememberDatePickerState()
DatePickerDialog(
onDismissRequest = { showDatePickerVon = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let {
von = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
}
showDatePickerVon = false
}) { Text("OK") }
} }
}, ) { DatePicker(state) }
confirmButton = { TextButton(onClick = { showZnsLog = false }) { Text("Schließen") } } }
)
}
if (showDatePickerVon) { showDatePickerBis -> {
val state = rememberDatePickerState() val state = rememberDatePickerState()
DatePickerDialog( DatePickerDialog(
onDismissRequest = { showDatePickerVon = false }, onDismissRequest = { showDatePickerBis = false },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
state.selectedDateMillis?.let { state.selectedDateMillis?.let {
von = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString() bis = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
} }
showDatePickerVon = false showDatePickerBis = false
}) { Text("OK") } }) { Text("OK") }
} }
) { DatePicker(state) } ) { DatePicker(state) }
} }
if (showDatePickerBis) {
val state = rememberDatePickerState()
DatePickerDialog(
onDismissRequest = { showDatePickerBis = false },
confirmButton = {
TextButton(onClick = {
state.selectedDateMillis?.let {
bis = LocalDate.ofEpochDay(it / (24 * 60 * 60 * 1000)).toString()
}
showDatePickerBis = false
}) { Text("OK") }
}
) { DatePicker(state) }
} }
} }

View File

@ -0,0 +1,7 @@
{
"geraetName": "Meldestelle",
"sharedKey": "Meldestelle",
"backupPath": "/home/stefan/WsMeldestelle/Meldestelle/meldestelle/docs/temp",
"networkRole": "MASTER",
"syncInterval": 20
}

View File

@ -10,6 +10,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import at.mocode.desktop.navigation.DesktopNavigationPort import at.mocode.desktop.navigation.DesktopNavigationPort
import at.mocode.desktop.screens.layout.DesktopMainLayout import at.mocode.desktop.screens.layout.DesktopMainLayout
import at.mocode.desktop.screens.onboarding.SettingsManager
import at.mocode.frontend.core.auth.data.AuthTokenManager import at.mocode.frontend.core.auth.data.AuthTokenManager
import at.mocode.frontend.core.auth.presentation.LoginScreen import at.mocode.frontend.core.auth.presentation.LoginScreen
import at.mocode.frontend.core.auth.presentation.LoginViewModel import at.mocode.frontend.core.auth.presentation.LoginViewModel
@ -34,6 +35,13 @@ fun DesktopApp() {
val currentScreen by nav.currentScreen.collectAsState() val currentScreen by nav.currentScreen.collectAsState()
val loginViewModel: LoginViewModel = koinViewModel() val loginViewModel: LoginViewModel = koinViewModel()
// Onboarding-Check beim Start
LaunchedEffect(Unit) {
if (!SettingsManager.isConfigured()) {
nav.navigateToScreen(AppScreen.Onboarding)
}
}
val authState by authTokenManager.authState.collectAsState() val authState by authTokenManager.authState.collectAsState()
// Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt // Login-Gate: Nicht-authentifizierte Screens → Login, außer Onboarding ist erlaubt

View File

@ -4,11 +4,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.* import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -16,6 +16,8 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import at.mocode.desktop.screens.onboarding.OnboardingSettings
import at.mocode.desktop.screens.onboarding.SettingsManager
import at.mocode.frontend.core.designsystem.theme.AppColors import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.core.designsystem.theme.Dimens import at.mocode.frontend.core.designsystem.theme.Dimens
import at.mocode.frontend.core.navigation.AppScreen import at.mocode.frontend.core.navigation.AppScreen
@ -60,9 +62,8 @@ fun DesktopMainLayout(
onBack: () -> Unit, onBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
) { ) {
// Onboarding-Eingaben zwischen Navigationswechseln behalten // Onboarding-Daten (On-the-fly geladen oder Default)
var obGeraet by rememberSaveable { mutableStateOf("") } var onboardingSettings by remember { mutableStateOf(SettingsManager.loadSettings() ?: OnboardingSettings()) }
var obKey by rememberSaveable { mutableStateOf("") }
Row(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Row(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
// Navigation Rail (Modernere Seitenleiste) // Navigation Rail (Modernere Seitenleiste)
@ -84,10 +85,8 @@ fun DesktopMainLayout(
currentScreen = currentScreen, currentScreen = currentScreen,
onNavigate = onNavigate, onNavigate = onNavigate,
onBack = onBack, onBack = onBack,
obGeraet = obGeraet, onSettingsChange = { onboardingSettings = it },
obKey = obKey, settings = onboardingSettings,
onObGeraetChange = { obGeraet = it },
onObKeyChange = { obKey = it },
) )
} }
@ -151,15 +150,25 @@ private fun DesktopNavRail(
) )
NavRailItem( NavRailItem(
icon = Icons.Default.Settings, icon = Icons.Default.WifiTethering,
label = "Tools", label = "Sync",
selected = currentScreen is AppScreen.Ping, selected = currentScreen is AppScreen.Ping,
onClick = { onNavigate(AppScreen.Ping) } onClick = { onNavigate(AppScreen.Ping) }
) )
Spacer(Modifier.weight(1f))
NavRailItem(
icon = Icons.Default.AppRegistration,
label = "Setup",
selected = currentScreen is AppScreen.Onboarding,
onClick = { onNavigate(AppScreen.Onboarding) }
)
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun NavRailItem( private fun NavRailItem(
icon: ImageVector, icon: ImageVector,
@ -170,23 +179,35 @@ private fun NavRailItem(
val tint = if (selected) MaterialTheme.colorScheme.primary else AppColors.NavigationContent val tint = if (selected) MaterialTheme.colorScheme.primary else AppColors.NavigationContent
val background = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else Color.Transparent val background = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) else Color.Transparent
Surface( TooltipBox(
modifier = Modifier positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
.size(48.dp) positioning = TooltipAnchorPosition.Right
.clickable(onClick = onClick), ),
shape = MaterialTheme.shapes.medium, tooltip = {
color = background PlainTooltip {
Text(label)
}
},
state = rememberTooltipState()
) { ) {
Column( Surface(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier
verticalArrangement = Arrangement.Center .size(48.dp)
.clickable(onClick = onClick),
shape = MaterialTheme.shapes.medium,
color = background
) { ) {
Icon( Column(
imageVector = icon, horizontalAlignment = Alignment.CenterHorizontally,
contentDescription = label, verticalArrangement = Arrangement.Center
tint = tint, ) {
modifier = Modifier.size(Dimens.IconSizeM) Icon(
) imageVector = icon,
contentDescription = label,
tint = tint,
modifier = Modifier.size(Dimens.IconSizeM)
)
}
} }
} }
} }
@ -475,28 +496,20 @@ private fun DesktopContentArea(
currentScreen: AppScreen, currentScreen: AppScreen,
onNavigate: (AppScreen) -> Unit, onNavigate: (AppScreen) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
obGeraet: String, settings: OnboardingSettings,
obKey: String, onSettingsChange: (OnboardingSettings) -> Unit,
onObGeraetChange: (String) -> Unit,
onObKeyChange: (String) -> Unit,
) { ) {
when (currentScreen) { when (currentScreen) {
// Onboarding ohne Login // Onboarding (Geräte-Setup)
is AppScreen.Onboarding -> { is AppScreen.Onboarding -> {
val authTokenManager: at.mocode.frontend.core.auth.data.AuthTokenManager = koinInject() at.mocode.desktop.v2.OnboardingScreen(
at.mocode.frontend.core.designsystem.theme.AppTheme { settings = settings,
Surface(color = MaterialTheme.colorScheme.background) { onSettingsChange = onSettingsChange,
at.mocode.desktop.v2.OnboardingScreen( onContinue = { finalSettings ->
geraetName = obGeraet, SettingsManager.saveSettings(finalSettings)
secureKey = obKey, onNavigate(AppScreen.VeranstaltungVerwaltung)
onGeraetNameChange = onObGeraetChange,
onSecureKeyChange = onObKeyChange,
) { _, _ ->
authTokenManager.setToken("dummy.jwt.token")
onNavigate(AppScreen.VeranstaltungVerwaltung)
}
} }
} )
} }
// Haupt-Zentrale: Veranstaltung-Verwaltung // Haupt-Zentrale: Veranstaltung-Verwaltung

View File

@ -1,93 +0,0 @@
package at.mocode.desktop.screens.onboarding
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
enum class ZnsStatus { NONE, LOCAL, SYNCED }
@Composable
fun OnboardingScreen(
initialName: String = "",
initialKey: String = "",
initialZns: ZnsStatus = ZnsStatus.NONE,
onZnsSync: () -> Unit = {},
onZnsUsb: () -> Unit = {},
onContinue: (geraetName: String, sharedKey: String, znsStatus: ZnsStatus) -> Unit,
) {
var geraetName by rememberSaveable { mutableStateOf(initialName) }
var sharedKey by rememberSaveable { mutableStateOf(initialKey) }
var znsStatus by rememberSaveable { mutableStateOf(initialZns) }
var showPassword by remember { mutableStateOf(false) }
val nameValid = OnboardingValidator.isNameValid(geraetName)
val keyValid = OnboardingValidator.isKeyValid(sharedKey)
val canContinue = OnboardingValidator.canContinue(geraetName, sharedKey)
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Onboarding", style = MaterialTheme.typography.headlineSmall)
Card {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Gerätename (Pflicht)", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = geraetName,
onValueChange = { geraetName = it },
placeholder = { Text("z. B. Meldestelle") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = !nameValid && geraetName.isNotBlank()
)
Text("Sicherheitsschlüssel (Pflicht)", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = sharedKey,
onValueChange = { sharedKey = it },
placeholder = { Text("z. B. Neumarkt2026") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = !keyValid && sharedKey.isNotBlank(),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
val label = if (showPassword) "Verbergen" else "Anzeigen"
TextButton(onClick = { showPassword = !showPassword }) {
Text(label)
}
}
)
Text("ZNS-Daten (optional)", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = {
znsStatus = ZnsStatus.SYNCED
onZnsSync()
}, label = { Text("Aktualisieren") })
AssistChip(onClick = {
znsStatus = ZnsStatus.LOCAL
onZnsUsb()
}, label = { Text("USB-Import") })
Spacer(Modifier.width(8.dp))
Text("Status: $znsStatus")
}
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { onContinue(geraetName.trim(), sharedKey.trim(), znsStatus) }, enabled = canContinue) {
Text("Weiter zu den Veranstaltungen")
}
if (!canContinue) {
Text("Bitte Gerätename (min. 3) und Schlüssel (min. 8) angeben.", color = MaterialTheme.colorScheme.error)
}
}
}
}

View File

@ -0,0 +1,19 @@
package at.mocode.desktop.screens.onboarding
import kotlinx.serialization.Serializable
@Serializable
enum class NetworkRole {
MASTER,
CLIENT
}
@Serializable
data class OnboardingSettings(
val geraetName: String = "",
val sharedKey: String = "",
val backupPath: String = "",
val networkRole: NetworkRole = NetworkRole.CLIENT,
val syncInterval: Int = 30, // in Minuten
val defaultPrinter: String = ""
)

View File

@ -7,6 +7,8 @@ package at.mocode.desktop.screens.onboarding
* Regeln gemäß Onboarding-Spezifikation: * Regeln gemäß Onboarding-Spezifikation:
* - Gerätename: mindestens 3 Zeichen (nach trim) * - Gerätename: mindestens 3 Zeichen (nach trim)
* - Sicherheitsschlüssel: mindestens 8 Zeichen (nach trim) * - Sicherheitsschlüssel: mindestens 8 Zeichen (nach trim)
* - Backup-Pfad: darf nicht leer sein und muss existieren (Prüfung optional hier)
* - Sync-Intervall: zwischen 1 und 60 Minuten
*/ */
object OnboardingValidator { object OnboardingValidator {
@ -16,15 +18,28 @@ object OnboardingValidator {
/** Mindestlänge für den Sicherheitsschlüssel. */ /** Mindestlänge für den Sicherheitsschlüssel. */
const val MIN_KEY_LENGTH = 8 const val MIN_KEY_LENGTH = 8
/** Standard-Sync-Intervall in Minuten. */
const val DEFAULT_SYNC_INTERVAL = 30
/** Gibt `true` zurück, wenn der Gerätename gültig ist. */ /** Gibt `true` zurück, wenn der Gerätename gültig ist. */
fun isNameValid(name: String): Boolean = name.trim().length >= MIN_NAME_LENGTH fun isNameValid(name: String): Boolean = name.trim().length >= MIN_NAME_LENGTH
/** Gibt `true` zurück, wenn der Sicherheitsschlüssel gültig ist. */ /** Gibt `true` zurück, wenn der Sicherheitsschlüssel gültig ist. */
fun isKeyValid(key: String): Boolean = key.trim().length >= MIN_KEY_LENGTH fun isKeyValid(key: String): Boolean = key.trim().length >= MIN_KEY_LENGTH
/** Gibt `true` zurück, wenn der Backup-Pfad gültig ist. */
fun isBackupPathValid(path: String): Boolean = path.isNotBlank()
/** Gibt `true` zurück, wenn das Sync-Intervall gültig ist. */
fun isSyncIntervalValid(interval: Int): Boolean = interval in 1..60
/** /**
* Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und * Gibt `true` zurück, wenn alle Pflichtfelder gültig sind und
* der Weiter"-Button aktiviert werden darf. * der Weiter"-Button aktiviert werden darf.
*/ */
fun canContinue(name: String, key: String): Boolean = isNameValid(name) && isKeyValid(key) fun canContinue(settings: OnboardingSettings): Boolean =
isNameValid(settings.geraetName) &&
isKeyValid(settings.sharedKey) &&
isBackupPathValid(settings.backupPath) &&
isSyncIntervalValid(settings.syncInterval)
} }

View File

@ -0,0 +1,34 @@
package at.mocode.desktop.screens.onboarding
import kotlinx.serialization.json.Json
import java.io.File
object SettingsManager {
private val settingsFile = File("settings.json")
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
fun saveSettings(settings: OnboardingSettings) {
try {
val content = json.encodeToString(settings)
settingsFile.writeText(content)
} catch (e: Exception) {
println("Fehler beim Speichern der Einstellungen: ${e.message}")
}
}
fun loadSettings(): OnboardingSettings? {
if (!settingsFile.exists()) return null
return try {
val content = settingsFile.readText()
json.decodeFromString<OnboardingSettings>(content)
} catch (e: Exception) {
println("Fehler beim Laden der Einstellungen: ${e.message}")
null
}
}
fun isConfigured(): Boolean {
val settings = loadSettings() ?: return false
return OnboardingValidator.canContinue(settings)
}
}

View File

@ -5,136 +5,242 @@ 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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
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
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.*
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.desktop.screens.onboarding.NetworkRole
import at.mocode.desktop.screens.onboarding.OnboardingSettings
import at.mocode.desktop.screens.onboarding.OnboardingValidator
import at.mocode.frontend.core.designsystem.components.MsTextField import at.mocode.frontend.core.designsystem.components.MsTextField
import javax.print.PrintServiceLookup
import javax.swing.JFileChooser
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
geraetName: String, settings: OnboardingSettings,
secureKey: String, onSettingsChange: (OnboardingSettings) -> Unit,
onGeraetNameChange: (String) -> Unit, onContinue: (OnboardingSettings) -> Unit,
onSecureKeyChange: (String) -> Unit,
onContinue: (String, String) -> Unit,
) { ) {
DesktopThemeV2 { DesktopThemeV2 {
Surface(color = MaterialTheme.colorScheme.background) { Surface(color = MaterialTheme.colorScheme.background) {
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(
Text("Onboarding", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold) modifier = Modifier.fillMaxSize().padding(24.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"Willkommen beim Meldestelle-Biest",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold
)
Text(
"Bitte konfiguriere deine lokale Instanz (Geburtsurkunde).",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
var showPw by remember { mutableStateOf(false) } var showPw by remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val frName = remember { FocusRequester() }
val frKey = remember { FocusRequester() }
val frBtn = remember { FocusRequester() }
MsTextField( Card(modifier = Modifier.fillMaxWidth()) {
value = geraetName, Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
onValueChange = { onGeraetNameChange(it) }, Text("🛡️ Identität & Sicherheit", style = MaterialTheme.typography.titleMedium)
label = "Gerätename (Pflicht)",
modifier = Modifier
.fillMaxWidth()
.focusRequester(frName)
.onKeyEvent { e ->
if (e.type == KeyEventType.KeyUp) {
when (e.key) {
Key.Tab, Key.Enter -> {
focusManager.moveFocus(FocusDirection.Next)
true
}
else -> false
}
} else false
}
,
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
MsTextField(
value = secureKey,
onValueChange = { onSecureKeyChange(it) },
label = "Sicherheitsschlüssel (Pflicht)",
trailingIcon = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
onTrailingIconClick = { showPw = !showPw },
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier
.fillMaxWidth()
.focusRequester(frKey)
.onKeyEvent { e ->
if (e.type == KeyEventType.KeyUp) {
when (e.key) {
Key.Tab -> {
focusManager.moveFocus(FocusDirection.Next)
true
}
Key.Enter -> {
if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) {
onContinue(geraetName, secureKey)
} else {
focusManager.moveFocus(FocusDirection.Next)
}
true
}
else -> false
}
} else false
}
,
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(onDone = {
if (geraetName.trim().length >= 3 && secureKey.trim().length >= 8) {
onContinue(geraetName, secureKey)
} else {
focusManager.moveFocus(FocusDirection.Next)
}
})
)
val enabled = geraetName.trim().length >= 3 && secureKey.trim().length >= 8 MsTextField(
Button( value = settings.geraetName,
onClick = { onContinue(geraetName, secureKey) }, onValueChange = { onSettingsChange(settings.copy(geraetName = it)) },
enabled = enabled, label = "Gerätename (Pflicht)",
modifier = Modifier placeholder = "z. B. Meldestelle-PC-1",
.focusRequester(frBtn) modifier = Modifier.fillMaxWidth(),
.onKeyEvent { e -> imeAction = ImeAction.Next,
if (e.type == KeyEventType.KeyUp && (e.key == Key.Enter)) { keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
if (enabled) onContinue(geraetName, secureKey) )
true
} else false MsTextField(
} value = settings.sharedKey,
) { onValueChange = { onSettingsChange(settings.copy(sharedKey = it)) },
Text("Zu den Veranstaltungen") label = "Sicherheitsschlüssel (Pflicht)",
placeholder = "Shared Secret für Netzwerk-Sync",
trailingIcon = if (showPw) Icons.Default.VisibilityOff else Icons.Default.Visibility,
onTrailingIconClick = { showPw = !showPw },
visualTransformation = if (showPw) VisualTransformation.None else PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("⚙️ Lokale Einstellungen", style = MaterialTheme.typography.titleMedium)
MsTextField(
value = settings.backupPath,
onValueChange = { onSettingsChange(settings.copy(backupPath = it)) },
label = "💾 Datenbank-Sicherungspfad (Backup)",
placeholder = "Pfad zum Backup-Verzeichnis",
modifier = Modifier.fillMaxWidth(),
trailingIcon = Icons.Default.FolderOpen,
onTrailingIconClick = {
val chooser = JFileChooser()
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.dialogTitle = "Backup-Verzeichnis auswählen"
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
onSettingsChange(settings.copy(backupPath = chooser.selectedFile.absolutePath))
}
},
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Next) })
)
Text("🌐 Netzwerk-Rolle", style = MaterialTheme.typography.labelLarge)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = settings.networkRole == NetworkRole.MASTER,
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) }
)
Text(
"Master (Hostet lokale DB)",
modifier = Modifier.clickable { onSettingsChange(settings.copy(networkRole = NetworkRole.MASTER)) })
}
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = settings.networkRole == NetworkRole.CLIENT,
onClick = { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) }
)
Text(
"Client",
modifier = Modifier.clickable { onSettingsChange(settings.copy(networkRole = NetworkRole.CLIENT)) })
}
}
Column {
Text("📡 Sync-Intervall: ${settings.syncInterval} Minuten", style = MaterialTheme.typography.labelLarge)
Slider(
value = settings.syncInterval.toFloat(),
onValueChange = { onSettingsChange(settings.copy(syncInterval = it.toInt())) },
valueRange = 1f..60f,
steps = 59,
modifier = Modifier.fillMaxWidth()
)
}
var showPrinterDialog by remember { mutableStateOf(false) }
val availablePrinters = remember {
PrintServiceLookup.lookupPrintServices(null, null).map { it.name }
}
MsTextField(
value = settings.defaultPrinter,
onValueChange = { onSettingsChange(settings.copy(defaultPrinter = it)) },
label = "🖨️ Standard-Drucker",
placeholder = "Name des Standard-Druckers",
modifier = Modifier.fillMaxWidth(),
trailingIcon = Icons.Default.Print,
onTrailingIconClick = { showPrinterDialog = true },
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(onDone = {
if (OnboardingValidator.canContinue(settings)) onContinue(settings)
})
)
if (showPrinterDialog) {
AlertDialog(
onDismissRequest = { showPrinterDialog = false },
title = { Text("Drucker auswählen") },
text = {
Column(Modifier.verticalScroll(rememberScrollState())) {
if (availablePrinters.isEmpty()) {
Text("Keine Drucker gefunden", style = MaterialTheme.typography.bodyMedium)
} else {
availablePrinters.forEach { printer ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onSettingsChange(settings.copy(defaultPrinter = printer))
showPrinterDialog = false
}
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = settings.defaultPrinter == printer,
onClick = null
)
Spacer(Modifier.width(8.dp))
Text(printer)
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showPrinterDialog = false }) {
Text("Schließen")
}
}
)
}
}
}
val canContinue = OnboardingValidator.canContinue(settings)
Button(
onClick = { onContinue(settings) },
enabled = canContinue,
modifier = Modifier.align(Alignment.End)
) {
Text("Konfiguration speichern & starten")
}
if (!canContinue) {
Text(
"Bitte alle Pflichtfelder korrekt ausfüllen (Name min. 3, Key min. 8, Backup-Pfad gesetzt).",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall
)
} }
if (!enabled) Text("Mind. 3 Zeichen für Namen und 8 Zeichen für Schlüssel", color = Color(0xFFB00020))
} }
} }
} }
} }
@Preview
@Composable
fun OnboardingScreenPreview() {
var settings by remember { mutableStateOf(OnboardingSettings()) }
OnboardingScreen(
settings = settings,
onSettingsChange = { settings = it },
onContinue = {}
)
}
@Composable @Composable
fun PferdProfilV2(id: Long, onBack: () -> Unit) { fun PferdProfilV2(id: Long, onBack: () -> Unit) {
DesktopThemeV2 { DesktopThemeV2 {
val pferd = remember(id) { StoreV2.pferde.firstOrNull { it.id == id } } val pferd = remember(id) { StoreV2.pferde.firstOrNull { it.id == id } }
if (pferd == null) { Text("Pferd nicht gefunden"); return@DesktopThemeV2 } if (pferd == null) {
Text("Pferd nicht gefunden"); return@DesktopThemeV2
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
@ -144,13 +250,17 @@ fun PferdProfilV2(id: Long, onBack: () -> Unit) {
var editOpen by remember { mutableStateOf(false) } var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(56.dp).background(Color(0xFF374151), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) { Box(
modifier = Modifier.size(56.dp).background(Color(0xFF374151), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
Text(pferd.name.take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold) Text(pferd.name.take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(pferd.name, style = MaterialTheme.typography.titleMedium) Text(pferd.name, style = MaterialTheme.typography.titleMedium)
val l2 = listOfNotNull(pferd.oepsNummer?.let { "OEPS: $it" }, pferd.feiId?.let { "FEI: $it" }).joinToString(" · ") val l2 =
listOfNotNull(pferd.oepsNummer?.let { "OEPS: $it" }, pferd.feiId?.let { "FEI: $it" }).joinToString(" · ")
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280)) if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
val l3 = listOfNotNull(pferd.geburtsdatum?.let { "geb. $it" }, pferd.farbe).joinToString(" · ") val l3 = listOfNotNull(pferd.geburtsdatum?.let { "geb. $it" }, pferd.farbe).joinToString(" · ")
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280)) if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
@ -203,7 +313,9 @@ fun PferdProfilV2(id: Long, onBack: () -> Unit) {
fun ReiterProfilV2(id: Long, onBack: () -> Unit) { fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
DesktopThemeV2 { DesktopThemeV2 {
val r = remember(id) { StoreV2.reiter.firstOrNull { it.id == id } } val r = remember(id) { StoreV2.reiter.firstOrNull { it.id == id } }
if (r == null) { Text("Reiter nicht gefunden"); return@DesktopThemeV2 } if (r == null) {
Text("Reiter nicht gefunden"); return@DesktopThemeV2
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
@ -213,14 +325,22 @@ fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
var editOpen by remember { mutableStateOf(false) } var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(56.dp).background(Color(0xFF4B5563), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) { Box(
val initials = (r.vorname + " " + r.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString("") modifier = Modifier.size(56.dp).background(Color(0xFF4B5563), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
val initials =
(r.vorname + " " + r.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2)
.joinToString("")
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold) Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("${r.vorname} ${r.nachname}", style = MaterialTheme.typography.titleMedium) Text("${r.vorname} ${r.nachname}", style = MaterialTheme.typography.titleMedium)
val l2 = listOfNotNull(r.oepsNummer?.let { "OEPS: $it" }, r.feiId?.let { "FEI: $it" }, r.lizenzKlasse.takeIf { it.isNotBlank() } ).joinToString(" · ") val l2 = listOfNotNull(
r.oepsNummer?.let { "OEPS: $it" },
r.feiId?.let { "FEI: $it" },
r.lizenzKlasse.takeIf { it.isNotBlank() }).joinToString(" · ")
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280)) if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
r.verein?.let { Text(it, color = Color(0xFF6B7280)) } r.verein?.let { Text(it, color = Color(0xFF6B7280)) }
} }
@ -277,7 +397,9 @@ fun ReiterProfilV2(id: Long, onBack: () -> Unit) {
fun VereinProfilV2(id: Long, onBack: () -> Unit) { fun VereinProfilV2(id: Long, onBack: () -> Unit) {
DesktopThemeV2 { DesktopThemeV2 {
val v = remember(id) { StoreV2.vereine.firstOrNull { it.id == id } } val v = remember(id) { StoreV2.vereine.firstOrNull { it.id == id } }
if (v == null) { Text("Verein nicht gefunden"); return@DesktopThemeV2 } if (v == null) {
Text("Verein nicht gefunden"); return@DesktopThemeV2
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
@ -287,13 +409,17 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) {
var editOpen by remember { mutableStateOf(false) } var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) { Box(
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
Text((v.kurzname ?: v.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold) Text((v.kurzname ?: v.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(v.name, style = MaterialTheme.typography.titleMedium) Text(v.name, style = MaterialTheme.typography.titleMedium)
val l2 = listOfNotNull("OEPS: ${v.oepsNummer}", v.ort, v.plz, v.strasse).filter { it.isNotBlank() }.joinToString(" · ") val l2 = listOfNotNull("OEPS: ${v.oepsNummer}", v.ort, v.plz, v.strasse).filter { it.isNotBlank() }
.joinToString(" · ")
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280)) if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
val l3 = listOfNotNull(v.email, v.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ") val l3 = listOfNotNull(v.email, v.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280)) if (l3.isNotBlank()) Text(l3, color = Color(0xFF6B7280))
@ -340,7 +466,12 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) {
OutlinedTextField(ort, { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f)) OutlinedTextField(ort, { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
OutlinedTextField(plz, { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f)) OutlinedTextField(plz, { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
} }
OutlinedTextField(strasse, { strasse = it }, label = { Text("Straße / Adresse") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(
strasse,
{ strasse = it },
label = { Text("Straße / Adresse") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f)) OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f))
OutlinedTextField(tel, { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f)) OutlinedTextField(tel, { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f))
@ -357,7 +488,9 @@ fun VereinProfilV2(id: Long, onBack: () -> Unit) {
fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) { fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
DesktopThemeV2 { DesktopThemeV2 {
val f = remember(id) { StoreV2.funktionaere.firstOrNull { it.id == id } } val f = remember(id) { StoreV2.funktionaere.firstOrNull { it.id == id } }
if (f == null) { Text("Funktionär nicht gefunden"); return@DesktopThemeV2 } if (f == null) {
Text("Funktionär nicht gefunden"); return@DesktopThemeV2
}
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") } IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück") }
@ -367,14 +500,21 @@ fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
var editOpen by remember { mutableStateOf(false) } var editOpen by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(56.dp).background(Color(0xFF111827), shape = MaterialTheme.shapes.small), contentAlignment = Alignment.Center) { Box(
val initials = (f.vorname + " " + f.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2).joinToString("") modifier = Modifier.size(56.dp).background(Color(0xFF111827), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center
) {
val initials =
(f.vorname + " " + f.nachname).trim().split(" ").mapNotNull { it.firstOrNull()?.toString() }.take(2)
.joinToString("")
Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold) Text(initials.uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold)
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("${f.vorname} ${f.nachname}", style = MaterialTheme.typography.titleMedium) Text("${f.vorname} ${f.nachname}", style = MaterialTheme.typography.titleMedium)
val l2 = listOfNotNull(f.richterNummer?.let { "Nr. $it" }, f.richterQualifikation?.let { "Qual.: $it" }).joinToString(" · ") val l2 = listOfNotNull(
f.richterNummer?.let { "Nr. $it" },
f.richterQualifikation?.let { "Qual.: $it" }).joinToString(" · ")
if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280)) if (l2.isNotBlank()) Text(l2, color = Color(0xFF6B7280))
f.email?.let { Text(it, color = Color(0xFF6B7280)) } f.email?.let { Text(it, color = Color(0xFF6B7280)) }
} }
@ -411,7 +551,12 @@ fun FunktionaerProfilV2(id: Long, onBack: () -> Unit) {
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(num, { num = it }, label = { Text("Nummer") }, modifier = Modifier.weight(1f)) OutlinedTextField(num, { num = it }, label = { Text("Nummer") }, modifier = Modifier.weight(1f))
OutlinedTextField(qual, { qual = it }, label = { Text("Qualifikation") }, modifier = Modifier.weight(1f)) OutlinedTextField(
qual,
{ qual = it },
label = { Text("Qualifikation") },
modifier = Modifier.weight(1f)
)
} }
OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(email, { email = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
} }
@ -500,12 +645,21 @@ fun VeranstalterDetailV2(
modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small), modifier = Modifier.size(56.dp).background(Color(0xFF1F2937), shape = MaterialTheme.shapes.small),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text((verein.kurzname ?: verein.name).take(2).uppercase(), color = Color.White, fontWeight = FontWeight.SemiBold) Text(
(verein.kurzname ?: verein.name).take(2).uppercase(),
color = Color.White,
fontWeight = FontWeight.SemiBold
)
} }
Spacer(Modifier.width(12.dp)) Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(verein.name, style = MaterialTheme.typography.titleMedium) Text(verein.name, style = MaterialTheme.typography.titleMedium)
val line2 = listOfNotNull("OEPS: ${verein.oepsNummer}", verein.ort, verein.plz, verein.strasse).filter { it.isNotBlank() }.joinToString(" · ") val line2 = listOfNotNull(
"OEPS: ${verein.oepsNummer}",
verein.ort,
verein.plz,
verein.strasse
).filter { it.isNotBlank() }.joinToString(" · ")
if (line2.isNotBlank()) Text(line2, color = Color(0xFF6B7280)) if (line2.isNotBlank()) Text(line2, color = Color(0xFF6B7280))
val line3 = listOfNotNull(verein.email, verein.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ") val line3 = listOfNotNull(verein.email, verein.telefon).filter { !it.isNullOrBlank() }.joinToString(" · ")
if (line3.isNotBlank()) Text(line3, color = Color(0xFF6B7280)) if (line3.isNotBlank()) Text(line3, color = Color(0xFF6B7280))
@ -545,19 +699,59 @@ fun VeranstalterDetailV2(
title = { Text("Veranstalter bearbeiten") }, title = { Text("Veranstalter bearbeiten") },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = oeps, onValueChange = { oeps = it }, label = { Text("OEPS-Nummer") }, modifier = Modifier.weight(1f)) OutlinedTextField(
OutlinedTextField(value = logo, onValueChange = { logo = it }, label = { Text("Logo-URL") }, modifier = Modifier.weight(1f)) value = oeps,
onValueChange = { oeps = it },
label = { Text("OEPS-Nummer") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = logo,
onValueChange = { logo = it },
label = { Text("Logo-URL") },
modifier = Modifier.weight(1f)
)
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = ort, onValueChange = { ort = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f)) OutlinedTextField(
OutlinedTextField(value = plz, onValueChange = { plz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f)) value = ort,
onValueChange = { ort = it },
label = { Text("Ort") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = plz,
onValueChange = { plz = it },
label = { Text("PLZ") },
modifier = Modifier.weight(1f)
)
} }
OutlinedTextField(value = strasse, onValueChange = { strasse = it }, label = { Text("Straße / Adresse") }, modifier = Modifier.fillMaxWidth()) OutlinedTextField(
value = strasse,
onValueChange = { strasse = it },
label = { Text("Straße / Adresse") },
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = email, onValueChange = { email = it }, label = { Text("E-Mail") }, modifier = Modifier.weight(1f)) OutlinedTextField(
OutlinedTextField(value = tel, onValueChange = { tel = it }, label = { Text("Telefon") }, modifier = Modifier.weight(1f)) value = email,
onValueChange = { email = it },
label = { Text("E-Mail") },
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = tel,
onValueChange = { tel = it },
label = { Text("Telefon") },
modifier = Modifier.weight(1f)
)
} }
} }
} }
@ -580,9 +774,9 @@ fun VeranstalterDetailV2(
val q = search.trim() val q = search.trim()
if (q.isEmpty()) events else events.filter { if (q.isEmpty()) events else events.filter {
it.titel.contains(q, ignoreCase = true) || it.titel.contains(q, ignoreCase = true) ||
it.status.contains(q, ignoreCase = true) || it.status.contains(q, ignoreCase = true) ||
it.datumVon.contains(q, ignoreCase = true) || it.datumVon.contains(q, ignoreCase = true) ||
(it.datumBis?.contains(q, ignoreCase = true) == true) (it.datumBis?.contains(q, ignoreCase = true) == true)
} }
} }
if (filtered.isEmpty()) Text("Keine passenden Veranstaltungen gefunden.", color = Color(0xFF6B7280)) if (filtered.isEmpty()) Text("Keine passenden Veranstaltungen gefunden.", color = Color(0xFF6B7280))
@ -613,7 +807,13 @@ fun VeranstalterDetailV2(
text = { Text("Diese Aktion entfernt die Veranstaltung und alle zugehörigen Turniere im Prototypen.") } text = { Text("Diese Aktion entfernt die Veranstaltung und alle zugehörigen Turniere im Prototypen.") }
) )
} }
IconButton(onClick = { confirm = true }) { Icon(Icons.Default.Delete, contentDescription = "Löschen", tint = Color(0xFFDC2626)) } IconButton(onClick = { confirm = true }) {
Icon(
Icons.Default.Delete,
contentDescription = "Löschen",
tint = Color(0xFFDC2626)
)
}
} }
} }
} }

View File

@ -82,37 +82,54 @@ class OnboardingValidatorTest {
@Test @Test
fun `B2 canContinue false wenn beide Felder leer`() { fun `B2 canContinue false wenn beide Felder leer`() {
assertFalse(OnboardingValidator.canContinue("", "")) assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "", sharedKey = "")))
} }
@Test @Test
fun `B2 canContinue false wenn nur Name gültig`() { fun `B2 canContinue false wenn nur Name gültig`() {
assertFalse(OnboardingValidator.canContinue("Meldestelle", "kurz")) assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "Meldestelle", sharedKey = "kurz")))
} }
@Test @Test
fun `B2 canContinue false wenn nur Schlüssel gültig`() { fun `B2 canContinue false wenn nur Schlüssel gültig`() {
assertFalse(OnboardingValidator.canContinue("AB", "Neumarkt2026")) assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "AB", sharedKey = "Neumarkt2026")))
} }
@Test @Test
fun `B2 canContinue true wenn beide Felder gültig`() { fun `B2 canContinue true wenn beide Felder gültig`() {
assertTrue(OnboardingValidator.canContinue("Meldestelle", "Neumarkt2026")) // Beachte: backupPath muss für true auch gesetzt sein
assertTrue(
OnboardingValidator.canContinue(
OnboardingSettings(
geraetName = "Meldestelle",
sharedKey = "Neumarkt2026",
backupPath = "/tmp"
)
)
)
} }
@Test @Test
fun `B2 canContinue false bei Grenzfall Name 2 Zeichen und gültigem Schlüssel`() { fun `B2 canContinue false bei Grenzfall Name 2 Zeichen und gültigem Schlüssel`() {
assertFalse(OnboardingValidator.canContinue("AB", "12345678")) assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "AB", sharedKey = "12345678")))
} }
@Test @Test
fun `B2 canContinue false bei gültigem Namen und Grenzfall Schlüssel 7 Zeichen`() { fun `B2 canContinue false bei gültigem Namen und Grenzfall Schlüssel 7 Zeichen`() {
assertFalse(OnboardingValidator.canContinue("Meldestelle", "1234567")) assertFalse(OnboardingValidator.canContinue(OnboardingSettings(geraetName = "Meldestelle", sharedKey = "1234567")))
} }
@Test @Test
fun `B2 canContinue true bei exakten Mindestlängen`() { fun `B2 canContinue true bei exakten Mindestlängen`() {
assertTrue(OnboardingValidator.canContinue("ABC", "12345678")) assertTrue(
OnboardingValidator.canContinue(
OnboardingSettings(
geraetName = "ABC",
sharedKey = "12345678",
backupPath = "/tmp"
)
)
)
} }
// ─── Doppelklick-Schutz (Submit-Guard) ────────────────────────────────────── // ─── Doppelklick-Schutz (Submit-Guard) ──────────────────────────────────────
@ -120,10 +137,9 @@ class OnboardingValidatorTest {
@Test @Test
fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() { fun `B2 canContinue bleibt stabil bei wiederholtem Aufruf mit gleichen Werten`() {
// Simuliert schnelles Doppelklick: canContinue darf sich nicht ändern // Simuliert schnelles Doppelklick: canContinue darf sich nicht ändern
val name = "Meldestelle" val settings = OnboardingSettings(geraetName = "Meldestelle", sharedKey = "Neumarkt2026", backupPath = "/tmp")
val key = "Neumarkt2026" val first = OnboardingValidator.canContinue(settings)
val first = OnboardingValidator.canContinue(name, key) val second = OnboardingValidator.canContinue(settings)
val second = OnboardingValidator.canContinue(name, key)
assertTrue(first) assertTrue(first)
assertTrue(second) assertTrue(second)
} }
@ -147,7 +163,13 @@ class OnboardingValidatorTest {
"Sicherheitsschlüssel muss nach Zurück-Navigation noch gültig sein (rememberSaveable-Fix)" "Sicherheitsschlüssel muss nach Zurück-Navigation noch gültig sein (rememberSaveable-Fix)"
) )
assertTrue( assertTrue(
OnboardingValidator.canContinue(wiederhergestellterName, wiederhergestellterKey), OnboardingValidator.canContinue(
OnboardingSettings(
geraetName = wiederhergestellterName,
sharedKey = wiederhergestellterKey,
backupPath = "/tmp"
)
),
"Weiter-Button muss nach Zurück-Navigation aktiviert bleiben" "Weiter-Button muss nach Zurück-Navigation aktiviert bleiben"
) )
} }
@ -160,7 +182,7 @@ class OnboardingValidatorTest {
val nameNachReset = "" val nameNachReset = ""
val keyNachReset = "" val keyNachReset = ""
assertFalse( assertFalse(
OnboardingValidator.canContinue(nameNachReset, keyNachReset), OnboardingValidator.canContinue(OnboardingSettings(geraetName = nameNachReset, sharedKey = keyNachReset)),
"Nach Abbrechen darf der Weiter-Button nicht aktiviert sein" "Nach Abbrechen darf der Weiter-Button nicht aktiviert sein"
) )
} }

View File

@ -15,13 +15,18 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.theme.AppColors import at.mocode.frontend.core.designsystem.theme.AppColors
import at.mocode.frontend.features.billing.presentation.BillingViewModel import at.mocode.frontend.features.billing.presentation.BillingViewModel
import at.mocode.frontend.features.nennung.domain.NennungRemoteRepository
import at.mocode.frontend.features.nennung.presentation.web.OnlineNennungFormular import at.mocode.frontend.features.nennung.presentation.web.OnlineNennungFormular
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun WebMainScreen() { fun WebMainScreen() {
val billingViewModel: BillingViewModel = koinViewModel() val billingViewModel: BillingViewModel = koinViewModel()
val nennungRepository: NennungRemoteRepository = koinInject()
val scope = rememberCoroutineScope()
var currentScreen by remember { mutableStateOf<WebScreen>(WebScreen.Landing) } var currentScreen by remember { mutableStateOf<WebScreen>(WebScreen.Landing) }
Scaffold( Scaffold(
@ -48,9 +53,15 @@ fun WebMainScreen() {
is WebScreen.Nennung -> OnlineNennungFormular( is WebScreen.Nennung -> OnlineNennungFormular(
turnierNr = screen.turnierId.toString(), turnierNr = screen.turnierId.toString(),
onNennenAbgeschickt = { payload -> onNennenAbgeschickt = { payload ->
// Hier wird später der Mail-Versand oder API-Call integriert scope.launch {
println("Nennung abgeschickt: $payload") val result = nennungRepository.sendeNennung(screen.turnierId.toString(), payload)
currentScreen = WebScreen.Erfolg(payload.email) if (result.isSuccess) {
currentScreen = WebScreen.Erfolg(payload.email)
} else {
// Hier könnte man eine Fehlermeldung anzeigen
println("Fehler beim Senden der Nennung: ${result.exceptionOrNull()?.message}")
}
}
}, },
onBack = { currentScreen = WebScreen.Landing } onBack = { currentScreen = WebScreen.Landing }
) )