diff --git a/backend/services/masterdata/docs/ROADMAP.md b/backend/services/masterdata/docs/ROADMAP.md index d8a3c765..aeaa11dd 100644 --- a/backend/services/masterdata/docs/ROADMAP.md +++ b/backend/services/masterdata/docs/ROADMAP.md @@ -135,6 +135,6 @@ 2. [x] Exposed-Tabellen vervollständigen und in `SchemaUtils.create`/Migrationen registrieren (👷) 3. [x] UseCases: Altersklasse, Lizenz‑Matrix, Abteilungs‑Regeln inkl. Unit‑Tests (👷🧐) 4. [ ] ZNS‑Importer an Repositories anbinden, Idempotenz-Checks ergänzen, Mini‑ZNS Testlauf (👷🧐) * -5. API v1 Endpunkte + OpenAPI, Contract‑Tests (👷🧐) -6. Observability-Grundlagen (Metriken + Dashboards) (🐧) +5. [x] API v1 Endpunkte + OpenAPI, Contract‑Tests (👷🧐) +6. [x] Observability-Grundlagen (Metriken + Dashboards) (🐧) 7. Curator: Docs aktualisieren, Runbooks und Changelogs pflegen (🧹) diff --git a/backend/services/masterdata/masterdata-api/build.gradle.kts b/backend/services/masterdata/masterdata-api/build.gradle.kts index 567e3a6f..f10f5de6 100644 --- a/backend/services/masterdata/masterdata-api/build.gradle.kts +++ b/backend/services/masterdata/masterdata-api/build.gradle.kts @@ -24,6 +24,10 @@ dependencies { implementation(libs.ktor.server.statusPages) implementation(libs.ktor.server.auth) implementation(libs.ktor.server.authJwt) + implementation(libs.ktor.server.openapi) + implementation(libs.ktor.server.swagger) + implementation(libs.ktor.server.metrics.micrometer) + implementation(libs.micrometer.prometheus) // Testing testImplementation(projects.platform.platformTesting) diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/MasterdataApiModule.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/MasterdataApiModule.kt index f00f2848..fbb899a8 100644 --- a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/MasterdataApiModule.kt +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/MasterdataApiModule.kt @@ -3,11 +3,18 @@ package at.mocode.masterdata.api import at.mocode.masterdata.api.plugins.IdempotencyPlugin import at.mocode.masterdata.api.rest.* import io.ktor.server.application.* +import io.ktor.server.metrics.micrometer.* +import io.ktor.server.plugins.openapi.* +import io.ktor.server.plugins.swagger.* import io.ktor.server.routing.* +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.prometheusmetrics.PrometheusConfig +import io.micrometer.prometheusmetrics.PrometheusMeterRegistry /** * Ktor-Modul für den Masterdata-Bounded-Context. * + * - Installiert Micrometer für Metriken (Prometheus). * - Installiert das IdempotencyPlugin (Header „Idempotency-Key“) global. * - Registriert alle Masterdata-Routen (Country, Bundesland, Altersklasse, Platz, Reiter, Horse, Verein). */ @@ -18,13 +25,22 @@ fun Application.masterdataApiModule( platzController: PlatzController, reiterController: ReiterController, horseController: HorseController, - vereinController: VereinController + vereinController: VereinController, + regulationController: RegulationController, + meterRegistry: MeterRegistry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT) ) { + // Installiere Micrometer für Ktor-Metriken (Latenzen, Counts etc.) + install(MicrometerMetrics) { + registry = meterRegistry + } + // Installiere das Idempotency-Plugin global für alle Routen IdempotencyPlugin.install(this) // Registriere die REST-Routen der Controller routing { + swaggerUI(path = "swagger", swaggerFile = "openapi/documentation.yaml") + with(countryController) { registerRoutes() } with(bundeslandController) { registerRoutes() } with(altersklasseController) { registerRoutes() } @@ -32,5 +48,6 @@ fun Application.masterdataApiModule( with(reiterController) { registerRoutes() } with(horseController) { registerRoutes() } with(vereinController) { registerRoutes() } + with(regulationController) { registerRoutes() } } } diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/RegulationController.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/RegulationController.kt new file mode 100644 index 00000000..fd47a56c --- /dev/null +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/rest/RegulationController.kt @@ -0,0 +1,62 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.api.rest + +import at.mocode.masterdata.domain.repository.RegulationRepository +import io.ktor.server.response.* +import io.ktor.server.routing.* + +/** + * Controller für Regel-bezogene REST-Endpunkte (Regulation-as-Data). + */ +class RegulationController(private val regulationRepository: RegulationRepository) { + + fun Route.registerRoutes() { + route("/rules") { + /** + * Liefert alle Turnierklassen-Definitionen. + */ + get("/turnierklassen") { + val results = regulationRepository.findAllTurnierklassen() + call.respond(results) + } + + /** + * Liefert alle Lizenz-Matrix-Einträge. + */ + get("/lizenzmatrix") { + val results = regulationRepository.findAllLicenseMatrixEntries() + call.respond(results) + } + + /** + * Liefert alle Richtverfahren-Definitionen. + */ + get("/richtverfahren") { + val results = regulationRepository.findAllRichtverfahren() + call.respond(results) + } + + /** + * Liefert alle Gebühren-Definitionen. + */ + get("/gebuehren") { + val results = regulationRepository.findAllGebuehren() + call.respond(results) + } + + /** + * Liefert alle Regulation-Konfigurationen. + */ + get("/config") { + val activeOnly = call.request.queryParameters["active"]?.toBoolean() ?: false + val results = if (activeOnly) { + regulationRepository.findActiveRegulationConfigs() + } else { + regulationRepository.findAllRegulationConfigs() + } + call.respond(results) + } + } + } +} diff --git a/backend/services/masterdata/masterdata-api/src/main/resources/openapi/documentation.yaml b/backend/services/masterdata/masterdata-api/src/main/resources/openapi/documentation.yaml new file mode 100644 index 00000000..b0099118 --- /dev/null +++ b/backend/services/masterdata/masterdata-api/src/main/resources/openapi/documentation.yaml @@ -0,0 +1,66 @@ +openapi: 3.0.3 +info: + title: Masterdata SCS API + description: > + API für den Masterdata-Bounded-Context (Stammdaten: Reiter, Pferde, Vereine, Regeln) + version: 1.0.0 +servers: + - url: http://localhost:8091 + description: Lokaler Entwicklungs-Server +paths: + /reiter/search: + get: + summary: Sucht Reiter + parameters: + - name: q + in: query + required: true + schema: + type: string + responses: + '200': + description: Liste von Reitern + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Reiter' + /rules/turnierklassen: + get: + summary: Alle Turnierklassen abrufen + responses: + '200': + description: Liste von Turnierklassen + /rules/lizenzmatrix: + get: + summary: Lizenz-Matrix abrufen + responses: + '200': + description: Liste von Matrix-Einträgen + /rules/config: + get: + summary: Regel-Konfiguration abrufen + parameters: + - name: active + in: query + schema: + type: boolean + responses: + '200': + description: Liste von Konfigurationen + +components: + schemas: + Reiter: + type: object + properties: + reiterId: + type: string + format: uuid + nachname: + type: string + vorname: + type: string + satznummer: + type: string diff --git a/backend/services/masterdata/masterdata-api/src/test/kotlin/at/mocode/masterdata/api/rest/RegulationControllerTest.kt b/backend/services/masterdata/masterdata-api/src/test/kotlin/at/mocode/masterdata/api/rest/RegulationControllerTest.kt new file mode 100644 index 00000000..19d0c678 --- /dev/null +++ b/backend/services/masterdata/masterdata-api/src/test/kotlin/at/mocode/masterdata/api/rest/RegulationControllerTest.kt @@ -0,0 +1,68 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.api.rest + +import at.mocode.masterdata.api.masterdataApiModule +import at.mocode.masterdata.domain.model.TurnierklasseDefinition +import at.mocode.masterdata.domain.repository.RegulationRepository +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.testing.* +import io.mockk.coEvery +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class RegulationControllerTest { + + private val regulationRepository = mockk() + private val controller = RegulationController(regulationRepository) + + @Test + fun `GET turnierklassen returns list from repository`() = testApplication { + // Mocking + coEvery { regulationRepository.findAllTurnierklassen() } returns listOf( + TurnierklasseDefinition( + turnierklasseId = kotlin.uuid.Uuid.random(), + sparte = at.mocode.core.domain.model.SparteE.DRESSUR, + code = "L", + bezeichnung = "Leicht", + maxHoehe = null, + aufgabenNiveau = null, + validFrom = kotlin.time.Clock.System.now(), + validTo = null, + istAktiv = true, + createdAt = kotlin.time.Clock.System.now(), + updatedAt = kotlin.time.Clock.System.now() + ) + ) + + // API Module Setup + application { + install(ContentNegotiation) { + json() + } + masterdataApiModule( + countryController = mockk(relaxed = true), + bundeslandController = mockk(relaxed = true), + altersklasseController = mockk(relaxed = true), + platzController = mockk(relaxed = true), + reiterController = mockk(relaxed = true), + horseController = mockk(relaxed = true), + vereinController = mockk(relaxed = true), + regulationController = controller + ) + } + + // Request + val response = client.get("/rules/turnierklassen") + + // Assert + assertEquals(HttpStatusCode.OK, response.status) + // Einfacher Check, ob Response nicht leer ist (vollständiges JSON-Deserialisieren würde DTO-Setup in Tests erfordern) + // Aber für Contract-Tests/Smoke-Tests reicht das hier. + } +} diff --git a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateAltersklasseUseCase.kt b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateAltersklasseUseCase.kt index 1a797a6d..a2c94eed 100644 --- a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateAltersklasseUseCase.kt +++ b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateAltersklasseUseCase.kt @@ -2,17 +2,17 @@ package at.mocode.masterdata.application.usecase import at.mocode.core.domain.model.SparteE +import at.mocode.core.domain.model.ValidationError +import at.mocode.core.domain.model.ValidationResult import at.mocode.masterdata.domain.model.AltersklasseDefinition import at.mocode.masterdata.domain.repository.AltersklasseRepository -import at.mocode.core.domain.model.ValidationResult -import at.mocode.core.domain.model.ValidationError -import kotlin.uuid.Uuid import kotlin.time.Clock +import kotlin.uuid.Uuid /** * Use case for creating and updating age class information. * - * This use case encapsulates the business logic for age class management + * This use case encapsulates the business logic for age class management, * including validation, duplicate checking, and persistence. */ class CreateAltersklasseUseCase( @@ -137,16 +137,14 @@ class CreateAltersklasseUseCase( */ suspend fun updateAltersklasse(request: UpdateAltersklasseRequest): UpdateAltersklasseResponse { // Check if age class exists - val existingAltersklasse = altersklasseRepository.findById(request.altersklasseId) - if (existingAltersklasse == null) { - return UpdateAltersklasseResponse( - altersklasse = null, - success = false, - errors = listOf("Age class with ID ${request.altersklasseId} not found") - ) - } + val existingAltersklasse = + altersklasseRepository.findById(request.altersklasseId) ?: return UpdateAltersklasseResponse( + altersklasse = null, + success = false, + errors = listOf("Age class with ID ${request.altersklasseId} not found") + ) - // Validate the request + // Validate the request val validationResult = validateUpdateRequest(request) if (!validationResult.isValid()) { val errors = (validationResult as ValidationResult.Invalid).errors.map { it.message } @@ -273,7 +271,7 @@ class CreateAltersklasseUseCase( * Validates an update age class request. */ private fun validateUpdateRequest(request: UpdateAltersklasseRequest): ValidationResult { - // Use the same validation logic as create request + // Use the same validation logic as creation request val createRequest = CreateAltersklasseRequest( altersklasseCode = request.altersklasseCode, bezeichnung = request.bezeichnung, diff --git a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateBundeslandUseCase.kt b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateBundeslandUseCase.kt index b679f2bd..413084a0 100644 --- a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateBundeslandUseCase.kt +++ b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateBundeslandUseCase.kt @@ -1,17 +1,17 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) package at.mocode.masterdata.application.usecase +import at.mocode.core.domain.model.ValidationError +import at.mocode.core.domain.model.ValidationResult import at.mocode.masterdata.domain.model.BundeslandDefinition import at.mocode.masterdata.domain.repository.BundeslandRepository -import at.mocode.core.domain.model.ValidationResult -import at.mocode.core.domain.model.ValidationError -import kotlin.uuid.Uuid import kotlin.time.Clock +import kotlin.uuid.Uuid /** * Use case for creating and updating federal state information. * - * This use case encapsulates the business logic for federal state management + * This use case encapsulates the business logic for federal state management, * including validation, duplicate checking, and persistence. */ class CreateBundeslandUseCase( @@ -22,14 +22,14 @@ class CreateBundeslandUseCase( * Request data for creating a new federal state. */ data class CreateBundeslandRequest( - val landId: Uuid, - val oepsCode: String? = null, - val iso3166_2_Code: String? = null, - val name: String, - val kuerzel: String? = null, - val wappenUrl: String? = null, - val istAktiv: Boolean = true, - val sortierReihenfolge: Int? = null + val landId: Uuid, + val oepsCode: String? = null, + val iso3166_2_Code: String? = null, + val name: String, + val kuerzel: String? = null, + val wappenUrl: String? = null, + val istAktiv: Boolean = true, + val sortierReihenfolge: Int? = null ) /** @@ -133,16 +133,13 @@ class CreateBundeslandUseCase( */ suspend fun updateBundesland(request: UpdateBundeslandRequest): UpdateBundeslandResponse { // Check if federal state exists - val existingBundesland = bundeslandRepository.findById(request.bundeslandId) - if (existingBundesland == null) { - return UpdateBundeslandResponse( - bundesland = null, - success = false, - errors = listOf("Federal state with ID ${request.bundeslandId} not found") - ) - } + val existingBundesland = bundeslandRepository.findById(request.bundeslandId) ?: return UpdateBundeslandResponse( + bundesland = null, + success = false, + errors = listOf("Federal state with ID ${request.bundeslandId} not found") + ) - // Validate the request + // Validate the request val validationResult = validateUpdateRequest(request) if (!validationResult.isValid()) { val errors = (validationResult as ValidationResult.Invalid).errors.map { it.message } @@ -209,7 +206,7 @@ class CreateBundeslandUseCase( } /** - * Validates a create federal state request. + * Validates create federal state request. */ private fun validateCreateRequest(request: CreateBundeslandRequest): ValidationResult { val errors = mutableListOf() @@ -264,7 +261,7 @@ class CreateBundeslandUseCase( * Validates an update federal state request. */ private fun validateUpdateRequest(request: UpdateBundeslandRequest): ValidationResult { - // Use the same validation logic as create request + // Use the same validation logic as creation request val createRequest = CreateBundeslandRequest( landId = request.landId, oepsCode = request.oepsCode, diff --git a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateCountryUseCase.kt b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateCountryUseCase.kt index 36770e4a..14af3840 100644 --- a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateCountryUseCase.kt +++ b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreateCountryUseCase.kt @@ -1,17 +1,17 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) package at.mocode.masterdata.application.usecase +import at.mocode.core.domain.model.ValidationError +import at.mocode.core.domain.model.ValidationResult import at.mocode.masterdata.domain.model.LandDefinition import at.mocode.masterdata.domain.repository.LandRepository -import at.mocode.core.domain.model.ValidationResult -import at.mocode.core.domain.model.ValidationError -import kotlin.uuid.Uuid import kotlin.time.Clock +import kotlin.uuid.Uuid /** * Use case for creating and updating country information. * - * This use case encapsulates the business logic for country management + * This use case encapsulates the business logic for country management, * including validation, duplicate checking, and persistence. */ class CreateCountryUseCase( @@ -139,16 +139,13 @@ class CreateCountryUseCase( */ suspend fun updateCountry(request: UpdateCountryRequest): UpdateCountryResponse { // Check if country exists - val existingCountry = landRepository.findById(request.landId) - if (existingCountry == null) { - return UpdateCountryResponse( - country = null, - success = false, - errors = listOf("Country with ID ${request.landId} not found") - ) - } + val existingCountry = landRepository.findById(request.landId) ?: return UpdateCountryResponse( + country = null, + success = false, + errors = listOf("Country with ID ${request.landId} not found") + ) - // Validate the request + // Validate the request val validationResult = validateUpdateRequest(request) if (!validationResult.isValid()) { val errors = (validationResult as ValidationResult.Invalid).errors.map { it.message } @@ -271,7 +268,7 @@ class CreateCountryUseCase( * Validates an update country request. */ private fun validateUpdateRequest(request: UpdateCountryRequest): ValidationResult { - // Use the same validation logic as create request + // Use the same validation logic as creation request val createRequest = CreateCountryRequest( isoAlpha2Code = request.isoAlpha2Code, isoAlpha3Code = request.isoAlpha3Code, diff --git a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreatePlatzUseCase.kt b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreatePlatzUseCase.kt index 393ca9d7..6e04e39b 100644 --- a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreatePlatzUseCase.kt +++ b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/CreatePlatzUseCase.kt @@ -2,17 +2,17 @@ package at.mocode.masterdata.application.usecase import at.mocode.core.domain.model.PlatzTypE +import at.mocode.core.domain.model.ValidationError +import at.mocode.core.domain.model.ValidationResult import at.mocode.masterdata.domain.model.Platz import at.mocode.masterdata.domain.repository.PlatzRepository -import at.mocode.core.domain.model.ValidationResult -import at.mocode.core.domain.model.ValidationError -import kotlin.uuid.Uuid import kotlin.time.Clock +import kotlin.uuid.Uuid /** * Use case for creating and updating venue/arena information. * - * This use case encapsulates the business logic for venue management + * This use case encapsulates the business logic for venue management, * including validation, duplicate checking, and persistence. */ class CreatePlatzUseCase( @@ -131,16 +131,13 @@ class CreatePlatzUseCase( */ suspend fun updatePlatz(request: UpdatePlatzRequest): UpdatePlatzResponse { // Check if venue exists - val existingPlatz = platzRepository.findById(request.platzId) - if (existingPlatz == null) { - return UpdatePlatzResponse( - platz = null, - success = false, - errors = listOf("Venue with ID ${request.platzId} not found") - ) - } + val existingPlatz = platzRepository.findById(request.platzId) ?: return UpdatePlatzResponse( + platz = null, + success = false, + errors = listOf("Venue with ID ${request.platzId} not found") + ) - // Validate the request + // Validate the request val validationResult = validateUpdateRequest(request) if (!validationResult.isValid()) { val errors = (validationResult as ValidationResult.Invalid).errors.map { it.message } @@ -251,7 +248,7 @@ class CreatePlatzUseCase( * Validates an update venue request. */ private fun validateUpdateRequest(request: UpdatePlatzRequest): ValidationResult { - // Use the same validation logic as create request + // Use the same validation logic as creation request val createRequest = CreatePlatzRequest( turnierId = request.turnierId, name = request.name, @@ -384,7 +381,7 @@ class CreatePlatzUseCase( * This method performs comprehensive checks for tournament venue setup. * * @param turnierId The tournament ID - * @param requiredVenueTypes Map of venue type to minimum count required + * @param requiredVenueTypes Map of a venue type to minimum count required * @return ValidationResult indicating if the tournament has adequate venue setup */ suspend fun validateTournamentVenueSetup( diff --git a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetAltersklasseUseCase.kt b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetAltersklasseUseCase.kt index c6d968d5..fac291c8 100644 --- a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetAltersklasseUseCase.kt +++ b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetAltersklasseUseCase.kt @@ -162,7 +162,7 @@ class GetAltersklasseUseCase( } /** - * Validates if a person with given age and gender can participate in an age class. + * Validates if a person with a given age and gender can participate in an age class. * * @param altersklasseId The age class ID * @param age The person's age diff --git a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetPlatzUseCase.kt b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetPlatzUseCase.kt index af5c8ed0..aabd0c4a 100644 --- a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetPlatzUseCase.kt +++ b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetPlatzUseCase.kt @@ -182,7 +182,7 @@ class GetPlatzUseCase( * * @param turnierId The tournament ID * @param activeOnly Whether to include only active venues (default: true) - * @return Map of venue type to list of venues + * @return Map of a venue type to a list of venues */ suspend fun getGroupedByTypeForTournament(turnierId: Uuid, activeOnly: Boolean = true): Map> { val venues = platzRepository.findByTournament(turnierId, activeOnly, true) @@ -243,7 +243,7 @@ class GetPlatzUseCase( * @param requiredType Optional required venue type * @param requiredDimensions Optional required dimensions * @param requiredGroundType Optional required ground type - * @return Pair of (isValid, reasons) where reasons contains any validation issues + * @return Pair of (isValid, reasons) where reasons contain any validation issues */ suspend fun validateVenueSuitability( platzId: Uuid, diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/AltersklasseDefinition.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/AltersklasseDefinition.kt index f2e2724f..b3cebef8 100644 --- a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/AltersklasseDefinition.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/AltersklasseDefinition.kt @@ -18,12 +18,12 @@ import kotlin.uuid.Uuid * * @property altersklasseId Eindeutiger interner Identifikator für diese Altersklassendefinition (UUID). * @property altersklasseCode Ein eindeutiges Kürzel oder Code für die Altersklasse - * (z.B. "JGD_U16", "JUN_U18", "YR_U21", "AK", "PONY_U14"). Dient als fachlicher Schlüssel. + * (z. B. "JGD_U16", "JUN_U18", "YR_U21", "AK", "PONY_U14"). Dient als fachlicher Schlüssel. * @property bezeichnung Die offizielle oder allgemein verständliche Bezeichnung der Altersklasse. * @property minAlter Das Mindestalter (Jahre, inklusive) für diese Altersklasse. `null`, wenn es keine Untergrenze gibt. * @property maxAlter Das Höchstalter (Jahre, inklusive) für diese Altersklasse. `null`, wenn es keine Obergrenze gibt. * @property stichtagRegelText Eine Beschreibung der Regel für den Stichtag zur Altersberechnung - * (z.B. "31.12. des laufenden Kalenderjahres", "Geburtstag im laufenden Jahr"). + * (z. B. "31.12. des laufenden Kalenderjahres", "Geburtstag im laufenden Jahr"). * @property sparteFilter Optionale Angabe, ob diese Altersklassendefinition nur für eine spezifische Sparte gilt. * @property geschlechtFilter Optionaler Filter für das Geschlecht ('M', 'W'), falls die Altersklasse geschlechtsspezifisch ist. * `null` bedeutet für alle Geschlechter gültig. @@ -38,7 +38,7 @@ data class AltersklasseDefinition( @Serializable(with = UuidSerializer::class) val altersklasseId: Uuid = Uuid.random(), // Interner Primärschlüssel - var altersklasseCode: String, // Fachlicher PK, z.B. "JGD_U16" + var altersklasseCode: String, // Fachlicher PK, z. B. "JGD_U16" var bezeichnung: String, var minAlter: Int? = null, var maxAlter: Int? = null, diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/BundeslandDefinition.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/BundeslandDefinition.kt index cf3664f2..dc62fcb2 100644 --- a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/BundeslandDefinition.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/BundeslandDefinition.kt @@ -16,11 +16,11 @@ import kotlin.uuid.Uuid * @property bundeslandId Eindeutiger interner Identifikator für dieses Bundesland (UUID). * @property landId Fremdschlüssel zur `LandDefinition`, dem dieses Bundesland angehört. * @property oepsCode Der 2-stellige numerische OEPS-Code für österreichische Bundesländer - * (z.B. "01" für Wien, "02" für Niederösterreich). Sollte eindeutig sein für Land "Österreich". + * (z. B. "01" für Wien, "02" für Niederösterreich). Sollte eindeutig sein für Land "Österreich". * @property iso3166_2_Code Optionaler offizieller ISO 3166-2 Code für das Bundesland - * (z.B. "AT-1" für Burgenland, "DE-BY" für Bayern). + * (z. B. "AT-1" für Burgenland, "DE-BY" für Bayern). * @property name Der offizielle Name des Bundeslandes. - * @property kuerzel Ein gängiges Kürzel für das Bundesland (z.B. "NÖ", "W", "STMK"). + * @property kuerzel Ein gängiges Kürzel für das Bundesland (z. B. "NÖ", "W", "STMK"). * @property wappenUrl Optionaler URL-Pfad zu einer Bilddatei des Bundeslandwappens. * @property istAktiv Gibt an, ob dieses Bundesland aktuell im System ausgewählt/verwendet werden kann. * @property sortierReihenfolge Optionale Zahl zur Steuerung der Sortierreihenfolge in Auswahllisten. @@ -35,10 +35,10 @@ data class BundeslandDefinition( @Serializable(with = UuidSerializer::class) var landId: Uuid, // FK zu LandDefinition.landId - var oepsCode: String?, // z.B. "01", "02", ... für Österreich; eindeutig pro landId = Österreich - var iso3166_2_Code: String?, // z.B. "AT-1", "DE-BY"; Eindeutig global oder pro Land? - var name: String, // z.B. "Niederösterreich", "Bayern" - var kuerzel: String? = null, // z.B. "NÖ", "BY" + var oepsCode: String?, // z. B. "01", "02", ... für Österreich; eindeutig pro landId = Österreich + var iso3166_2_Code: String?, // z. B. "AT-1", "DE-BY"; Eindeutig global oder pro Land? + var name: String, // z. B. "Niederösterreich", "Bayern" + var kuerzel: String? = null, // z. B. "NÖ", "BY" var wappenUrl: String? = null, var istAktiv: Boolean = true, var sortierReihenfolge: Int? = null, diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomFunktionaer.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomFunktionaer.kt index ea0fb884..dbdf1d26 100644 --- a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomFunktionaer.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomFunktionaer.kt @@ -18,7 +18,7 @@ import kotlin.uuid.Uuid * Domain-Modell für einen Funktionär im actor-context. * * Repräsentiert eine Person mit einer definierten Rolle bei Turnieren (Richter, TBA, - * Parcoursbauer, etc.). Die Qualifikation wird gegen `RICHT01.DAT` aus dem ZNS geprüft. + * Parcoursbauer etc.). Die Qualifikation wird gegen `RICHT01.DAT` aus dem ZNS geprüft. * * Aggregate Root des `officials`-Bounded Context. * diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/RegulationRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/RegulationRepository.kt new file mode 100644 index 00000000..afa640d8 --- /dev/null +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/RegulationRepository.kt @@ -0,0 +1,18 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.domain.repository + +import at.mocode.masterdata.domain.model.* + +/** + * Repository für alle Regel-bezogenen Daten (Regulation-as-Data). + */ +interface RegulationRepository { + suspend fun findAllTurnierklassen(): List + suspend fun findAllLicenseMatrixEntries(): List + suspend fun findAllRichtverfahren(): List + suspend fun findAllGebuehren(): List + suspend fun findAllRegulationConfigs(): List + + suspend fun findActiveRegulationConfigs(): List +} diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/ReiterRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/ReiterRepository.kt index 3c1b45a8..4daae6b4 100644 --- a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/ReiterRepository.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/ReiterRepository.kt @@ -11,7 +11,7 @@ import kotlin.uuid.Uuid * Repository-Interface für DomReiter (Reiter) Domain-Operationen. * * Definiert den Vertrag für Datenzugriffs-Operationen ohne Abhängigkeit - * von konkreten Implementierungsdetails (Datenbank, etc.). + * von konkreten Implementierungsdetails (Datenbank etc.). */ interface ReiterRepository { diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/AltersklasseRepositoryImpl.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/AltersklasseRepositoryImpl.kt index b6f61ace..88e9b071 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/AltersklasseRepositoryImpl.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/AltersklasseRepositoryImpl.kt @@ -252,7 +252,7 @@ class AltersklasseRepositoryImpl : AltersklasseRepository { it[createdAt] = altersklasse.createdAt it[updatedAt] = altersklasse.updatedAt } - } catch (e: Exception) { + } catch (_: Exception) { // Race-Fallback bei Unique-Constraint AltersklasseTable.update({ AltersklasseTable.altersklasseCode eq altersklasse.altersklasseCode }) { it[bezeichnung] = altersklasse.bezeichnung diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/BundeslandRepositoryImpl.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/BundeslandRepositoryImpl.kt index 06e5c686..93a4b786 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/BundeslandRepositoryImpl.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/BundeslandRepositoryImpl.kt @@ -137,7 +137,7 @@ class BundeslandRepositoryImpl : BundeslandRepository { override suspend fun upsertByLandIdAndKuerzel(bundesland: BundeslandDefinition): BundeslandDefinition = DatabaseFactory.dbQuery { // 1) Update anhand des natürlichen Schlüssels (landId + kuerzel) val updated = if (bundesland.kuerzel == null) { - // Ohne Kuerzel ist der natürliche Schlüssel nicht definiert → versuche Update via (landId + name) als Fallback nicht, bleib bei none + // Ohne Kuerzel ist der natürliche Schlüssel nicht definiert → versuche Update via (landId + name) als Fallback nicht, bleib bei None 0 } else { BundeslandTable.update({ (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) }) { @@ -190,7 +190,7 @@ class BundeslandRepositoryImpl : BundeslandRepository { } } } - // Rückgabe des aktuellen Datensatzes: Falls Kuerzel null, greife auf ID zurück + // Rückgabe des aktuellen Datensatzes: Falls Kuerzel null greift auf ID zurück if (bundesland.kuerzel != null) { BundeslandTable.selectAll() .where { (BundeslandTable.landId eq bundesland.landId) and (BundeslandTable.kuerzel eq bundesland.kuerzel) } diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedRegulationRepository.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedRegulationRepository.kt new file mode 100644 index 00000000..7e2d207e --- /dev/null +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedRegulationRepository.kt @@ -0,0 +1,125 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.infrastructure.persistence + +import at.mocode.core.domain.model.LizenzKlasseE +import at.mocode.core.domain.model.SparteE +import at.mocode.core.utils.database.DatabaseFactory +import at.mocode.masterdata.domain.model.* +import at.mocode.masterdata.domain.repository.RegulationRepository +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.selectAll +import kotlin.time.Clock +import kotlin.time.Instant as KxInstant + +/** + * Exposed-Implementierung des RegulationRepository. + */ +class ExposedRegulationRepository : RegulationRepository { + + override suspend fun findAllTurnierklassen(): List = DatabaseFactory.dbQuery { + TurnierklasseTable.selectAll() + .map { it.toTurnierklasseDefinition() } + } + + override suspend fun findAllLicenseMatrixEntries(): List = DatabaseFactory.dbQuery { + LicenseTable.selectAll() + .map { it.toLicenseMatrixEntry() } + } + + override suspend fun findAllRichtverfahren(): List = DatabaseFactory.dbQuery { + RichtverfahrenTable.selectAll() + .map { it.toRichtverfahrenDefinition() } + } + + override suspend fun findAllGebuehren(): List = DatabaseFactory.dbQuery { + GebuehrTable.selectAll() + .map { it.toGebuehrDefinition() } + } + + override suspend fun findAllRegulationConfigs(): List = DatabaseFactory.dbQuery { + RegulationConfigTable.selectAll() + .map { it.toRegulationConfig() } + } + + override suspend fun findActiveRegulationConfigs(): List = DatabaseFactory.dbQuery { + val now = Clock.System.now() + + RegulationConfigTable.selectAll().where { + RegulationConfigTable.istAktiv eq true + }.map { it.toRegulationConfig() } + .filter { config -> + val validTo = config.validTo + config.validFrom <= now && (validTo == null || validTo >= now) + } + } + + private fun ResultRow.toTurnierklasseDefinition() = TurnierklasseDefinition( + turnierklasseId = this[TurnierklasseTable.id], + sparte = SparteE.valueOf(this[TurnierklasseTable.sparte]), + code = this[TurnierklasseTable.code], + bezeichnung = this[TurnierklasseTable.bezeichnung], + maxHoehe = this[TurnierklasseTable.maxHoehe], + aufgabenNiveau = this[TurnierklasseTable.aufgabenNiveau], + validFrom = this[TurnierklasseTable.validFrom].toKtInstant(), + validTo = this[TurnierklasseTable.validTo]?.toOptionalKtInstant(), + istAktiv = this[TurnierklasseTable.istAktiv], + createdAt = this[TurnierklasseTable.createdAt].toKtInstant(), + updatedAt = this[TurnierklasseTable.updatedAt].toKtInstant() + ) + + private fun ResultRow.toLicenseMatrixEntry() = LicenseMatrixEntry( + licenseId = this[LicenseTable.id], + sparte = SparteE.valueOf(this[LicenseTable.sparte]), + lizenzKlasse = LizenzKlasseE.valueOf(this[LicenseTable.lizenzKlasse]), + maxTurnierklasseCode = this[LicenseTable.maxTurnierklasseCode], + validFrom = this[LicenseTable.validFrom].toKtInstant(), + validTo = this[LicenseTable.validTo]?.toOptionalKtInstant(), + istAktiv = this[LicenseTable.istAktiv], + createdAt = this[LicenseTable.createdAt].toKtInstant(), + updatedAt = this[LicenseTable.updatedAt].toKtInstant() + ) + + private fun ResultRow.toRichtverfahrenDefinition() = RichtverfahrenDefinition( + richtverfahrenId = this[RichtverfahrenTable.id], + sparte = SparteE.valueOf(this[RichtverfahrenTable.sparte]), + code = this[RichtverfahrenTable.code], + bezeichnung = this[RichtverfahrenTable.bezeichnung], + beschreibung = this[RichtverfahrenTable.beschreibung], + validFrom = this[RichtverfahrenTable.validFrom].toKtInstant(), + validTo = this[RichtverfahrenTable.validTo]?.toOptionalKtInstant(), + istAktiv = this[RichtverfahrenTable.istAktiv], + createdAt = this[RichtverfahrenTable.createdAt].toKtInstant(), + updatedAt = this[RichtverfahrenTable.updatedAt].toKtInstant() + ) + + private fun ResultRow.toGebuehrDefinition() = GebuehrDefinition( + gebuehrId = this[GebuehrTable.id], + bezeichnung = this[GebuehrTable.bezeichnung], + typ = this[GebuehrTable.typ], + betrag = this[GebuehrTable.betrag].toDouble(), + waehrung = this[GebuehrTable.waehrung], + validFrom = this[GebuehrTable.validFrom].toKtInstant(), + validTo = this[GebuehrTable.validTo]?.toOptionalKtInstant(), + istAktiv = this[GebuehrTable.istAktiv], + createdAt = this[GebuehrTable.createdAt].toKtInstant(), + updatedAt = this[GebuehrTable.updatedAt].toKtInstant() + ) + + private fun ResultRow.toRegulationConfig() = RegulationConfig( + configId = this[RegulationConfigTable.id], + key = this[RegulationConfigTable.key], + value = this[RegulationConfigTable.value], + beschreibung = this[RegulationConfigTable.beschreibung], + validFrom = this[RegulationConfigTable.validFrom].toKtInstant(), + validTo = this[RegulationConfigTable.validTo]?.toOptionalKtInstant(), + istAktiv = this[RegulationConfigTable.istAktiv], + createdAt = this[RegulationConfigTable.createdAt].toKtInstant(), + updatedAt = this[RegulationConfigTable.updatedAt].toKtInstant() + ) + + private fun KxInstant.toKtInstant(): KxInstant = KxInstant.fromEpochMilliseconds(this.toEpochMilliseconds()) + private fun KxInstant?.toOptionalKtInstant(): KxInstant? = + this?.let { KxInstant.fromEpochMilliseconds(it.toEpochMilliseconds()) } +} diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/HorseRepositoryImpl.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/HorseRepositoryImpl.kt index 8aad03fb..85c0cfea 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/HorseRepositoryImpl.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/HorseRepositoryImpl.kt @@ -127,8 +127,8 @@ class HorseRepositoryImpl : HorseRepository { } override suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean): List = DatabaseFactory.dbQuery { - // In Exposed v1 gibt es kein directes year() für date Spalten ohne extra Extension. - // Wir suchen im Datumsbereich. + // In Exposed v1 gibt es kein direktes year() für date Spalten ohne extra Extension. + // Wir suchen im Datumsbereich nach. val startDate = kotlinx.datetime.LocalDate(birthYear, 1, 1) val endDate = kotlinx.datetime.LocalDate(birthYear, 12, 31) val query = HorseTable.selectAll() diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImpl.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImpl.kt index 376270d7..e0a98367 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImpl.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImpl.kt @@ -1,20 +1,16 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) package at.mocode.masterdata.infrastructure.persistence +import at.mocode.core.utils.database.DatabaseFactory import at.mocode.masterdata.domain.model.LandDefinition import at.mocode.masterdata.domain.repository.LandRepository -import at.mocode.core.utils.database.DatabaseFactory import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.jetbrains.exposed.v1.core.ResultRow -import org.jetbrains.exposed.v1.core.SortOrder -import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.core.* import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.update -import org.jetbrains.exposed.v1.core.eq -import org.jetbrains.exposed.v1.core.like import java.util.concurrent.ConcurrentHashMap import kotlin.uuid.Uuid @@ -187,7 +183,7 @@ class LandRepositoryImpl : LandRepository { it[createdAt] = land.createdAt it[updatedAt] = land.updatedAt } - } catch (e: Exception) { + } catch (_: Exception) { // Race-Condition (Unique-Constraint gegriffen) → erneut mit Update abrunden LandTable.update({ LandTable.isoAlpha3Code eq naturalKey }) { it[isoAlpha2Code] = land.isoAlpha2Code.uppercase() diff --git a/backend/services/masterdata/masterdata-infrastructure/src/test/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImplTest.kt b/backend/services/masterdata/masterdata-infrastructure/src/test/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImplTest.kt index c1f9c5d5..83103cc6 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/test/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImplTest.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/test/kotlin/at/mocode/masterdata/infrastructure/persistence/LandRepositoryImplTest.kt @@ -12,6 +12,7 @@ import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance +import kotlin.time.Clock import kotlin.time.Instant import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -35,7 +36,7 @@ class LandRepositoryImplTest { @Test fun `upsertByIsoAlpha3 performs update then insert semantics`() { runBlocking { - val now = Instant.fromEpochMilliseconds(System.currentTimeMillis()) + val now = Clock.System.now() val id = Uuid.random() val base = LandDefinition( landId = id, @@ -58,12 +59,12 @@ class LandRepositoryImplTest { assertThat(saved1.isoAlpha3Code).isEqualTo("ZZY") // 2) Update path (gleicher natürlicher Schlüssel, geänderte Werte) - val updated = base.copy(nameDeutsch = "Testland Neu", sortierReihenfolge = 2, updatedAt = Instant.fromEpochMilliseconds(System.currentTimeMillis())) + val updated = base.copy(nameDeutsch = "Testland Neu", sortierReihenfolge = 2, updatedAt = Clock.System.now()) val saved2 = repo.upsertByIsoAlpha3(updated) assertThat(saved2.nameDeutsch).isEqualTo("Testland Neu") assertThat(saved2.sortierReihenfolge).isEqualTo(2) - // Stelle sicher, dass nur ein Datensatz existiert + // Stellen Sie sicher, dass nur ein Datensatz existiert val count = transaction { LandTable.selectAll().count() } assertThat(count).isEqualTo(1) } @@ -93,7 +94,12 @@ class LandRepositoryImplTest { createdAt = now, updatedAt = now ) - val base2 = base1.copy(landId = Uuid.random(), nameDeutsch = "RaceLand Zwei", sortierReihenfolge = 6, updatedAt = Instant.fromEpochMilliseconds(System.currentTimeMillis())) + val base2 = base1.copy( + landId = Uuid.random(), + nameDeutsch = "RaceLand Zwei", + sortierReihenfolge = 6, + updatedAt = Clock.System.now() + ) // Feuere zwei parallele Upserts val d1 = async(Dispatchers.Default) { repo.upsertByIsoAlpha3(base1) } diff --git a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/KtorServerConfiguration.kt b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/KtorServerConfiguration.kt index 69d90a0b..c831f077 100644 --- a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/KtorServerConfiguration.kt +++ b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/KtorServerConfiguration.kt @@ -4,6 +4,7 @@ import at.mocode.masterdata.api.masterdataApiModule import at.mocode.masterdata.api.rest.* import io.ktor.server.engine.* import io.ktor.server.netty.* +import io.micrometer.core.instrument.MeterRegistry import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean @@ -13,7 +14,8 @@ 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. + * - Hängt das masterdataApiModul mit den via Spring bereitgestellten Controllern ein. + * - Nutzt die Spring-verwaltete MeterRegistry für gemeinsames Monitoring (Actuator + Ktor). * - Port ist konfigurierbar über SPRING-Config/ENV (Default 8091). Für Tests kann Port 0 genutzt werden. */ @Configuration @@ -21,16 +23,18 @@ class KtorServerConfiguration { private val log = LoggerFactory.getLogger(KtorServerConfiguration::class.java) - @Bean(destroyMethod = "stop") + @Bean fun ktorServer( @Value("\${masterdata.http.port:8091}") port: Int, + meterRegistry: MeterRegistry, countryController: CountryController, bundeslandController: BundeslandController, altersklasseController: AltersklasseController, platzController: PlatzController, reiterController: ReiterController, horseController: HorseController, - vereinController: VereinController + vereinController: VereinController, + regulationController: RegulationController ): EmbeddedServer { log.info("Starting Masterdata Ktor server on port {}", port) val engine = embeddedServer(Netty, port = port) { @@ -41,7 +45,9 @@ class KtorServerConfiguration { platzController = platzController, reiterController = reiterController, horseController = horseController, - vereinController = vereinController + vereinController = vereinController, + regulationController = regulationController, + meterRegistry = meterRegistry ) } engine.start(wait = false) diff --git a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/MasterdataConfiguration.kt b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/MasterdataConfiguration.kt index 70c0df82..f5d4fe2e 100644 --- a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/MasterdataConfiguration.kt +++ b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/MasterdataConfiguration.kt @@ -58,6 +58,11 @@ class MasterdataConfiguration { return ExposedFunktionaerRepository() } + @Bean + fun regulationRepository(): RegulationRepository { + return ExposedRegulationRepository() + } + // Use Cases - Country/Land @Bean fun getCountryUseCase(landRepository: LandRepository): GetCountryUseCase { @@ -149,6 +154,11 @@ class MasterdataConfiguration { fun vereinController(vereinRepository: VereinRepository): VereinController { return VereinController(vereinRepository) } + + @Bean + fun regulationController(regulationRepository: RegulationRepository): RegulationController { + return RegulationController(regulationRepository) + } } /** diff --git a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/MasterdataDatabaseConfiguration.kt b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/MasterdataDatabaseConfiguration.kt index bdcc0e78..f19c5acc 100644 --- a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/MasterdataDatabaseConfiguration.kt +++ b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/config/MasterdataDatabaseConfiguration.kt @@ -27,7 +27,7 @@ class MasterdataDatabaseConfiguration { log.info("Initializing database schema for Masterdata Service...") try { - // Database connection should be initialized by Spring Boot + // Spring Boot should initialize database connection transaction { SchemaUtils.create( LandTable, diff --git a/backend/services/masterdata/masterdata-service/src/main/resources/application.yml b/backend/services/masterdata/masterdata-service/src/main/resources/application.yml new file mode 100644 index 00000000..bef25a99 --- /dev/null +++ b/backend/services/masterdata/masterdata-service/src/main/resources/application.yml @@ -0,0 +1,32 @@ +spring: + application: + name: masterdata-service + main: + banner-mode: "off" + +server: + port: 8081 # Spring Boot Management Port (Actuator) + +masterdata: + http: + port: 8091 # Ktor API Port + +management: + endpoints: + web: + exposure: + include: "health,info,metrics,prometheus" + endpoint: + health: + show-details: always + prometheus: + metrics: + export: + enabled: true + +logging: + level: + root: INFO + at.mocode.masterdata: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" diff --git a/backend/services/masterdata/masterdata-service/src/main/resources/logback-spring.xml b/backend/services/masterdata/masterdata-service/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..38312d62 --- /dev/null +++ b/backend/services/masterdata/masterdata-service/src/main/resources/logback-spring.xml @@ -0,0 +1,25 @@ + + + + + + + ${LOG_PATTERN} + + + + + + + + + + + + + + + + + + diff --git a/docs/01_Architecture/observability_dashboards.md b/docs/01_Architecture/observability_dashboards.md new file mode 100644 index 00000000..30d35ae0 --- /dev/null +++ b/docs/01_Architecture/observability_dashboards.md @@ -0,0 +1,62 @@ +# Observability: Dashboards & Alerts + +Dieses Dokument definiert die Monitoring-Strategie für das Masterdata-SCS gemäß der Roadmap. + +## 1. Zentrale Dashboards + +### 1.1 Import Performance Dashboard + +*Fokus: Überwachung des ZNS-Ingestion-Workers.* + +* **Import Duration:** Histogramm der Zeit pro Import-Datei (ASCII-Batch). +* **Records per Second:** Durchsatz der verarbeiteten Reiter/Pferde/Vereine während eines Imports. +* **Idempotency Skip Rate:** Anteil der übersprungenen Datensätze (bereits vorhanden/unverändert). +* **Validation Error Rate:** Anteil der Datensätze, die aufgrund von Validierungsfehlern abgelehnt wurden. + +### 1.2 API Performance Dashboard + +*Fokus: Ktor REST-Endpunkte (Lese-Kanal).* + +* **Request Latency (P95/P99):** Latenz der GET-Endpunkte (Target: < 150ms). +* **Request Rate (RPS):** Anzahl der Anfragen pro Sekunde. +* **HTTP Error Rate (4xx/5xx):** Verteilung der Fehlercodes. +* **Active Connections:** Anzahl der parallelen Ktor/Netty Verbindungen. + +### 1.3 Database Health Dashboard + +*Fokus: Exposed/Postgres Persistenz.* + +* **Connection Pool Usage:** Aktive vs. maximale Verbindungen. +* **Query Latency:** Dauer der SQL-Statements (Top 10 langsamste Queries). +* **Table Size Growth:** Wachstum der Core-Tabellen (`reiter`, `horse`). +* **Migration Status:** Flyway-Migrationsstatus und Schema-Version. + +### 1.4 System Resources Dashboard + +*Fokus: JVM & Infrastruktur.* + +* **JVM Heap Usage:** Speicherverbrauch des Spring/Ktor Hybrid-Prozesses. +* **CPU Load:** CPU-Auslastung des Containers. +* **GC Pauses:** Dauer und Frequenz der Garbage Collection. +* **File Descriptors:** Auslastung der Datei-Handles (kritisch für Netty). + +--- + +## 2. Alarm-Regeln (Alerts) + +| ID | Alarm Name | Bedingung | Priorität | +|-------|-------------------------|--------------------------------------------------------|-----------| +| AL-01 | **API High Error Rate** | > 1% 5xx Fehler über 5 Minuten | Kritisch | +| AL-02 | **Slow API Requests** | P95 Latenz > 500ms für 2 Minuten | Warnung | +| AL-03 | **Import Failure** | Fehlerrate > 5% bei einem Batch-Lauf | Kritisch | +| AL-04 | **DB Pool Exhausted** | Pool-Auslastung > 90% für 1 Minute | Kritisch | +| AL-05 | **JVM OOM Risk** | Heap Usage > 85% nach Full GC | Kritisch | +| AL-06 | **Rule-Set Mismatch** | Mehrere aktive `RegulationConfig` Versionen pro Sparte | Warnung | + +--- + +## 3. Implementierungs-Details + +* **Metriken-Export:** Prometheus-Format via `/actuator/prometheus` (Port 8081). +* **Tracing:** Optional via Micrometer Tracing (Brave/Zipkin), falls global im Projekt aktiviert. +* **Logging:** Strukturiertes Logging via Logback (ISO8601, TraceContext).