diff --git a/backend/services/masterdata/masterdata-api/build.gradle.kts b/backend/services/masterdata/masterdata-api/build.gradle.kts index a58859af..1b9db7ba 100644 --- a/backend/services/masterdata/masterdata-api/build.gradle.kts +++ b/backend/services/masterdata/masterdata-api/build.gradle.kts @@ -9,21 +9,14 @@ application { } dependencies { - api(platform(libs.spring.boot.dependencies)) // Interne Module implementation(projects.platform.platformDependencies) - implementation(projects.backend.services.masterdata.masterdataDomain) - implementation(projects.backend.services.masterdata.masterdataCommon) + implementation(projects.backend.services.masterdata.masterdataDomain) + implementation(projects.backend.services.masterdata.masterdataCommon) implementation(projects.core.coreDomain) implementation(projects.core.coreUtils) - // KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen. - - // Spring dependencies - implementation(libs.spring.web) - implementation(libs.springdoc.openapi.starter.common) - - // Ktor Server + // Ktor Server (API ist Ktor-basiert, daher keine Spring BOM/Abhängigkeiten hier) implementation(libs.ktor.server.core) implementation(libs.ktor.server.netty) implementation(libs.ktor.server.contentNegotiation) 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 94df67fc..a65532c3 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 @@ -2,6 +2,11 @@ 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.masterdata.application.usecase.CreateCountryUseCase import at.mocode.masterdata.application.usecase.GetCountryUseCase import at.mocode.masterdata.domain.model.LandDefinition @@ -11,6 +16,9 @@ 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 /** * REST API controller for country (Land) management. @@ -33,8 +41,10 @@ class CountryController( val istEwrMitglied: Boolean? = 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 @@ -54,9 +64,117 @@ class CountryController( fun Route.registerRoutes() { route("/countries") { get { - val response = getCountryUseCase.getAllActive() - val dtos = response.countries.map { it.toDto() } - call.respond(ApiResponse.success(dtos)) + // Query-Parameter: search, active (true|false|all), page, size, sort=field,dir (multi) + 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, "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 + 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}") { @@ -70,13 +188,13 @@ class CountryController( } ?: 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 = getCountryUseCase.getById(id) response.country?.let { call.respond(ApiResponse.success(it.toDto())) - } ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error("NOT_FOUND", "Country not found")) + } ?: call.respond(HttpStatusCode.NotFound, ApiResponse.error(ErrorCodes.NOT_FOUND, "Country not found")) } post { @@ -101,7 +219,7 @@ class CountryController( } else { call.respond( HttpStatusCode.BadRequest, - ApiResponse.error("CREATION_FAILED", response.errors.joinToString()) + ApiResponse.error(ErrorCodes.CREATION_FAILED, response.errors.joinToString()) ) } } @@ -120,7 +238,7 @@ class CountryController( istEwrMitglied = istEwrMitglied, istAktiv = istAktiv, sortierReihenfolge = sortierReihenfolge, - createdAt = createdAt.toString(), - updatedAt = updatedAt.toString() + createdAt = createdAt, + updatedAt = updatedAt ) } diff --git a/backend/services/masterdata/masterdata-common/build.gradle.kts b/backend/services/masterdata/masterdata-common/build.gradle.kts index dc4d791c..19c346e1 100644 --- a/backend/services/masterdata/masterdata-common/build.gradle.kts +++ b/backend/services/masterdata/masterdata-common/build.gradle.kts @@ -1,11 +1,11 @@ plugins { - kotlin("jvm") + alias(libs.plugins.kotlinJvm) } dependencies { implementation(projects.backend.services.masterdata.masterdataDomain) - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) + implementation(projects.core.coreDomain) + implementation(projects.core.coreUtils) implementation(libs.kotlinx.datetime) - testImplementation(projects.platform.platformTesting) + testImplementation(projects.platform.platformTesting) } diff --git a/backend/services/masterdata/masterdata-domain/build.gradle.kts b/backend/services/masterdata/masterdata-domain/build.gradle.kts index f1be904c..9e1396e5 100644 --- a/backend/services/masterdata/masterdata-domain/build.gradle.kts +++ b/backend/services/masterdata/masterdata-domain/build.gradle.kts @@ -4,28 +4,27 @@ plugins { } kotlin { - jvm() - js(IR) { - browser() - } + jvm() + js(IR) { + browser() + } - sourceSets { - val commonMain by getting { - dependencies { - // KORREKTUR: Diese zwei Zeilen hinzufügen - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - } - } - val commonTest by getting { - dependencies { - implementation(kotlin("test")) - } - } - val jvmTest by getting { - dependencies { - implementation(projects.platform.platformTesting) - } - } + sourceSets { + val commonMain by getting { + dependencies { + implementation(projects.core.coreDomain) + implementation(projects.core.coreUtils) + } } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + val jvmTest by getting { + dependencies { + implementation(projects.platform.platformTesting) + } + } + } } diff --git a/backend/services/masterdata/masterdata-infrastructure/build.gradle.kts b/backend/services/masterdata/masterdata-infrastructure/build.gradle.kts index 25be836f..18399c77 100644 --- a/backend/services/masterdata/masterdata-infrastructure/build.gradle.kts +++ b/backend/services/masterdata/masterdata-infrastructure/build.gradle.kts @@ -1,16 +1,16 @@ plugins { - kotlin("jvm") + alias(libs.plugins.kotlinJvm) alias(libs.plugins.spring.boot) apply false - alias(libs.plugins.spring.dependencyManagement) + alias(libs.plugins.spring.dependencyManagement) alias(libs.plugins.kotlinSpring) } dependencies { - implementation(projects.platform.platformDependencies) + implementation(projects.platform.platformDependencies) implementation(projects.backend.services.masterdata.masterdataDomain) - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) + implementation(projects.core.coreDomain) + implementation(projects.core.coreUtils) implementation(projects.backend.infrastructure.cache.cacheApi) implementation(projects.backend.infrastructure.eventStore.eventStoreApi) implementation(projects.backend.infrastructure.messaging.messagingClient) @@ -21,7 +21,8 @@ dependencies { implementation(libs.exposed.jdbc) 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) } diff --git a/backend/services/masterdata/masterdata-service/build.gradle.kts b/backend/services/masterdata/masterdata-service/build.gradle.kts index 3f05e49a..786cf744 100644 --- a/backend/services/masterdata/masterdata-service/build.gradle.kts +++ b/backend/services/masterdata/masterdata-service/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(projects.backend.services.masterdata.masterdataDomain) implementation(projects.backend.services.masterdata.masterdataInfrastructure) implementation(projects.backend.services.masterdata.masterdataCommon) + // Benötigt, da der Service die API-Controller (Ktor) als Beans verdrahtet implementation(projects.backend.services.masterdata.masterdataApi) // Infrastruktur-Clients diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/ErrorCodes.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/ErrorCodes.kt index 6f98abbb..7a582d6a 100644 --- a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/ErrorCodes.kt +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/ErrorCodes.kt @@ -13,4 +13,15 @@ object ErrorCodes { val DATABASE_ERROR = ErrorCode("DATABASE_ERROR") val TRANSACTION_ERROR = ErrorCode("TRANSACTION_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") } diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/QueryModels.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/QueryModels.kt new file mode 100644 index 00000000..df438dd3 --- /dev/null +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/QueryModels.kt @@ -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 = 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 +)