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:
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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) }
|
||||
|
||||
+125
@@ -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()) }
|
||||
}
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+3
-7
@@ -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()
|
||||
|
||||
+10
-4
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user