Compare commits
5 Commits
6ef1fd4ca6
...
8c2a82403e
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c2a82403e | |||
| eedce74a85 | |||
| 9ec8535ff7 | |||
| 74df3514ae | |||
| f91b067b36 |
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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$$;
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
// Per‑Key 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user