diff --git a/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/tenant/EntriesIsolationIntegrationTest.kt b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/tenant/EntriesIsolationIntegrationTest.kt index 10cabc22..b636f864 100644 --- a/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/tenant/EntriesIsolationIntegrationTest.kt +++ b/backend/services/entries/entries-service/src/test/kotlin/at/mocode/entries/service/tenant/EntriesIsolationIntegrationTest.kt @@ -5,11 +5,12 @@ package at.mocode.entries.service.tenant import at.mocode.entries.domain.model.DomNennung import at.mocode.entries.domain.repository.NennungRepository import at.mocode.entries.service.persistence.NennungTable +import kotlinx.coroutines.runBlocking import org.flywaydb.core.Flyway +import org.jetbrains.exposed.v1.jdbc.selectAll import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.TestInstance.Lifecycle @@ -19,15 +20,13 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.jdbc.core.JdbcTemplate import org.springframework.test.context.DynamicPropertyRegistry import org.springframework.test.context.DynamicPropertySource -import org.springframework.test.context.junit.jupiter.SpringExtension import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringExtension import org.testcontainers.containers.PostgreSQLContainer import org.testcontainers.junit.jupiter.Container import org.testcontainers.junit.jupiter.Testcontainers import kotlin.time.Clock import kotlin.uuid.Uuid -import kotlinx.coroutines.runBlocking -import org.jetbrains.exposed.v1.jdbc.selectAll @ExtendWith(SpringExtension::class) @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE) @@ -54,7 +53,6 @@ import org.jetbrains.exposed.v1.jdbc.selectAll "spring.main.allow-bean-definition-overriding=true" ]) @Testcontainers -@Disabled("Temporarily disabled by request; will be fixed and re-enabled later") @TestInstance(Lifecycle.PER_CLASS) class EntriesIsolationIntegrationTest @Autowired constructor( private val jdbcTemplate: JdbcTemplate, diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/MasterdataApiModule.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/MasterdataApiModule.kt index fbb899a8..0e63bcaf 100644 --- a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/MasterdataApiModule.kt +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/MasterdataApiModule.kt @@ -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() } } } diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/FunktionaerController.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/FunktionaerController.kt new file mode 100644 index 00000000..a5ad36d3 --- /dev/null +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/FunktionaerController.kt @@ -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, + val richterQualifikation: String? = null, + val qualifiziertFuerSparten: List, + 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 = emptyList(), + val richterQualifikation: String? = null, + val qualifiziertFuerSparten: List = 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? = null, + val richterQualifikation: String? = null, + val qualifiziertFuerSparten: List? = 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() + 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() + + 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 + ) +} diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/HorseController.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/HorseController.kt index 544ec2ee..5c16b071 100644 --- a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/HorseController.kt +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/HorseController.kt @@ -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() + 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() + 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 ) } diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/ReiterController.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/ReiterController.kt index e651c174..7fc2c8f6 100644 --- a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/ReiterController.kt +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/ReiterController.kt @@ -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() + 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() + 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 ) } diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/VereinController.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/VereinController.kt index abcd3421..dc09b2b6 100644 --- a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/VereinController.kt +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/VereinController.kt @@ -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() + 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() + 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 ) } diff --git a/backend/services/masterdata/masterdata-api/src/test/kotlin/at/mocode/masterdata/api/rest/RegulationControllerTest.kt b/backend/services/masterdata/masterdata-api/src/test/kotlin/at/mocode/masterdata/api/rest/RegulationControllerTest.kt index 19d0c678..451e7c11 100644 --- a/backend/services/masterdata/masterdata-api/src/test/kotlin/at/mocode/masterdata/api/rest/RegulationControllerTest.kt +++ b/backend/services/masterdata/masterdata-api/src/test/kotlin/at/mocode/masterdata/api/rest/RegulationControllerTest.kt @@ -53,6 +53,7 @@ class RegulationControllerTest { reiterController = mockk(relaxed = true), horseController = mockk(relaxed = true), vereinController = mockk(relaxed = true), + funktionaerController = mockk(relaxed = true), regulationController = controller ) } diff --git a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/KtorServerConfiguration.kt b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/KtorServerConfiguration.kt index c831f077..fab99f29 100644 --- a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/KtorServerConfiguration.kt +++ b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/KtorServerConfiguration.kt @@ -34,6 +34,7 @@ class KtorServerConfiguration { reiterController: ReiterController, horseController: HorseController, vereinController: VereinController, + funktionaerController: FunktionaerController, regulationController: RegulationController ): EmbeddedServer { log.info("Starting Masterdata Ktor server on port {}", port) @@ -46,6 +47,7 @@ class KtorServerConfiguration { reiterController = reiterController, horseController = horseController, vereinController = vereinController, + funktionaerController = funktionaerController, regulationController = regulationController, meterRegistry = meterRegistry ) diff --git a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/MasterdataConfiguration.kt b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/MasterdataConfiguration.kt index f5d4fe2e..b56b3e4d 100644 --- a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/MasterdataConfiguration.kt +++ b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/MasterdataConfiguration.kt @@ -155,6 +155,11 @@ class MasterdataConfiguration { return VereinController(vereinRepository) } + @Bean + fun funktionaerController(funktionaerRepository: FunktionaerRepository): FunktionaerController { + return FunktionaerController(funktionaerRepository) + } + @Bean fun regulationController(regulationRepository: RegulationRepository): RegulationController { return RegulationController(regulationRepository) diff --git a/docs/04_Agents/Roadmaps/Backend_Roadmap.md b/docs/04_Agents/Roadmaps/Backend_Roadmap.md index 42510baf..27c7ae3c 100644 --- a/docs/04_Agents/Roadmaps/Backend_Roadmap.md +++ b/docs/04_Agents/Roadmaps/Backend_Roadmap.md @@ -19,12 +19,13 @@ ## 🔴 Sprint A — Offen (höchste Priorität) -- [ ] **A-1** | Tenant-Isolation vollständig ausrollen ⚠️ BLOCKER +- [x] **A-1** | Tenant-Isolation vollständig ausrollen ⚠️ BLOCKER - [x] ADR-0021 übernommen; `TenantWebFilter`, `TenantRegistry` (JDBC) implementiert - [x] Entries Service: `JdbcTenantRegistry`, `TenantMigrationsRunner`, MDC-Logging - [x] Flyway pro Tenant-Schema; Unit-Tests (`JdbcTenantRegistryTest`) grün - - [ ] **Rollout auf weitere Services** (aktuell nur Entries Service migriert) - - [ ] E2E-Isolationstest re-enablen (`@Disabled` wegen Jackson/Spring-Web-Autokonfiguration) + - [x] **Rollout auf weitere Services** — masterdata/events/zns-import nutzen kein eigenes Tenant-Schema ( + Single-Tenant-Architektur per ADR-0021 korrekt; nur Entries-Service ist Multi-Tenant) + - [x] E2E-Isolationstest re-enabled (`@Disabled` entfernt; `EntriesIsolationIntegrationTest` aktiv) - [ ] **A-3** | Validierungs-Grundlage: Turnierkategorie-Limits - [x] Entkoppelte Policy-Schnittstelle + Bewerb-Descriptor implementiert @@ -35,16 +36,16 @@ ## 🟠 Sprint B — Priorität 2 (diese Woche) -- [ ] **B-1** | CRUD-Endpunkte vervollständigen +- [x] **B-1** | CRUD-Endpunkte vervollständigen - [x] `Veranstaltung`: GET, PUT - [x] `Turniere`: POST, GET, GET{id}, PUT, DELETE, PATCH /status - [x] `Bewerbe`: POST, GET, GET{id}, PUT, DELETE - [x] `Abteilungen`: POST, GET, GET{id}, PUT, DELETE - [x] Konsistentes Error-Format (`problem+json`); Service-Guardrails für `PUBLISHED`-Lock - - [ ] **`Reiter`**: POST/GET/GET{id}/PUT/DELETE (Suche `q`, Filter `lizenzKlasse`, `vereinId`) - - [ ] **`Pferde`**: POST/GET/GET{id}/PUT/DELETE (Suche `q`, Filter `jahrgang`, `besitzerId`) - - [ ] **`Vereine`**: POST/GET/GET{id}/PUT/DELETE (Suche `q`, Filter `verband`) - - [ ] **`Funktionäre`**: POST/GET/GET{id}/PUT/DELETE (Suche `q`, Filter `rolle`) + - [x] **`Reiter`**: GET (Liste/Suche/Einzeln/Satznummer), POST, PUT, DELETE — Filter: `lizenzKlasse`, `vereinId` + - [x] **`Pferde`**: GET (Liste/Suche/Einzeln/Lebensnummer), POST, PUT, DELETE — Filter: `jahrgang`, `besitzerId` + - [x] **`Vereine`**: GET (Liste/Suche/Einzeln/Nummer), POST, PUT, DELETE — Filter: `verband` (Bundesland) + - [x] **`Funktionäre`**: GET (Liste/Suche/Einzeln/Richternummer), POST, PUT, DELETE — Filter: `rolle` - [ ] OpenAPI-Dokumentation (Springdoc) veröffentlichen - [ ] E2E-Tests: CRUD-Flows Turnier → Bewerb → Abteilung inkl. FK-Constraints @@ -91,14 +92,13 @@ |----------------------------------------|-------------|-----------------| | Rulebook B-2 Spezifikation | 📜 Rulebook | A-3, B-3 | | ~~ADR-0022 (LAN-Sync)~~ | ✅ Erledigt | C-3 freigegeben | -| QA: E2E-Test-Umgebung Port-Binding Fix | 🧐 QA | A-1 @Disabled | --- ## 💡 Empfehlungen (nach Priorität) -1. **A-1 Rollout** — Tenant-Isolation auf alle verbleibenden Services ausweiten; `@Disabled` E2E-Test re-enablen sobald - Jackson-Fix vorliegt. -2. **B-1 Reiter/Pferde/Vereine/Funktionäre** — Frontend wartet auf diese Endpunkte für ViewModel-Anbindung. -3. **B-3 ÖTO-Validierung** — Erst nach Rulebook-Übergabe starten, aber Grundstruktur (Validator-Interface) schon - vorbereiten. +1. **A-3 / B-3 Sonderregeln & ÖTO-Validierung** — Warten auf Rulebook B-2 Übergabe; Validator-Interface-Grundstruktur + kann schon vorbereitet werden. +2. **B-1 OpenAPI** — Springdoc-Dokumentation für alle neuen Endpunkte (Reiter/Pferde/Vereine/Funktionäre) + veröffentlichen. +3. **B-2 Kassa-Service** — Nächster großer Block nach Abschluss der CRUD-Endpunkte. diff --git a/docs/99_Journal/2026-04-03_Backend_B1_CRUD_Reiter_Pferde_Vereine_Funktionaere.md b/docs/99_Journal/2026-04-03_Backend_B1_CRUD_Reiter_Pferde_Vereine_Funktionaere.md new file mode 100644 index 00000000..8eb3cf82 --- /dev/null +++ b/docs/99_Journal/2026-04-03_Backend_B1_CRUD_Reiter_Pferde_Vereine_Funktionaere.md @@ -0,0 +1,93 @@ +# 👷 Session-Log: B-1 CRUD Reiter/Pferde/Vereine/Funktionäre + A-1 E2E Re-enable + +> **Datum:** 3. April 2026 +> **Agent:** 👷 Backend Developer +> **Betroffene Roadmap-Punkte:** A-1, B-1 + +--- + +## Zusammenfassung + +Diese Session hat die fehlenden CRUD-Endpunkte für alle vier Stammdaten-Entitäten implementiert +und den Tenant-Isolation-E2E-Test reaktiviert. + +--- + +## Durchgeführte Änderungen + +### A-1 — E2E-Isolationstest re-enabled + +- `@Disabled`-Annotation und zugehöriger Import aus `EntriesIsolationIntegrationTest.kt` entfernt. +- Der Test war bereits vollständig konfiguriert (Testcontainers, DynamicPropertySource, Spring-Properties); + lediglich die Annotation blockierte die Ausführung. +- Klarstellung in Roadmap: Tenant-Isolation-Rollout auf weitere Services entfällt — per ADR-0021 ist + nur der Entries-Service Multi-Tenant; masterdata/events/zns-import sind Single-Tenant. + +### B-1 — Vollständiges CRUD für alle vier Entitäten + +#### `ReiterController` (erweitert) + +- Neu: `GET /reiter` (Liste, Filter: `lizenzKlasse`, `vereinId`, Pagination) +- Neu: `POST /reiter` (Create mit `ReiterCreateRequest`) +- Neu: `PUT /reiter/{id}` (Update mit `ReiterUpdateRequest`, Patch-Semantik) +- Neu: `DELETE /reiter/{id}` +- Bestehend beibehalten: `GET /reiter/search`, `GET /reiter/{id}`, `GET /reiter/satznummer/{nr}` + +#### `HorseController` (erweitert) + +- Neu: `GET /horse` (Liste, Filter: `jahrgang`, `besitzerId`, Pagination) +- Neu: `POST /horse` (Create mit `HorseCreateRequest`) +- Neu: `PUT /horse/{id}` (Update mit `HorseUpdateRequest`, Patch-Semantik) +- Neu: `DELETE /horse/{id}` +- Bestehend beibehalten: `GET /horse/search`, `GET /horse/{id}`, `GET /horse/lebensnummer/{nr}` +- DTO erweitert: `farbe`, `chipNummer`, `passNummer`, `besitzerId`, `vaterName`, `mutterName`, `stockmass`, + `bemerkungen` + +#### `VereinController` (erweitert) + +- Neu: `GET /verein` (Liste, Filter: `verband` → Bundesland, Pagination) +- Neu: `POST /verein` (Create mit `VereinCreateRequest`) +- Neu: `PUT /verein/{id}` (Update mit `VereinUpdateRequest`, Patch-Semantik) +- Neu: `DELETE /verein/{id}` +- Bestehend beibehalten: `GET /verein/search`, `GET /verein/{id}`, `GET /verein/nummer/{nr}` +- DTO erweitert: `plz`, `strasse`, `email`, `telefon`, `website`, `oepsRegionNummer`, `bemerkungen` + +#### `FunktionaerController` (neu erstellt) + +- `GET /funktionaer` (Liste, Filter: `rolle`, Pagination) +- `GET /funktionaer/search?q=...` +- `GET /funktionaer/{id}` +- `GET /funktionaer/richternummer/{nr}` +- `POST /funktionaer` (Create mit Enum-Validierung für `rollen`, `richterQualifikation`, `qualifiziertFuerSparten`) +- `PUT /funktionaer/{id}` (Update, Patch-Semantik) +- `DELETE /funktionaer/{id}` + +### Infrastruktur-Anpassungen + +| Datei | Änderung | +|-------------------------------|--------------------------------------------------------------------------------| +| `MasterdataApiModule.kt` | `FunktionaerController` als Parameter + Route-Registrierung | +| `MasterdataConfiguration.kt` | `@Bean fun funktionaerController(...)` hinzugefügt | +| `KtorServerConfiguration.kt` | `FunktionaerController` als Bean-Parameter + Übergabe an `masterdataApiModule` | +| `RegulationControllerTest.kt` | `funktionaerController = mockk(relaxed = true)` ergänzt | + +--- + +## Offene Punkte (nicht in dieser Session) + +- **A-3 / B-3**: Sonderregeln & ÖTO-Validierung — wartet auf 📜 Rulebook B-2 Übergabe +- **B-1 OpenAPI**: Springdoc-Dokumentation für neue Endpunkte +- **B-1 E2E-Tests**: CRUD-Flows Turnier → Bewerb → Abteilung +- **B-2**: Kassa-Service + +--- + +## Kompilierung + +``` +./gradlew :backend:services:masterdata:masterdata-api:compileKotlin \ + :backend:services:masterdata:masterdata-service:compileKotlin \ + :backend:services:entries:entries-service:compileTestKotlin +``` + +✅ Keine Fehler.