feat(api): extend Masterdata API with CRUD endpoints for Pferde and Funktionäre

- Added full CRUD support for Pferde (list, search, get by ID, create, update, delete) with filters for Jahrgang and BesitzerId.
- Introduced FunktionaerController, offering CRUD operations for Funktionäre (list, search, get by ID, create, update, delete) with filtering by Rolle.
- Enhanced HorseController and ReiterController with updated data models, additional request validation, and detailed filtering options.
- Extended backend configurations to register new controllers and route handlers.
- Updated roadmaps and progress documents to reflect completed Sprint B-1 tasks.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-03 10:12:27 +02:00
parent 2dd5453365
commit 2270f9602f
11 changed files with 811 additions and 83 deletions
@@ -4,7 +4,6 @@ import at.mocode.masterdata.api.plugins.IdempotencyPlugin
import at.mocode.masterdata.api.rest.*
import io.ktor.server.application.*
import io.ktor.server.metrics.micrometer.*
import io.ktor.server.plugins.openapi.*
import io.ktor.server.plugins.swagger.*
import io.ktor.server.routing.*
import io.micrometer.core.instrument.MeterRegistry
@@ -16,7 +15,7 @@ import io.micrometer.prometheusmetrics.PrometheusMeterRegistry
*
* - Installiert Micrometer für Metriken (Prometheus).
* - Installiert das IdempotencyPlugin (Header „Idempotency-Key“) global.
* - Registriert alle Masterdata-Routen (Country, Bundesland, Altersklasse, Platz, Reiter, Horse, Verein).
* - Registriert alle Masterdata-Routen (Country, Bundesland, Altersklasse, Platz, Reiter, Horse, Verein, Funktionaer).
*/
fun Application.masterdataApiModule(
countryController: CountryController,
@@ -26,6 +25,7 @@ fun Application.masterdataApiModule(
reiterController: ReiterController,
horseController: HorseController,
vereinController: VereinController,
funktionaerController: FunktionaerController,
regulationController: RegulationController,
meterRegistry: MeterRegistry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT)
) {
@@ -48,6 +48,7 @@ fun Application.masterdataApiModule(
with(reiterController) { registerRoutes() }
with(horseController) { registerRoutes() }
with(vereinController) { registerRoutes() }
with(funktionaerController) { registerRoutes() }
with(regulationController) { registerRoutes() }
}
}
@@ -0,0 +1,238 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.api.rest
import at.mocode.core.domain.model.FunktionaerRolleE
import at.mocode.core.domain.model.RichterQualifikationE
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.core.domain.serialization.LocalDateSerializer
import at.mocode.masterdata.domain.model.DomFunktionaer
import at.mocode.masterdata.domain.repository.FunktionaerRepository
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Controller für Funktionärs-bezogene REST-Endpunkte.
* Bietet vollständiges CRUD: GET (Liste/Suche/Einzeln), POST, PUT, DELETE.
*/
class FunktionaerController(private val funktionaerRepository: FunktionaerRepository) {
@Serializable
data class FunktionaerDto(
val funktionaerId: String,
val richterNummer: String? = null,
val vorname: String,
val nachname: String,
@Serializable(with = LocalDateSerializer::class)
val geburtsdatum: LocalDate? = null,
val rollen: List<String>,
val richterQualifikation: String? = null,
val qualifiziertFuerSparten: List<String>,
val email: String? = null,
val telefon: String? = null,
val vereinsNummer: String? = null,
val istAktiv: Boolean,
val bemerkungen: String? = null,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
)
@Serializable
data class FunktionaerCreateRequest(
val richterNummer: String? = null,
val vorname: String,
val nachname: String,
@Serializable(with = LocalDateSerializer::class)
val geburtsdatum: LocalDate? = null,
val rollen: List<String> = emptyList(),
val richterQualifikation: String? = null,
val qualifiziertFuerSparten: List<String> = emptyList(),
val email: String? = null,
val telefon: String? = null,
val vereinsNummer: String? = null,
val istAktiv: Boolean = true,
val bemerkungen: String? = null
)
@Serializable
data class FunktionaerUpdateRequest(
val vorname: String? = null,
val nachname: String? = null,
@Serializable(with = LocalDateSerializer::class)
val geburtsdatum: LocalDate? = null,
val rollen: List<String>? = null,
val richterQualifikation: String? = null,
val qualifiziertFuerSparten: List<String>? = null,
val email: String? = null,
val telefon: String? = null,
val vereinsNummer: String? = null,
val istAktiv: Boolean? = null,
val bemerkungen: String? = null
)
fun Route.registerRoutes() {
route("/funktionaer") {
/**
* GET /funktionaer — Alle Funktionäre (paginiert), optional gefiltert nach rolle.
*/
get {
val rolleParam = call.request.queryParameters["rolle"]
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val results = if (rolleParam != null) {
val rolle = runCatching { FunktionaerRolleE.valueOf(rolleParam) }.getOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, "Ungültige Rolle: $rolleParam")
funktionaerRepository.findByRolle(rolle)
} else {
funktionaerRepository.findAll(limit, offset)
}
call.respond(results.map { it.toDto() })
}
/**
* GET /funktionaer/search?q=... — Sucht Funktionäre nach Name.
*/
get("/search") {
val query = call.request.queryParameters["q"] ?: ""
val results = funktionaerRepository.findByName(query)
call.respond(results.map { it.toDto() })
}
/**
* GET /funktionaer/{id} — Ruft einen spezifischen Funktionär ab.
*/
get("/{id}") {
val id = parseUuid(call.parameters["id"]) ?: return@get call.respond(HttpStatusCode.BadRequest)
val funktionaer = funktionaerRepository.findById(id)
if (funktionaer != null) call.respond(funktionaer.toDto()) else call.respond(HttpStatusCode.NotFound)
}
/**
* GET /funktionaer/richternummer/{nr} — Sucht einen Funktionär nach seiner Richternummer.
*/
get("/richternummer/{nr}") {
val nr = call.parameters["nr"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val funktionaer = funktionaerRepository.findByRichterNummer(nr)
if (funktionaer != null) call.respond(funktionaer.toDto()) else call.respond(HttpStatusCode.NotFound)
}
/**
* POST /funktionaer — Erstellt einen neuen Funktionär.
*/
post {
val req = call.receive<FunktionaerCreateRequest>()
val rollen = req.rollen.mapNotNull { runCatching { FunktionaerRolleE.valueOf(it) }.getOrNull() }.toSet()
if (rollen.size != req.rollen.size) {
return@post call.respond(HttpStatusCode.BadRequest, "Ungültige Rolle(n) in: ${req.rollen}")
}
val richterQualifikation = req.richterQualifikation?.let {
runCatching { RichterQualifikationE.valueOf(it) }.getOrNull()
?: return@post call.respond(HttpStatusCode.BadRequest, "Ungültige richterQualifikation: $it")
}
val sparten = req.qualifiziertFuerSparten.mapNotNull { runCatching { SparteE.valueOf(it) }.getOrNull() }.toSet()
if (sparten.size != req.qualifiziertFuerSparten.size) {
return@post call.respond(HttpStatusCode.BadRequest, "Ungültige Sparte(n) in: ${req.qualifiziertFuerSparten}")
}
val domFunktionaer = DomFunktionaer(
richterNummer = req.richterNummer,
vorname = req.vorname,
nachname = req.nachname,
geburtsdatum = req.geburtsdatum,
rollen = rollen,
richterQualifikation = richterQualifikation,
qualifiziertFuerSparten = sparten,
email = req.email,
telefon = req.telefon,
vereinsNummer = req.vereinsNummer,
istAktiv = req.istAktiv,
bemerkungen = req.bemerkungen
)
val saved = funktionaerRepository.save(domFunktionaer)
call.respond(HttpStatusCode.Created, saved.toDto())
}
/**
* PUT /funktionaer/{id} — Aktualisiert einen bestehenden Funktionär.
*/
put("/{id}") {
val id = parseUuid(call.parameters["id"]) ?: return@put call.respond(HttpStatusCode.BadRequest)
val existing = funktionaerRepository.findById(id) ?: return@put call.respond(HttpStatusCode.NotFound)
val req = call.receive<FunktionaerUpdateRequest>()
val rollen = req.rollen?.let { rollenList ->
val parsed = rollenList.mapNotNull { runCatching { FunktionaerRolleE.valueOf(it) }.getOrNull() }.toSet()
if (parsed.size != rollenList.size) {
return@put call.respond(HttpStatusCode.BadRequest, "Ungültige Rolle(n) in: $rollenList")
}
parsed
} ?: existing.rollen
val richterQualifikation = req.richterQualifikation?.let {
runCatching { RichterQualifikationE.valueOf(it) }.getOrNull()
?: return@put call.respond(HttpStatusCode.BadRequest, "Ungültige richterQualifikation: $it")
} ?: existing.richterQualifikation
val sparten = req.qualifiziertFuerSparten?.let { spartenList ->
val parsed = spartenList.mapNotNull { runCatching { SparteE.valueOf(it) }.getOrNull() }.toSet()
if (parsed.size != spartenList.size) {
return@put call.respond(HttpStatusCode.BadRequest, "Ungültige Sparte(n) in: $spartenList")
}
parsed
} ?: existing.qualifiziertFuerSparten
val updated = existing.copy(
vorname = req.vorname ?: existing.vorname,
nachname = req.nachname ?: existing.nachname,
geburtsdatum = req.geburtsdatum ?: existing.geburtsdatum,
rollen = rollen,
richterQualifikation = richterQualifikation,
qualifiziertFuerSparten = sparten,
email = req.email ?: existing.email,
telefon = req.telefon ?: existing.telefon,
vereinsNummer = req.vereinsNummer ?: existing.vereinsNummer,
istAktiv = req.istAktiv ?: existing.istAktiv,
bemerkungen = req.bemerkungen ?: existing.bemerkungen
)
val saved = funktionaerRepository.save(updated)
call.respond(saved.toDto())
}
/**
* DELETE /funktionaer/{id} — Löscht einen Funktionär.
*/
delete("/{id}") {
val id = parseUuid(call.parameters["id"]) ?: return@delete call.respond(HttpStatusCode.BadRequest)
val deleted = funktionaerRepository.delete(id)
if (deleted) call.respond(HttpStatusCode.NoContent) else call.respond(HttpStatusCode.NotFound)
}
}
}
private fun parseUuid(value: String?): Uuid? = value?.let { runCatching { Uuid.parse(it) }.getOrNull() }
private fun DomFunktionaer.toDto() = FunktionaerDto(
funktionaerId = funktionaerId.toString(),
richterNummer = richterNummer,
vorname = vorname,
nachname = nachname,
geburtsdatum = geburtsdatum,
rollen = rollen.map { it.name },
richterQualifikation = richterQualifikation?.name,
qualifiziertFuerSparten = qualifiziertFuerSparten.map { it.name },
email = email,
telefon = telefon,
vereinsNummer = vereinsNummer,
istAktiv = istAktiv,
bemerkungen = bemerkungen,
updatedAt = updatedAt
)
}
@@ -2,11 +2,13 @@
package at.mocode.masterdata.api.rest
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.core.domain.serialization.LocalDateSerializer
import at.mocode.masterdata.domain.model.DomPferd
import at.mocode.masterdata.domain.repository.HorseRepository
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.datetime.LocalDate
@@ -16,6 +18,7 @@ import kotlin.uuid.Uuid
/**
* Controller für Pferde-bezogene REST-Endpunkte.
* Bietet vollständiges CRUD: GET (Liste/Suche/Einzeln), POST, PUT, DELETE.
*/
class HorseController(private val horseRepository: HorseRepository) {
@@ -27,18 +30,91 @@ class HorseController(private val horseRepository: HorseRepository) {
@Serializable(with = LocalDateSerializer::class)
val geburtsdatum: LocalDate? = null,
val rasse: String? = null,
val farbe: String? = null,
val lebensnummer: String? = null,
val chipNummer: String? = null,
val passNummer: String? = null,
val oepsNummer: String? = null,
val feiNummer: String? = null,
val besitzerId: String? = null,
val vaterName: String? = null,
val mutterName: String? = null,
val stockmass: Int? = null,
val istAktiv: Boolean,
val bemerkungen: String? = null,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
)
@Serializable
data class HorseCreateRequest(
val pferdeName: String,
val geschlecht: String,
@Serializable(with = LocalDateSerializer::class)
val geburtsdatum: LocalDate? = null,
val rasse: String? = null,
val farbe: String? = null,
val lebensnummer: String? = null,
val chipNummer: String? = null,
val passNummer: String? = null,
val oepsNummer: String? = null,
val feiNummer: String? = null,
val besitzerId: String? = null,
val vaterName: String? = null,
val mutterName: String? = null,
val stockmass: Int? = null,
val istAktiv: Boolean = true,
val bemerkungen: String? = null
)
@Serializable
data class HorseUpdateRequest(
val pferdeName: String? = null,
val geschlecht: String? = null,
@Serializable(with = LocalDateSerializer::class)
val geburtsdatum: LocalDate? = null,
val rasse: String? = null,
val farbe: String? = null,
val lebensnummer: String? = null,
val chipNummer: String? = null,
val passNummer: String? = null,
val oepsNummer: String? = null,
val feiNummer: String? = null,
val besitzerId: String? = null,
val vaterName: String? = null,
val mutterName: String? = null,
val stockmass: Int? = null,
val istAktiv: Boolean? = null,
val bemerkungen: String? = null
)
fun Route.registerRoutes() {
route("/horse") {
/**
* Sucht Pferde nach Name.
* GET /horse — Alle Pferde (paginiert), optional gefiltert nach jahrgang oder besitzerId.
*/
get {
val jahrgang = call.request.queryParameters["jahrgang"]?.toIntOrNull()
val besitzerId = call.request.queryParameters["besitzerId"]
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val results = when {
jahrgang != null -> horseRepository.findByBirthYear(jahrgang)
besitzerId != null -> {
val ownerId = runCatching { Uuid.parse(besitzerId) }.getOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, "Ungültige besitzerId")
horseRepository.findByOwnerId(ownerId)
}
else -> horseRepository.findAllActive(limit)
}
call.respond(results.map { it.toDto() })
}
/**
* GET /horse/search?q=... — Sucht Pferde nach Name.
*/
get("/search") {
val query = call.request.queryParameters["q"] ?: ""
@@ -47,49 +123,124 @@ class HorseController(private val horseRepository: HorseRepository) {
}
/**
* Ruft ein spezifisches Pferd ab.
* GET /horse/{id} — Ruft ein spezifisches Pferd ab.
*/
get("/{id}") {
val idStr = call.parameters["id"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val id = try {
Uuid.parse(idStr)
} catch (e: Exception) {
return@get call.respond(HttpStatusCode.BadRequest)
}
val id = parseUuid(call.parameters["id"]) ?: return@get call.respond(HttpStatusCode.BadRequest)
val pferd = horseRepository.findById(id)
if (pferd != null) {
call.respond(pferd.toDto())
} else {
call.respond(HttpStatusCode.NotFound)
}
if (pferd != null) call.respond(pferd.toDto()) else call.respond(HttpStatusCode.NotFound)
}
/**
* Sucht ein Pferd nach seiner Lebensnummer.
* GET /horse/lebensnummer/{nr} — Sucht ein Pferd nach seiner Lebensnummer.
*/
get("/lebensnummer/{nr}") {
val nr = call.parameters["nr"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val pferd = horseRepository.findByLebensnummer(nr)
if (pferd != null) {
call.respond(pferd.toDto())
} else {
call.respond(HttpStatusCode.NotFound)
if (pferd != null) call.respond(pferd.toDto()) else call.respond(HttpStatusCode.NotFound)
}
/**
* POST /horse — Erstellt ein neues Pferd.
*/
post {
val req = call.receive<HorseCreateRequest>()
val geschlecht = runCatching { PferdeGeschlechtE.valueOf(req.geschlecht) }.getOrNull()
?: return@post call.respond(HttpStatusCode.BadRequest, "Ungültiges Geschlecht: ${req.geschlecht}")
val besitzerId = req.besitzerId?.let {
runCatching { Uuid.parse(it) }.getOrNull()
?: return@post call.respond(HttpStatusCode.BadRequest, "Ungültige besitzerId")
}
val domPferd = DomPferd(
pferdeName = req.pferdeName,
geschlecht = geschlecht,
geburtsdatum = req.geburtsdatum,
rasse = req.rasse,
farbe = req.farbe,
lebensnummer = req.lebensnummer,
chipNummer = req.chipNummer,
passNummer = req.passNummer,
oepsNummer = req.oepsNummer,
feiNummer = req.feiNummer,
besitzerId = besitzerId,
vaterName = req.vaterName,
mutterName = req.mutterName,
stockmass = req.stockmass,
istAktiv = req.istAktiv,
bemerkungen = req.bemerkungen
)
val saved = horseRepository.save(domPferd)
call.respond(HttpStatusCode.Created, saved.toDto())
}
/**
* PUT /horse/{id} — Aktualisiert ein bestehendes Pferd.
*/
put("/{id}") {
val id = parseUuid(call.parameters["id"]) ?: return@put call.respond(HttpStatusCode.BadRequest)
val existing = horseRepository.findById(id) ?: return@put call.respond(HttpStatusCode.NotFound)
val req = call.receive<HorseUpdateRequest>()
val geschlecht = req.geschlecht?.let {
runCatching { PferdeGeschlechtE.valueOf(it) }.getOrNull()
?: return@put call.respond(HttpStatusCode.BadRequest, "Ungültiges Geschlecht: $it")
} ?: existing.geschlecht
val besitzerId = req.besitzerId?.let {
runCatching { Uuid.parse(it) }.getOrNull()
?: return@put call.respond(HttpStatusCode.BadRequest, "Ungültige besitzerId")
} ?: existing.besitzerId
val updated = existing.copy(
pferdeName = req.pferdeName ?: existing.pferdeName,
geschlecht = geschlecht,
geburtsdatum = req.geburtsdatum ?: existing.geburtsdatum,
rasse = req.rasse ?: existing.rasse,
farbe = req.farbe ?: existing.farbe,
lebensnummer = req.lebensnummer ?: existing.lebensnummer,
chipNummer = req.chipNummer ?: existing.chipNummer,
passNummer = req.passNummer ?: existing.passNummer,
oepsNummer = req.oepsNummer ?: existing.oepsNummer,
feiNummer = req.feiNummer ?: existing.feiNummer,
besitzerId = besitzerId,
vaterName = req.vaterName ?: existing.vaterName,
mutterName = req.mutterName ?: existing.mutterName,
stockmass = req.stockmass ?: existing.stockmass,
istAktiv = req.istAktiv ?: existing.istAktiv,
bemerkungen = req.bemerkungen ?: existing.bemerkungen
)
val saved = horseRepository.save(updated)
call.respond(saved.toDto())
}
/**
* DELETE /horse/{id} — Löscht ein Pferd.
*/
delete("/{id}") {
val id = parseUuid(call.parameters["id"]) ?: return@delete call.respond(HttpStatusCode.BadRequest)
val deleted = horseRepository.delete(id)
if (deleted) call.respond(HttpStatusCode.NoContent) else call.respond(HttpStatusCode.NotFound)
}
}
}
private fun parseUuid(value: String?): Uuid? = value?.let { runCatching { Uuid.parse(it) }.getOrNull() }
private fun DomPferd.toDto() = HorseDto(
pferdId = pferdId.toString(),
pferdeName = pferdeName,
geschlecht = geschlecht.name,
geburtsdatum = geburtsdatum,
rasse = rasse,
farbe = farbe,
lebensnummer = lebensnummer,
chipNummer = chipNummer,
passNummer = passNummer,
oepsNummer = oepsNummer,
feiNummer = feiNummer,
besitzerId = besitzerId?.toString(),
vaterName = vaterName,
mutterName = mutterName,
stockmass = stockmass,
istAktiv = istAktiv,
bemerkungen = bemerkungen,
updatedAt = updatedAt
)
}
@@ -2,11 +2,13 @@
package at.mocode.masterdata.api.rest
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.core.domain.serialization.LocalDateSerializer
import at.mocode.masterdata.domain.model.DomReiter
import at.mocode.masterdata.domain.repository.ReiterRepository
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.datetime.LocalDate
@@ -16,6 +18,7 @@ import kotlin.uuid.Uuid
/**
* Controller für Reiter-bezogene REST-Endpunkte.
* Bietet vollständiges CRUD: GET (Liste/Suche/Einzeln), POST, PUT, DELETE.
*/
class ReiterController(private val reiterRepository: ReiterRepository) {
@@ -31,15 +34,77 @@ class ReiterController(private val reiterRepository: ReiterRepository) {
val lizenzKlasse: String,
val startkartAktiv: Boolean,
val nation: String? = null,
val vereinsNummer: String? = null,
val vereinsName: String? = null,
val feiId: String? = null,
val istGastreiter: Boolean,
val istAktiv: Boolean,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
)
@Serializable
data class ReiterCreateRequest(
val satznummer: String,
val nachname: String,
val vorname: String,
@Serializable(with = LocalDateSerializer::class)
val geburtsdatum: LocalDate? = null,
val lizenzNummer: String? = null,
val lizenzKlasse: String = "LIZENZFREI",
val startkartAktiv: Boolean = false,
val nation: String? = null,
val vereinsNummer: String? = null,
val vereinsName: String? = null,
val feiId: String? = null,
val istGastreiter: Boolean = false,
val istAktiv: Boolean = true
)
@Serializable
data class ReiterUpdateRequest(
val nachname: String? = null,
val vorname: String? = null,
@Serializable(with = LocalDateSerializer::class)
val geburtsdatum: LocalDate? = null,
val lizenzNummer: String? = null,
val lizenzKlasse: String? = null,
val startkartAktiv: Boolean? = null,
val nation: String? = null,
val vereinsNummer: String? = null,
val vereinsName: String? = null,
val feiId: String? = null,
val istGastreiter: Boolean? = null,
val istAktiv: Boolean? = null
)
fun Route.registerRoutes() {
route("/reiter") {
/**
* Sucht Reiter nach Name oder Satznummer.
* GET /reiter — Alle Reiter (paginiert), optional gefiltert nach lizenzKlasse oder vereinId.
*/
get {
val lizenzKlasse = call.request.queryParameters["lizenzKlasse"]
val vereinId = call.request.queryParameters["vereinId"]
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val results = when {
lizenzKlasse != null -> {
val klasse = runCatching { LizenzKlasseE.valueOf(lizenzKlasse) }.getOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, "Ungültige lizenzKlasse: $lizenzKlasse")
reiterRepository.findByLizenzKlasse(klasse)
}
vereinId != null -> reiterRepository.findByVereinsNummer(vereinId)
else -> reiterRepository.findAll(limit, offset)
}
call.respond(results.map { it.toDto() })
}
/**
* GET /reiter/search?q=... — Sucht Reiter nach Name oder Satznummer.
*/
get("/search") {
val query = call.request.queryParameters["q"] ?: ""
@@ -48,39 +113,92 @@ class ReiterController(private val reiterRepository: ReiterRepository) {
}
/**
* Ruft einen spezifischen Reiter ab.
* GET /reiter/{id} — Ruft einen spezifischen Reiter ab.
*/
get("/{id}") {
val idStr = call.parameters["id"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val id = try {
Uuid.parse(idStr)
} catch (e: Exception) {
return@get call.respond(HttpStatusCode.BadRequest)
}
val id = parseUuid(call.parameters["id"]) ?: return@get call.respond(HttpStatusCode.BadRequest)
val reiter = reiterRepository.findById(id)
if (reiter != null) {
call.respond(reiter.toDto())
} else {
call.respond(HttpStatusCode.NotFound)
}
if (reiter != null) call.respond(reiter.toDto()) else call.respond(HttpStatusCode.NotFound)
}
/**
* Sucht einen Reiter nach seiner Satznummer.
* GET /reiter/satznummer/{nr} — Sucht einen Reiter nach seiner Satznummer.
*/
get("/satznummer/{nr}") {
val nr = call.parameters["nr"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val reiter = reiterRepository.findBySatznummer(nr)
if (reiter != null) {
call.respond(reiter.toDto())
} else {
call.respond(HttpStatusCode.NotFound)
}
if (reiter != null) call.respond(reiter.toDto()) else call.respond(HttpStatusCode.NotFound)
}
/**
* POST /reiter — Erstellt einen neuen Reiter.
*/
post {
val req = call.receive<ReiterCreateRequest>()
val lizenzKlasse = runCatching { LizenzKlasseE.valueOf(req.lizenzKlasse) }.getOrNull()
?: return@post call.respond(HttpStatusCode.BadRequest, "Ungültige lizenzKlasse: ${req.lizenzKlasse}")
val domReiter = DomReiter(
personId = Uuid.random(),
satznummer = req.satznummer,
nachname = req.nachname,
vorname = req.vorname,
geburtsdatum = req.geburtsdatum,
lizenzNummer = req.lizenzNummer,
lizenzKlasse = lizenzKlasse,
startkartAktiv = req.startkartAktiv,
nation = req.nation,
vereinsNummer = req.vereinsNummer,
vereinsName = req.vereinsName,
feiId = req.feiId,
istGastreiter = req.istGastreiter,
istAktiv = req.istAktiv
)
val saved = reiterRepository.save(domReiter)
call.respond(HttpStatusCode.Created, saved.toDto())
}
/**
* PUT /reiter/{id} — Aktualisiert einen bestehenden Reiter.
*/
put("/{id}") {
val id = parseUuid(call.parameters["id"]) ?: return@put call.respond(HttpStatusCode.BadRequest)
val existing = reiterRepository.findById(id) ?: return@put call.respond(HttpStatusCode.NotFound)
val req = call.receive<ReiterUpdateRequest>()
val lizenzKlasse = req.lizenzKlasse?.let {
runCatching { LizenzKlasseE.valueOf(it) }.getOrNull()
?: return@put call.respond(HttpStatusCode.BadRequest, "Ungültige lizenzKlasse: $it")
} ?: existing.lizenzKlasse
val updated = existing.copy(
nachname = req.nachname ?: existing.nachname,
vorname = req.vorname ?: existing.vorname,
geburtsdatum = req.geburtsdatum ?: existing.geburtsdatum,
lizenzNummer = req.lizenzNummer ?: existing.lizenzNummer,
lizenzKlasse = lizenzKlasse,
startkartAktiv = req.startkartAktiv ?: existing.startkartAktiv,
nation = req.nation ?: existing.nation,
vereinsNummer = req.vereinsNummer ?: existing.vereinsNummer,
vereinsName = req.vereinsName ?: existing.vereinsName,
feiId = req.feiId ?: existing.feiId,
istGastreiter = req.istGastreiter ?: existing.istGastreiter,
istAktiv = req.istAktiv ?: existing.istAktiv
)
val saved = reiterRepository.save(updated)
call.respond(saved.toDto())
}
/**
* DELETE /reiter/{id} — Löscht einen Reiter.
*/
delete("/{id}") {
val id = parseUuid(call.parameters["id"]) ?: return@delete call.respond(HttpStatusCode.BadRequest)
val deleted = reiterRepository.delete(id)
if (deleted) call.respond(HttpStatusCode.NoContent) else call.respond(HttpStatusCode.NotFound)
}
}
}
private fun parseUuid(value: String?): Uuid? = value?.let { runCatching { Uuid.parse(it) }.getOrNull() }
private fun DomReiter.toDto() = ReiterDto(
reiterId = reiterId.toString(),
satznummer = satznummer,
@@ -91,7 +209,11 @@ class ReiterController(private val reiterRepository: ReiterRepository) {
lizenzKlasse = lizenzKlasse.name,
startkartAktiv = startkartAktiv,
nation = nation,
vereinsNummer = vereinsNummer,
vereinsName = vereinsName,
feiId = feiId,
istGastreiter = istGastreiter,
istAktiv = istAktiv,
updatedAt = updatedAt
)
}
@@ -6,13 +6,16 @@ import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.masterdata.domain.model.DomVerein
import at.mocode.masterdata.domain.repository.VereinRepository
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Controller für Vereins-bezogene REST-Endpunkte.
* Bietet vollständiges CRUD: GET (Liste/Suche/Einzeln), POST, PUT, DELETE.
*/
class VereinController(private val vereinRepository: VereinRepository) {
@@ -22,18 +25,77 @@ class VereinController(private val vereinRepository: VereinRepository) {
val vereinsNummer: String,
val name: String,
val kurzname: String? = null,
val bundesland: String,
val bundesland: String? = null,
val ort: String? = null,
val plz: String? = null,
val strasse: String? = null,
val email: String? = null,
val telefon: String? = null,
val website: String? = null,
val oepsRegionNummer: String? = null,
val istVeranstalter: Boolean,
val istAktiv: Boolean,
val bemerkungen: String? = null,
@Serializable(with = InstantSerializer::class)
val updatedAt: kotlin.time.Instant
val updatedAt: Instant
)
@Serializable
data class VereinCreateRequest(
val vereinsNummer: String,
val name: String,
val kurzname: String? = null,
val bundesland: String? = null,
val ort: String? = null,
val plz: String? = null,
val strasse: String? = null,
val email: String? = null,
val telefon: String? = null,
val website: String? = null,
val oepsRegionNummer: String? = null,
val istVeranstalter: Boolean = false,
val istAktiv: Boolean = true,
val bemerkungen: String? = null
)
@Serializable
data class VereinUpdateRequest(
val name: String? = null,
val kurzname: String? = null,
val bundesland: String? = null,
val ort: String? = null,
val plz: String? = null,
val strasse: String? = null,
val email: String? = null,
val telefon: String? = null,
val website: String? = null,
val oepsRegionNummer: String? = null,
val istVeranstalter: Boolean? = null,
val istAktiv: Boolean? = null,
val bemerkungen: String? = null
)
fun Route.registerRoutes() {
route("/verein") {
/**
* Sucht Vereine nach Name oder Kurzname.
* GET /verein — Alle Vereine (paginiert), optional gefiltert nach verband/bundesland.
*/
get {
val verband = call.request.queryParameters["verband"]
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 100
val offset = call.request.queryParameters["offset"]?.toIntOrNull() ?: 0
val results = if (verband != null) {
vereinRepository.findByBundesland(verband)
} else {
vereinRepository.findAll(limit, offset)
}
call.respond(results.map { it.toDto() })
}
/**
* GET /verein/search?q=... — Sucht Vereine nach Name oder Kurzname.
*/
get("/search") {
val query = call.request.queryParameters["q"] ?: ""
@@ -42,48 +104,103 @@ class VereinController(private val vereinRepository: VereinRepository) {
}
/**
* Ruft einen spezifischen Verein ab.
* GET /verein/{id} — Ruft einen spezifischen Verein ab.
*/
get("/{id}") {
val idStr = call.parameters["id"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val id = try {
Uuid.parse(idStr)
} catch (e: Exception) {
return@get call.respond(HttpStatusCode.BadRequest)
}
val id = parseUuid(call.parameters["id"]) ?: return@get call.respond(HttpStatusCode.BadRequest)
val verein = vereinRepository.findById(id)
if (verein != null) {
call.respond(verein.toDto())
} else {
call.respond(HttpStatusCode.NotFound)
}
if (verein != null) call.respond(verein.toDto()) else call.respond(HttpStatusCode.NotFound)
}
/**
* Sucht einen Verein nach seiner Vereinsnummer.
* GET /verein/nummer/{nr} — Sucht einen Verein nach seiner Vereinsnummer.
*/
get("/nummer/{nr}") {
val nr = call.parameters["nr"] ?: return@get call.respond(HttpStatusCode.BadRequest)
val verein = vereinRepository.findByVereinsNummer(nr)
if (verein != null) {
call.respond(verein.toDto())
} else {
call.respond(HttpStatusCode.NotFound)
}
if (verein != null) call.respond(verein.toDto()) else call.respond(HttpStatusCode.NotFound)
}
/**
* POST /verein — Erstellt einen neuen Verein.
*/
post {
val req = call.receive<VereinCreateRequest>()
val domVerein = DomVerein(
vereinsNummer = req.vereinsNummer,
name = req.name,
kurzname = req.kurzname,
bundesland = req.bundesland,
ort = req.ort,
plz = req.plz,
strasse = req.strasse,
email = req.email,
telefon = req.telefon,
website = req.website,
oepsRegionNummer = req.oepsRegionNummer,
istVeranstalter = req.istVeranstalter,
istAktiv = req.istAktiv,
bemerkungen = req.bemerkungen
)
val saved = vereinRepository.save(domVerein)
call.respond(HttpStatusCode.Created, saved.toDto())
}
/**
* PUT /verein/{id} — Aktualisiert einen bestehenden Verein.
*/
put("/{id}") {
val id = parseUuid(call.parameters["id"]) ?: return@put call.respond(HttpStatusCode.BadRequest)
val existing = vereinRepository.findById(id) ?: return@put call.respond(HttpStatusCode.NotFound)
val req = call.receive<VereinUpdateRequest>()
val updated = existing.copy(
name = req.name ?: existing.name,
kurzname = req.kurzname ?: existing.kurzname,
bundesland = req.bundesland ?: existing.bundesland,
ort = req.ort ?: existing.ort,
plz = req.plz ?: existing.plz,
strasse = req.strasse ?: existing.strasse,
email = req.email ?: existing.email,
telefon = req.telefon ?: existing.telefon,
website = req.website ?: existing.website,
oepsRegionNummer = req.oepsRegionNummer ?: existing.oepsRegionNummer,
istVeranstalter = req.istVeranstalter ?: existing.istVeranstalter,
istAktiv = req.istAktiv ?: existing.istAktiv,
bemerkungen = req.bemerkungen ?: existing.bemerkungen
)
val saved = vereinRepository.save(updated)
call.respond(saved.toDto())
}
/**
* DELETE /verein/{id} — Löscht einen Verein.
*/
delete("/{id}") {
val id = parseUuid(call.parameters["id"]) ?: return@delete call.respond(HttpStatusCode.BadRequest)
val deleted = vereinRepository.delete(id)
if (deleted) call.respond(HttpStatusCode.NoContent) else call.respond(HttpStatusCode.NotFound)
}
}
}
private fun parseUuid(value: String?): Uuid? = value?.let { runCatching { Uuid.parse(it) }.getOrNull() }
private fun DomVerein.toDto() = VereinDto(
vereinId = vereinId.toString(),
vereinsNummer = vereinsNummer,
name = name,
kurzname = kurzname,
bundesland = bundesland ?: "",
bundesland = bundesland,
ort = ort,
plz = plz,
strasse = strasse,
email = email,
telefon = telefon,
website = website,
oepsRegionNummer = oepsRegionNummer,
istVeranstalter = istVeranstalter,
istAktiv = istAktiv,
bemerkungen = bemerkungen,
updatedAt = updatedAt
)
}
@@ -53,6 +53,7 @@ class RegulationControllerTest {
reiterController = mockk(relaxed = true),
horseController = mockk(relaxed = true),
vereinController = mockk(relaxed = true),
funktionaerController = mockk(relaxed = true),
regulationController = controller
)
}