Compare commits

...

5 Commits

Author SHA1 Message Date
8c2a82403e Introduce Ktor-based HTTP server for Masterdata context, implement upsert logic for Altersklasse, Bundesland, and Land repositories, enhance IdempotencyPlugin, and add integration tests.
Some checks failed
Build and Publish Docker Images / build-and-push (., backend/services/ping/Dockerfile, ping-service, ping-service) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/caddy/web-app/Dockerfile, web-app, web-app) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., config/docker/keycloak/Dockerfile, keycloak, keycloak) (push) Has been cancelled
Build and Publish Docker Images / build-and-push (., backend/infrastructure/gateway/Dockerfile, api-gateway, api-gateway) (push) Has been cancelled
2026-03-29 00:05:43 +01:00
eedce74a85 Add IdempotencyPlugin for handling Idempotency-Key headers in Ktor applications 2026-03-28 21:07:41 +01:00
9ec8535ff7 Enforce natural key uniqueness by adding unique constraints to Bundesland, Platz, Altersklasse, and Land tables. 2026-03-28 20:53:06 +01:00
74df3514ae Integrate advanced filtering, sorting, and pagination logic into Altersklasse, Bundesland, and Platz controllers. Enhance error handling with centralized ErrorCodes and update date serialization for consistent handling of Instant values. 2026-03-28 20:48:44 +01:00
f91b067b36 Enhance CountryController with advanced filtering, sorting, and pagination. Refactor Gradle scripts with version catalogs, improve error code handling, and centralize query models in core-domain. 2026-03-28 20:28:10 +01:00
33 changed files with 1515 additions and 214 deletions

View File

@ -34,5 +34,6 @@ dependencies {
implementation(libs.ktor.server.authJwt) implementation(libs.ktor.server.authJwt)
testImplementation(projects.platform.platformTesting) testImplementation(projects.platform.platformTesting)
testImplementation(libs.ktor.server.tests) // Ktor 3.x Test-Host statt veraltetes tests-Artefakt
testImplementation(libs.ktor.server.testHost)
} }

View File

@ -9,21 +9,14 @@ application {
} }
dependencies { dependencies {
api(platform(libs.spring.boot.dependencies))
// Interne Module // Interne Module
implementation(projects.platform.platformDependencies) implementation(projects.platform.platformDependencies)
implementation(projects.backend.services.masterdata.masterdataDomain) implementation(projects.backend.services.masterdata.masterdataDomain)
implementation(projects.backend.services.masterdata.masterdataCommon) implementation(projects.backend.services.masterdata.masterdataCommon)
implementation(projects.core.coreDomain) implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils) implementation(projects.core.coreUtils)
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen. // Ktor Server (API ist Ktor-basiert, daher keine Spring BOM/Abhängigkeiten hier)
// Spring dependencies
implementation(libs.spring.web)
implementation(libs.springdoc.openapi.starter.common)
// Ktor Server
implementation(libs.ktor.server.core) implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty) implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.contentNegotiation) implementation(libs.ktor.server.contentNegotiation)
@ -34,5 +27,6 @@ dependencies {
// Testing // Testing
testImplementation(projects.platform.platformTesting) testImplementation(projects.platform.platformTesting)
testImplementation(libs.ktor.server.tests) // Ktor 3.x: Verwende das Test-Host-Artefakt statt des veralteten "ktor-server-tests-jvm"
testImplementation(libs.ktor.server.testHost)
} }

View File

@ -0,0 +1,33 @@
package at.mocode.masterdata.api
import at.mocode.masterdata.api.plugins.IdempotencyPlugin
import at.mocode.masterdata.api.rest.AltersklasseController
import at.mocode.masterdata.api.rest.BundeslandController
import at.mocode.masterdata.api.rest.CountryController
import at.mocode.masterdata.api.rest.PlatzController
import io.ktor.server.application.Application
import io.ktor.server.routing.routing
/**
* Ktor-Modul für den Masterdata-Bounded-Context.
*
* - Installiert das IdempotencyPlugin (Header Idempotency-Key) global.
* - Registriert alle Masterdata-Routen (Country, Bundesland, Altersklasse, Platz).
*/
fun Application.masterdataApiModule(
countryController: CountryController,
bundeslandController: BundeslandController,
altersklasseController: AltersklasseController,
platzController: PlatzController
) {
// Installiere das Idempotency-Plugin global für alle Routen
IdempotencyPlugin.install(this)
// Registriere die REST-Routen der Controller
routing {
with(countryController) { registerRoutes() }
with(bundeslandController) { registerRoutes() }
with(altersklasseController) { registerRoutes() }
with(platzController) { registerRoutes() }
}
}

View File

@ -0,0 +1,182 @@
package at.mocode.masterdata.api.plugins
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.request.*
import io.ktor.util.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap
/**
* Ktor-Plugin für Idempotency-Key Verarbeitung mit leichtgewichtigem In-Memory-Cache (TTL, Default 5 Min).
*
* Verhalten:
* - Liest Header "Idempotency-Key" für POST/PUT.
* - Prüft Cache: bei Treffer (nicht abgelaufen) wird die zuvor gespeicherte Response sofort zurückgegeben (Short-Circuit).
* - Nach erfolgreicher Antwort (200/201/204) wird falls möglich der serialisierte Response-Body inkl. Status/Headers im Cache abgelegt.
*
* Hinweise:
* - Es werden ausschließlich Antworten zwischengespeichert, deren Body als Text oder ByteArray vorliegt (typisch für JSON).
* - Kein Persistenz-Layer (lean MVP). Für Multi-Node/HA später Option B (idempotency_records) umsetzen.
*/
object IdempotencyPlugin {
@JvmInline
value class Configuration(val ttl: Duration = Duration.ofMinutes(5))
val IdempotencyKeyAttr: AttributeKey<String> = AttributeKey("IdempotencyKey")
private val CacheAttr: AttributeKey<java.util.concurrent.ConcurrentHashMap<String, CacheEntry>> = AttributeKey("IdempotencyCache")
private val InflightAttr: AttributeKey<java.util.concurrent.ConcurrentHashMap<String, CompletableDeferred<CacheEntry>>> = AttributeKey("IdempotencyInflight")
private val LeaderFlagAttr: AttributeKey<Boolean> = AttributeKey("IdempotencyLeaderFlag")
private data class CacheEntry(
val status: HttpStatusCode,
val contentType: ContentType?,
val body: ByteArray,
val storedAtMillis: Long
)
// Hinweis: Kein globaler Cache mehr! Der Cache ist pro Application-Instance gebunden,
// um Test-Interferenzen und Cross-App-Leaks zu vermeiden.
fun install(application: Application, configuration: Configuration = Configuration()) {
val ttlMillis = configuration.ttl.toMillis()
// Per-Application Cache initialisieren (falls nicht vorhanden)
if (!application.attributes.contains(CacheAttr)) {
application.attributes.put(CacheAttr, ConcurrentHashMap())
}
if (!application.attributes.contains(InflightAttr)) {
application.attributes.put(InflightAttr, ConcurrentHashMap())
}
// Vor der eigentlichen Verarbeitung: Cache prüfen und ggf. Short-Circuit
application.intercept(ApplicationCallPipeline.Plugins) {
val key = call.request.headers["Idempotency-Key"]?.trim()
if (!key.isNullOrBlank()) {
call.attributes.put(IdempotencyKeyAttr, key)
val cache = application.attributes[CacheAttr]
val entry = cache[key]
if (entry != null) {
val now = System.currentTimeMillis()
if (now - entry.storedAtMillis <= ttlMillis) {
// Short-Circuit mit gecachter Antwort
call.response.status(entry.status)
val ct = entry.contentType ?: ContentType.Application.Json
call.respondBytes(bytes = entry.body, contentType = ct)
finish()
return@intercept
} else if (now - entry.storedAtMillis > ttlMillis) {
cache.remove(key)
}
}
// Concurrent duplicate handling: warte auf in-flight Ergebnis
val inflight = application.attributes[InflightAttr]
val parentJob = call.coroutineContext[Job]
val deferred = CompletableDeferred<CacheEntry>(parent = parentJob)
val existing = inflight.putIfAbsent(key, deferred)
if (existing != null) {
// Follower: auf Ergebnis warten und sofort antworten
try {
val result = existing.await()
call.response.status(result.status)
val ct = result.contentType ?: ContentType.Application.Json
call.respondBytes(bytes = result.body, contentType = ct)
finish()
return@intercept
} catch (e: Exception) {
// Leader wird Aufräumen übernehmen (invokeOnCompletion). Normale Verarbeitung zulassen.
}
} else {
// Leader dieses Keys für diese Call-Pipeline markieren
call.attributes.put(LeaderFlagAttr, true)
// Sicherheitsnetz: Wenn der Call endet, aber kein Ergebnis gesetzt wurde,
// verhindere hängende Deferreds und bereinige In-Flight-Eintrag.
parentJob?.invokeOnCompletion { cause ->
val d = inflight.remove(key) ?: return@invokeOnCompletion
if (!d.isCompleted) {
if (cause != null) d.completeExceptionally(cause)
else d.completeExceptionally(IllegalStateException("Idempotency: call finished without completing result"))
}
}
}
}
}
// Nach dem Serialisieren der Antwort: ggf. in Cache legen
application.sendPipeline.intercept(ApplicationSendPipeline.After) { subject ->
val key = call.attributes.getOrNull(IdempotencyKeyAttr) ?: return@intercept
val status = call.response.status() ?: return@intercept
if (status != HttpStatusCode.OK && status != HttpStatusCode.Created && status != HttpStatusCode.NoContent) return@intercept
// Nur ausgewählte Content-Typen/Body-Formen cachen
var bodyBytes: ByteArray? = null
var contentType: ContentType? = null
when (subject) {
is OutgoingContent.ByteArrayContent -> {
bodyBytes = subject.bytes()
contentType = subject.contentType
}
is OutgoingContent.NoContent -> {
bodyBytes = ByteArray(0)
contentType = subject.contentType
}
is OutgoingContent.ReadChannelContent -> {
// Nicht trivial ohne Consumption; überspringen
}
is OutgoingContent.WriteChannelContent -> {
// Nicht trivial; überspringen
}
is TextContent -> {
bodyBytes = subject.text.toByteArray(Charsets.UTF_8)
contentType = subject.contentType
}
}
if (bodyBytes != null) {
val entry = CacheEntry(
status = status,
contentType = contentType,
body = bodyBytes,
storedAtMillis = System.currentTimeMillis()
)
val cache = application.attributes[CacheAttr]
cache[key] = entry
// Wenn dieser Call der Leader war, vervollständige alle wartenden Requests
if (call.attributes.getOrNull(LeaderFlagAttr) == true) {
val inflight = application.attributes[InflightAttr]
val deferred = inflight.remove(key)
deferred?.complete(entry)
}
} else {
// Kein cachebarer Body in-flight ggf. bereinigen, damit Follower nicht ewig warten
if (call.attributes.getOrNull(LeaderFlagAttr) == true) {
val inflight = application.attributes[InflightAttr]
inflight.remove(key)?.completeExceptionally(IllegalStateException("Idempotency: no cacheable body"))
}
}
}
}
/**
* Ermöglicht das Leeren des per-Application-Caches (z.B. für Tests).
*/
fun clear(application: Application) {
if (application.attributes.contains(CacheAttr)) {
application.attributes[CacheAttr].clear()
}
if (application.attributes.contains(InflightAttr)) {
val inflight = application.attributes[InflightAttr]
// Alle offenen Deferreds abbrechen, um Leaks in Tests zu verhindern
inflight.values.forEach { d -> if (!d.isCompleted) d.completeExceptionally(CancellationException("Idempotency: cleared")) }
inflight.clear()
}
}
}

View File

@ -3,6 +3,9 @@
package at.mocode.masterdata.api.rest package at.mocode.masterdata.api.rest
import at.mocode.core.domain.model.ApiResponse import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorCodes
import at.mocode.core.domain.model.PagedResponse
import at.mocode.core.domain.model.SortDirection
import at.mocode.core.domain.model.SparteE import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.application.usecase.CreateAltersklasseUseCase import at.mocode.masterdata.application.usecase.CreateAltersklasseUseCase
import at.mocode.masterdata.application.usecase.GetAltersklasseUseCase import at.mocode.masterdata.application.usecase.GetAltersklasseUseCase
@ -13,6 +16,9 @@ import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import at.mocode.core.domain.serialization.InstantSerializer
import kotlin.time.Instant
import kotlin.math.min
/** /**
* REST API controller for age class management operations. * REST API controller for age class management operations.
@ -34,8 +40,10 @@ class AltersklasseController(
val geschlechtFilter: String? = null, val geschlechtFilter: String? = null,
val oetoRegelReferenzId: String? = null, val oetoRegelReferenzId: String? = null,
val istAktiv: Boolean = true, val istAktiv: Boolean = true,
val createdAt: String, @Serializable(with = InstantSerializer::class)
val updatedAt: String val createdAt: Instant,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
) )
@Serializable @Serializable
@ -76,9 +84,93 @@ class AltersklasseController(
} }
val geschlecht = call.request.queryParameters["geschlecht"]?.getOrNull(0) val geschlecht = call.request.queryParameters["geschlecht"]?.getOrNull(0)
val response = getAltersklasseUseCase.getAllActive(sparte, geschlecht) val search = call.request.queryParameters["search"]?.trim().takeUnless { it.isNullOrBlank() }
val dtos = response.altersklassen.map { it.toDto() } val activeParam = call.request.queryParameters["active"]?.lowercase()
call.respond(ApiResponse.success(dtos)) val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 0
val sizeParam = call.request.queryParameters["size"]?.toIntOrNull()
val unpaged = call.request.queryParameters["unpaged"]?.let { v ->
when (v.lowercase()) { "true", "1", "yes" -> true; else -> false }
} ?: false
val size = sizeParam ?: 20
val sortParams = call.request.queryParameters.getAll("sort")?.mapNotNull { token ->
val parts = token.split(",")
if (parts.isEmpty()) null else {
val field = parts[0].trim()
val dir = if (parts.getOrNull(1)?.trim()?.equals("desc", ignoreCase = true) == true) SortDirection.DESC else SortDirection.ASC
field to dir
}
} ?: listOf("bezeichnung" to SortDirection.ASC)
val baseList = when (activeParam) {
null, "true" -> getAltersklasseUseCase.getAllActive(sparte, geschlecht).altersklassen
"false", "all" -> getAltersklasseUseCase.getAllActive(null, null).altersklassen // TODO: getAll wenn verfügbar
else -> getAltersklasseUseCase.getAllActive(sparte, geschlecht).altersklassen
}
val filtered = baseList.asSequence()
.filter { item ->
when (activeParam) {
"true", null -> item.istAktiv
"false" -> !item.istAktiv
"all" -> true
else -> true
}
}
.filter { item ->
val q = search
if (q.isNullOrBlank()) return@filter true
val s = q.lowercase()
item.altersklasseCode.lowercase().contains(s) || item.bezeichnung.lowercase().contains(s)
}
.toList()
var list = filtered
sortParams.forEach { (field, dir) ->
list = when (field) {
"bezeichnung" -> if (dir == SortDirection.ASC) list.sortedBy { it.bezeichnung.lowercase() } else list.sortedByDescending { it.bezeichnung.lowercase() }
"altersklasseCode" -> if (dir == SortDirection.ASC) list.sortedBy { it.altersklasseCode.lowercase() } else list.sortedByDescending { it.altersklasseCode.lowercase() }
else -> list
}
}
val total = list.size.toLong()
val isUnpaged = unpaged || size == -1
val pageContent: List<AltersklasseDefinition>
val effPage: Int
val effSize: Int
val totalPages: Int
val hasNext: Boolean
val hasPrevious: Boolean
if (isUnpaged) {
pageContent = list
effPage = 0
effSize = list.size
totalPages = 1
hasNext = false
hasPrevious = false
} else {
val fromIndex = (page * size).coerceAtLeast(0)
val toIndex = min(fromIndex + size, list.size)
pageContent = if (fromIndex in 0..list.size) list.subList(fromIndex, toIndex) else emptyList()
totalPages = if (size > 0) ((total + size - 1) / size).toInt() else 1
hasNext = page + 1 < totalPages
hasPrevious = page > 0
effPage = page
effSize = size
}
val dtoPage = pageContent.map { it.toDto() }
val paged = PagedResponse.create(
content = dtoPage,
page = effPage,
size = effSize,
totalElements = total,
totalPages = totalPages,
hasNext = hasNext,
hasPrevious = hasPrevious
)
call.respond(ApiResponse.success(paged))
} }
get("/{id}") { get("/{id}") {
@ -92,13 +184,13 @@ class AltersklasseController(
} }
?: return@get call.respond( ?: return@get call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("INVALID_ID", "Missing or invalid ID") ApiResponse.error<Unit>(ErrorCodes.INVALID_ID, "Missing or invalid ID")
) )
val response = getAltersklasseUseCase.getById(id) val response = getAltersklasseUseCase.getById(id)
response.altersklasse?.let { response.altersklasse?.let {
call.respond(ApiResponse.success(it.toDto())) call.respond(ApiResponse.success(it.toDto()))
} ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>("NOT_FOUND", "Age class not found")) } ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>(ErrorCodes.NOT_FOUND, "Age class not found"))
} }
post { post {
@ -150,7 +242,7 @@ class AltersklasseController(
} }
?: return@put call.respond( ?: return@put call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("INVALID_ID", "Missing or invalid ID") ApiResponse.error<Unit>(ErrorCodes.INVALID_ID, "Missing or invalid ID")
) )
val dto = call.receive<UpdateAltersklasseDto>() val dto = call.receive<UpdateAltersklasseDto>()
@ -186,7 +278,7 @@ class AltersklasseController(
} else { } else {
call.respond( call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("UPDATE_FAILED", response.errors.joinToString()) ApiResponse.error<Unit>(ErrorCodes.UPDATE_FAILED, response.errors.joinToString())
) )
} }
} }
@ -202,7 +294,7 @@ class AltersklasseController(
} }
?: return@delete call.respond( ?: return@delete call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("INVALID_ID", "Missing or invalid ID") ApiResponse.error<Unit>(ErrorCodes.INVALID_ID, "Missing or invalid ID")
) )
val response = createAltersklasseUseCase.deleteAltersklasse(id) val response = createAltersklasseUseCase.deleteAltersklasse(id)
@ -211,7 +303,7 @@ class AltersklasseController(
} else { } else {
call.respond( call.respond(
HttpStatusCode.NotFound, HttpStatusCode.NotFound,
ApiResponse.error<Unit>("DELETE_FAILED", response.errors.joinToString()) ApiResponse.error<Unit>(ErrorCodes.DELETE_FAILED, response.errors.joinToString())
) )
} }
} }
@ -229,7 +321,7 @@ class AltersklasseController(
geschlechtFilter = geschlechtFilter?.toString(), geschlechtFilter = geschlechtFilter?.toString(),
oetoRegelReferenzId = oetoRegelReferenzId?.toString(), oetoRegelReferenzId = oetoRegelReferenzId?.toString(),
istAktiv = istAktiv, istAktiv = istAktiv,
createdAt = createdAt.toString(), createdAt = createdAt,
updatedAt = updatedAt.toString() updatedAt = updatedAt
) )
} }

View File

@ -2,6 +2,9 @@
package at.mocode.masterdata.api.rest package at.mocode.masterdata.api.rest
import at.mocode.core.domain.model.ApiResponse import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorCodes
import at.mocode.core.domain.model.PagedResponse
import at.mocode.core.domain.model.SortDirection
import at.mocode.masterdata.application.usecase.CreateBundeslandUseCase import at.mocode.masterdata.application.usecase.CreateBundeslandUseCase
import at.mocode.masterdata.application.usecase.GetBundeslandUseCase import at.mocode.masterdata.application.usecase.GetBundeslandUseCase
import at.mocode.masterdata.domain.model.BundeslandDefinition import at.mocode.masterdata.domain.model.BundeslandDefinition
@ -11,6 +14,9 @@ import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import at.mocode.core.domain.serialization.InstantSerializer
import kotlin.time.Instant
import kotlin.math.min
/** /**
* REST API controller for federal state (Bundesland) management. * REST API controller for federal state (Bundesland) management.
@ -31,8 +37,10 @@ class BundeslandController(
val wappenUrl: String? = null, val wappenUrl: String? = null,
val istAktiv: Boolean = true, val istAktiv: Boolean = true,
val sortierReihenfolge: Int? = null, val sortierReihenfolge: Int? = null,
val createdAt: String, @Serializable(with = InstantSerializer::class)
val updatedAt: String val createdAt: Instant,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
) )
@Serializable @Serializable
@ -58,14 +66,97 @@ class BundeslandController(
} }
} }
val response = if (landId != null) { val search = call.request.queryParameters["search"]?.trim().takeUnless { it.isNullOrBlank() }
getBundeslandUseCase.getByCountry(landId) val activeParam = call.request.queryParameters["active"]?.lowercase()
} else { val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 0
getBundeslandUseCase.getAllActive() val sizeParam = call.request.queryParameters["size"]?.toIntOrNull()
val unpaged = call.request.queryParameters["unpaged"]?.let {
when (it.lowercase()) { "true", "1", "yes" -> true; else -> false }
} ?: false
val size = sizeParam ?: 20
val sortParams = call.request.queryParameters.getAll("sort")?.mapNotNull { token ->
val parts = token.split(",")
if (parts.isEmpty()) null else {
val field = parts[0].trim()
val dir = if (parts.getOrNull(1)?.trim()?.equals("desc", ignoreCase = true) == true) SortDirection.DESC else SortDirection.ASC
field to dir
}
} ?: listOf("sortierReihenfolge" to SortDirection.ASC, "name" to SortDirection.ASC)
val baseList = when (activeParam) {
null, "true" -> if (landId != null) getBundeslandUseCase.getByCountry(landId).bundeslaender.filter { it.istAktiv } else getBundeslandUseCase.getAllActive().bundeslaender
"false", "all" -> if (landId != null) getBundeslandUseCase.getByCountry(landId).bundeslaender else getBundeslandUseCase.getAllActive().bundeslaender
else -> if (landId != null) getBundeslandUseCase.getByCountry(landId).bundeslaender else getBundeslandUseCase.getAllActive().bundeslaender
} }
val dtos = response.bundeslaender.map { it.toDto() } val filtered = baseList.asSequence()
call.respond(ApiResponse.success(dtos)) .filter { item ->
when (activeParam) {
"true", null -> item.istAktiv
"false" -> !item.istAktiv
"all" -> true
else -> true
}
}
.filter { item ->
val q = search
if (q.isNullOrBlank()) return@filter true
val s = q.lowercase()
item.name.lowercase().contains(s) ||
(item.kuerzel?.lowercase()?.contains(s) == true) ||
(item.oepsCode?.lowercase()?.contains(s) == true) ||
(item.iso3166_2_Code?.lowercase()?.contains(s) == true)
}
.toList()
var list = filtered
sortParams.forEach { (field, dir) ->
list = when (field) {
"sortierReihenfolge" -> if (dir == SortDirection.ASC) list.sortedBy { it.sortierReihenfolge ?: Int.MAX_VALUE } else list.sortedByDescending { it.sortierReihenfolge ?: Int.MIN_VALUE }
"name" -> if (dir == SortDirection.ASC) list.sortedBy { it.name.lowercase() } else list.sortedByDescending { it.name.lowercase() }
"kuerzel" -> if (dir == SortDirection.ASC) list.sortedBy { it.kuerzel?.lowercase() } else list.sortedByDescending { it.kuerzel?.lowercase() }
else -> list
}
}
val total = list.size.toLong()
val isUnpaged = unpaged || size == -1
val pageContent: List<BundeslandDefinition>
val effPage: Int
val effSize: Int
val totalPages: Int
val hasNext: Boolean
val hasPrevious: Boolean
if (isUnpaged) {
pageContent = list
effPage = 0
effSize = list.size
totalPages = 1
hasNext = false
hasPrevious = false
} else {
val fromIndex = (page * size).coerceAtLeast(0)
val toIndex = min(fromIndex + size, list.size)
pageContent = if (fromIndex in 0..list.size) list.subList(fromIndex, toIndex) else emptyList()
totalPages = if (size > 0) ((total + size - 1) / size).toInt() else 1
hasNext = page + 1 < totalPages
hasPrevious = page > 0
effPage = page
effSize = size
}
val dtoPage = pageContent.map { it.toDto() }
val paged = PagedResponse.create(
content = dtoPage,
page = effPage,
size = effSize,
totalElements = total,
totalPages = totalPages,
hasNext = hasNext,
hasPrevious = hasPrevious
)
call.respond(ApiResponse.success(paged))
} }
get("/{id}") { get("/{id}") {
@ -79,7 +170,7 @@ class BundeslandController(
} }
?: return@get call.respond( ?: return@get call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("INVALID_ID", "Missing or invalid ID") ApiResponse.error<Unit>(ErrorCodes.INVALID_ID, "Missing or invalid ID")
) )
val response = getBundeslandUseCase.getById(id) val response = getBundeslandUseCase.getById(id)
@ -87,7 +178,7 @@ class BundeslandController(
call.respond(ApiResponse.success(it.toDto())) call.respond(ApiResponse.success(it.toDto()))
} ?: call.respond( } ?: call.respond(
HttpStatusCode.NotFound, HttpStatusCode.NotFound,
ApiResponse.error<Unit>("NOT_FOUND", "Federal state not found") ApiResponse.error<Unit>(ErrorCodes.NOT_FOUND, "Federal state not found")
) )
} }
@ -98,7 +189,7 @@ class BundeslandController(
} catch (e: Exception) { } catch (e: Exception) {
return@post call.respond( return@post call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("INVALID_LAND_ID", "Invalid landId format") ApiResponse.error<Unit>(ErrorCodes.INVALID_PARAMETER, "Invalid landId format")
) )
} }
@ -120,7 +211,7 @@ class BundeslandController(
} else { } else {
call.respond( call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("CREATION_FAILED", response.errors.joinToString()) ApiResponse.error<Unit>(ErrorCodes.CREATION_FAILED, response.errors.joinToString())
) )
} }
} }
@ -137,7 +228,7 @@ class BundeslandController(
wappenUrl = wappenUrl, wappenUrl = wappenUrl,
istAktiv = istAktiv, istAktiv = istAktiv,
sortierReihenfolge = sortierReihenfolge, sortierReihenfolge = sortierReihenfolge,
createdAt = createdAt.toString(), createdAt = createdAt,
updatedAt = updatedAt.toString() updatedAt = updatedAt
) )
} }

View File

@ -2,15 +2,21 @@
package at.mocode.masterdata.api.rest package at.mocode.masterdata.api.rest
import at.mocode.core.domain.model.ApiResponse import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorCodes
import at.mocode.core.domain.model.PagedResponse
import at.mocode.core.domain.model.SortDirection
import at.mocode.core.domain.serialization.InstantSerializer
import at.mocode.masterdata.application.usecase.CreateCountryUseCase import at.mocode.masterdata.application.usecase.CreateCountryUseCase
import at.mocode.masterdata.application.usecase.GetCountryUseCase import at.mocode.masterdata.application.usecase.GetCountryUseCase
import at.mocode.masterdata.domain.model.LandDefinition import at.mocode.masterdata.domain.model.LandDefinition
import kotlin.uuid.Uuid
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.request.* import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.math.min
import kotlin.time.Instant
import kotlin.uuid.Uuid
/** /**
* REST API controller for country (Land) management. * REST API controller for country (Land) management.
@ -33,8 +39,10 @@ class CountryController(
val istEwrMitglied: Boolean? = null, val istEwrMitglied: Boolean? = null,
val istAktiv: Boolean = true, val istAktiv: Boolean = true,
val sortierReihenfolge: Int? = null, val sortierReihenfolge: Int? = null,
val createdAt: String, @Serializable(with = InstantSerializer::class)
val updatedAt: String val createdAt: Instant,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
) )
@Serializable @Serializable
@ -54,9 +62,117 @@ class CountryController(
fun Route.registerRoutes() { fun Route.registerRoutes() {
route("/countries") { route("/countries") {
get { get {
val response = getCountryUseCase.getAllActive() // Query-Parameter: search, active (true|false|all), page, size, sort=field,dir (multi)
val dtos = response.countries.map { it.toDto() } val search = call.request.queryParameters["search"]?.trim().takeUnless { it.isNullOrBlank() }
call.respond(ApiResponse.success(dtos)) val activeParam = call.request.queryParameters["active"]?.lowercase()
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 0
val sizeParam = call.request.queryParameters["size"]?.toIntOrNull()
val unpaged = call.request.queryParameters["unpaged"]?.let {
when (it.lowercase()) {
"true", "1", "yes" -> true
else -> false
}
} ?: false
val size = sizeParam ?: 20
val sortParams = call.request.queryParameters.getAll("sort")?.mapNotNull { token ->
val parts = token.split(",")
if (parts.isEmpty()) null else {
val field = parts[0].trim()
val dir = if (parts.getOrNull(1)?.trim()?.equals("desc", ignoreCase = true) == true) SortDirection.DESC else SortDirection.ASC
field to dir
}
} ?: listOf("sortierReihenfolge" to SortDirection.ASC, "nameDeutsch" to SortDirection.ASC)
// Datenbasis: per Default nur aktive laden, sonst alle
val baseList = when (activeParam) {
null, "true" -> getCountryUseCase.getAllActive().countries
"false", "all" -> {
// Es gibt (noch) keinen Endpunkt für alle; Workaround: aktive holen und per Repository ergänzen wäre nötig.
// Übergang: wenn "false" gewünscht, filtern wir nach aktiven=false aus gesamter Liste:
// Da kein getAll existiert, nehmen wir aktive und ignorieren Filter (wird in Repo-Erweiterung ergänzt).
getCountryUseCase.getAllActive().countries
}
else -> getCountryUseCase.getAllActive().countries
}
// Suche: isoAlpha2/isoAlpha3/nameDeutsch/nameEnglisch
val filtered = baseList.asSequence()
.filter { item ->
when (activeParam) {
"true", null -> item.istAktiv
"false" -> !item.istAktiv
"all" -> true
else -> true
}
}
.filter { item ->
val q = search
if (q.isNullOrBlank()) return@filter true
val s = q.lowercase()
item.isoAlpha2Code.lowercase().contains(s) ||
item.isoAlpha3Code.lowercase().contains(s) ||
item.nameDeutsch.lowercase().contains(s) ||
(item.nameEnglisch?.lowercase()?.contains(s) == true)
}
.toList()
// Sortierung anwenden
val sorted = filtered.sortedWith(compareBy(
{ _: LandDefinition -> 0 }
)).let { _ ->
var list = filtered
sortParams.forEach { (field, dir) ->
list = when (field) {
"sortierReihenfolge" -> if (dir == SortDirection.ASC) list.sortedBy { it.sortierReihenfolge ?: Int.MAX_VALUE } else list.sortedByDescending { it.sortierReihenfolge ?: Int.MIN_VALUE }
"nameDeutsch" -> if (dir == SortDirection.ASC) list.sortedBy { it.nameDeutsch.lowercase() } else list.sortedByDescending { it.nameDeutsch.lowercase() }
"nameEnglisch" -> if (dir == SortDirection.ASC) list.sortedBy { it.nameEnglisch?.lowercase() } else list.sortedByDescending { it.nameEnglisch?.lowercase() }
"isoAlpha2Code" -> if (dir == SortDirection.ASC) list.sortedBy { it.isoAlpha2Code.lowercase() } else list.sortedByDescending { it.isoAlpha2Code.lowercase() }
"isoAlpha3Code" -> if (dir == SortDirection.ASC) list.sortedBy { it.isoAlpha3Code.lowercase() } else list.sortedByDescending { it.isoAlpha3Code.lowercase() }
else -> list // unbekanntes Feld ignorieren
}
}
list
}
// Paging (unterstützt unpaged=true oder size=-1)
val total = sorted.size.toLong()
val isUnpaged = unpaged || size == -1
val pageContent: List<LandDefinition>
val effPage: Int
val effSize: Int
val totalPages: Int
val hasNext: Boolean
val hasPrevious: Boolean
if (isUnpaged) {
pageContent = sorted
effPage = 0
effSize = sorted.size
totalPages = 1
hasNext = false
hasPrevious = false
} else {
val fromIndex = (page * size).coerceAtLeast(0)
val toIndex = min(fromIndex + size, sorted.size)
pageContent = if (fromIndex in 0..sorted.size) sorted.subList(fromIndex, toIndex) else emptyList()
totalPages = if (size > 0) ((total + size - 1) / size).toInt() else 1
hasNext = page + 1 < totalPages
hasPrevious = page > 0
effPage = page
effSize = size
}
val dtoPage = pageContent.map { it.toDto() }
val paged = PagedResponse.create(
content = dtoPage,
page = effPage,
size = effSize,
totalElements = total,
totalPages = totalPages,
hasNext = hasNext,
hasPrevious = hasPrevious
)
call.respond(ApiResponse.success(paged))
} }
get("/{id}") { get("/{id}") {
@ -70,13 +186,13 @@ class CountryController(
} }
?: return@get call.respond( ?: return@get call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("INVALID_ID", "Missing or invalid ID") ApiResponse.error<Unit>(ErrorCodes.INVALID_ID, "Missing or invalid ID")
) )
val response = getCountryUseCase.getById(id) val response = getCountryUseCase.getById(id)
response.country?.let { response.country?.let {
call.respond(ApiResponse.success(it.toDto())) call.respond(ApiResponse.success(it.toDto()))
} ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>("NOT_FOUND", "Country not found")) } ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>(ErrorCodes.NOT_FOUND, "Country not found"))
} }
post { post {
@ -101,7 +217,7 @@ class CountryController(
} else { } else {
call.respond( call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("CREATION_FAILED", response.errors.joinToString()) ApiResponse.error<Unit>(ErrorCodes.CREATION_FAILED, response.errors.joinToString())
) )
} }
} }
@ -120,7 +236,7 @@ class CountryController(
istEwrMitglied = istEwrMitglied, istEwrMitglied = istEwrMitglied,
istAktiv = istAktiv, istAktiv = istAktiv,
sortierReihenfolge = sortierReihenfolge, sortierReihenfolge = sortierReihenfolge,
createdAt = createdAt.toString(), createdAt = createdAt,
updatedAt = updatedAt.toString() updatedAt = updatedAt
) )
} }

View File

@ -2,6 +2,9 @@
package at.mocode.masterdata.api.rest package at.mocode.masterdata.api.rest
import at.mocode.core.domain.model.ApiResponse import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorCodes
import at.mocode.core.domain.model.PagedResponse
import at.mocode.core.domain.model.SortDirection
import at.mocode.core.domain.model.PlatzTypE import at.mocode.core.domain.model.PlatzTypE
import at.mocode.masterdata.application.usecase.CreatePlatzUseCase import at.mocode.masterdata.application.usecase.CreatePlatzUseCase
import at.mocode.masterdata.application.usecase.GetPlatzUseCase import at.mocode.masterdata.application.usecase.GetPlatzUseCase
@ -12,6 +15,9 @@ import io.ktor.server.request.*
import io.ktor.server.response.* import io.ktor.server.response.*
import io.ktor.server.routing.* import io.ktor.server.routing.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import at.mocode.core.domain.serialization.InstantSerializer
import kotlin.time.Instant
import kotlin.math.min
/** /**
* REST API controller for venue/arena (Platz) management. * REST API controller for venue/arena (Platz) management.
@ -31,8 +37,10 @@ class PlatzController(
val typ: String, val typ: String,
val istAktiv: Boolean = true, val istAktiv: Boolean = true,
val sortierReihenfolge: Int? = null, val sortierReihenfolge: Int? = null,
val createdAt: String, @Serializable(with = InstantSerializer::class)
val updatedAt: String val createdAt: Instant,
@Serializable(with = InstantSerializer::class)
val updatedAt: Instant
) )
@Serializable @Serializable
@ -58,12 +66,93 @@ class PlatzController(
} }
?: return@get call.respond( ?: return@get call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("MISSING_TURNIER_ID", "Query parameter turnierId is required") ApiResponse.error<Unit>(ErrorCodes.MISSING_PARAMETER, "Query parameter turnierId is required")
) )
val response = getPlatzUseCase.getByTournament(turnierId) val search = call.request.queryParameters["search"]?.trim().takeUnless { it.isNullOrBlank() }
val dtos = response.plaetze.map { it.toDto() } val activeParam = call.request.queryParameters["active"]?.lowercase()
call.respond(ApiResponse.success(dtos)) val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 0
val sizeParam = call.request.queryParameters["size"]?.toIntOrNull()
val unpaged = call.request.queryParameters["unpaged"]?.let { v ->
when (v.lowercase()) { "true", "1", "yes" -> true; else -> false }
} ?: false
val size = sizeParam ?: 20
val sortParams = call.request.queryParameters.getAll("sort")?.mapNotNull { token ->
val parts = token.split(",")
if (parts.isEmpty()) null else {
val field = parts[0].trim()
val dir = if (parts.getOrNull(1)?.trim()?.equals("desc", ignoreCase = true) == true) SortDirection.DESC else SortDirection.ASC
field to dir
}
} ?: listOf("sortierReihenfolge" to SortDirection.ASC, "name" to SortDirection.ASC)
val baseList = getPlatzUseCase.getByTournament(turnierId).plaetze
val filtered = baseList.asSequence()
.filter { item ->
when (activeParam) {
"true", null -> item.istAktiv
"false" -> !item.istAktiv
"all" -> true
else -> true
}
}
.filter { item ->
val q = search
if (q.isNullOrBlank()) return@filter true
val s = q.lowercase()
item.name.lowercase().contains(s) ||
(item.dimension?.lowercase()?.contains(s) == true) ||
(item.boden?.lowercase()?.contains(s) == true)
}
.toList()
var list = filtered
sortParams.forEach { (field, dir) ->
list = when (field) {
"sortierReihenfolge" -> if (dir == SortDirection.ASC) list.sortedBy { it.sortierReihenfolge ?: Int.MAX_VALUE } else list.sortedByDescending { it.sortierReihenfolge ?: Int.MIN_VALUE }
"name" -> if (dir == SortDirection.ASC) list.sortedBy { it.name.lowercase() } else list.sortedByDescending { it.name.lowercase() }
else -> list
}
}
val total = list.size.toLong()
val isUnpaged = unpaged || size == -1
val pageContent: List<Platz>
val effPage: Int
val effSize: Int
val totalPages: Int
val hasNext: Boolean
val hasPrevious: Boolean
if (isUnpaged) {
pageContent = list
effPage = 0
effSize = list.size
totalPages = 1
hasNext = false
hasPrevious = false
} else {
val fromIndex = (page * size).coerceAtLeast(0)
val toIndex = min(fromIndex + size, list.size)
pageContent = if (fromIndex in 0..list.size) list.subList(fromIndex, toIndex) else emptyList()
totalPages = if (size > 0) ((total + size - 1) / size).toInt() else 1
hasNext = page + 1 < totalPages
hasPrevious = page > 0
effPage = page
effSize = size
}
val dtoPage = pageContent.map { it.toDto() }
val paged = PagedResponse.create(
content = dtoPage,
page = effPage,
size = effSize,
totalElements = total,
totalPages = totalPages,
hasNext = hasNext,
hasPrevious = hasPrevious
)
call.respond(ApiResponse.success(paged))
} }
get("/{id}") { get("/{id}") {
@ -77,13 +166,13 @@ class PlatzController(
} }
?: return@get call.respond( ?: return@get call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("INVALID_ID", "Missing or invalid ID") ApiResponse.error<Unit>(ErrorCodes.INVALID_ID, "Missing or invalid ID")
) )
val response = getPlatzUseCase.getById(id) val response = getPlatzUseCase.getById(id)
response.platz?.let { response.platz?.let {
call.respond(ApiResponse.success(it.toDto())) call.respond(ApiResponse.success(it.toDto()))
} ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>("NOT_FOUND", "Venue not found")) } ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>(ErrorCodes.NOT_FOUND, "Venue not found"))
} }
post { post {
@ -93,7 +182,7 @@ class PlatzController(
} catch (e: Exception) { } catch (e: Exception) {
return@post call.respond( return@post call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("INVALID_TURNIER_ID", "Invalid turnierId format") ApiResponse.error<Unit>(ErrorCodes.INVALID_PARAMETER, "Invalid turnierId format")
) )
} }
@ -118,7 +207,7 @@ class PlatzController(
} else { } else {
call.respond( call.respond(
HttpStatusCode.BadRequest, HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("CREATION_FAILED", response.errors.joinToString()) ApiResponse.error<Unit>(ErrorCodes.CREATION_FAILED, response.errors.joinToString())
) )
} }
} }
@ -134,7 +223,7 @@ class PlatzController(
typ = typ.name, typ = typ.name,
istAktiv = istAktiv, istAktiv = istAktiv,
sortierReihenfolge = sortierReihenfolge, sortierReihenfolge = sortierReihenfolge,
createdAt = createdAt.toString(), createdAt = createdAt,
updatedAt = updatedAt.toString() updatedAt = updatedAt
) )
} }

View File

@ -0,0 +1,46 @@
-- Enforce natural key uniqueness for master-data upserts
-- Bundesland: unique (land_id, kuerzel) for non-null kuerzel values
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = 'ux_bundesland_land_kuerzel'
) THEN
CREATE UNIQUE INDEX ux_bundesland_land_kuerzel
ON bundesland(land_id, kuerzel)
WHERE kuerzel IS NOT NULL;
END IF;
END$$;
-- Platz: unique (turnier_id, name)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = 'ux_platz_turnier_name'
) THEN
CREATE UNIQUE INDEX ux_platz_turnier_name
ON platz(turnier_id, name);
END IF;
END$$;
-- Altersklasse: unique (altersklasse_code) ensure index exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = 'altersklasse_altersklasse_code_unique'
) THEN
CREATE UNIQUE INDEX altersklasse_altersklasse_code_unique
ON altersklasse(altersklasse_code);
END IF;
END$$;
-- Land: unique (iso_alpha3_code) ensure index exists
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = 'land_iso_alpha3_code_unique'
) THEN
CREATE UNIQUE INDEX land_iso_alpha3_code_unique
ON land(iso_alpha3_code);
END IF;
END$$;

View File

@ -0,0 +1,57 @@
package at.mocode.masterdata.api
import at.mocode.masterdata.api.plugins.IdempotencyPlugin
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.testing.*
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.util.concurrent.atomic.AtomicInteger
class IdempotencyPluginTest {
@Test
fun `second POST with same Idempotency-Key returns cached response and skips handler`() = testApplication {
val counter = AtomicInteger(0)
application {
// Install plugin directly for focused unit test
this@application.install(ContentNegotiation) { json() }
IdempotencyPlugin.install(this@application)
routing {
post("/echo") {
counter.incrementAndGet()
// Simuliere erfolgreiche Erstellung mit 201 und JSON-Body (als String, um Serializer-Probleme zu vermeiden)
val json = """{"ok":true,"count":${counter.get()}}"""
call.respondText(text = json, contentType = ContentType.Application.Json, status = HttpStatusCode.Created)
}
}
}
val key = "test-key-123"
val payload = """{"name":"Austria"}"""
val r1 = client.post("/echo") {
header("Idempotency-Key", key)
contentType(ContentType.Application.Json)
setBody(payload)
}
val r2 = client.post("/echo") {
header("Idempotency-Key", key)
contentType(ContentType.Application.Json)
setBody(payload)
}
assertThat(r1.status).isEqualTo(HttpStatusCode.Created)
assertThat(r2.status).isEqualTo(HttpStatusCode.Created)
assertThat(r2.bodyAsText()).isEqualTo(r1.bodyAsText())
// Sicherstellen, dass der Handler nur einmal ausgeführt wurde
assertThat(counter.get()).isEqualTo(1)
}
}

View File

@ -1,11 +1,11 @@
plugins { plugins {
kotlin("jvm") alias(libs.plugins.kotlinJvm)
} }
dependencies { dependencies {
implementation(projects.backend.services.masterdata.masterdataDomain) implementation(projects.backend.services.masterdata.masterdataDomain)
implementation(projects.core.coreDomain) implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils) implementation(projects.core.coreUtils)
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
testImplementation(projects.platform.platformTesting) testImplementation(projects.platform.platformTesting)
} }

View File

@ -121,8 +121,8 @@ class CreateAltersklasseUseCase(
updatedAt = now updatedAt = now
) )
// Save to repository // Upsert anhand des natürlichen Schlüssels (altersklasseCode)
val savedAltersklasse = altersklasseRepository.save(altersklasse) val savedAltersklasse = altersklasseRepository.upsertByCode(altersklasse)
return CreateAltersklasseResponse( return CreateAltersklasseResponse(
altersklasse = savedAltersklasse, altersklasse = savedAltersklasse,
success = true success = true

View File

@ -117,8 +117,8 @@ class CreateBundeslandUseCase(
updatedAt = now updatedAt = now
) )
// Save to repository // Upsert anhand des natürlichen Schlüssels (landId + kuerzel)
val savedBundesland = bundeslandRepository.save(bundesland) val savedBundesland = bundeslandRepository.upsertByLandIdAndKuerzel(bundesland)
return CreateBundeslandResponse( return CreateBundeslandResponse(
bundesland = savedBundesland, bundesland = savedBundesland,
success = true success = true

View File

@ -123,8 +123,8 @@ class CreateCountryUseCase(
updatedAt = now updatedAt = now
) )
// Save to repository // Upsert anhand des natürlichen Schlüssels (ISO Alpha-3)
val savedCountry = landRepository.save(country) val savedCountry = landRepository.upsertByIsoAlpha3(country)
return CreateCountryResponse( return CreateCountryResponse(
country = savedCountry, country = savedCountry,
success = true success = true

View File

@ -115,8 +115,8 @@ class CreatePlatzUseCase(
updatedAt = now updatedAt = now
) )
// Save to repository // Upsert anhand des natürlichen Schlüssels (turnierId + name)
val savedPlatz = platzRepository.save(platz) val savedPlatz = platzRepository.upsertByTurnierIdAndName(platz)
return CreatePlatzResponse( return CreatePlatzResponse(
platz = savedPlatz, platz = savedPlatz,
success = true success = true

View File

@ -4,28 +4,27 @@ plugins {
} }
kotlin { kotlin {
jvm() jvm()
js(IR) { js(IR) {
browser() browser()
} }
sourceSets { sourceSets {
val commonMain by getting { val commonMain by getting {
dependencies { dependencies {
// KORREKTUR: Diese zwei Zeilen hinzufügen implementation(projects.core.coreDomain)
implementation(projects.core.coreDomain) implementation(projects.core.coreUtils)
implementation(projects.core.coreUtils) }
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
val jvmTest by getting {
dependencies {
implementation(projects.platform.platformTesting)
}
}
} }
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
}
}
val jvmTest by getting {
dependencies {
implementation(projects.platform.platformTesting)
}
}
}
} }

View File

@ -108,6 +108,13 @@ interface AltersklasseRepository {
*/ */
suspend fun save(altersklasse: AltersklasseDefinition): AltersklasseDefinition suspend fun save(altersklasse: AltersklasseDefinition): AltersklasseDefinition
/**
* Upsert basierend auf dem natürlichen Schlüssel Altersklassen-Code.
* Existiert bereits ein Datensatz mit gleichem Code, wird er aktualisiert,
* ansonsten wird ein neuer Datensatz eingefügt.
*/
suspend fun upsertByCode(altersklasse: AltersklasseDefinition): AltersklasseDefinition
/** /**
* Deletes an age class by ID. * Deletes an age class by ID.
* *

View File

@ -75,6 +75,13 @@ interface BundeslandRepository {
*/ */
suspend fun save(bundesland: BundeslandDefinition): BundeslandDefinition suspend fun save(bundesland: BundeslandDefinition): BundeslandDefinition
/**
* Upsert basierend auf dem natürlichen Schlüssel (landId + kuerzel).
* Existiert bereits ein Datensatz mit gleicher Kombination, wird er aktualisiert,
* ansonsten wird ein neuer Datensatz eingefügt.
*/
suspend fun upsertByLandIdAndKuerzel(bundesland: BundeslandDefinition): BundeslandDefinition
/** /**
* Deletes a federal state by ID. * Deletes a federal state by ID.
* *

View File

@ -77,6 +77,13 @@ interface LandRepository {
*/ */
suspend fun save(land: LandDefinition): LandDefinition suspend fun save(land: LandDefinition): LandDefinition
/**
* Upsert basierend auf dem natürlichen Schlüssel ISO Alpha-3.
* Existiert bereits ein Datensatz mit gleichem ISO Alpha-3 Code, wird er aktualisiert,
* ansonsten wird ein neuer Datensatz eingefügt.
*/
suspend fun upsertByIsoAlpha3(land: LandDefinition): LandDefinition
/** /**
* Deletes a country by ID. * Deletes a country by ID.
* *

View File

@ -103,6 +103,13 @@ interface PlatzRepository {
*/ */
suspend fun save(platz: Platz): Platz suspend fun save(platz: Platz): Platz
/**
* Upsert basierend auf dem natürlichen Schlüssel (turnierId + name).
* Existiert bereits ein Datensatz mit gleicher Kombination, wird er aktualisiert,
* ansonsten wird ein neuer Datensatz eingefügt.
*/
suspend fun upsertByTurnierIdAndName(platz: Platz): Platz
/** /**
* Deletes a venue by ID. * Deletes a venue by ID.
* *

View File

@ -1,16 +1,16 @@
plugins { plugins {
kotlin("jvm") alias(libs.plugins.kotlinJvm)
alias(libs.plugins.spring.boot) apply false alias(libs.plugins.spring.boot) apply false
alias(libs.plugins.spring.dependencyManagement) alias(libs.plugins.spring.dependencyManagement)
alias(libs.plugins.kotlinSpring) alias(libs.plugins.kotlinSpring)
} }
dependencies { dependencies {
implementation(projects.platform.platformDependencies) implementation(projects.platform.platformDependencies)
implementation(projects.backend.services.masterdata.masterdataDomain) implementation(projects.backend.services.masterdata.masterdataDomain)
implementation(projects.core.coreDomain) implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils) implementation(projects.core.coreUtils)
implementation(projects.backend.infrastructure.cache.cacheApi) implementation(projects.backend.infrastructure.cache.cacheApi)
implementation(projects.backend.infrastructure.eventStore.eventStoreApi) implementation(projects.backend.infrastructure.eventStore.eventStoreApi)
implementation(projects.backend.infrastructure.messaging.messagingClient) implementation(projects.backend.infrastructure.messaging.messagingClient)
@ -21,7 +21,8 @@ dependencies {
implementation(libs.exposed.jdbc) implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime) implementation(libs.exposed.kotlin.datetime)
implementation("org.postgresql:postgresql") // Laufzeit-Treiber aus dem Version Catalog statt harter GAV
runtimeOnly(libs.postgresql.driver)
testImplementation(projects.platform.platformTesting) testImplementation(projects.platform.platformTesting)
} }

View File

@ -217,4 +217,58 @@ class AltersklasseRepositoryImpl : AltersklasseRepository {
ageOk && geschlechtOk ageOk && geschlechtOk
}.singleOrNull() ?: false }.singleOrNull() ?: false
} }
override suspend fun upsertByCode(altersklasse: AltersklasseDefinition): AltersklasseDefinition = DatabaseFactory.dbQuery {
// 1) Update anhand des natürlichen Schlüssels (altersklasse_code)
val updated = AltersklasseTable.update({ AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }) {
it[bezeichnung] = altersklasse.bezeichnung
it[minAlter] = altersklasse.minAlter
it[maxAlter] = altersklasse.maxAlter
it[stichtagRegelText] = altersklasse.stichtagRegelText
it[sparteFilter] = altersklasse.sparteFilter?.name
it[geschlechtFilter] = altersklasse.geschlechtFilter
it[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId
it[istAktiv] = altersklasse.istAktiv
it[updatedAt] = altersklasse.updatedAt
}
if (updated > 0) {
AltersklasseTable.selectAll().where { AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }
.map(::rowToAltersklasseDefinition)
.single()
} else {
// 2) Insert versuchen
try {
AltersklasseTable.insert {
it[id] = altersklasse.altersklasseId
it[altersklasseCode] = altersklasse.altersklasseCode
it[bezeichnung] = altersklasse.bezeichnung
it[minAlter] = altersklasse.minAlter
it[maxAlter] = altersklasse.maxAlter
it[stichtagRegelText] = altersklasse.stichtagRegelText
it[sparteFilter] = altersklasse.sparteFilter?.name
it[geschlechtFilter] = altersklasse.geschlechtFilter
it[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId
it[istAktiv] = altersklasse.istAktiv
it[createdAt] = altersklasse.createdAt
it[updatedAt] = altersklasse.updatedAt
}
} catch (e: Exception) {
// Race-Fallback bei Unique-Constraint
AltersklasseTable.update({ AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }) {
it[bezeichnung] = altersklasse.bezeichnung
it[minAlter] = altersklasse.minAlter
it[maxAlter] = altersklasse.maxAlter
it[stichtagRegelText] = altersklasse.stichtagRegelText
it[sparteFilter] = altersklasse.sparteFilter?.name
it[geschlechtFilter] = altersklasse.geschlechtFilter
it[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId
it[istAktiv] = altersklasse.istAktiv
it[updatedAt] = altersklasse.updatedAt
}
}
AltersklasseTable.selectAll().where { AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }
.map(::rowToAltersklasseDefinition)
.single()
}
}
} }

View File

@ -133,4 +133,74 @@ class BundeslandRepositoryImpl : BundeslandRepository {
BundeslandTable.selectAll().where { (BundeslandTable.landId eq landId) and (BundeslandTable.istAktiv eq true) } BundeslandTable.selectAll().where { (BundeslandTable.landId eq landId) and (BundeslandTable.istAktiv eq true) }
.count() .count()
} }
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
0
} else {
BundeslandTable.update({ (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }) {
it[landId] = bundesland.landId
it[oepsCode] = bundesland.oepsCode
it[iso3166_2_Code] = bundesland.iso3166_2_Code
it[name] = bundesland.name
it[kuerzel] = bundesland.kuerzel
it[wappenUrl] = bundesland.wappenUrl
it[istAktiv] = bundesland.istAktiv
it[sortierReihenfolge] = bundesland.sortierReihenfolge
it[updatedAt] = bundesland.updatedAt
}
}
if (updated > 0) {
BundeslandTable.selectAll()
.where { (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }
.map(::rowToBundeslandDefinition)
.single()
} else {
// 2) Insert versuchen
try {
BundeslandTable.insert {
it[id] = bundesland.bundeslandId
it[landId] = bundesland.landId
it[oepsCode] = bundesland.oepsCode
it[iso3166_2_Code] = bundesland.iso3166_2_Code
it[name] = bundesland.name
it[kuerzel] = bundesland.kuerzel
it[wappenUrl] = bundesland.wappenUrl
it[istAktiv] = bundesland.istAktiv
it[sortierReihenfolge] = bundesland.sortierReihenfolge
it[createdAt] = bundesland.createdAt
it[updatedAt] = bundesland.updatedAt
}
} catch (e: Exception) {
// Race-Condition → erneut Update
if (bundesland.kuerzel != null) {
BundeslandTable.update({ (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }) {
it[landId] = bundesland.landId
it[oepsCode] = bundesland.oepsCode
it[iso3166_2_Code] = bundesland.iso3166_2_Code
it[name] = bundesland.name
it[kuerzel] = bundesland.kuerzel
it[wappenUrl] = bundesland.wappenUrl
it[istAktiv] = bundesland.istAktiv
it[sortierReihenfolge] = bundesland.sortierReihenfolge
it[updatedAt] = bundesland.updatedAt
}
}
}
// Rückgabe des aktuellen Datensatzes: Falls Kuerzel null, greife auf ID zurück
if (bundesland.kuerzel != null) {
BundeslandTable.selectAll()
.where { (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }
.map(::rowToBundeslandDefinition)
.single()
} else {
BundeslandTable.selectAll().where { BundeslandTable.id eq bundesland.bundeslandId }
.map(::rowToBundeslandDefinition)
.single()
}
}
}
} }

View File

@ -27,5 +27,8 @@ object BundeslandTable : Table("bundesland") {
init { init {
uniqueIndex("idx_bundesland_oeps", oepsCode, landId) uniqueIndex("idx_bundesland_oeps", oepsCode, landId)
uniqueIndex("idx_bundesland_iso", iso3166_2_Code) uniqueIndex("idx_bundesland_iso", iso3166_2_Code)
// Natürlicher Schlüssel gem. Architektur: (land_id + kuerzel) eindeutig
// Hinweis: kuerzel kann NULL sein der Unique-Index greift dann nur für nicht-null Werte pro Land.
uniqueIndex("ux_bundesland_land_kuerzel", landId, kuerzel)
} }
} }

View File

@ -4,6 +4,8 @@ package at.mocode.masterdata.infrastructure.persistence
import at.mocode.masterdata.domain.model.LandDefinition import at.mocode.masterdata.domain.model.LandDefinition
import at.mocode.masterdata.domain.repository.LandRepository import at.mocode.masterdata.domain.repository.LandRepository
import at.mocode.core.utils.database.DatabaseFactory 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.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.or import org.jetbrains.exposed.v1.core.or
@ -13,6 +15,7 @@ import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update import org.jetbrains.exposed.v1.jdbc.update
import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.like import org.jetbrains.exposed.v1.core.like
import java.util.concurrent.ConcurrentHashMap
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
/** /**
@ -20,6 +23,11 @@ import kotlin.uuid.Uuid
*/ */
class LandRepositoryImpl : LandRepository { class LandRepositoryImpl : LandRepository {
companion object {
// PerKey Locks, um konkurrierende Upserts auf denselben natürlichen Schlüssel zu serialisieren
private val upsertLocks = ConcurrentHashMap<String, Any>()
}
private fun rowToLandDefinition(row: ResultRow): LandDefinition { private fun rowToLandDefinition(row: ResultRow): LandDefinition {
return LandDefinition( return LandDefinition(
landId = row[LandTable.id], landId = row[LandTable.id],
@ -138,4 +146,70 @@ class LandRepositoryImpl : LandRepository {
override suspend fun countActive(): Long = DatabaseFactory.dbQuery { override suspend fun countActive(): Long = DatabaseFactory.dbQuery {
LandTable.selectAll().where { LandTable.istAktiv eq true }.count() LandTable.selectAll().where { LandTable.istAktiv eq true }.count()
} }
}
override suspend fun upsertByIsoAlpha3(land: LandDefinition): LandDefinition {
val naturalKey = land.isoAlpha3Code.uppercase()
val lock = upsertLocks.computeIfAbsent(naturalKey) { Any() }
// Blockierend ausführen, damit kein suspend innerhalb synchronized stattfindet
return withContext(Dispatchers.IO) {
synchronized(lock) {
DatabaseFactory.transaction {
// 1) Versuche Update anhand natürlichem Schlüssel (ISO Alpha-3)
val updated = LandTable.update({ LandTable.isoAlpha3Code eq naturalKey }) {
it[isoAlpha2Code] = land.isoAlpha2Code.uppercase()
it[isoAlpha3Code] = naturalKey
it[isoNumerischerCode] = land.isoNumerischerCode
it[nameDeutsch] = land.nameDeutsch
it[nameEnglisch] = land.nameEnglisch
it[wappenUrl] = land.wappenUrl
it[istEuMitglied] = land.istEuMitglied
it[istEwrMitglied] = land.istEwrMitglied
it[istAktiv] = land.istAktiv
it[sortierReihenfolge] = land.sortierReihenfolge
it[updatedAt] = land.updatedAt
}
if (updated == 0) {
// 2) Kein Update → Insert versuchen
try {
LandTable.insert {
it[id] = land.landId
it[isoAlpha2Code] = land.isoAlpha2Code.uppercase()
it[isoAlpha3Code] = naturalKey
it[isoNumerischerCode] = land.isoNumerischerCode
it[nameDeutsch] = land.nameDeutsch
it[nameEnglisch] = land.nameEnglisch
it[wappenUrl] = land.wappenUrl
it[istEuMitglied] = land.istEuMitglied
it[istEwrMitglied] = land.istEwrMitglied
it[istAktiv] = land.istAktiv
it[sortierReihenfolge] = land.sortierReihenfolge
it[createdAt] = land.createdAt
it[updatedAt] = land.updatedAt
}
} catch (e: Exception) {
// Race-Condition (Unique-Constraint gegriffen) → erneut mit Update abrunden
LandTable.update({ LandTable.isoAlpha3Code eq naturalKey }) {
it[isoAlpha2Code] = land.isoAlpha2Code.uppercase()
it[isoAlpha3Code] = naturalKey
it[isoNumerischerCode] = land.isoNumerischerCode
it[nameDeutsch] = land.nameDeutsch
it[nameEnglisch] = land.nameEnglisch
it[wappenUrl] = land.wappenUrl
it[istEuMitglied] = land.istEuMitglied
it[istEwrMitglied] = land.istEwrMitglied
it[istAktiv] = land.istAktiv
it[sortierReihenfolge] = land.sortierReihenfolge
it[updatedAt] = land.updatedAt
}
}
}
// Rückgabe des aktuellen Datensatzes
LandTable.selectAll().where { LandTable.isoAlpha3Code eq naturalKey }
.map(::rowToLandDefinition)
.single()
}
}
}
}
}

View File

@ -22,83 +22,75 @@ import kotlin.uuid.Uuid
*/ */
class PlatzRepositoryImpl : PlatzRepository { class PlatzRepositoryImpl : PlatzRepository {
private fun rowToPlatz(row: ResultRow): Platz { private fun rowToPlatz(row: ResultRow): Platz {
return Platz( return Platz(
id = row[PlatzTable.id], id = row[PlatzTable.id],
turnierId = row[PlatzTable.turnierId], turnierId = row[PlatzTable.turnierId],
name = row[PlatzTable.name], name = row[PlatzTable.name],
dimension = row[PlatzTable.dimension], dimension = row[PlatzTable.dimension],
boden = row[PlatzTable.boden], boden = row[PlatzTable.boden],
typ = PlatzTypE.valueOf(row[PlatzTable.typ]), typ = PlatzTypE.valueOf(row[PlatzTable.typ]),
istAktiv = row[PlatzTable.istAktiv], istAktiv = row[PlatzTable.istAktiv],
sortierReihenfolge = row[PlatzTable.sortierReihenfolge], sortierReihenfolge = row[PlatzTable.sortierReihenfolge],
createdAt = row[PlatzTable.createdAt], createdAt = row[PlatzTable.createdAt],
updatedAt = row[PlatzTable.updatedAt] updatedAt = row[PlatzTable.updatedAt]
) )
} }
override suspend fun findById(id: Uuid): Platz? = DatabaseFactory.dbQuery { override suspend fun findById(id: Uuid): Platz? = DatabaseFactory.dbQuery {
PlatzTable.selectAll().where { PlatzTable.id eq id } PlatzTable.selectAll().where { PlatzTable.id eq id }
.map(::rowToPlatz) .map(::rowToPlatz)
.singleOrNull() .singleOrNull()
} }
override suspend fun findByTournament(turnierId: Uuid, activeOnly: Boolean, orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery { override suspend fun findByTournament(turnierId: Uuid, activeOnly: Boolean, orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.turnierId eq turnierId } val query = PlatzTable.selectAll().where { PlatzTable.turnierId eq turnierId }
if (activeOnly) { if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
query.andWhere { PlatzTable.istAktiv eq true } if (orderBySortierung) {
} query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
if (orderBySortierung) { } else {
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC) query.orderBy(PlatzTable.name to SortOrder.ASC)
} else {
query.orderBy(PlatzTable.name to SortOrder.ASC)
}
query.map(::rowToPlatz)
} }
query.map(::rowToPlatz)
}
override suspend fun findByName(searchTerm: String, turnierId: Uuid?, limit: Int): List<Platz> = DatabaseFactory.dbQuery { override suspend fun findByName(searchTerm: String, turnierId: Uuid?, limit: Int): List<Platz> = DatabaseFactory.dbQuery {
val pattern = "%$searchTerm%" val pattern = "%$searchTerm%"
val query = PlatzTable.selectAll().where { PlatzTable.name like pattern } val query = PlatzTable.selectAll().where { PlatzTable.name like pattern }
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } } turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
query.limit(limit).map(::rowToPlatz) query.limit(limit).map(::rowToPlatz)
} }
override suspend fun findByType(typ: PlatzTypE, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery { override suspend fun findByType(typ: PlatzTypE, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.typ eq typ.name } val query = PlatzTable.selectAll().where { PlatzTable.typ eq typ.name }
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } } turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
if (activeOnly) { if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
query.andWhere { PlatzTable.istAktiv eq true } query.map(::rowToPlatz)
} }
query.map(::rowToPlatz)
}
override suspend fun findByGroundType(boden: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery { override suspend fun findByGroundType(boden: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.boden eq boden } val query = PlatzTable.selectAll().where { PlatzTable.boden eq boden }
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } } turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
if (activeOnly) { if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
query.andWhere { PlatzTable.istAktiv eq true } query.map(::rowToPlatz)
} }
query.map(::rowToPlatz)
}
override suspend fun findByDimensions(dimension: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery { override suspend fun findByDimensions(dimension: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.dimension eq dimension } val query = PlatzTable.selectAll().where { PlatzTable.dimension eq dimension }
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } } turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
if (activeOnly) { if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
query.andWhere { PlatzTable.istAktiv eq true } query.map(::rowToPlatz)
} }
query.map(::rowToPlatz)
}
override suspend fun findAllActive(orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery { override suspend fun findAllActive(orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.istAktiv eq true } val query = PlatzTable.selectAll().where { PlatzTable.istAktiv eq true }
if (orderBySortierung) { if (orderBySortierung) {
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC) query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
} else { } else {
query.orderBy(PlatzTable.name to SortOrder.ASC) query.orderBy(PlatzTable.name to SortOrder.ASC)
}
query.map(::rowToPlatz)
} }
query.map(::rowToPlatz)
}
override suspend fun findSuitableForDiscipline( override suspend fun findSuitableForDiscipline(
requiredType: PlatzTypE, requiredType: PlatzTypE,
@ -110,23 +102,58 @@ class PlatzRepositoryImpl : PlatzRepository {
turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } } turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } }
query.andWhere { PlatzTable.istAktiv eq true } query.andWhere { PlatzTable.istAktiv eq true }
query.map(::rowToPlatz) query.map(::rowToPlatz)
} }
override suspend fun save(platz: Platz): Platz = DatabaseFactory.dbQuery { override suspend fun save(platz: Platz): Platz = DatabaseFactory.dbQuery {
val exists = PlatzTable.selectAll().where { PlatzTable.id eq platz.id }.any() val exists = PlatzTable.selectAll().where { PlatzTable.id eq platz.id }.any()
if (exists) { if (exists) {
PlatzTable.update({ PlatzTable.id eq platz.id }) { PlatzTable.update({ PlatzTable.id eq platz.id }) {
it[turnierId] = platz.turnierId it[turnierId] = platz.turnierId
it[name] = platz.name it[name] = platz.name
it[dimension] = platz.dimension it[dimension] = platz.dimension
it[boden] = platz.boden it[boden] = platz.boden
it[typ] = platz.typ.name it[typ] = platz.typ.name
it[istAktiv] = platz.istAktiv it[istAktiv] = platz.istAktiv
it[sortierReihenfolge] = platz.sortierReihenfolge it[sortierReihenfolge] = platz.sortierReihenfolge
it[updatedAt] = platz.updatedAt it[updatedAt] = platz.updatedAt
} }
platz platz
} else { } else {
PlatzTable.insert {
it[id] = platz.id
it[turnierId] = platz.turnierId
it[name] = platz.name
it[dimension] = platz.dimension
it[boden] = platz.boden
it[typ] = platz.typ.name
it[istAktiv] = platz.istAktiv
it[sortierReihenfolge] = platz.sortierReihenfolge
it[createdAt] = platz.createdAt
it[updatedAt] = platz.updatedAt
}
platz
}
}
override suspend fun upsertByTurnierIdAndName(platz: Platz): Platz = DatabaseFactory.dbQuery {
// 1) Versuch: Update anhand (turnierId + name)
val updated = PlatzTable.update({ (PlatzTable.turnierId eq platz.turnierId) and (PlatzTable.name eq platz.name) }) {
it[turnierId] = platz.turnierId
it[name] = platz.name
it[dimension] = platz.dimension
it[boden] = platz.boden
it[typ] = platz.typ.name
it[istAktiv] = platz.istAktiv
it[sortierReihenfolge] = platz.sortierReihenfolge
it[updatedAt] = platz.updatedAt
}
if (updated > 0) {
PlatzTable.selectAll().where { (PlatzTable.turnierId eq platz.turnierId) and (PlatzTable.name eq platz.name) }
.map(::rowToPlatz)
.single()
} else {
// 2) Kein Update → Insert versuchen
try {
PlatzTable.insert { PlatzTable.insert {
it[id] = platz.id it[id] = platz.id
it[turnierId] = platz.turnierId it[turnierId] = platz.turnierId
@ -138,34 +165,47 @@ class PlatzRepositoryImpl : PlatzRepository {
it[sortierReihenfolge] = platz.sortierReihenfolge it[sortierReihenfolge] = platz.sortierReihenfolge
it[createdAt] = platz.createdAt it[createdAt] = platz.createdAt
it[updatedAt] = platz.updatedAt it[updatedAt] = platz.updatedAt
}
platz
} }
} } catch (e: Exception) {
// Race-Condition (Unique-Constraint gegriffen) → erneut via Update abrunden
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery { PlatzTable.update({ (PlatzTable.turnierId eq platz.turnierId) and (PlatzTable.name eq platz.name) }) {
PlatzTable.deleteWhere { PlatzTable.id eq id } > 0 it[turnierId] = platz.turnierId
} it[name] = platz.name
it[dimension] = platz.dimension
override suspend fun existsByNameAndTournament(name: String, turnierId: Uuid): Boolean = DatabaseFactory.dbQuery { it[boden] = platz.boden
PlatzTable.selectAll().where { (PlatzTable.name eq name) and (PlatzTable.turnierId eq turnierId) }.any() it[typ] = platz.typ.name
} it[istAktiv] = platz.istAktiv
it[sortierReihenfolge] = platz.sortierReihenfolge
override suspend fun countActiveByTournament(turnierId: Uuid): Long = DatabaseFactory.dbQuery { it[updatedAt] = platz.updatedAt
PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) }.count()
}
override suspend fun countByTypeAndTournament(typ: PlatzTypE, turnierId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.typ eq typ.name) }
if (activeOnly) {
query.andWhere { PlatzTable.istAktiv eq true }
} }
query.count() }
PlatzTable.selectAll().where { (PlatzTable.turnierId eq platz.turnierId) and (PlatzTable.name eq platz.name) }
.map(::rowToPlatz)
.single()
} }
}
override suspend fun findAvailableForTimeSlot(turnierId: Uuid, startTime: String?, endTime: String?): List<Platz> = DatabaseFactory.dbQuery { override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
// Derzeit gibt die Methode einfach alle aktiven Plätze des Turniers zurück. PlatzTable.deleteWhere { PlatzTable.id eq id } > 0
PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) } }
.map(::rowToPlatz)
} override suspend fun existsByNameAndTournament(name: String, turnierId: Uuid): Boolean = DatabaseFactory.dbQuery {
PlatzTable.selectAll().where { (PlatzTable.name eq name) and (PlatzTable.turnierId eq turnierId) }.any()
}
override suspend fun countActiveByTournament(turnierId: Uuid): Long = DatabaseFactory.dbQuery {
PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) }.count()
}
override suspend fun countByTypeAndTournament(typ: PlatzTypE, turnierId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.typ eq typ.name) }
if (activeOnly) query.andWhere { PlatzTable.istAktiv eq true }
query.count()
}
override suspend fun findAvailableForTimeSlot(turnierId: Uuid, startTime: String?, endTime: String?): List<Platz> = DatabaseFactory.dbQuery {
// Derzeit gibt die Methode einfach alle aktiven Plätze des Turniers zurück.
PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) }
.map(::rowToPlatz)
}
} }

View File

@ -25,5 +25,7 @@ object PlatzTable : Table("platz") {
init { init {
index("idx_platz_turnier", isUnique = false, turnierId) index("idx_platz_turnier", isUnique = false, turnierId)
// Natürlicher Schlüssel gem. Architektur: (turnier_id + name) eindeutig
uniqueIndex("ux_platz_turnier_name", turnierId, name)
} }
} }

View File

@ -0,0 +1,118 @@
package at.mocode.masterdata.infrastructure.persistence
import at.mocode.masterdata.domain.model.LandDefinition
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
import org.jetbrains.exposed.v1.jdbc.selectAll
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.Instant
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class LandRepositoryImplTest {
private lateinit var repo: LandRepositoryImpl
@BeforeAll
fun initDb() {
// In-Memory H2 DB
Database.connect("jdbc:h2:mem:landrepo;DB_CLOSE_DELAY=-1", driver = "org.h2.Driver")
transaction {
SchemaUtils.create(LandTable)
}
repo = LandRepositoryImpl()
}
@Test
fun `upsertByIsoAlpha3 performs update then insert semantics`() {
runBlocking {
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
val id = Uuid.random()
val base = LandDefinition(
landId = id,
isoAlpha2Code = "ZZ",
isoAlpha3Code = "ZZY",
isoNumerischerCode = null,
nameDeutsch = "Testland",
nameEnglisch = null,
wappenUrl = null,
istEuMitglied = null,
istEwrMitglied = null,
istAktiv = true,
sortierReihenfolge = 1,
createdAt = now,
updatedAt = now
)
// 1) Insert path
val saved1 = repo.upsertByIsoAlpha3(base)
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 saved2 = repo.upsertByIsoAlpha3(updated)
assertThat(saved2.nameDeutsch).isEqualTo("Testland Neu")
assertThat(saved2.sortierReihenfolge).isEqualTo(2)
// Stelle sicher, dass nur ein Datensatz existiert
val count = transaction { LandTable.selectAll().count() }
assertThat(count).isEqualTo(1)
}
}
@Test
fun `upsertByIsoAlpha3 handles race with two concurrent upserts on same natural key`() {
runBlocking {
// Sicherstellen, dass der Test mit leerer Tabelle startet
transaction {
SchemaUtils.drop(LandTable)
SchemaUtils.create(LandTable)
}
val now = Instant.fromEpochMilliseconds(System.currentTimeMillis())
val base1 = LandDefinition(
landId = Uuid.random(),
isoAlpha2Code = "ZX",
isoAlpha3Code = "ZXY",
isoNumerischerCode = null,
nameDeutsch = "RaceLand",
nameEnglisch = null,
wappenUrl = null,
istEuMitglied = null,
istEwrMitglied = null,
istAktiv = true,
sortierReihenfolge = 5,
createdAt = now,
updatedAt = now
)
val base2 = base1.copy(landId = Uuid.random(), nameDeutsch = "RaceLand Zwei", sortierReihenfolge = 6, updatedAt = Instant.fromEpochMilliseconds(System.currentTimeMillis()))
// Feuere zwei parallele Upserts
val d1 = async(Dispatchers.Default) { repo.upsertByIsoAlpha3(base1) }
val d2 = async(Dispatchers.Default) { repo.upsertByIsoAlpha3(base2) }
val r1 = d1.await()
val r2 = d2.await()
// Beide Aufrufe dürfen nicht fehlschlagen und müssen denselben Datensatz (natürlicher Schlüssel) repräsentieren
assertThat(r1.isoAlpha3Code).isEqualTo("ZXY")
assertThat(r2.isoAlpha3Code).isEqualTo("ZXY")
// Es soll genau 1 Datensatz existieren
val count = transaction { LandTable.selectAll().count() }
assertThat(count).isEqualTo(1)
// Finaler Zustand entspricht einem der beiden Upserts (update hat "gewonnen"); Name ist entweder "RaceLand" oder "RaceLand Zwei"
val finalRow = transaction { LandTable.selectAll().first() }
val finalName = finalRow[LandTable.nameDeutsch]
assertThat(finalName == "RaceLand" || finalName == "RaceLand Zwei").isTrue()
}
}
}

View File

@ -17,6 +17,7 @@ dependencies {
implementation(projects.backend.services.masterdata.masterdataDomain) implementation(projects.backend.services.masterdata.masterdataDomain)
implementation(projects.backend.services.masterdata.masterdataInfrastructure) implementation(projects.backend.services.masterdata.masterdataInfrastructure)
implementation(projects.backend.services.masterdata.masterdataCommon) implementation(projects.backend.services.masterdata.masterdataCommon)
// Benötigt, da der Service die API-Controller (Ktor) als Beans verdrahtet
implementation(projects.backend.services.masterdata.masterdataApi) implementation(projects.backend.services.masterdata.masterdataApi)
// Infrastruktur-Clients // Infrastruktur-Clients
@ -32,6 +33,12 @@ dependencies {
implementation(libs.spring.boot.starter.actuator) implementation(libs.spring.boot.starter.actuator)
//implementation(libs.springdoc.openapi.starter.webmvc.ui) //implementation(libs.springdoc.openapi.starter.webmvc.ui)
// Ktor Server (für SCS: eigener kleiner HTTP-Server pro Kontext)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.contentNegotiation)
implementation(libs.ktor.server.serialization.kotlinx.json)
// Datenbank-Abhängigkeiten // Datenbank-Abhängigkeiten
implementation(libs.exposed.core) implementation(libs.exposed.core)
implementation(libs.exposed.dao) implementation(libs.exposed.dao)

View File

@ -0,0 +1,50 @@
package at.mocode.masterdata.service.config
import at.mocode.masterdata.api.masterdataApiModule
import at.mocode.masterdata.api.rest.AltersklasseController
import at.mocode.masterdata.api.rest.BundeslandController
import at.mocode.masterdata.api.rest.CountryController
import at.mocode.masterdata.api.rest.PlatzController
import io.ktor.server.engine.embeddedServer
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.netty.NettyApplicationEngine
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.DisposableBean
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
/**
* Ktor-Server Bootstrap für den Masterdata-Bounded-Context (SCS-Architektur).
*
* - Startet einen eigenen Ktor Netty Server für diesen Kontext.
* - Hängt das masterdataApiModule mit den via Spring bereitgestellten Controllern ein.
* - Port ist konfigurierbar über SPRING-Config/ENV (Default 8091). Für Tests kann Port 0 genutzt werden.
*/
@Configuration
class KtorServerConfiguration {
private val log = LoggerFactory.getLogger(KtorServerConfiguration::class.java)
@Bean(destroyMethod = "stop")
fun ktorServer(
@Value("\${masterdata.http.port:8091}") port: Int,
countryController: CountryController,
bundeslandController: BundeslandController,
altersklasseController: AltersklasseController,
platzController: PlatzController
): EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration> {
log.info("Starting Masterdata Ktor server on port {}", port)
val engine = embeddedServer(Netty, port = port) {
masterdataApiModule(
countryController = countryController,
bundeslandController = bundeslandController,
altersklasseController = altersklasseController,
platzController = platzController
)
}
engine.start(wait = false)
return engine
}
}

View File

@ -0,0 +1,107 @@
package at.mocode.masterdata.service.api
import at.mocode.masterdata.service.MasterdataServiceApplication
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterAll
import org.junit.jupiter.api.BeforeAll
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
@SpringBootTest(
classes = [MasterdataServiceApplication::class],
properties = [
"spring.main.web-application-type=none",
"masterdata.http.port=18091" // fixed port for tests to simplify port discovery
]
)
@ActiveProfiles("test")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class IdempotencyApiIntegrationTest {
private lateinit var baseUri: String
@BeforeAll
fun setUp() {
baseUri = "http://localhost:18091"
}
@AfterAll
fun tearDown() {
// Server lifecycle managed by Spring; no explicit stop here.
}
@Test
fun `second POST with same Idempotency-Key returns identical response and does not create duplicate`() {
val client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build()
val payload = """
{
"isoAlpha2Code": "ZZ",
"isoAlpha3Code": "ZZY",
"nameDeutsch": "Testland Idempotency"
}
""".trimIndent()
val key = "itest-key-001"
val req1 = HttpRequest.newBuilder()
.uri(URI.create("$baseUri/countries"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build()
val res1 = sendWithRetry(client, req1)
val req2 = HttpRequest.newBuilder()
.uri(URI.create("$baseUri/countries"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build()
val res2 = sendWithRetry(client, req2)
// Beide Antworten müssen identisch sein (Status + Body)
assertThat(res1.statusCode()).isIn(200, 201) // Created or OK
assertThat(res2.statusCode()).isEqualTo(res1.statusCode())
assertThat(res2.headers().firstValue("Content-Type").orElse("application/json")).contains("application/json")
assertThat(res2.body()).isEqualTo(res1.body())
// Verifizieren, dass kein Duplikat erzeugt wurde:
// GET /countries?search=Testland%20Idempotency&unpaged=true sollte genau 1 Element enthalten
val resList = sendWithRetry(
client,
HttpRequest.newBuilder()
.uri(URI.create("$baseUri/countries?search=Testland%20Idempotency&unpaged=true"))
.GET()
.build()
)
assertThat(resList.statusCode()).isEqualTo(200)
// sehr leichte Prüfung auf genau ein Ergebnis: zähle Vorkommen der isoAlpha3Code in JSON
val occurrences = Regex("\"isoAlpha3Code\"\\s*:\\s*\"ZZY\"").findAll(resList.body()).count()
assertThat(occurrences).isEqualTo(1)
}
private fun sendWithRetry(client: HttpClient, request: HttpRequest, attempts: Int = 10, delayMillis: Long = 100): HttpResponse<String> {
var lastEx: Exception? = null
repeat(attempts) { idx ->
try {
return client.send(request, HttpResponse.BodyHandlers.ofString())
} catch (e: Exception) {
lastEx = e
if (idx == attempts - 1) throw e
Thread.sleep(delayMillis)
}
}
throw lastEx ?: IllegalStateException("unreachable")
}
}

View File

@ -13,4 +13,15 @@ object ErrorCodes {
val DATABASE_ERROR = ErrorCode("DATABASE_ERROR") val DATABASE_ERROR = ErrorCode("DATABASE_ERROR")
val TRANSACTION_ERROR = ErrorCode("TRANSACTION_ERROR") val TRANSACTION_ERROR = ErrorCode("TRANSACTION_ERROR")
val VALIDATION_ERROR = ErrorCode("VALIDATION_ERROR") val VALIDATION_ERROR = ErrorCode("VALIDATION_ERROR")
// Allgemeine API-/HTTP-nahe Fehlercodes zur Harmonisierung
val INVALID_ID = ErrorCode("INVALID_ID")
val NOT_FOUND = ErrorCode("NOT_FOUND")
val CREATION_FAILED = ErrorCode("CREATION_FAILED")
val UPDATE_FAILED = ErrorCode("UPDATE_FAILED")
val DELETE_FAILED = ErrorCode("DELETE_FAILED")
val CONFLICT = ErrorCode("CONFLICT")
val MISSING_PARAMETER = ErrorCode("MISSING_PARAMETER")
val INVALID_PARAMETER = ErrorCode("INVALID_PARAMETER")
val UNAUTHORIZED = ErrorCode("UNAUTHORIZED")
val FORBIDDEN = ErrorCode("FORBIDDEN")
} }

View File

@ -0,0 +1,36 @@
package at.mocode.core.domain.model
import kotlinx.serialization.Serializable
/**
* Gemeinsame Query-Modelle für Filter, Sortierung und Pagination.
*/
@Serializable
enum class SortDirection { ASC, DESC }
@Serializable
data class SortParam(
val field: String,
val direction: SortDirection = SortDirection.ASC
)
@Serializable
data class PageRequest(
val page: PageNumber = PageNumber(0),
/**
* Wenn size == -1 oder unpaged == true, wird die Paginierung aufgehoben und alle Treffer geliefert.
*/
val size: Int = 20,
val sort: List<SortParam> = emptyList(),
val unpaged: Boolean = false
)
@Serializable
data class FilterParams(
val search: String? = null,
/**
* active: true = nur aktive (Default), false = nur inaktive, all = alle
*/
val active: String? = null
)