feat(masterdata): introduce Regulation domain with API, persistence, and metrics integration

- Added `RegulationRepository` and its `Exposed` implementation for persistence.
- Implemented REST endpoints for regulations (`/rules`) in `RegulationController`, including support for tournament classes, license matrix, guidelines, fees, and configuration retrieval.
- Integrated OpenAPI documentation for `/rules` endpoints with Swagger UI in `masterdataApiModule`.
- Enabled Micrometer-based metrics for Prometheus in the API layer.
- Updated Gradle dependencies to include OpenAPI, Swagger, and Micrometer libraries.
- Registered `RegulationRepository` and `RegulationController` in `MasterdataConfiguration`.
- Improved database access patterns and reduced repetitive validation logic across domain services.
- Added unit and application tests for `RegulationController` to verify API behavior and repository interactions.
- Updated the service's `ROADMAP.md` to mark API v1 endpoints and observability as complete.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-30 15:38:13 +02:00
parent d8c9d11adb
commit 2f17778df6
29 changed files with 591 additions and 105 deletions
@@ -252,7 +252,7 @@ class AltersklasseRepositoryImpl : AltersklasseRepository {
it[createdAt] = altersklasse.createdAt
it[updatedAt] = altersklasse.updatedAt
}
} catch (e: Exception) {
} catch (_: Exception) {
// Race-Fallback bei Unique-Constraint
AltersklasseTable.update({ AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }) {
it[bezeichnung] = altersklasse.bezeichnung
@@ -137,7 +137,7 @@ class BundeslandRepositoryImpl : BundeslandRepository {
override suspend fun upsertByLandIdAndKuerzel(bundesland: BundeslandDefinition): BundeslandDefinition = DatabaseFactory.dbQuery {
// 1) Update anhand des natürlichen Schlüssels (landId + kuerzel)
val updated = if (bundesland.kuerzel == null) {
// Ohne Kuerzel ist der natürliche Schlüssel nicht definiert → versuche Update via (landId + name) als Fallback nicht, bleib bei none
// Ohne Kuerzel ist der natürliche Schlüssel nicht definiert → versuche Update via (landId + name) als Fallback nicht, bleib bei None
0
} else {
BundeslandTable.update({ (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }) {
@@ -190,7 +190,7 @@ class BundeslandRepositoryImpl : BundeslandRepository {
}
}
}
// Rückgabe des aktuellen Datensatzes: Falls Kuerzel null, greife auf ID zurück
// Rückgabe des aktuellen Datensatzes: Falls Kuerzel null greift auf ID zurück
if (bundesland.kuerzel != null) {
BundeslandTable.selectAll()
.where { (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }
@@ -0,0 +1,125 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.infrastructure.persistence
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.masterdata.domain.model.*
import at.mocode.masterdata.domain.repository.RegulationRepository
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.time.Clock
import kotlin.time.Instant as KxInstant
/**
* Exposed-Implementierung des RegulationRepository.
*/
class ExposedRegulationRepository : RegulationRepository {
override suspend fun findAllTurnierklassen(): List<TurnierklasseDefinition> = DatabaseFactory.dbQuery {
TurnierklasseTable.selectAll()
.map { it.toTurnierklasseDefinition() }
}
override suspend fun findAllLicenseMatrixEntries(): List<LicenseMatrixEntry> = DatabaseFactory.dbQuery {
LicenseTable.selectAll()
.map { it.toLicenseMatrixEntry() }
}
override suspend fun findAllRichtverfahren(): List<RichtverfahrenDefinition> = DatabaseFactory.dbQuery {
RichtverfahrenTable.selectAll()
.map { it.toRichtverfahrenDefinition() }
}
override suspend fun findAllGebuehren(): List<GebuehrDefinition> = DatabaseFactory.dbQuery {
GebuehrTable.selectAll()
.map { it.toGebuehrDefinition() }
}
override suspend fun findAllRegulationConfigs(): List<RegulationConfig> = DatabaseFactory.dbQuery {
RegulationConfigTable.selectAll()
.map { it.toRegulationConfig() }
}
override suspend fun findActiveRegulationConfigs(): List<RegulationConfig> = DatabaseFactory.dbQuery {
val now = Clock.System.now()
RegulationConfigTable.selectAll().where {
RegulationConfigTable.istAktiv eq true
}.map { it.toRegulationConfig() }
.filter { config ->
val validTo = config.validTo
config.validFrom <= now && (validTo == null || validTo >= now)
}
}
private fun ResultRow.toTurnierklasseDefinition() = TurnierklasseDefinition(
turnierklasseId = this[TurnierklasseTable.id],
sparte = SparteE.valueOf(this[TurnierklasseTable.sparte]),
code = this[TurnierklasseTable.code],
bezeichnung = this[TurnierklasseTable.bezeichnung],
maxHoehe = this[TurnierklasseTable.maxHoehe],
aufgabenNiveau = this[TurnierklasseTable.aufgabenNiveau],
validFrom = this[TurnierklasseTable.validFrom].toKtInstant(),
validTo = this[TurnierklasseTable.validTo]?.toOptionalKtInstant(),
istAktiv = this[TurnierklasseTable.istAktiv],
createdAt = this[TurnierklasseTable.createdAt].toKtInstant(),
updatedAt = this[TurnierklasseTable.updatedAt].toKtInstant()
)
private fun ResultRow.toLicenseMatrixEntry() = LicenseMatrixEntry(
licenseId = this[LicenseTable.id],
sparte = SparteE.valueOf(this[LicenseTable.sparte]),
lizenzKlasse = LizenzKlasseE.valueOf(this[LicenseTable.lizenzKlasse]),
maxTurnierklasseCode = this[LicenseTable.maxTurnierklasseCode],
validFrom = this[LicenseTable.validFrom].toKtInstant(),
validTo = this[LicenseTable.validTo]?.toOptionalKtInstant(),
istAktiv = this[LicenseTable.istAktiv],
createdAt = this[LicenseTable.createdAt].toKtInstant(),
updatedAt = this[LicenseTable.updatedAt].toKtInstant()
)
private fun ResultRow.toRichtverfahrenDefinition() = RichtverfahrenDefinition(
richtverfahrenId = this[RichtverfahrenTable.id],
sparte = SparteE.valueOf(this[RichtverfahrenTable.sparte]),
code = this[RichtverfahrenTable.code],
bezeichnung = this[RichtverfahrenTable.bezeichnung],
beschreibung = this[RichtverfahrenTable.beschreibung],
validFrom = this[RichtverfahrenTable.validFrom].toKtInstant(),
validTo = this[RichtverfahrenTable.validTo]?.toOptionalKtInstant(),
istAktiv = this[RichtverfahrenTable.istAktiv],
createdAt = this[RichtverfahrenTable.createdAt].toKtInstant(),
updatedAt = this[RichtverfahrenTable.updatedAt].toKtInstant()
)
private fun ResultRow.toGebuehrDefinition() = GebuehrDefinition(
gebuehrId = this[GebuehrTable.id],
bezeichnung = this[GebuehrTable.bezeichnung],
typ = this[GebuehrTable.typ],
betrag = this[GebuehrTable.betrag].toDouble(),
waehrung = this[GebuehrTable.waehrung],
validFrom = this[GebuehrTable.validFrom].toKtInstant(),
validTo = this[GebuehrTable.validTo]?.toOptionalKtInstant(),
istAktiv = this[GebuehrTable.istAktiv],
createdAt = this[GebuehrTable.createdAt].toKtInstant(),
updatedAt = this[GebuehrTable.updatedAt].toKtInstant()
)
private fun ResultRow.toRegulationConfig() = RegulationConfig(
configId = this[RegulationConfigTable.id],
key = this[RegulationConfigTable.key],
value = this[RegulationConfigTable.value],
beschreibung = this[RegulationConfigTable.beschreibung],
validFrom = this[RegulationConfigTable.validFrom].toKtInstant(),
validTo = this[RegulationConfigTable.validTo]?.toOptionalKtInstant(),
istAktiv = this[RegulationConfigTable.istAktiv],
createdAt = this[RegulationConfigTable.createdAt].toKtInstant(),
updatedAt = this[RegulationConfigTable.updatedAt].toKtInstant()
)
private fun KxInstant.toKtInstant(): KxInstant = KxInstant.fromEpochMilliseconds(this.toEpochMilliseconds())
private fun KxInstant?.toOptionalKtInstant(): KxInstant? =
this?.let { KxInstant.fromEpochMilliseconds(it.toEpochMilliseconds()) }
}
@@ -127,8 +127,8 @@ class HorseRepositoryImpl : HorseRepository {
}
override suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean): List<DomPferd> = DatabaseFactory.dbQuery {
// In Exposed v1 gibt es kein directes year() für date Spalten ohne extra Extension.
// Wir suchen im Datumsbereich.
// In Exposed v1 gibt es kein direktes year() für date Spalten ohne extra Extension.
// Wir suchen im Datumsbereich nach.
val startDate = kotlinx.datetime.LocalDate(birthYear, 1, 1)
val endDate = kotlinx.datetime.LocalDate(birthYear, 12, 31)
val query = HorseTable.selectAll()
@@ -1,20 +1,16 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.masterdata.infrastructure.persistence
import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.masterdata.domain.model.LandDefinition
import at.mocode.masterdata.domain.repository.LandRepository
import at.mocode.core.utils.database.DatabaseFactory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.like
import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.Uuid
@@ -187,7 +183,7 @@ class LandRepositoryImpl : LandRepository {
it[createdAt] = land.createdAt
it[updatedAt] = land.updatedAt
}
} catch (e: Exception) {
} catch (_: Exception) {
// Race-Condition (Unique-Constraint gegriffen) → erneut mit Update abrunden
LandTable.update({ LandTable.isoAlpha3Code eq naturalKey }) {
it[isoAlpha2Code] = land.isoAlpha2Code.uppercase()
@@ -12,6 +12,7 @@ import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@@ -35,7 +36,7 @@ class LandRepositoryImplTest {
@Test
fun `upsertByIsoAlpha3 performs update then insert semantics`() {
runBlocking {
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
val now = Clock.System.now()
val id = Uuid.random()
val base = LandDefinition(
landId = id,
@@ -58,12 +59,12 @@ class LandRepositoryImplTest {
assertThat(saved1.isoAlpha3Code).isEqualTo("ZZY")
// 2) Update path (gleicher natürlicher Schlüssel, geänderte Werte)
val updated = base.copy(nameDeutsch = "Testland Neu", sortierReihenfolge = 2, updatedAt = Instant.fromEpochMilliseconds(System.currentTimeMillis()))
val updated = base.copy(nameDeutsch = "Testland Neu", sortierReihenfolge = 2, updatedAt = Clock.System.now())
val saved2 = repo.upsertByIsoAlpha3(updated)
assertThat(saved2.nameDeutsch).isEqualTo("Testland Neu")
assertThat(saved2.sortierReihenfolge).isEqualTo(2)
// Stelle sicher, dass nur ein Datensatz existiert
// Stellen Sie sicher, dass nur ein Datensatz existiert
val count = transaction { LandTable.selectAll().count() }
assertThat(count).isEqualTo(1)
}
@@ -93,7 +94,12 @@ class LandRepositoryImplTest {
createdAt = now,
updatedAt = now
)
val base2 = base1.copy(landId = Uuid.random(), nameDeutsch = "RaceLand Zwei", sortierReihenfolge = 6, updatedAt = Instant.fromEpochMilliseconds(System.currentTimeMillis()))
val base2 = base1.copy(
landId = Uuid.random(),
nameDeutsch = "RaceLand Zwei",
sortierReihenfolge = 6,
updatedAt = Clock.System.now()
)
// Feuere zwei parallele Upserts
val d1 = async(Dispatchers.Default) { repo.upsertByIsoAlpha3(base1) }