From 74df3514aeca713815f981378707201aed282601 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Sat, 28 Mar 2026 20:48:36 +0100 Subject: [PATCH] Integrate advanced filtering, sorting, and pagination logic into Altersklasse, Bundesland, and Platz controllers. Enhance error handling with centralized ErrorCodes and update date serialization for consistent handling of Instant values. --- .../api/rest/AltersklasseController.kt | 118 +++++++++++++++-- .../api/rest/BundeslandController.kt | 119 +++++++++++++++--- .../masterdata/api/rest/CountryController.kt | 6 +- .../masterdata/api/rest/PlatzController.kt | 113 +++++++++++++++-- 4 files changed, 313 insertions(+), 43 deletions(-) diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/AltersklasseController.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/AltersklasseController.kt index f3aa2ea2..6bb99a1e 100644 --- a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/AltersklasseController.kt +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/AltersklasseController.kt @@ -3,6 +3,9 @@ package at.mocode.masterdata.api.rest 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.masterdata.application.usecase.CreateAltersklasseUseCase 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.routing.* 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. @@ -34,8 +40,10 @@ class AltersklasseController( val geschlechtFilter: String? = null, val oetoRegelReferenzId: String? = null, val istAktiv: Boolean = true, - val createdAt: String, - val updatedAt: String + @Serializable(with = InstantSerializer::class) + val createdAt: Instant, + @Serializable(with = InstantSerializer::class) + val updatedAt: Instant ) @Serializable @@ -76,9 +84,93 @@ class AltersklasseController( } val geschlecht = call.request.queryParameters["geschlecht"]?.getOrNull(0) - val response = getAltersklasseUseCase.getAllActive(sparte, geschlecht) - val dtos = response.altersklassen.map { it.toDto() } - call.respond(ApiResponse.success(dtos)) + val search = call.request.queryParameters["search"]?.trim().takeUnless { it.isNullOrBlank() } + 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 { 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 + 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}") { @@ -92,13 +184,13 @@ class AltersklasseController( } ?: return@get call.respond( HttpStatusCode.BadRequest, - ApiResponse.error("INVALID_ID", "Missing or invalid ID") + ApiResponse.error(ErrorCodes.INVALID_ID, "Missing or invalid ID") ) val response = getAltersklasseUseCase.getById(id) response.altersklasse?.let { call.respond(ApiResponse.success(it.toDto())) - } ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error("NOT_FOUND", "Age class not found")) + } ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error(ErrorCodes.NOT_FOUND, "Age class not found")) } post { @@ -150,7 +242,7 @@ class AltersklasseController( } ?: return@put call.respond( HttpStatusCode.BadRequest, - ApiResponse.error("INVALID_ID", "Missing or invalid ID") + ApiResponse.error(ErrorCodes.INVALID_ID, "Missing or invalid ID") ) val dto = call.receive() @@ -186,7 +278,7 @@ class AltersklasseController( } else { call.respond( HttpStatusCode.BadRequest, - ApiResponse.error("UPDATE_FAILED", response.errors.joinToString()) + ApiResponse.error(ErrorCodes.UPDATE_FAILED, response.errors.joinToString()) ) } } @@ -202,7 +294,7 @@ class AltersklasseController( } ?: return@delete call.respond( HttpStatusCode.BadRequest, - ApiResponse.error("INVALID_ID", "Missing or invalid ID") + ApiResponse.error(ErrorCodes.INVALID_ID, "Missing or invalid ID") ) val response = createAltersklasseUseCase.deleteAltersklasse(id) @@ -211,7 +303,7 @@ class AltersklasseController( } else { call.respond( HttpStatusCode.NotFound, - ApiResponse.error("DELETE_FAILED", response.errors.joinToString()) + ApiResponse.error(ErrorCodes.DELETE_FAILED, response.errors.joinToString()) ) } } @@ -229,7 +321,7 @@ class AltersklasseController( geschlechtFilter = geschlechtFilter?.toString(), oetoRegelReferenzId = oetoRegelReferenzId?.toString(), istAktiv = istAktiv, - createdAt = createdAt.toString(), - updatedAt = updatedAt.toString() + createdAt = createdAt, + updatedAt = updatedAt ) } diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/BundeslandController.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/BundeslandController.kt index 660bcb0b..3ee09c68 100644 --- a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/BundeslandController.kt +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/BundeslandController.kt @@ -2,6 +2,9 @@ package at.mocode.masterdata.api.rest 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.GetBundeslandUseCase 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.routing.* 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. @@ -31,8 +37,10 @@ class BundeslandController( val wappenUrl: String? = null, val istAktiv: Boolean = true, val sortierReihenfolge: Int? = null, - val createdAt: String, - val updatedAt: String + @Serializable(with = InstantSerializer::class) + val createdAt: Instant, + @Serializable(with = InstantSerializer::class) + val updatedAt: Instant ) @Serializable @@ -58,14 +66,97 @@ class BundeslandController( } } - val response = if (landId != null) { - getBundeslandUseCase.getByCountry(landId) - } else { - getBundeslandUseCase.getAllActive() + val search = call.request.queryParameters["search"]?.trim().takeUnless { it.isNullOrBlank() } + 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, "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() } - call.respond(ApiResponse.success(dtos)) + 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.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 + 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}") { @@ -79,7 +170,7 @@ class BundeslandController( } ?: return@get call.respond( HttpStatusCode.BadRequest, - ApiResponse.error("INVALID_ID", "Missing or invalid ID") + ApiResponse.error(ErrorCodes.INVALID_ID, "Missing or invalid ID") ) val response = getBundeslandUseCase.getById(id) @@ -87,7 +178,7 @@ class BundeslandController( call.respond(ApiResponse.success(it.toDto())) } ?: call.respond( HttpStatusCode.NotFound, - ApiResponse.error("NOT_FOUND", "Federal state not found") + ApiResponse.error(ErrorCodes.NOT_FOUND, "Federal state not found") ) } @@ -98,7 +189,7 @@ class BundeslandController( } catch (e: Exception) { return@post call.respond( HttpStatusCode.BadRequest, - ApiResponse.error("INVALID_LAND_ID", "Invalid landId format") + ApiResponse.error(ErrorCodes.INVALID_PARAMETER, "Invalid landId format") ) } @@ -120,7 +211,7 @@ class BundeslandController( } else { call.respond( HttpStatusCode.BadRequest, - ApiResponse.error("CREATION_FAILED", response.errors.joinToString()) + ApiResponse.error(ErrorCodes.CREATION_FAILED, response.errors.joinToString()) ) } } @@ -137,7 +228,7 @@ class BundeslandController( wappenUrl = wappenUrl, istAktiv = istAktiv, sortierReihenfolge = sortierReihenfolge, - createdAt = createdAt.toString(), - updatedAt = updatedAt.toString() + createdAt = createdAt, + updatedAt = updatedAt ) } diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/CountryController.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/CountryController.kt index a65532c3..3da2e098 100644 --- a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/CountryController.kt +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/CountryController.kt @@ -4,21 +4,19 @@ package at.mocode.masterdata.api.rest 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.PageNumber -import at.mocode.core.domain.model.PageSize 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.GetCountryUseCase import at.mocode.masterdata.domain.model.LandDefinition -import kotlin.uuid.Uuid import io.ktor.http.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import kotlinx.serialization.Serializable import kotlin.math.min -import at.mocode.core.domain.serialization.InstantSerializer import kotlin.time.Instant +import kotlin.uuid.Uuid /** * REST API controller for country (Land) management. diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/PlatzController.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/PlatzController.kt index 01136844..20acceb1 100644 --- a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/PlatzController.kt +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/PlatzController.kt @@ -2,6 +2,9 @@ package at.mocode.masterdata.api.rest 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.masterdata.application.usecase.CreatePlatzUseCase 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.routing.* 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. @@ -31,8 +37,10 @@ class PlatzController( val typ: String, val istAktiv: Boolean = true, val sortierReihenfolge: Int? = null, - val createdAt: String, - val updatedAt: String + @Serializable(with = InstantSerializer::class) + val createdAt: Instant, + @Serializable(with = InstantSerializer::class) + val updatedAt: Instant ) @Serializable @@ -58,12 +66,93 @@ class PlatzController( } ?: return@get call.respond( HttpStatusCode.BadRequest, - ApiResponse.error("MISSING_TURNIER_ID", "Query parameter turnierId is required") + ApiResponse.error(ErrorCodes.MISSING_PARAMETER, "Query parameter turnierId is required") ) - val response = getPlatzUseCase.getByTournament(turnierId) - val dtos = response.plaetze.map { it.toDto() } - call.respond(ApiResponse.success(dtos)) + val search = call.request.queryParameters["search"]?.trim().takeUnless { it.isNullOrBlank() } + 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 { 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 + 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}") { @@ -77,13 +166,13 @@ class PlatzController( } ?: return@get call.respond( HttpStatusCode.BadRequest, - ApiResponse.error("INVALID_ID", "Missing or invalid ID") + ApiResponse.error(ErrorCodes.INVALID_ID, "Missing or invalid ID") ) val response = getPlatzUseCase.getById(id) response.platz?.let { call.respond(ApiResponse.success(it.toDto())) - } ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error("NOT_FOUND", "Venue not found")) + } ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error(ErrorCodes.NOT_FOUND, "Venue not found")) } post { @@ -93,7 +182,7 @@ class PlatzController( } catch (e: Exception) { return@post call.respond( HttpStatusCode.BadRequest, - ApiResponse.error("INVALID_TURNIER_ID", "Invalid turnierId format") + ApiResponse.error(ErrorCodes.INVALID_PARAMETER, "Invalid turnierId format") ) } @@ -118,7 +207,7 @@ class PlatzController( } else { call.respond( HttpStatusCode.BadRequest, - ApiResponse.error("CREATION_FAILED", response.errors.joinToString()) + ApiResponse.error(ErrorCodes.CREATION_FAILED, response.errors.joinToString()) ) } } @@ -134,7 +223,7 @@ class PlatzController( typ = typ.name, istAktiv = istAktiv, sortierReihenfolge = sortierReihenfolge, - createdAt = createdAt.toString(), - updatedAt = updatedAt.toString() + createdAt = createdAt, + updatedAt = updatedAt ) }