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.
This commit is contained in:
+89
@@ -0,0 +1,89 @@
|
|||||||
|
package at.mocode.mail.service.api
|
||||||
|
|
||||||
|
import at.mocode.mail.service.persistence.NennungEntity
|
||||||
|
import at.mocode.mail.service.persistence.NennungRepository
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
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(
|
||||||
|
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?
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/mail")
|
||||||
|
@CrossOrigin(origins = ["*"]) // 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)
|
||||||
|
|
||||||
|
@PostMapping("/nennung")
|
||||||
|
fun receiveNennung(@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()
|
||||||
|
message.from = "online-nennen@mo-code.at"
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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. 🚀
|
||||||
@@ -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"
|
||||||
@@ -54,6 +55,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 {
|
||||||
|
|||||||
+3
@@ -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() }
|
||||||
}
|
}
|
||||||
|
|||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
package at.mocode.frontend.features.nennung.domain
|
||||||
|
|
||||||
|
import at.mocode.frontend.features.nennung.presentation.web.NennungPayload
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.request.post
|
||||||
|
import io.ktor.client.request.setBody
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import io.ktor.http.contentType
|
||||||
|
import 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) {
|
||||||
|
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 direkt an den mail-service (Port 8085)
|
||||||
|
// In einer Prod-Umgebung würde dies über das Gateway laufen.
|
||||||
|
client.post("http://localhost:8085/api/mail/nennung") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(request)
|
||||||
|
}
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -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,
|
||||||
|
|||||||
+14
-3
@@ -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 }
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user