diff --git a/backend/infrastructure/zns-importer/build.gradle.kts b/backend/infrastructure/zns-importer/build.gradle.kts index a4f41db8..e75b0d3b 100644 --- a/backend/infrastructure/zns-importer/build.gradle.kts +++ b/backend/infrastructure/zns-importer/build.gradle.kts @@ -7,10 +7,7 @@ dependencies { implementation(projects.core.coreUtils) implementation(projects.core.coreDomain) implementation(projects.core.znsParser) - implementation(projects.backend.services.clubs.clubsDomain) - implementation(projects.backend.services.persons.personsDomain) - implementation(projects.backend.services.horses.horsesDomain) - implementation(projects.backend.services.officials.officialsDomain) + implementation(projects.backend.services.masterdata.masterdataDomain) testImplementation(projects.platform.platformTesting) } diff --git a/backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportService.kt b/backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportService.kt index 8a5d7abf..6038cd65 100644 --- a/backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportService.kt +++ b/backend/infrastructure/zns-importer/src/main/kotlin/at/mocode/zns/importer/ZnsImportService.kt @@ -2,10 +2,10 @@ package at.mocode.zns.importer -import at.mocode.clubs.domain.repository.VereinRepository -import at.mocode.horses.domain.repository.HorseRepository -import at.mocode.officials.domain.repository.FunktionaerRepository -import at.mocode.persons.domain.repository.ReiterRepository +import at.mocode.masterdata.domain.repository.VereinRepository +import at.mocode.masterdata.domain.repository.HorseRepository +import at.mocode.masterdata.domain.repository.FunktionaerRepository +import at.mocode.masterdata.domain.repository.ReiterRepository import at.mocode.zns.parser.ZnsLegacyParsers import java.io.InputStream import java.nio.charset.Charset diff --git a/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt b/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt index 315857d5..4c7123a1 100644 --- a/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt +++ b/backend/infrastructure/zns-importer/src/test/kotlin/at/mocode/zns/importer/ZnsImportServiceTest.kt @@ -1,13 +1,15 @@ package at.mocode.zns.importer -import at.mocode.clubs.domain.model.DomVerein -import at.mocode.clubs.domain.repository.VereinRepository -import at.mocode.horses.domain.model.DomPferd -import at.mocode.horses.domain.repository.HorseRepository -import at.mocode.officials.domain.model.DomFunktionaer -import at.mocode.officials.domain.repository.FunktionaerRepository -import at.mocode.persons.domain.model.DomReiter -import at.mocode.persons.domain.repository.ReiterRepository +import at.mocode.masterdata.domain.model.DomVerein +import at.mocode.masterdata.domain.repository.VereinRepository +import at.mocode.masterdata.domain.model.DomPferd +import at.mocode.masterdata.domain.repository.HorseRepository +import at.mocode.masterdata.domain.model.DomFunktionaer +import at.mocode.masterdata.domain.repository.FunktionaerRepository +import at.mocode.masterdata.domain.model.DomReiter +import at.mocode.masterdata.domain.repository.ReiterRepository +import at.mocode.core.domain.model.PferdeGeschlechtE +import kotlin.uuid.Uuid import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -110,7 +112,7 @@ class ZnsImportServiceTest { val zip = buildZip("VEREIN01.DAT" to vereinZeile()) coEvery { vereinRepository.findByVereinsNummer(any()) } returns null - coEvery { vereinRepository.save(any()) } answers { firstArg() } + coEvery { vereinRepository.save(any()) } answers { firstArg() } val result = service.importiereZip(zip) @@ -126,7 +128,7 @@ class ZnsImportServiceTest { val vorhanden = DomVerein(vereinsNummer = "0001", name = "Alter Name") coEvery { vereinRepository.findByVereinsNummer("0001") } returns vorhanden - coEvery { vereinRepository.save(any()) } answers { firstArg() } + coEvery { vereinRepository.save(any()) } answers { firstArg() } val result = service.importiereZip(zip) @@ -140,7 +142,7 @@ class ZnsImportServiceTest { val zip = buildZip("LIZENZ01.DAT" to lizenzZeile()) coEvery { reiterRepository.findBySatznummer(any()) } returns null - coEvery { reiterRepository.save(any()) } answers { firstArg() } + coEvery { reiterRepository.save(any()) } answers { firstArg() } val result = service.importiereZip(zip) @@ -155,7 +157,7 @@ class ZnsImportServiceTest { val zip = buildZip("PFERDE01.DAT" to pferdeZeile()) coEvery { horseRepository.findByLebensnummer(any()) } returns null - coEvery { horseRepository.save(any()) } answers { firstArg() } + coEvery { horseRepository.save(any()) } answers { firstArg() } val result = service.importiereZip(zip) @@ -170,7 +172,7 @@ class ZnsImportServiceTest { val zip = buildZip("RICHT01.DAT" to richterZeile()) coEvery { funktionaerRepository.findByRichterNummer(any()) } returns null - coEvery { funktionaerRepository.save(any()) } answers { firstArg() } + coEvery { funktionaerRepository.save(any()) } answers { firstArg() } val result = service.importiereZip(zip) @@ -190,13 +192,13 @@ class ZnsImportServiceTest { ) coEvery { vereinRepository.findByVereinsNummer(any()) } returns null - coEvery { vereinRepository.save(any()) } answers { firstArg() } + coEvery { vereinRepository.save(any()) } answers { firstArg() } coEvery { reiterRepository.findBySatznummer(any()) } returns null - coEvery { reiterRepository.save(any()) } answers { firstArg() } + coEvery { reiterRepository.save(any()) } answers { firstArg() } coEvery { horseRepository.findByLebensnummer(any()) } returns null - coEvery { horseRepository.save(any()) } answers { firstArg() } + coEvery { horseRepository.save(any()) } answers { firstArg() } coEvery { funktionaerRepository.findByRichterNummer(any()) } returns null - coEvery { funktionaerRepository.save(any()) } answers { firstArg() } + coEvery { funktionaerRepository.save(any()) } answers { firstArg() } val result = service.importiereZip(zip) diff --git a/backend/services/clubs/clubs-domain/build.gradle.kts b/backend/services/clubs/clubs-domain/build.gradle.kts deleted file mode 100644 index 80e97efb..00000000 --- a/backend/services/clubs/clubs-domain/build.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.kotlinSerialization) -} - -kotlin { - jvm() - - sourceSets { - commonMain { - kotlin.srcDir("src/main/kotlin") - dependencies { - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - implementation(libs.kotlinx.datetime) - implementation(libs.kotlinx.serialization.json) - } - } - commonTest { - kotlin.srcDir("src/test/kotlin") - dependencies { - implementation(projects.platform.platformTesting) - } - } - } -} diff --git a/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/model/DomClub.kt b/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/model/DomClub.kt deleted file mode 100644 index 77a3e694..00000000 --- a/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/model/DomClub.kt +++ /dev/null @@ -1,38 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) - -package at.mocode.clubs.domain.model - -import at.mocode.core.domain.model.DatenQuelleE -import at.mocode.core.domain.serialization.KotlinInstantSerializer -import at.mocode.core.domain.serialization.UuidSerializer -import kotlinx.serialization.Serializable -import kotlin.time.Clock -import kotlin.time.Instant -import kotlin.uuid.Uuid - -/** - * Domain model representing a club (Verein) in the registry system. - * - * @property clubId Unique internal identifier (UUID). - * @property vereinsNummer ÖPS club number (from ZNS VEREIN01.dat). - * @property name Club name. - * @property datenQuelle Source of the data. - * @property createdAt Timestamp when this record was created. - * @property updatedAt Timestamp when this record was last updated. - */ -@Serializable -data class DomClub( - @Serializable(with = UuidSerializer::class) - val clubId: Uuid = Uuid.random(), - - val vereinsNummer: String, - val name: String, - - val istAktiv: Boolean = true, - val datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS, - - @Serializable(with = KotlinInstantSerializer::class) - val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) - var updatedAt: Instant = Clock.System.now() -) diff --git a/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/ExposedVereinRepository.kt b/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/ExposedVereinRepository.kt deleted file mode 100644 index c290fdc9..00000000 --- a/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/ExposedVereinRepository.kt +++ /dev/null @@ -1,141 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) - -package at.mocode.clubs.infrastructure.persistence - -import at.mocode.clubs.domain.model.DomVerein -import at.mocode.clubs.domain.repository.VereinRepository -import at.mocode.core.domain.model.DatenQuelleE -import org.jetbrains.exposed.v1.core.ResultRow -import org.jetbrains.exposed.v1.core.and -import org.jetbrains.exposed.v1.core.eq -import org.jetbrains.exposed.v1.core.like -import org.jetbrains.exposed.v1.core.statements.UpdateBuilder -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.transactions.transaction -import org.jetbrains.exposed.v1.jdbc.update -import kotlin.time.Clock -import kotlin.uuid.Uuid -import kotlin.uuid.toJavaUuid -import kotlin.uuid.toKotlinUuid - -/** - * Exposed-basierte Implementierung des VereinRepository. - */ -class ExposedVereinRepository : VereinRepository { - - override suspend fun findById(id: Uuid): DomVerein? = transaction { - VereinTable.selectAll().where { VereinTable.id eq id.toJavaUuid() } - .map { rowToVerein(it) } - .singleOrNull() - } - - override suspend fun findByVereinsNummer(vereinsNummer: String): DomVerein? = transaction { - VereinTable.selectAll().where { VereinTable.vereinsNummer eq vereinsNummer } - .map { rowToVerein(it) } - .singleOrNull() - } - - override suspend fun findByName(searchTerm: String, limit: Int): List = transaction { - VereinTable.selectAll().where { VereinTable.name like "%$searchTerm%" } - .limit(limit).map { rowToVerein(it) } - } - - override suspend fun findByBundesland(bundesland: String, activeOnly: Boolean): List = transaction { - VereinTable.selectAll().where { - (VereinTable.bundesland eq bundesland).let { - if (activeOnly) it and (VereinTable.istAktiv eq true) else it - } - }.map { rowToVerein(it) } - } - - override suspend fun findVeranstalter(activeOnly: Boolean): List = transaction { - VereinTable.selectAll().where { - (VereinTable.istVeranstalter eq true).let { - if (activeOnly) it and (VereinTable.istAktiv eq true) else it - } - }.map { rowToVerein(it) } - } - - override suspend fun findAllActive(limit: Int, offset: Int): List = transaction { - VereinTable.selectAll().where { VereinTable.istAktiv eq true } - .limit(limit).offset(offset.toLong()) - .map { rowToVerein(it) } - } - - override suspend fun findAll(limit: Int, offset: Int): List = transaction { - VereinTable.selectAll() - .limit(limit).offset(offset.toLong()) - .map { rowToVerein(it) } - } - - override suspend fun save(verein: DomVerein): DomVerein = transaction { - val now = Clock.System.now() - val updated = verein.copy(updatedAt = now) - val javaId = verein.vereinId.toJavaUuid() - val existing = VereinTable.selectAll().where { VereinTable.id eq javaId }.singleOrNull() - if (existing != null) { - VereinTable.update({ VereinTable.id eq javaId }) { vereinToStatement(it, updated) } - } else { - VereinTable.insert { - it[id] = javaId - vereinToStatement(it, updated) - } - } - updated - } - - override suspend fun delete(id: Uuid): Boolean = transaction { - VereinTable.deleteWhere { VereinTable.id eq id.toJavaUuid() } > 0 - } - - override suspend fun countActive(): Long = transaction { - VereinTable.selectAll().where { VereinTable.istAktiv eq true }.count() - } - - override suspend fun existsByVereinsNummer(vereinsNummer: String): Boolean = transaction { - VereinTable.selectAll().where { VereinTable.vereinsNummer eq vereinsNummer }.count() > 0 - } - - private fun rowToVerein(row: ResultRow): DomVerein = DomVerein( - vereinId = row[VereinTable.id].toKotlinUuid(), - vereinsNummer = row[VereinTable.vereinsNummer], - name = row[VereinTable.name], - kurzname = row[VereinTable.kurzname], - bundesland = row[VereinTable.bundesland], - ort = row[VereinTable.ort], - plz = row[VereinTable.plz], - strasse = row[VereinTable.strasse], - email = row[VereinTable.email], - telefon = row[VereinTable.telefon], - website = row[VereinTable.webseite], - oepsRegionNummer = row[VereinTable.oepsRegionsNummer], - istVeranstalter = row[VereinTable.istVeranstalter], - istAktiv = row[VereinTable.istAktiv], - bemerkungen = row[VereinTable.bemerkungen], - datenQuelle = runCatching { DatenQuelleE.valueOf(row[VereinTable.datenQuelle]) }.getOrDefault(DatenQuelleE.IMPORT_ZNS), - createdAt = row[VereinTable.createdAt], - updatedAt = row[VereinTable.updatedAt] - ) - - private fun vereinToStatement(stmt: UpdateBuilder<*>, v: DomVerein) { - stmt[VereinTable.vereinsNummer] = v.vereinsNummer - stmt[VereinTable.name] = v.name - stmt[VereinTable.kurzname] = v.kurzname - stmt[VereinTable.bundesland] = v.bundesland - stmt[VereinTable.ort] = v.ort - stmt[VereinTable.plz] = v.plz - stmt[VereinTable.strasse] = v.strasse - stmt[VereinTable.email] = v.email - stmt[VereinTable.telefon] = v.telefon - stmt[VereinTable.webseite] = v.website - stmt[VereinTable.oepsRegionsNummer] = v.oepsRegionNummer - stmt[VereinTable.istVeranstalter] = v.istVeranstalter - stmt[VereinTable.istAktiv] = v.istAktiv - stmt[VereinTable.bemerkungen] = v.bemerkungen - stmt[VereinTable.datenQuelle] = v.datenQuelle.name - stmt[VereinTable.createdAt] = v.createdAt - stmt[VereinTable.updatedAt] = v.updatedAt - } -} diff --git a/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/VereinTable.kt b/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/VereinTable.kt deleted file mode 100644 index 9c6d64dc..00000000 --- a/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/VereinTable.kt +++ /dev/null @@ -1,56 +0,0 @@ -package at.mocode.clubs.infrastructure.persistence - -import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.core.java.javaUUID -import org.jetbrains.exposed.v1.datetime.timestamp - -/** - * Exposed-Tabellendefinition für Vereine (DomVerein). - * - * Speichert alle Vereins-Daten inkl. Adresse, Kontakt und OEPS-Regionsnummer. - */ -object VereinTable : Table("vereine") { - - val id = javaUUID("id").autoGenerate() - override val primaryKey = PrimaryKey(id) - - // Identifikation - val vereinsNummer = varchar("vereins_nummer", 20).uniqueIndex() - - // Vereinsdaten - val name = varchar("name", 200) - val kurzname = varchar("kurzname", 50).nullable() - - // Adresse - val strasse = varchar("strasse", 200).nullable() - val plz = varchar("plz", 10).nullable() - val ort = varchar("ort", 100).nullable() - val bundesland = varchar("bundesland", 50).nullable() - val land = varchar("land", 50).nullable().default("AT") - - // Kontakt - val email = varchar("email", 255).nullable() - val telefon = varchar("telefon", 50).nullable() - val webseite = varchar("webseite", 255).nullable() - - // OEPS-Daten - val oepsRegionsNummer = varchar("oeps_regions_nummer", 10).nullable() - - // Status & Verwaltung - val istAktiv = bool("ist_aktiv").default(true) - val istVeranstalter = bool("ist_veranstalter").default(false) - val bemerkungen = text("bemerkungen").nullable() - val datenQuelle = varchar("daten_quelle", 50) - - // Audit-Felder - val createdAt = timestamp("created_at") - val updatedAt = timestamp("updated_at") - - init { - index(false, name) - index(false, bundesland) - index(false, istAktiv) - index(false, istVeranstalter) - index(false, oepsRegionsNummer) - } -} diff --git a/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/ZnsClubTable.kt b/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/ZnsClubTable.kt deleted file mode 100644 index 99bad1d4..00000000 --- a/backend/services/clubs/clubs-infrastructure/src/main/kotlin/at/mocode/clubs/infrastructure/persistence/ZnsClubTable.kt +++ /dev/null @@ -1,14 +0,0 @@ -package at.mocode.clubs.infrastructure.persistence - -import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable -import org.jetbrains.exposed.v1.datetime.timestamp - -/** - * Tabelle für importierte Vereine aus dem ZNS-Datenbestand (VEREIN01.dat). - * Wird ausschließlich als Dev-Seed-Daten verwendet. - */ -object ZnsClubTable : UUIDTable("zns_clubs") { - val vereinsNummer = varchar("vereins_nummer", 4).uniqueIndex() - val name = varchar("name", 50) - val createdAt = timestamp("created_at") -} diff --git a/backend/services/clubs/clubs-service/build.gradle.kts b/backend/services/clubs/clubs-service/build.gradle.kts deleted file mode 100644 index 6c38b8de..00000000 --- a/backend/services/clubs/clubs-service/build.gradle.kts +++ /dev/null @@ -1,39 +0,0 @@ -plugins { - alias(libs.plugins.kotlinJvm) - alias(libs.plugins.kotlinSpring) - alias(libs.plugins.spring.boot) - alias(libs.plugins.spring.dependencyManagement) -} - -springBoot { - mainClass.set("at.mocode.clubs.service.ClubsServiceApplicationKt") -} - -dependencies { - implementation(projects.platform.platformDependencies) - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - implementation(projects.backend.services.clubs.clubsDomain) - implementation(projects.backend.services.clubs.clubsInfrastructure) - - implementation(libs.spring.boot.starter.web) - implementation(libs.spring.boot.starter.validation) - implementation(libs.spring.boot.starter.actuator) - - implementation(libs.exposed.core) - implementation(libs.exposed.dao) - implementation(libs.exposed.jdbc) - implementation(libs.exposed.migration.jdbc) - implementation(libs.exposed.kotlin.datetime) - implementation(libs.hikari.cp) - runtimeOnly(libs.postgresql.driver) - testRuntimeOnly(libs.h2.driver) - - testImplementation(projects.platform.platformTesting) - testImplementation(libs.spring.boot.starter.test) - testImplementation(libs.logback.classic) -} - -tasks.test { - useJUnitPlatform() -} diff --git a/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/ClubsServiceApplication.kt b/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/ClubsServiceApplication.kt deleted file mode 100644 index 3ff901d1..00000000 --- a/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/ClubsServiceApplication.kt +++ /dev/null @@ -1,18 +0,0 @@ -package at.mocode.clubs.service - -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.runApplication -import org.springframework.context.annotation.ComponentScan - -@SpringBootApplication -@ComponentScan( - basePackages = [ - "at.mocode.clubs.service", - "at.mocode.clubs.infrastructure" - ] -) -class ClubsServiceApplication - -fun main(args: Array) { - runApplication(*args) -} diff --git a/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/config/DatabaseConfiguration.kt b/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/config/DatabaseConfiguration.kt deleted file mode 100644 index 6ea7ae75..00000000 --- a/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/config/DatabaseConfiguration.kt +++ /dev/null @@ -1,32 +0,0 @@ -package at.mocode.clubs.service.config - -import at.mocode.clubs.infrastructure.persistence.ZnsClubTable -import jakarta.annotation.PostConstruct -import org.jetbrains.exposed.v1.jdbc.Database -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Profile - -@Configuration -@Profile("dev") -class ClubsDatabaseConfiguration( - @Value("\${spring.datasource.url}") private val jdbcUrl: String, - @Value("\${spring.datasource.username}") private val username: String, - @Value("\${spring.datasource.password}") private val password: String -) { - private val log = LoggerFactory.getLogger(ClubsDatabaseConfiguration::class.java) - - @PostConstruct - fun initializeDatabase() { - log.info("Initialisiere Datenbank-Schema für Clubs-Service...") - Database.connect(jdbcUrl, user = username, password = password) - transaction { - val statements = MigrationUtils.statementsRequiredForDatabaseMigration(ZnsClubTable) - statements.forEach { exec(it) } - log.info("Datenbank-Schema erfolgreich initialisiert") - } - } -} diff --git a/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/dev/ZnsClubSeeder.kt b/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/dev/ZnsClubSeeder.kt deleted file mode 100644 index d16efe01..00000000 --- a/backend/services/clubs/clubs-service/src/main/kotlin/at/mocode/clubs/service/dev/ZnsClubSeeder.kt +++ /dev/null @@ -1,81 +0,0 @@ -package at.mocode.clubs.service.dev - -import at.mocode.clubs.infrastructure.persistence.ZnsClubTable -import org.jetbrains.exposed.v1.jdbc.batchInsert -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.CommandLineRunner -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Component -import java.io.File -import kotlin.time.Clock - -/** - * Dev-only Seeder: Importiert VEREIN01.dat (ZNS) → zns_clubs. - * Aktivierung: Umgebungsvariable ZNS_DATA_DIR setzen + Profil "dev". - */ -@Component -@Profile("dev") -class ZnsClubSeeder( - @Value("\${zns.data.dir:#{null}}") private val znsDataDir: String? -) : CommandLineRunner { - - private val log = LoggerFactory.getLogger(ZnsClubSeeder::class.java) - - override fun run(vararg args: String) { - if (znsDataDir == null) { - log.info("ZNS_DATA_DIR nicht gesetzt – ZnsClubSeeder wird übersprungen.") - return - } - val dir = File(znsDataDir) - if (!dir.exists() || !dir.isDirectory) { - log.warn("ZNS_DATA_DIR '{}' existiert nicht oder ist kein Verzeichnis.", znsDataDir) - return - } - log.info("Starte ZNS-Vereine-Import aus: {}", dir.absolutePath) - transaction { - val statements = MigrationUtils.statementsRequiredForDatabaseMigration(ZnsClubTable) - statements.forEach { exec(it) } - } - seedClubs(dir) - log.info("ZNS-Vereine-Import abgeschlossen.") - } - - // ------------------------------------------------------------------------- - // VEREIN01.dat → zns_clubs - // Format: - // [0-3] VereinsNr (4 Zeichen) - // [4-53] Name (50 Zeichen) - // ------------------------------------------------------------------------- - private fun seedClubs(dir: File) { - val file = File(dir, "VEREIN01.dat") - if (!file.exists()) { - log.warn("VEREIN01.dat nicht gefunden – Vereine werden übersprungen.") - return - } - - data class ClubRow(val nr: String, val name: String) - - val rows = file.readLines(Charsets.ISO_8859_1) - .filter { it.length >= 4 } - .map { line -> - ClubRow( - nr = line.substring(0, 4).trim(), - name = line.substring(4).trim().take(50) - ) - } - .filter { it.nr.isNotBlank() } - - val now = Clock.System.now() - transaction { - ZnsClubTable.batchInsert(rows, ignore = true) { row -> - this[ZnsClubTable.vereinsNummer] = row.nr - this[ZnsClubTable.name] = row.name - this[ZnsClubTable.createdAt] = now - } - } - log.info("Vereine importiert: {} Datensätze", rows.size) - } -} diff --git a/backend/services/clubs/clubs-service/src/main/resources/db/migration/V001__Create_Vereine_Table.sql b/backend/services/clubs/clubs-service/src/main/resources/db/migration/V001__Create_Vereine_Table.sql deleted file mode 100644 index 8be02b8f..00000000 --- a/backend/services/clubs/clubs-service/src/main/resources/db/migration/V001__Create_Vereine_Table.sql +++ /dev/null @@ -1,95 +0,0 @@ --- Migration V001: Create Vereine (Clubs) table --- Speichert alle Vereins-Daten inkl. Adresse, Kontakt und OEPS-Regionsnummer. - -CREATE TABLE IF NOT EXISTS vereine -( - id - UUID - PRIMARY - KEY - DEFAULT - gen_random_uuid -( -), - vereins_nummer VARCHAR -( - 20 -) NOT NULL, - name VARCHAR -( - 200 -) NOT NULL, - kurzname VARCHAR -( - 50 -), - strasse VARCHAR -( - 200 -), - plz VARCHAR -( - 10 -), - ort VARCHAR -( - 100 -), - bundesland VARCHAR -( - 50 -), - land VARCHAR -( - 50 -) DEFAULT 'AT', - email VARCHAR -( - 255 -), - telefon VARCHAR -( - 50 -), - webseite VARCHAR -( - 255 -), - oeps_regions_nummer VARCHAR -( - 10 -), - ist_aktiv BOOLEAN NOT NULL DEFAULT true, - ist_veranstalter BOOLEAN NOT NULL DEFAULT false, - bemerkungen TEXT, - daten_quelle VARCHAR -( - 50 -) NOT NULL DEFAULT 'MANUELL', - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - --- Unique index für Vereinsnummer -CREATE UNIQUE INDEX IF NOT EXISTS uk_vereine_vereins_nummer ON vereine(vereins_nummer); - --- Performance-Indizes -CREATE INDEX IF NOT EXISTS idx_vereine_name ON vereine(name); -CREATE INDEX IF NOT EXISTS idx_vereine_bundesland ON vereine(bundesland); -CREATE INDEX IF NOT EXISTS idx_vereine_ist_aktiv ON vereine(ist_aktiv); -CREATE INDEX IF NOT EXISTS idx_vereine_ist_veranstalter ON vereine(ist_veranstalter); -CREATE INDEX IF NOT EXISTS idx_vereine_oeps_region ON vereine(oeps_regions_nummer); - --- Dokumentation -COMMENT -ON TABLE vereine IS 'Reitsportvereine gemäß OEPS-Vereinsregister'; -COMMENT -ON COLUMN vereine.id IS 'Eindeutige interne ID (UUID)'; -COMMENT -ON COLUMN vereine.vereins_nummer IS 'Offizielle OEPS-Vereinsnummer (eindeutig)'; -COMMENT -ON COLUMN vereine.oeps_regions_nummer IS 'OEPS-Regionsnummer des Landesverbands'; -COMMENT -ON COLUMN vereine.ist_veranstalter IS 'Gibt an ob der Verein als Veranstalter von Turnieren zugelassen ist'; -COMMENT -ON COLUMN vereine.daten_quelle IS 'Datenherkunft: MANUELL, IMPORT_ZNS, IMPORT_FEI'; diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomAbteilung.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomAbteilung.kt index e26c62a2..3de2fa16 100644 --- a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomAbteilung.kt +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomAbteilung.kt @@ -3,7 +3,7 @@ package at.mocode.entries.domain.model import at.mocode.core.domain.model.AbteilungsTeilungsTypE -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlinx.serialization.Serializable import kotlin.time.Clock @@ -59,9 +59,9 @@ data class DomAbteilung( var bemerkungen: String? = null, // Audit - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) var updatedAt: Instant = Clock.System.now() ) { /** diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomBewerb.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomBewerb.kt index 71fba7af..50644b85 100644 --- a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomBewerb.kt +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomBewerb.kt @@ -6,7 +6,7 @@ import at.mocode.core.domain.model.AbteilungsTeilungsTypE import at.mocode.core.domain.model.PruefungsTypE import at.mocode.core.domain.model.SparteE import at.mocode.core.domain.model.TurnierkategorieE -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlinx.serialization.Serializable import kotlin.time.Clock @@ -69,9 +69,9 @@ data class DomBewerb( var bemerkungen: String? = null, // Audit - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) var updatedAt: Instant = Clock.System.now() ) { /** diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennung.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennung.kt index c4e97ae1..dc3f9181 100644 --- a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennung.kt +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennung.kt @@ -4,7 +4,7 @@ package at.mocode.entries.domain.model import at.mocode.core.domain.model.NennungsStatusE import at.mocode.core.domain.model.StartwunschE -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlinx.serialization.Serializable import kotlin.time.Clock @@ -74,9 +74,9 @@ data class DomNennung( val bemerkungen: String? = null, // Audit Fields - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) var updatedAt: Instant = Clock.System.now() ) { /** diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennungsTransfer.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennungsTransfer.kt index b8afa669..17e6514f 100644 --- a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennungsTransfer.kt +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomNennungsTransfer.kt @@ -2,7 +2,7 @@ package at.mocode.entries.domain.model -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlinx.serialization.Serializable import kotlin.time.Clock @@ -72,7 +72,7 @@ data class DomNennungsTransfer( val grund: String? = null, // Audit Fields - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) val createdAt: Instant = Clock.System.now() ) { /** diff --git a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomStartliste.kt b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomStartliste.kt index 33b4347d..c9573a29 100644 --- a/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomStartliste.kt +++ b/backend/services/entries/entries-domain/src/main/kotlin/at/mocode/entries/domain/model/DomStartliste.kt @@ -3,7 +3,7 @@ package at.mocode.entries.domain.model import at.mocode.core.domain.model.StartlistenStatusE -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlinx.serialization.Serializable import kotlin.time.Clock @@ -49,18 +49,18 @@ data class DomStartliste( var eintraege: List = emptyList(), // Zeitstempel Workflow - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) var veroeffentlichtAt: Instant? = null, - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) var gesperrtAt: Instant? = null, // Verwaltung var bemerkungen: String? = null, // Audit - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) var updatedAt: Instant = Clock.System.now() ) { /** diff --git a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomAusschreibung.kt b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomAusschreibung.kt index 7cc37ecf..5ddf123c 100644 --- a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomAusschreibung.kt +++ b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomAusschreibung.kt @@ -4,7 +4,7 @@ package at.mocode.events.domain.model import at.mocode.core.domain.model.AusschreibungsStatusE import at.mocode.core.domain.model.SparteE -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.KotlinxInstantSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable @@ -88,9 +88,9 @@ data class DomAusschreibung( var genehmigungsNummer: String? = null, // Audit - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = KotlinxInstantSerializer::class) val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = KotlinxInstantSerializer::class) var updatedAt: Instant = Clock.System.now() ) { /** diff --git a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomTurnier.kt b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomTurnier.kt index 864d7c43..72be7125 100644 --- a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomTurnier.kt +++ b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomTurnier.kt @@ -5,7 +5,7 @@ package at.mocode.events.domain.model import at.mocode.core.domain.model.SparteE import at.mocode.core.domain.model.TurnierkategorieE import at.mocode.core.domain.model.TurnierStatusE -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.KotlinxInstantSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable @@ -67,9 +67,9 @@ data class DomTurnier( var bemerkungen: String? = null, // Audit - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = KotlinxInstantSerializer::class) val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = KotlinxInstantSerializer::class) var updatedAt: Instant = Clock.System.now() ) { /** diff --git a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomVeranstaltung.kt b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomVeranstaltung.kt index cdae1f23..80b8671e 100644 --- a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomVeranstaltung.kt +++ b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/DomVeranstaltung.kt @@ -5,7 +5,7 @@ package at.mocode.events.domain.model import at.mocode.core.domain.model.SparteE import at.mocode.core.domain.model.VeranstaltungsStatusE import at.mocode.core.domain.model.VeranstaltungsTypE -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.KotlinxInstantSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable @@ -71,9 +71,9 @@ data class DomVeranstaltung( var bemerkungen: String? = null, // Audit - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = KotlinxInstantSerializer::class) val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = KotlinxInstantSerializer::class) var updatedAt: Instant = Clock.System.now() ) { /** diff --git a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/Veranstaltung.kt b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/Veranstaltung.kt index 811bc5ef..b30aefcf 100644 --- a/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/Veranstaltung.kt +++ b/backend/services/events/events-domain/src/main/kotlin/at/mocode/events/domain/model/Veranstaltung.kt @@ -2,7 +2,7 @@ package at.mocode.events.domain.model import at.mocode.core.domain.model.SparteE -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.KotlinxInstantSerializer import at.mocode.core.domain.serialization.KotlinLocalDateSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlin.uuid.Uuid @@ -63,9 +63,9 @@ data class Veranstaltung( var anmeldeschluss: LocalDate? = null, // Audit Fields - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = KotlinxInstantSerializer::class) val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = KotlinxInstantSerializer::class) var updatedAt: Instant = Clock.System.now() ) { /** diff --git a/backend/services/horses/Dockerfile b/backend/services/horses/Dockerfile deleted file mode 100644 index ec1d851f..00000000 --- a/backend/services/horses/Dockerfile +++ /dev/null @@ -1,167 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -# =================================================================== -# Dockerfile for Horses Service -# Based on Spring Boot Service Template with Horses-specific configuration -# =================================================================== - -# === CENTRALIZED BUILD ARGUMENTS === -# Values sourced from docker/versions.toml and docker/build-args/ -# Global arguments (docker/build-args/global.env) -ARG GRADLE_VERSION -ARG JAVA_VERSION -ARG BUILD_DATE -ARG VERSION - -# Service-specific arguments (docker/build-args/services.env) -# Note: Keine Runtime-Profile/Ports als Build-ARGs -ARG SERVICE_PATH=horses/horses-service -ARG SERVICE_NAME=horses-service - -# =================================================================== -# Build Stage -# =================================================================== -FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION}-alpine AS builder - -# Re-declare build arguments for diesem Stage (nur Build-Zeit) -ARG SERVICE_PATH=horses/horses-service -ARG SERVICE_NAME=horses-service - -LABEL stage=builder -LABEL maintainer="Meldestelle Development Team" - -WORKDIR /workspace - -# Gradle optimizations -ENV GRADLE_OPTS="-Dorg.gradle.caching=true \ - -Dorg.gradle.daemon=false \ - -Dorg.gradle.parallel=true \ - -Dorg.gradle.configureondemand=true \ - -Xmx2g" - -# Copy build files in optimal order for caching -COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./ -COPY gradle/ gradle/ - -# Make gradlew executable (required on Linux/Unix systems) -RUN chmod +x gradlew - -COPY platform/ platform/ -COPY core/ core/ -COPY build.gradle.kts ./ - -# Copy horses service modules in dependency order -COPY horses/horses-domain/ horses/horses-domain/ -COPY horses/horses-api/ horses/horses-api/ -COPY horses/horses-application/ horses/horses-application/ -COPY horses/horses-infrastructure/ horses/horses-infrastructure/ -COPY horses/horses-service/ horses/horses-service/ - -# Build horses service (ohne Runtime-Profile bei Build) -RUN echo "Building Horses Service..." && \ - ./gradlew :horses:horses-service:dependencies --no-daemon --info && \ - ./gradlew :horses:horses-service:bootJar --no-daemon --info - -# Extract JAR layers for optimized Docker layer caching -WORKDIR /builder -RUN cp /workspace/horses/horses-service/build/libs/*.jar app.jar && \ - java -Djarmode=layertools -jar app.jar extract - -# =================================================================== -# Runtime Stage -# =================================================================== -FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime - -# Metadata -LABEL service="horses-service" \ - version="1.0.0" \ - description="Horses Management Service for Austrian Equestrian Federation" \ - maintainer="Meldestelle Development Team" \ - java.version="${JAVA_VERSION}" - -# Build arguments -ARG APP_USER=horsesuser -ARG APP_GROUP=horsesgroup -ARG APP_UID=1005 -ARG APP_GID=1005 - -WORKDIR /app - -# System setup -RUN apk update && \ - apk upgrade && \ - apk add --no-cache curl jq tzdata && \ - rm -rf /var/cache/apk/* - -# Non-root user creation -RUN addgroup -g ${APP_GID} -S ${APP_GROUP} && \ - adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh - -# Directory setup -RUN mkdir -p /app/logs /app/tmp && \ - chown -R ${APP_USER}:${APP_GROUP} /app - -# Re-declare build arguments for runtime stage -ARG SERVICE_PATH=horses/horses-service -ARG SERVICE_NAME=horses-service - -# Copy Spring Boot layers in optimal order for Docker layer caching -COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./ -COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./ -COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./ -COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./ - -USER ${APP_USER} - -# Expose application port and debug port -EXPOSE 8084 5005 - -# Health check -HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \ - CMD curl -fsS --max-time 2 http://localhost:8084/actuator/health/readiness || exit 1 - -# JVM configuration optimized for horses service -ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 \ - -XX:+UseG1GC \ - -XX:+UseStringDeduplication \ - -XX:+UseContainerSupport \ - -XX:G1HeapRegionSize=16m \ - -XX:+OptimizeStringConcat \ - -XX:+UseCompressedOops \ - -Djava.security.egd=file:/dev/./urandom \ - -Djava.awt.headless=true \ - -Dfile.encoding=UTF-8 \ - -Duser.timezone=Europe/Vienna \ - -Dmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus" - -# Spring Boot configuration (Profile nur zur Laufzeit via Compose/Env) -ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \ - SERVER_PORT=8084 \ - LOGGING_LEVEL_ROOT=INFO \ - LOGGING_LEVEL_AT_MOCODE_HORSES=DEBUG - -# Startup command with debug support -ENTRYPOINT ["sh", "-c", "\ - echo 'Starting Horses Service on port 8084...'; \ - if [ \"${DEBUG:-false}\" = \"true\" ]; then \ - echo 'Debug mode enabled on port 5005'; \ - exec java $JAVA_OPTS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 org.springframework.boot.loader.launch.JarLauncher; \ - else \ - exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher; \ - fi"] - -# =================================================================== -# Documentation -# =================================================================== -# Build commands: -# docker build -t meldestelle/horses-service:latest -f dockerfiles/services/horses-service/Dockerfile . -# docker run -p 8085:8085 --name horses-service meldestelle/horses-service:latest -# -# Key features: -# - Multi-stage build with JAR layer extraction for optimal caching -# - Non-root user execution for security (UID/GID 1005) -# - Optimized JVM settings for containers -# - Comprehensive health checks with horses-specific endpoint -# - Debug support on port 5005 -# - Vienna timezone configuration for Austrian operations -# =================================================================== diff --git a/backend/services/horses/horses-api/build.gradle.kts b/backend/services/horses/horses-api/build.gradle.kts deleted file mode 100644 index 7ceb013b..00000000 --- a/backend/services/horses/horses-api/build.gradle.kts +++ /dev/null @@ -1,44 +0,0 @@ -plugins { - // KORREKTUR: Alle Plugins werden jetzt konsistent über den Version Catalog geladen. - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.kotlin.spring) - alias(libs.plugins.kotlin.serialization) - - // Das Ktor-Plugin wird hier nicht benötigt, da Ktor als Bibliothek in Spring Boot läuft. - // Das 'application'-Plugin wird vom Spring Boot Plugin bereitgestellt. - alias(libs.plugins.spring.boot) - alias(libs.plugins.spring.dependencyManagement) -} - -// Der springBoot-Block konfiguriert die Anwendung, wenn sie als JAR-Datei ausgeführt wird. -springBoot { - mainClass.set("at.mocode.horses.api.ApplicationKt") -} - -dependencies { - // Interne Module - implementation(projects.platform.platformDependencies) - implementation(projects.backend.services.horses.horsesDomain) - implementation(projects.backend.services.horses.horsesApplication) - 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) - - // Ktor Server (als embedded Server in Spring) - implementation(libs.ktor.server.core) - implementation(libs.ktor.server.netty) - implementation(libs.ktor.server.contentNegotiation) - implementation(libs.ktor.server.serialization.kotlinx.json) - implementation(libs.ktor.server.statusPages) - implementation(libs.ktor.server.auth) - implementation(libs.ktor.server.authJwt) - - // Testing - testImplementation(projects.platform.platformTesting) - testImplementation(libs.ktor.server.tests) - testImplementation(libs.spring.boot.starter.test) -} diff --git a/backend/services/horses/horses-api/src/main/kotlin/at/mocode/horses/api/rest/HorseController.kt b/backend/services/horses/horses-api/src/main/kotlin/at/mocode/horses/api/rest/HorseController.kt deleted file mode 100644 index a4d627f0..00000000 --- a/backend/services/horses/horses-api/src/main/kotlin/at/mocode/horses/api/rest/HorseController.kt +++ /dev/null @@ -1,438 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.horses.api.rest - -import at.mocode.core.domain.model.ApiResponse -import at.mocode.core.domain.model.PferdeGeschlechtE -import at.mocode.horses.application.usecase.CreateHorseUseCase -import at.mocode.horses.application.usecase.DeleteHorseUseCase -import at.mocode.horses.application.usecase.GetHorseUseCase -import at.mocode.horses.application.usecase.UpdateHorseUseCase -import at.mocode.horses.domain.repository.HorseRepository -import at.mocode.core.utils.validation.ApiValidationUtils -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.Contextual -import kotlinx.serialization.Serializable - -/** - * REST API controller for horse registry operations. - * - * This controller provides HTTP endpoints for all horse-related operations - * following REST conventions and proper HTTP status codes. - */ -class HorseController( - private val horseRepository: HorseRepository -) { - - private val getHorseUseCase = GetHorseUseCase(horseRepository) - private val createHorseUseCase = CreateHorseUseCase(horseRepository) - private val updateHorseUseCase = UpdateHorseUseCase(horseRepository) - private val deleteHorseUseCase = DeleteHorseUseCase(horseRepository) - - /** - * Configures the horse-related routes. - */ - fun configureRoutes(routing: Routing) { - routing.route("/api/horses") { - - // GET /api/horses - Get all horses with optional filtering - get { - try { - // Validate query parameters - val validationErrors = ApiValidationUtils.validateQueryParameters( - limit = call.request.queryParameters["limit"], - search = call.request.queryParameters["search"] - ) - - if (!ApiValidationUtils.isValid(validationErrors)) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error(ApiValidationUtils.createErrorMessage(validationErrors)) - ) - return@get - } - - val activeOnly = call.request.queryParameters["activeOnly"]?.toBoolean() ?: true - val limit = call.request.queryParameters["limit"]?.toInt() ?: 100 - val ownerId = call.request.queryParameters["ownerId"]?.let { - ApiValidationUtils.validateUuidString(it) ?: return@get call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid ownerId format") - ) - } - val geschlecht = call.request.queryParameters["geschlecht"]?.let { - try { - PferdeGeschlechtE.valueOf(it) - } catch (_: IllegalArgumentException) { - return@get call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid geschlecht value. Valid values: ${PferdeGeschlechtE.entries.joinToString(", ")}") - ) - } - } - val rasse = call.request.queryParameters["rasse"] - val searchTerm = call.request.queryParameters["search"] - - val horses = when { - searchTerm != null -> getHorseUseCase.searchByName(searchTerm, limit) - ownerId != null -> getHorseUseCase.getByOwnerId(ownerId, activeOnly) - geschlecht != null -> getHorseUseCase.getByGeschlecht(geschlecht, activeOnly, limit) - rasse != null -> getHorseUseCase.getByRasse(rasse, activeOnly, limit) - else -> getHorseUseCase.getAllActive(limit) - } - - call.respond(HttpStatusCode.OK, ApiResponse.success(horses)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve horses: ${e.message}")) - } - } - - // GET /api/horses/{id} - Get horse by ID - get("/{id}") { - try { - val horseId = Uuid.parse(call.parameters["id"]!!) - val horse = getHorseUseCase.getById(horseId) - - if (horse != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(horse)) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Horse not found")) - } - } catch (_: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid horse ID format")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve horse: ${e.message}")) - } - } - - // GET /api/horses/search/lebensnummer/{nummer} - Find by life number - get("/search/lebensnummer/{nummer}") { - try { - val lebensnummer = call.parameters["nummer"]!! - val horse = getHorseUseCase.getByLebensnummer(lebensnummer) - - if (horse != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(horse)) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Horse with life number '$lebensnummer' not found")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to search horse: ${e.message}")) - } - } - - // GET /api/horses/search/chip/{nummer} - Find by chip number - get("/search/chip/{nummer}") { - try { - val chipNummer = call.parameters["nummer"]!! - val horse = getHorseUseCase.getByChipNummer(chipNummer) - - if (horse != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(horse)) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Horse with chip number '$chipNummer' not found")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to search horse: ${e.message}")) - } - } - - // GET /api/horses/search/passport/{nummer} - Find by passport number - get("/search/passport/{nummer}") { - try { - val passNummer = call.parameters["nummer"]!! - val horse = getHorseUseCase.getByPassNummer(passNummer) - - if (horse != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(horse)) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Horse with passport number '$passNummer' not found")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to search horse: ${e.message}")) - } - } - - // GET /api/horses/search/oeps/{nummer} - Find by OEPS number - get("/search/oeps/{nummer}") { - try { - val oepsNummer = call.parameters["nummer"]!! - val horse = getHorseUseCase.getByOepsNummer(oepsNummer) - - if (horse != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(horse)) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Horse with OEPS number '$oepsNummer' not found")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to search horse: ${e.message}")) - } - } - - // GET /api/horses/search/fei/{nummer} - Find by FEI number - get("/search/fei/{nummer}") { - try { - val feiNummer = call.parameters["nummer"]!! - val horse = getHorseUseCase.getByFeiNummer(feiNummer) - - if (horse != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(horse)) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Horse with FEI number '$feiNummer' not found")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to search horse: ${e.message}")) - } - } - - // GET /api/horses/oeps-registered - Get OEPS registered horses - get("/oeps-registered") { - try { - val activeOnly = call.request.queryParameters["activeOnly"]?.toBoolean() ?: true - val horses = getHorseUseCase.getOepsRegistered(activeOnly) - call.respond(HttpStatusCode.OK, ApiResponse.success(horses)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve OEPS horses: ${e.message}")) - } - } - - // GET /api/horses/fei-registered - Get FEI registered horses - get("/fei-registered") { - try { - val activeOnly = call.request.queryParameters["activeOnly"]?.toBoolean() ?: true - val horses = getHorseUseCase.getFeiRegistered(activeOnly) - call.respond(HttpStatusCode.OK, ApiResponse.success(horses)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve FEI horses: ${e.message}")) - } - } - - // GET /api/horses/stats - Get horse statistics - get("/stats") { - try { - val activeCount = getHorseUseCase.countActive() - val oepsCount = getHorseUseCase.countOepsRegistered(true) - val feiCount = getHorseUseCase.countFeiRegistered(true) - - val stats = HorseStats( - totalActive = activeCount, - oepsRegistered = oepsCount, - feiRegistered = feiCount - ) - - call.respond(HttpStatusCode.OK, ApiResponse.success(stats)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve statistics: ${e.message}")) - } - } - - // POST /api/horses - Create new horse - post { - try { - val createRequest = call.receive() - - // Validate input using shared validation utilities - val validationErrors = ApiValidationUtils.validateHorseRequest( - pferdeName = createRequest.pferdeName, - lebensnummer = createRequest.lebensnummer, - chipNummer = createRequest.chipNummer, - oepsNummer = createRequest.oepsNummer, - feiNummer = createRequest.feiNummer - ) - - if (!ApiValidationUtils.isValid(validationErrors)) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error(ApiValidationUtils.createErrorMessage(validationErrors)) - ) - return@post - } - - val response = createHorseUseCase.execute(createRequest) - - if (response.success) { - call.respond(HttpStatusCode.Created, ApiResponse.success(response.data!!)) - } else { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Validation failed")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to create horse: ${e.message}")) - } - } - - // PUT /api/horses/{id} - Update horse - put("/{id}") { - try { - val horseId = Uuid.parse(call.parameters["id"]!!) - val updateData = call.receive() - - // Validate input using shared validation utilities - val validationErrors = ApiValidationUtils.validateHorseRequest( - pferdeName = updateData.pferdeName, - lebensnummer = updateData.lebensnummer, - chipNummer = updateData.chipNummer, - oepsNummer = updateData.oepsNummer, - feiNummer = updateData.feiNummer - ) - - if (!ApiValidationUtils.isValid(validationErrors)) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error(ApiValidationUtils.createErrorMessage(validationErrors)) - ) - return@put - } - - val updateRequest = UpdateHorseUseCase.UpdateHorseRequest( - pferdId = horseId, - pferdeName = updateData.pferdeName, - geschlecht = updateData.geschlecht, - geburtsdatum = updateData.geburtsdatum, - rasse = updateData.rasse, - farbe = updateData.farbe, - besitzerId = updateData.besitzerId, - verantwortlichePersonId = updateData.verantwortlichePersonId, - zuechterName = updateData.zuechterName, - zuchtbuchNummer = updateData.zuchtbuchNummer, - lebensnummer = updateData.lebensnummer, - chipNummer = updateData.chipNummer, - passNummer = updateData.passNummer, - oepsNummer = updateData.oepsNummer, - feiNummer = updateData.feiNummer, - vaterName = updateData.vaterName, - mutterName = updateData.mutterName, - mutterVaterName = updateData.mutterVaterName, - stockmass = updateData.stockmass, - istAktiv = updateData.istAktiv, - bemerkungen = updateData.bemerkungen, - datenQuelle = updateData.datenQuelle - ) - - val response = updateHorseUseCase.execute(updateRequest) - - if (response.success && response.horse != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(response.horse)) - } else { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Update failed: ${response.errors.joinToString(", ")}")) - } - } catch (_: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid horse ID format")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to update horse: ${e.message}")) - } - } - - // DELETE /api/horses/{id} - Delete horse - delete("/{id}") { - try { - val horseId = Uuid.parse(call.parameters["id"]!!) - val forceDelete = call.request.queryParameters["force"]?.toBoolean() ?: false - - val deleteRequest = DeleteHorseUseCase.DeleteHorseRequest(horseId, forceDelete) - val response = deleteHorseUseCase.execute(deleteRequest) - - if (response.success) { - val message = if (response.warnings.isNotEmpty()) { - "Horse deleted successfully. Warnings: ${response.warnings.joinToString(", ")}" - } else { - "Horse deleted successfully" - } - call.respond(HttpStatusCode.OK, ApiResponse.success(message)) - } else { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Delete failed: ${response.errors.joinToString(", ")}")) - } - } catch (_: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid horse ID format")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to delete horse: ${e.message}")) - } - } - - // POST /api/horses/{id}/soft-delete - Soft delete horse (mark as inactive) - post("/{id}/soft-delete") { - try { - val horseId = Uuid.parse(call.parameters["id"]!!) - val response = deleteHorseUseCase.softDelete(horseId) - - if (response.success) { - val message = if (response.warnings.isNotEmpty()) { - "Horse marked as inactive. Warnings: ${response.warnings.joinToString(", ")}" - } else { - "Horse marked as inactive" - } - call.respond(HttpStatusCode.OK, ApiResponse.success(message)) - } else { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Soft delete failed: ${response.errors.joinToString(", ")}")) - } - } catch (_: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid horse ID format")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to soft delete horse: ${e.message}")) - } - } - - // POST /api/horses/batch-delete - Batch delete multiple horses - post("/batch-delete") { - try { - val batchRequest = call.receive() - val response = deleteHorseUseCase.batchDelete(batchRequest.horseIds, batchRequest.forceDelete) - - val statusCode = if (response.overallSuccess) HttpStatusCode.OK else HttpStatusCode.PartialContent - call.respond(statusCode, ApiResponse.success(response)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to batch delete horses: ${e.message}")) - } - } - } - } - - /** - * DTO for updating horse data via API. - */ - @Serializable - data class UpdateHorseRequest( - val pferdeName: String, - val geschlecht: PferdeGeschlechtE, - val geburtsdatum: kotlinx.datetime.LocalDate? = null, - val rasse: String? = null, - val farbe: String? = null, - @Contextual val besitzerId: Uuid? = null, - @Contextual val verantwortlichePersonId: Uuid? = null, - val zuechterName: String? = null, - val zuchtbuchNummer: String? = null, - val lebensnummer: String? = null, - val chipNummer: String? = null, - val passNummer: String? = null, - val oepsNummer: String? = null, - val feiNummer: String? = null, - val vaterName: String? = null, - val mutterName: String? = null, - val mutterVaterName: String? = null, - val stockmass: Int? = null, - val istAktiv: Boolean = true, - val bemerkungen: String? = null, - val datenQuelle: at.mocode.core.domain.model.DatenQuelleE = at.mocode.core.domain.model.DatenQuelleE.MANUELL - ) - - /** - * DTO for batch delete request. - */ - @Serializable - data class BatchDeleteRequest( - val horseIds: List<@Contextual Uuid>, - val forceDelete: Boolean = false - ) - - /** - * DTO for horse statistics. - */ - @Serializable - data class HorseStats( - val totalActive: Long, - val oepsRegistered: Long, - val feiRegistered: Long - ) -} diff --git a/backend/services/horses/horses-common/build.gradle.kts b/backend/services/horses/horses-common/build.gradle.kts deleted file mode 100644 index ead40886..00000000 --- a/backend/services/horses/horses-common/build.gradle.kts +++ /dev/null @@ -1,10 +0,0 @@ -plugins { - alias(libs.plugins.kotlinJvm) -} - -dependencies { - implementation(projects.backend.services.horses.horsesDomain) - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - testImplementation(projects.platform.platformTesting) -} diff --git a/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/CreateHorseUseCase.kt b/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/CreateHorseUseCase.kt deleted file mode 100644 index 8e9f03ff..00000000 --- a/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/CreateHorseUseCase.kt +++ /dev/null @@ -1,207 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.horses.application.usecase - -import at.mocode.horses.domain.model.DomPferd -import at.mocode.horses.domain.repository.HorseRepository -import at.mocode.core.domain.model.PferdeGeschlechtE -import at.mocode.core.domain.model.DatenQuelleE -import at.mocode.core.domain.model.ApiResponse -import at.mocode.core.domain.model.ErrorDto -import at.mocode.core.domain.model.ValidationResult -import at.mocode.core.domain.model.ValidationError -import kotlin.uuid.Uuid -import kotlinx.datetime.LocalDate -import kotlinx.datetime.todayIn - -/** - * Use case for creating a new horse in the registry. - * - * This use case handles the business logic for horse registration including - * validation, uniqueness checks, and persistence. - */ -class CreateHorseUseCase( - private val horseRepository: HorseRepository -) { - - /** - * Request data for creating a new horse. - */ - data class CreateHorseRequest( - val pferdeName: String, - val geschlecht: PferdeGeschlechtE, - val geburtsdatum: LocalDate? = null, - val rasse: String? = null, - val farbe: String? = null, - val besitzerId: Uuid? = null, - val verantwortlichePersonId: Uuid? = null, - val zuechterName: String? = null, - val zuchtbuchNummer: String? = null, - val lebensnummer: String? = null, - val chipNummer: String? = null, - val passNummer: String? = null, - val oepsNummer: String? = null, - val feiNummer: String? = null, - val vaterName: String? = null, - val mutterName: String? = null, - val mutterVaterName: String? = null, - val stockmass: Int? = null, - val bemerkungen: String? = null, - val datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL - ) - - - /** - * Executes the horse creation use case. - * - * @param request The horse creation request data - * @return ApiResponse with the created horse or validation errors - */ - suspend fun execute(request: CreateHorseRequest): ApiResponse { - // Create domain object - val horse = DomPferd( - pferdeName = request.pferdeName, - geschlecht = request.geschlecht, - geburtsdatum = request.geburtsdatum, - rasse = request.rasse, - farbe = request.farbe, - besitzerId = request.besitzerId, - verantwortlichePersonId = request.verantwortlichePersonId, - zuechterName = request.zuechterName, - zuchtbuchNummer = request.zuchtbuchNummer, - lebensnummer = request.lebensnummer, - chipNummer = request.chipNummer, - passNummer = request.passNummer, - oepsNummer = request.oepsNummer, - feiNummer = request.feiNummer, - vaterName = request.vaterName, - mutterName = request.mutterName, - mutterVaterName = request.mutterVaterName, - stockmass = request.stockmass, - bemerkungen = request.bemerkungen, - datenQuelle = request.datenQuelle - ) - - // Validate the horse - val validationResult = validateHorse(horse) - if (!validationResult.isValid()) { - val errors = (validationResult as ValidationResult.Invalid).errors - return ApiResponse( - success = false, - data = null, - error = ErrorDto( - code = "VALIDATION_ERROR", - message = "Horse validation failed", - details = errors.associate { it.field to it.message } - ) - ) - } - - // Check for uniqueness constraints - val uniquenessResult = checkUniquenessConstraints(horse) - if (!uniquenessResult.isValid()) { - val errors = (uniquenessResult as ValidationResult.Invalid).errors - return ApiResponse( - success = false, - data = null, - error = ErrorDto( - code = "UNIQUENESS_ERROR", - message = "Horse uniqueness validation failed", - details = errors.associate { it.field to it.message } - ) - ) - } - - // Save the horse - val savedHorse = horseRepository.save(horse) - - return ApiResponse( - success = true, - data = savedHorse, - message = "Horse created successfully" - ) - } - - /** - * Validates the horse data according to business rules. - */ - private fun validateHorse(horse: DomPferd): ValidationResult { - val errors = mutableListOf() - - // Use domain validation - val domainErrors = horse.validateForRegistration() - domainErrors.forEach { errorMessage -> - errors.add(ValidationError("horse", errorMessage, "DOMAIN_VALIDATION")) - } - - // Additional business validations - horse.stockmass?.let { height -> - if (height < 50 || height > 220) { - errors.add(ValidationError("stockmass", "Horse height must be between 50 and 220 cm", "INVALID_RANGE")) - } - } - - horse.geburtsdatum?.let { birthDate -> - val currentYear = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year - if (birthDate.year > currentYear) { - errors.add(ValidationError("geburtsdatum", "Birth date cannot be in the future", "FUTURE_DATE")) - } - if (birthDate.year < (currentYear - 50)) { - errors.add(ValidationError("geburtsdatum", "Birth date cannot be more than 50 years ago", "TOO_OLD")) - } - } - - return if (errors.isEmpty()) { - ValidationResult.Valid - } else { - ValidationResult.Invalid(errors) - } - } - - /** - * Checks uniqueness constraints for identification numbers. - */ - private suspend fun checkUniquenessConstraints(horse: DomPferd): ValidationResult { - val errors = mutableListOf() - - // Check lebensnummer uniqueness - horse.lebensnummer?.let { lebensnummer -> - if (lebensnummer.isNotBlank() && horseRepository.existsByLebensnummer(lebensnummer)) { - errors.add(ValidationError("lebensnummer", "A horse with this life number already exists", "DUPLICATE")) - } - } - - // Check chip number uniqueness - horse.chipNummer?.let { chipNummer -> - if (chipNummer.isNotBlank() && horseRepository.existsByChipNummer(chipNummer)) { - errors.add(ValidationError("chipNummer", "A horse with this chip number already exists", "DUPLICATE")) - } - } - - // Check passport number uniqueness - horse.passNummer?.let { passNummer -> - if (passNummer.isNotBlank() && horseRepository.existsByPassNummer(passNummer)) { - errors.add(ValidationError("passNummer", "A horse with this passport number already exists", "DUPLICATE")) - } - } - - // Check OEPS number uniqueness - horse.oepsNummer?.let { oepsNummer -> - if (oepsNummer.isNotBlank() && horseRepository.existsByOepsNummer(oepsNummer)) { - errors.add(ValidationError("oepsNummer", "A horse with this OEPS number already exists", "DUPLICATE")) - } - } - - // Check FEI number uniqueness - horse.feiNummer?.let { feiNummer -> - if (feiNummer.isNotBlank() && horseRepository.existsByFeiNummer(feiNummer)) { - errors.add(ValidationError("feiNummer", "A horse with this FEI number already exists", "DUPLICATE")) - } - } - - return if (errors.isEmpty()) { - ValidationResult.Valid - } else { - ValidationResult.Invalid(errors) - } - } -} diff --git a/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/DeleteHorseUseCase.kt b/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/DeleteHorseUseCase.kt deleted file mode 100644 index 79e2566c..00000000 --- a/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/DeleteHorseUseCase.kt +++ /dev/null @@ -1,215 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.horses.application.usecase - -import at.mocode.horses.domain.repository.HorseRepository -import kotlin.uuid.Uuid - -/** - * Use case for deleting a horse from the registry. - * - * This use case handles the business logic for horse deletion including - * existence checks and business rule validation. - */ -class DeleteHorseUseCase( - private val horseRepository: HorseRepository -) { - - /** - * Request data for deleting a horse. - */ - data class DeleteHorseRequest( - val pferdId: Uuid, - val forceDelete: Boolean = false - ) - - /** - * Response data for horse deletion. - */ - data class DeleteHorseResponse( - val success: Boolean, - val errors: List = emptyList(), - val warnings: List = emptyList() - ) - - /** - * Executes the horse deletion use case. - * - * @param request The horse deletion request data - * @return DeleteHorseResponse indicating success or failure with errors - */ - suspend fun execute(request: DeleteHorseRequest): DeleteHorseResponse { - // Check if horse exists - val existingHorse = horseRepository.findById(request.pferdId) - ?: return DeleteHorseResponse( - success = false, - errors = listOf("Horse not found") - ) - - // Check business rules for deletion - val businessRuleErrors = checkBusinessRules(request, existingHorse.pferdeName) - if (businessRuleErrors.isNotEmpty() && !request.forceDelete) { - return DeleteHorseResponse( - success = false, - errors = businessRuleErrors - ) - } - - // Generate warnings for important information - val warnings = generateWarnings(existingHorse.pferdeName, existingHorse.oepsNummer, existingHorse.feiNummer) - - // Perform the deletion - val deleted = horseRepository.delete(request.pferdId) - - return if (deleted) { - DeleteHorseResponse( - success = true, - warnings = warnings - ) - } else { - DeleteHorseResponse( - success = false, - errors = listOf("Failed to delete horse from database") - ) - } - } - - /** - * Soft delete alternative - marks horse as inactive instead of deleting. - */ - suspend fun softDelete(pferdId: Uuid): DeleteHorseResponse { - val existingHorse = horseRepository.findById(pferdId) - ?: return DeleteHorseResponse( - success = false, - errors = listOf("Horse not found") - ) - - if (!existingHorse.istAktiv) { - return DeleteHorseResponse( - success = false, - errors = listOf("Horse is already inactive") - ) - } - - // Mark as inactive - val inactiveHorse = existingHorse.copy(istAktiv = false).withUpdatedTimestamp() - horseRepository.save(inactiveHorse) - - return DeleteHorseResponse( - success = true, - warnings = listOf("Horse marked as inactive instead of deleted") - ) - } - - /** - * Checks business rules that might prevent deletion. - */ - private suspend fun checkBusinessRules(request: DeleteHorseRequest, horseName: String): List { - val errors = mutableListOf() - - // In a real system, you would check for: - // - Active competitions/entries - // - Historical records that should be preserved - // - Breeding records - // - License dependencies - - // For now, we'll implement basic checks - - // Example: Check if horse has OEPS or FEI registration - val horse = horseRepository.findById(request.pferdId) - if (horse != null) { - if (horse.isOepsRegistered() && !request.forceDelete) { - errors.add("Cannot delete OEPS registered horse without force delete flag") - } - - if (horse.isFeiRegistered() && !request.forceDelete) { - errors.add("Cannot delete FEI registered horse without force delete flag") - } - - // Check if horse has breeding information (might be important for pedigree) - if ((horse.vaterName != null || horse.mutterName != null) && !request.forceDelete) { - errors.add("Horse has pedigree information that might be referenced by other horses") - } - } - - return errors - } - - /** - * Generates warnings about the deletion. - */ - private fun generateWarnings(horseName: String, oepsNummer: String?, feiNummer: String?): List { - val warnings = mutableListOf() - - warnings.add("Horse '$horseName' will be permanently deleted") - - if (!oepsNummer.isNullOrBlank()) { - warnings.add("OEPS registration number '$oepsNummer' will be lost") - } - - if (!feiNummer.isNullOrBlank()) { - warnings.add("FEI registration number '$feiNummer' will be lost") - } - - warnings.add("This action cannot be undone") - - return warnings - } - - /** - * Batch delete multiple horses. - */ - suspend fun batchDelete(horseIds: List, forceDelete: Boolean = false): BatchDeleteResponse { - val results = mutableListOf() - var successCount = 0 - var errorCount = 0 - - for (horseId in horseIds) { - val request = DeleteHorseRequest(horseId, forceDelete) - val response = execute(request) - - results.add( - DeleteResult( - horseId = horseId, - success = response.success, - errors = response.errors, - warnings = response.warnings - ) - ) - - if (response.success) { - successCount++ - } else { - errorCount++ - } - } - - return BatchDeleteResponse( - results = results, - successCount = successCount, - errorCount = errorCount, - totalCount = horseIds.size - ) - } - - /** - * Result for individual horse deletion in batch operation. - */ - data class DeleteResult( - val horseId: Uuid, - val success: Boolean, - val errors: List = emptyList(), - val warnings: List = emptyList() - ) - - /** - * Response for batch delete operation. - */ - data class BatchDeleteResponse( - val results: List, - val successCount: Int, - val errorCount: Int, - val totalCount: Int - ) { - val overallSuccess: Boolean = errorCount == 0 - } -} diff --git a/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/GetHorseUseCase.kt b/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/GetHorseUseCase.kt deleted file mode 100644 index 00b345e3..00000000 --- a/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/GetHorseUseCase.kt +++ /dev/null @@ -1,304 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.horses.application.usecase - -import at.mocode.horses.domain.model.DomPferd -import at.mocode.horses.domain.repository.HorseRepository -import at.mocode.core.domain.model.PferdeGeschlechtE -import kotlin.uuid.Uuid -import kotlinx.datetime.todayIn - -/** - * Use case for retrieving horse information. - * - * This use case encapsulates the business logic for fetching horse data - * and provides a clean interface for the application layer. - */ -class GetHorseUseCase( - private val horseRepository: HorseRepository -) { - - /** - * Retrieves a horse by its unique ID. - * - * @param horseId The unique identifier of the horse - * @return The horse if found, null otherwise - */ - suspend fun getById(horseId: Uuid): DomPferd? { - return horseRepository.findById(horseId) - } - - /** - * Retrieves a horse by its life number. - * - * @param lebensnummer The life number to search for - * @return The horse if found, null otherwise - */ - suspend fun getByLebensnummer(lebensnummer: String): DomPferd? { - require(lebensnummer.isNotBlank()) { "Life number cannot be blank" } - return horseRepository.findByLebensnummer(lebensnummer.trim()) - } - - /** - * Retrieves a horse by its chip number. - * - * @param chipNummer The chip number to search for - * @return The horse if found, null otherwise - */ - suspend fun getByChipNummer(chipNummer: String): DomPferd? { - require(chipNummer.isNotBlank()) { "Chip number cannot be blank" } - return horseRepository.findByChipNummer(chipNummer.trim()) - } - - /** - * Retrieves a horse by its passport number. - * - * @param passNummer The passport number to search for - * @return The horse if found, null otherwise - */ - suspend fun getByPassNummer(passNummer: String): DomPferd? { - require(passNummer.isNotBlank()) { "Passport number cannot be blank" } - return horseRepository.findByPassNummer(passNummer.trim()) - } - - /** - * Retrieves a horse by its OEPS number. - * - * @param oepsNummer The OEPS number to search for - * @return The horse if found, null otherwise - */ - suspend fun getByOepsNummer(oepsNummer: String): DomPferd? { - require(oepsNummer.isNotBlank()) { "OEPS number cannot be blank" } - return horseRepository.findByOepsNummer(oepsNummer.trim()) - } - - /** - * Retrieves a horse by its FEI number. - * - * @param feiNummer The FEI number to search for - * @return The horse if found, null otherwise - */ - suspend fun getByFeiNummer(feiNummer: String): DomPferd? { - require(feiNummer.isNotBlank()) { "FEI number cannot be blank" } - return horseRepository.findByFeiNummer(feiNummer.trim()) - } - - /** - * Searches for horses by name (partial match). - * - * @param searchTerm The search term to match against horse names - * @param limit Maximum number of results to return (default: 50) - * @return List of matching horses - */ - suspend fun searchByName(searchTerm: String, limit: Int = 50): List { - require(searchTerm.isNotBlank()) { "Search term cannot be blank" } - require(limit > 0) { "Limit must be positive" } - return horseRepository.findByName(searchTerm.trim(), limit) - } - - /** - * Retrieves all horses owned by a specific person. - * - * @param ownerId The ID of the owner - * @param activeOnly Whether to return only active horses (default: true) - * @return List of horses owned by the person - */ - suspend fun getByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): List { - return horseRepository.findByOwnerId(ownerId, activeOnly) - } - - /** - * Retrieves all horses for which a person is responsible. - * - * @param responsiblePersonId The ID of the responsible person - * @param activeOnly Whether to return only active horses (default: true) - * @return List of horses for which the person is responsible - */ - suspend fun getByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean = true): List { - return horseRepository.findByResponsiblePersonId(responsiblePersonId, activeOnly) - } - - /** - * Retrieves horses by gender. - * - * @param geschlecht The gender to filter by - * @param activeOnly Whether to return only active horses (default: true) - * @param limit Maximum number of results to return (default: 100) - * @return List of horses with the specified gender - */ - suspend fun getByGeschlecht(geschlecht: PferdeGeschlechtE, activeOnly: Boolean = true, limit: Int = 100): List { - require(limit > 0) { "Limit must be positive" } - return horseRepository.findByGeschlecht(geschlecht, activeOnly, limit) - } - - /** - * Retrieves horses by breed. - * - * @param rasse The breed to filter by - * @param activeOnly Whether to return only active horses (default: true) - * @param limit Maximum number of results to return (default: 100) - * @return List of horses of the specified breed - */ - suspend fun getByRasse(rasse: String, activeOnly: Boolean = true, limit: Int = 100): List { - require(rasse.isNotBlank()) { "Breed cannot be blank" } - require(limit > 0) { "Limit must be positive" } - return horseRepository.findByRasse(rasse.trim(), activeOnly, limit) - } - - /** - * Retrieves horses by birth year. - * - * @param birthYear The birth year to filter by - * @param activeOnly Whether to return only active horses (default: true) - * @return List of horses born in the specified year - */ - suspend fun getByBirthYear(birthYear: Int, activeOnly: Boolean = true): List { - require(birthYear > 1900) { "Birth year must be after 1900" } - require(birthYear <= kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year) { - "Birth year cannot be in the future" - } - return horseRepository.findByBirthYear(birthYear, activeOnly) - } - - /** - * Retrieves horses by birth year range. - * - * @param fromYear The start year (inclusive) - * @param toYear The end year (inclusive) - * @param activeOnly Whether to return only active horses (default: true) - * @return List of horses born within the specified year range - */ - suspend fun getByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean = true): List { - require(fromYear > 1900) { "From year must be after 1900" } - require(toYear >= fromYear) { "To year must be greater than or equal to from year" } - val currentYear = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year - require(toYear <= currentYear) { "To year cannot be in the future" } - - return horseRepository.findByBirthYearRange(fromYear, toYear, activeOnly) - } - - /** - * Retrieves all active horses. - * - * @param limit Maximum number of results to return (default: 1000) - * @return List of active horses - */ - suspend fun getAllActive(limit: Int = 1000): List { - require(limit > 0) { "Limit must be positive" } - return horseRepository.findAllActive(limit) - } - - /** - * Retrieves horses with OEPS registration. - * - * @param activeOnly Whether to return only active horses (default: true) - * @return List of OEPS registered horses - */ - suspend fun getOepsRegistered(activeOnly: Boolean = true): List { - return horseRepository.findOepsRegistered(activeOnly) - } - - /** - * Retrieves horses with FEI registration. - * - * @param activeOnly Whether to return only active horses (default: true) - * @return List of FEI registered horses - */ - suspend fun getFeiRegistered(activeOnly: Boolean = true): List { - return horseRepository.findFeiRegistered(activeOnly) - } - - /** - * Checks if a horse with the given life number exists. - * - * @param lebensnummer The life number to check - * @return true if a horse with this life number exists, false otherwise - */ - suspend fun existsByLebensnummer(lebensnummer: String): Boolean { - require(lebensnummer.isNotBlank()) { "Life number cannot be blank" } - return horseRepository.existsByLebensnummer(lebensnummer.trim()) - } - - /** - * Checks if a horse with the given chip number exists. - * - * @param chipNummer The chip number to check - * @return true if a horse with this chip number exists, false otherwise - */ - suspend fun existsByChipNummer(chipNummer: String): Boolean { - require(chipNummer.isNotBlank()) { "Chip number cannot be blank" } - return horseRepository.existsByChipNummer(chipNummer.trim()) - } - - /** - * Checks if a horse with the given passport number exists. - * - * @param passNummer The passport number to check - * @return true if a horse with this passport number exists, false otherwise - */ - suspend fun existsByPassNummer(passNummer: String): Boolean { - require(passNummer.isNotBlank()) { "Passport number cannot be blank" } - return horseRepository.existsByPassNummer(passNummer.trim()) - } - - /** - * Checks if a horse with the given OEPS number exists. - * - * @param oepsNummer The OEPS number to check - * @return true if a horse with this OEPS number exists, false otherwise - */ - suspend fun existsByOepsNummer(oepsNummer: String): Boolean { - require(oepsNummer.isNotBlank()) { "OEPS number cannot be blank" } - return horseRepository.existsByOepsNummer(oepsNummer.trim()) - } - - /** - * Checks if a horse with the given FEI number exists. - * - * @param feiNummer The FEI number to check - * @return true if a horse with this FEI number exists, false otherwise - */ - suspend fun existsByFeiNummer(feiNummer: String): Boolean { - require(feiNummer.isNotBlank()) { "FEI number cannot be blank" } - return horseRepository.existsByFeiNummer(feiNummer.trim()) - } - - /** - * Counts the total number of active horses. - * - * @return The total count of active horses - */ - suspend fun countActive(): Long { - return horseRepository.countActive() - } - - /** - * Counts horses by owner. - * - * @param ownerId The ID of the owner - * @param activeOnly Whether to count only active horses (default: true) - * @return The count of horses owned by the person - */ - suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): Long { - return horseRepository.countByOwnerId(ownerId, activeOnly) - } - - /** - * Counts horses with OEPS registration. - * - * @param activeOnly Whether to count only active horses (default: true) - * @return The count of OEPS registered horses - */ - suspend fun countOepsRegistered(activeOnly: Boolean = true): Long { - return horseRepository.countOepsRegistered(activeOnly) - } - - /** - * Counts horses with FEI registration. - * - * @param activeOnly Whether to count only active horses (default: true) - * @return The count of FEI registered horses - */ - suspend fun countFeiRegistered(activeOnly: Boolean = true): Long { - return horseRepository.countFeiRegistered(activeOnly) - } -} diff --git a/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/TransactionalCreateHorseUseCase.kt b/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/TransactionalCreateHorseUseCase.kt deleted file mode 100644 index e4090ed8..00000000 --- a/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/TransactionalCreateHorseUseCase.kt +++ /dev/null @@ -1,256 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.horses.application.usecase - -import at.mocode.horses.domain.model.DomPferd -import at.mocode.horses.domain.repository.HorseRepository -import at.mocode.core.domain.model.PferdeGeschlechtE -import at.mocode.core.domain.model.DatenQuelleE -import at.mocode.core.domain.model.ApiResponse -import at.mocode.core.domain.model.ErrorDto -import at.mocode.core.domain.model.ValidationResult -import at.mocode.core.domain.model.ValidationError -import at.mocode.core.utils.database.DatabaseFactory -import kotlin.uuid.Uuid -import kotlinx.datetime.LocalDate -import kotlinx.datetime.todayIn - -/** - * Transactional version of CreateHorseUseCase that ensures all database operations - * run within a single transaction to maintain data consistency. - * - * This use case handles the business logic for horse registration including - * validation, uniqueness checks, and persistence - all within a single transaction. - */ -class TransactionalCreateHorseUseCase( - private val horseRepository: HorseRepository -) { - - /** - * Request data for creating a new horse. - */ - data class CreateHorseRequest( - val pferdeName: String, - val geschlecht: PferdeGeschlechtE, - val geburtsdatum: LocalDate? = null, - val rasse: String? = null, - val farbe: String? = null, - val besitzerId: Uuid? = null, - val verantwortlichePersonId: Uuid? = null, - val zuechterName: String? = null, - val zuchtbuchNummer: String? = null, - val lebensnummer: String? = null, - val chipNummer: String? = null, - val passNummer: String? = null, - val oepsNummer: String? = null, - val feiNummer: String? = null, - val vaterName: String? = null, - val mutterName: String? = null, - val mutterVaterName: String? = null, - val stockmass: Int? = null, - val bemerkungen: String? = null, - val datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL - ) - - /** - * Executes the horse creation use case within a single transaction. - * - * @param request The horse creation request data - * @return ApiResponse with the created horse or validation errors - */ - suspend fun execute(request: CreateHorseRequest): ApiResponse { - println("[DEBUG_LOG] TransactionalCreateHorseUseCase.execute() called for horse: ${request.pferdeName}") - - // Wrap the entire use case logic in a single transaction - return DatabaseFactory.dbQuery { - println("[DEBUG_LOG] Inside transaction for horse: ${request.pferdeName}") - // Create domain object - val horse = DomPferd( - pferdeName = request.pferdeName, - geschlecht = request.geschlecht, - geburtsdatum = request.geburtsdatum, - rasse = request.rasse, - farbe = request.farbe, - besitzerId = request.besitzerId, - verantwortlichePersonId = request.verantwortlichePersonId, - zuechterName = request.zuechterName, - zuchtbuchNummer = request.zuchtbuchNummer, - lebensnummer = request.lebensnummer, - chipNummer = request.chipNummer, - passNummer = request.passNummer, - oepsNummer = request.oepsNummer, - feiNummer = request.feiNummer, - vaterName = request.vaterName, - mutterName = request.mutterName, - mutterVaterName = request.mutterVaterName, - stockmass = request.stockmass, - bemerkungen = request.bemerkungen, - datenQuelle = request.datenQuelle - ) - - // Validate the horse - println("[DEBUG_LOG] Starting validation for horse: ${horse.pferdeName}") - val validationResult = validateHorse(horse) - if (!validationResult.isValid()) { - val errors = (validationResult as ValidationResult.Invalid).errors - println("[DEBUG_LOG] Validation failed for horse: ${horse.pferdeName}, errors: ${errors.map { "${it.field}: ${it.message}" }}") - return@dbQuery ApiResponse( - success = false, - data = null, - error = ErrorDto( - code = "VALIDATION_ERROR", - message = "Horse validation failed", - details = errors.associate { it.field to it.message } - ) - ) - } - println("[DEBUG_LOG] Validation passed for horse: ${horse.pferdeName}") - - // Check for uniqueness constraints - all within the same transaction - println("[DEBUG_LOG] Starting uniqueness check for horse: ${horse.pferdeName}") - val uniquenessResult = checkUniquenessConstraints(horse) - if (!uniquenessResult.isValid()) { - val errors = (uniquenessResult as ValidationResult.Invalid).errors - println("[DEBUG_LOG] Uniqueness check failed for horse: ${horse.pferdeName}, errors: ${errors.map { "${it.field}: ${it.message}" }}") - return@dbQuery ApiResponse( - success = false, - data = null, - error = ErrorDto( - code = "UNIQUENESS_ERROR", - message = "Horse uniqueness validation failed", - details = errors.associate { it.field to it.message } - ) - ) - } - println("[DEBUG_LOG] Uniqueness check passed for horse: ${horse.pferdeName}") - - // Save the horse - still within the same transaction - println("[DEBUG_LOG] Saving horse: ${horse.pferdeName}") - try { - val savedHorse = horseRepository.save(horse) - println("[DEBUG_LOG] Horse saved successfully: ${savedHorse.pferdeName} with ID: ${savedHorse.pferdId}") - - ApiResponse( - success = true, - data = savedHorse, - message = "Horse created successfully" - ) - } catch (e: Exception) { - println("[DEBUG_LOG] Database constraint violation for horse: ${horse.pferdeName}, error: ${e.message}") - - // Handle database constraint violations (duplicate keys) - if (e.message?.contains("unique", ignoreCase = true) == true || - e.message?.contains("duplicate", ignoreCase = true) == true) { - - // Determine which field caused the constraint violation - val constraintField = when { - e.message?.contains("lebensnummer", ignoreCase = true) == true -> "lebensnummer" - e.message?.contains("chip_nummer", ignoreCase = true) == true -> "chipNummer" - e.message?.contains("pass_nummer", ignoreCase = true) == true -> "passNummer" - e.message?.contains("oeps_nummer", ignoreCase = true) == true -> "oepsNummer" - e.message?.contains("fei_nummer", ignoreCase = true) == true -> "feiNummer" - else -> "identification" - } - - ApiResponse( - success = false, - data = null, - error = ErrorDto( - code = "UNIQUENESS_ERROR", - message = "Horse uniqueness validation failed due to database constraint", - details = mapOf(constraintField to "A horse with this ${constraintField} already exists") - ) - ) - } else { - // Re-throw other exceptions - throw e - } - } - } - } - - /** - * Validates the horse data according to business rules. - */ - private fun validateHorse(horse: DomPferd): ValidationResult { - val errors = mutableListOf() - - // Use domain validation - val domainErrors = horse.validateForRegistration() - domainErrors.forEach { errorMessage -> - errors.add(ValidationError("horse", errorMessage, "DOMAIN_VALIDATION")) - } - - // Additional business validations - horse.stockmass?.let { height -> - if (height < 50 || height > 220) { - errors.add(ValidationError("stockmass", "Horse height must be between 50 and 220 cm", "INVALID_RANGE")) - } - } - - horse.geburtsdatum?.let { birthDate -> - val currentYear = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year - if (birthDate.year > currentYear) { - errors.add(ValidationError("geburtsdatum", "Birth date cannot be in the future", "FUTURE_DATE")) - } - if (birthDate.year < (currentYear - 50)) { - errors.add(ValidationError("geburtsdatum", "Birth date cannot be more than 50 years ago", "TOO_OLD")) - } - } - - return if (errors.isEmpty()) { - ValidationResult.Valid - } else { - ValidationResult.Invalid(errors) - } - } - - /** - * Checks uniqueness constraints for identification numbers. - * Note: This method is called within a transaction, so all repository calls - * will use the same transaction context. - */ - private suspend fun checkUniquenessConstraints(horse: DomPferd): ValidationResult { - val errors = mutableListOf() - - // Check lebensnummer uniqueness - horse.lebensnummer?.let { lebensnummer -> - if (lebensnummer.isNotBlank() && horseRepository.existsByLebensnummer(lebensnummer)) { - errors.add(ValidationError("lebensnummer", "A horse with this life number already exists", "DUPLICATE")) - } - } - - // Check chip number uniqueness - horse.chipNummer?.let { chipNummer -> - if (chipNummer.isNotBlank() && horseRepository.existsByChipNummer(chipNummer)) { - errors.add(ValidationError("chipNummer", "A horse with this chip number already exists", "DUPLICATE")) - } - } - - // Check passport number uniqueness - horse.passNummer?.let { passNummer -> - if (passNummer.isNotBlank() && horseRepository.existsByPassNummer(passNummer)) { - errors.add(ValidationError("passNummer", "A horse with this passport number already exists", "DUPLICATE")) - } - } - - // Check OEPS number uniqueness - horse.oepsNummer?.let { oepsNummer -> - if (oepsNummer.isNotBlank() && horseRepository.existsByOepsNummer(oepsNummer)) { - errors.add(ValidationError("oepsNummer", "A horse with this OEPS number already exists", "DUPLICATE")) - } - } - - // Check FEI number uniqueness - horse.feiNummer?.let { feiNummer -> - if (feiNummer.isNotBlank() && horseRepository.existsByFeiNummer(feiNummer)) { - errors.add(ValidationError("feiNummer", "A horse with this FEI number already exists", "DUPLICATE")) - } - } - - return if (errors.isEmpty()) { - ValidationResult.Valid - } else { - ValidationResult.Invalid(errors) - } - } -} diff --git a/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/UpdateHorseUseCase.kt b/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/UpdateHorseUseCase.kt deleted file mode 100644 index 87236431..00000000 --- a/backend/services/horses/horses-common/src/main/kotlin/at/mocode/horses/application/usecase/UpdateHorseUseCase.kt +++ /dev/null @@ -1,213 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.horses.application.usecase - -import at.mocode.horses.domain.model.DomPferd -import at.mocode.horses.domain.repository.HorseRepository -import at.mocode.core.domain.model.PferdeGeschlechtE -import at.mocode.core.domain.model.DatenQuelleE -import kotlin.uuid.Uuid -import kotlinx.datetime.LocalDate -import kotlinx.datetime.todayIn - -/** - * Use case for updating an existing horse in the registry. - * - * This use case handles the business logic for horse updates including - * validation, uniqueness checks, and persistence. - */ -class UpdateHorseUseCase( - private val horseRepository: HorseRepository -) { - - /** - * Request data for updating a horse. - */ - data class UpdateHorseRequest( - val pferdId: Uuid, - val pferdeName: String, - val geschlecht: PferdeGeschlechtE, - val geburtsdatum: LocalDate? = null, - val rasse: String? = null, - val farbe: String? = null, - val besitzerId: Uuid? = null, - val verantwortlichePersonId: Uuid? = null, - val zuechterName: String? = null, - val zuchtbuchNummer: String? = null, - val lebensnummer: String? = null, - val chipNummer: String? = null, - val passNummer: String? = null, - val oepsNummer: String? = null, - val feiNummer: String? = null, - val vaterName: String? = null, - val mutterName: String? = null, - val mutterVaterName: String? = null, - val stockmass: Int? = null, - val istAktiv: Boolean = true, - val bemerkungen: String? = null, - val datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL - ) - - /** - * Response data for horse update. - */ - data class UpdateHorseResponse( - val horse: DomPferd?, - val success: Boolean, - val errors: List = emptyList() - ) - - /** - * Executes the horse update use case. - * - * @param request The horse update request data - * @return UpdateHorseResponse with the updated horse or validation errors - */ - suspend fun execute(request: UpdateHorseRequest): UpdateHorseResponse { - // Check if horse exists - val existingHorse = horseRepository.findById(request.pferdId) - ?: return UpdateHorseResponse( - horse = null, - success = false, - errors = listOf("Horse not found") - ) - - // Create updated domain object - val updatedHorse = existingHorse.copy( - pferdeName = request.pferdeName, - geschlecht = request.geschlecht, - geburtsdatum = request.geburtsdatum, - rasse = request.rasse, - farbe = request.farbe, - besitzerId = request.besitzerId, - verantwortlichePersonId = request.verantwortlichePersonId, - zuechterName = request.zuechterName, - zuchtbuchNummer = request.zuchtbuchNummer, - lebensnummer = request.lebensnummer, - chipNummer = request.chipNummer, - passNummer = request.passNummer, - oepsNummer = request.oepsNummer, - feiNummer = request.feiNummer, - vaterName = request.vaterName, - mutterName = request.mutterName, - mutterVaterName = request.mutterVaterName, - stockmass = request.stockmass, - istAktiv = request.istAktiv, - bemerkungen = request.bemerkungen, - datenQuelle = request.datenQuelle - ) - - // Validate the updated horse - val validationErrors = validateHorse(updatedHorse) - if (validationErrors.isNotEmpty()) { - return UpdateHorseResponse( - horse = updatedHorse, - success = false, - errors = validationErrors - ) - } - - // Check for uniqueness constraints (excluding current horse) - val uniquenessErrors = checkUniquenessConstraints(updatedHorse, existingHorse) - if (uniquenessErrors.isNotEmpty()) { - return UpdateHorseResponse( - horse = updatedHorse, - success = false, - errors = uniquenessErrors - ) - } - - // Save the updated horse - val savedHorse = horseRepository.save(updatedHorse) - - return UpdateHorseResponse( - horse = savedHorse, - success = true - ) - } - - /** - * Validates the horse data according to business rules. - */ - private fun validateHorse(horse: DomPferd): List { - val errors = mutableListOf() - - // Basic validation - if (horse.pferdeName.isBlank()) { - errors.add("Horse name is required") - } - - // Height validation - horse.stockmass?.let { height -> - if (height < 50 || height > 220) { - errors.add("Horse height must be between 50 and 220 cm") - } - } - - // Birth date validation - horse.geburtsdatum?.let { birthDate -> - val currentYear = kotlinx.datetime.Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()).year - if (birthDate.year > currentYear) { - errors.add("Birth date cannot be in the future") - } - if (birthDate.year < (currentYear - 50)) { - errors.add("Birth date cannot be more than 50 years ago") - } - } - - return errors - } - - /** - * Checks uniqueness constraints for identification numbers, excluding the current horse. - */ - private suspend fun checkUniquenessConstraints(updatedHorse: DomPferd, existingHorse: DomPferd): List { - val errors = mutableListOf() - - // Check lebensnummer uniqueness (if changed) - updatedHorse.lebensnummer?.let { lebensnummer -> - if (lebensnummer.isNotBlank() && - lebensnummer != existingHorse.lebensnummer && - horseRepository.existsByLebensnummer(lebensnummer)) { - errors.add("A horse with this life number already exists") - } - } - - // Check chip number uniqueness (if changed) - updatedHorse.chipNummer?.let { chipNummer -> - if (chipNummer.isNotBlank() && - chipNummer != existingHorse.chipNummer && - horseRepository.existsByChipNummer(chipNummer)) { - errors.add("A horse with this chip number already exists") - } - } - - // Check passport number uniqueness (if changed) - updatedHorse.passNummer?.let { passNummer -> - if (passNummer.isNotBlank() && - passNummer != existingHorse.passNummer && - horseRepository.existsByPassNummer(passNummer)) { - errors.add("A horse with this passport number already exists") - } - } - - // Check OEPS number uniqueness (if changed) - updatedHorse.oepsNummer?.let { oepsNummer -> - if (oepsNummer.isNotBlank() && - oepsNummer != existingHorse.oepsNummer && - horseRepository.existsByOepsNummer(oepsNummer)) { - errors.add("A horse with this OEPS number already exists") - } - } - - // Check FEI number uniqueness (if changed) - updatedHorse.feiNummer?.let { feiNummer -> - if (feiNummer.isNotBlank() && - feiNummer != existingHorse.feiNummer && - horseRepository.existsByFeiNummer(feiNummer)) { - errors.add("A horse with this FEI number already exists") - } - } - - return errors - } -} diff --git a/backend/services/horses/horses-domain/build.gradle.kts b/backend/services/horses/horses-domain/build.gradle.kts deleted file mode 100644 index 80e97efb..00000000 --- a/backend/services/horses/horses-domain/build.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.kotlinSerialization) -} - -kotlin { - jvm() - - sourceSets { - commonMain { - kotlin.srcDir("src/main/kotlin") - dependencies { - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - implementation(libs.kotlinx.datetime) - implementation(libs.kotlinx.serialization.json) - } - } - commonTest { - kotlin.srcDir("src/test/kotlin") - dependencies { - implementation(projects.platform.platformTesting) - } - } - } -} diff --git a/backend/services/horses/horses-domain/src/main/kotlin/at/mocode/horses/domain/model/DomPferd.kt b/backend/services/horses/horses-domain/src/main/kotlin/at/mocode/horses/domain/model/DomPferd.kt deleted file mode 100644 index 5e97c8ad..00000000 --- a/backend/services/horses/horses-domain/src/main/kotlin/at/mocode/horses/domain/model/DomPferd.kt +++ /dev/null @@ -1,174 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.horses.domain.model - -import at.mocode.core.domain.model.DatenQuelleE -import at.mocode.core.domain.model.PferdeGeschlechtE -import at.mocode.core.domain.serialization.KotlinInstantSerializer -import at.mocode.core.domain.serialization.UuidSerializer -import kotlinx.datetime.LocalDate -import kotlinx.datetime.number -import kotlinx.datetime.todayIn -import kotlinx.serialization.Serializable -import kotlin.time.Clock -import kotlin.time.Instant -import kotlin.uuid.Uuid - -/** - * Domain model representing a horse in the registry system. - * - * This entity contains all essential information about a horse including - * identification, ownership, breeding information, and administrative data. - * It serves as the core aggregate root for the horse-registry bounded context. - * - * @property pferdId Unique internal identifier for this horse (UUID). - * @property pferdeName Name of the horse. - * @property geschlecht Gender of the horse (Hengst, Stute, Wallach). - * @property geburtsdatum Birthdate of the horse. - * @property rasse Breed of the horse. - * @property farbe Color/coat of the horse. - * @property besitzerId ID of the current owner (Person from member-management context). - * @property verantwortlichePersonId ID of the responsible person (trainer, rider, etc.). - * @property zuechterName Name of the breeder. - * @property zuchtbuchNummer Studbook number if registered. - * @property lebensnummer Life number (unique identification number). - * @property chipNummer Microchip number for identification. - * @property passNummer Passport number. - * @property oepsNummer OEPS (Austrian Equestrian Federation) number. - * @property feiNummer FEI (International Equestrian Federation) number. - * @property vaterName Name of the sire (father). - * @property mutterName Name of the dam (mother). - * @property mutterVaterName Name of the maternal grandsire. - * @property stockmass Height of the horse in cm. - * @property istAktiv Whether the horse is currently active in the system. - * @property bemerkungen Additional notes or comments. - * @property datenQuelle Source of the data (manual entry, import, etc.). - * @property createdAt Timestamp when this record was created. - * @property updatedAt Timestamp when this record was last updated. - */ -@Serializable -data class DomPferd( - @Serializable(with = UuidSerializer::class) - val pferdId: Uuid = Uuid.random(), - - // Basic Information - var pferdeName: String, - var geschlecht: PferdeGeschlechtE, - var geburtsdatum: LocalDate? = null, - var rasse: String? = null, - var farbe: String? = null, - - // Ownership and Responsibility - @Serializable(with = UuidSerializer::class) - var besitzerId: Uuid? = null, - @Serializable(with = UuidSerializer::class) - var verantwortlichePersonId: Uuid? = null, - - // Breeding Information - var zuechterName: String? = null, - var zuchtbuchNummer: String? = null, - - // Identification Numbers - var lebensnummer: String? = null, - var chipNummer: String? = null, - var passNummer: String? = null, - var oepsNummer: String? = null, - var feiNummer: String? = null, - - // Pedigree Information - var vaterName: String? = null, - var mutterName: String? = null, - var mutterVaterName: String? = null, - - // Physical Characteristics - var stockmass: Int? = null, // Height in cm - - // Status and Administrative - var istAktiv: Boolean = true, - var bemerkungen: String? = null, - var datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL, - - // Audit Fields - @Serializable(with = KotlinInstantSerializer::class) - val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) - var updatedAt: Instant = Clock.System.now() -) { - /** - * Returns the display name for the horse, combining name and birth year if available. - */ - fun getDisplayName(): String { - return geburtsdatum?.let { birthDate -> - "$pferdeName (${birthDate.year})" - } ?: pferdeName - } - - /** - * Checks if the horse has complete identification information. - */ - fun hasCompleteIdentification(): Boolean { - return !lebensnummer.isNullOrBlank() || - !chipNummer.isNullOrBlank() || - !passNummer.isNullOrBlank() - } - - /** - * Checks if the horse is registered with OEPS. - */ - fun isOepsRegistered(): Boolean { - return !oepsNummer.isNullOrBlank() - } - - /** - * Checks if the horse is registered with FEI. - */ - fun isFeiRegistered(): Boolean { - return !feiNummer.isNullOrBlank() - } - - /** - * Returns the age of the horse in years, or null if birth date is unknown. - */ - fun getAge(): Int? { - return geburtsdatum?.let { birthDate -> - val today = Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()) - var age = today.year - birthDate.year - - // Check if a birthday has occurred this year - if (today.month.number < birthDate.month.number || - (today.month.number == birthDate.month.number && today.day < birthDate.day) - ) { - age-- - } - - age - } - } - - /** - * Validates that required fields are present for horse registration. - */ - fun validateForRegistration(): List { - val errors = mutableListOf() - - if (pferdeName.isBlank()) { - errors.add("Horse name is required") - } - - if (!hasCompleteIdentification()) { - errors.add("At least one identification number (life number, chip number, or passport number) is required") - } - - if (besitzerId == null) { - errors.add("Owner is required") - } - - return errors - } - - /** - * Creates a copy of this horse with an updated timestamp. - */ - fun withUpdatedTimestamp(): DomPferd { - return this.copy(updatedAt = Clock.System.now()) - } -} diff --git a/backend/services/horses/horses-domain/src/main/kotlin/at/mocode/horses/domain/repository/HorseRepository.kt b/backend/services/horses/horses-domain/src/main/kotlin/at/mocode/horses/domain/repository/HorseRepository.kt deleted file mode 100644 index 49c8ac73..00000000 --- a/backend/services/horses/horses-domain/src/main/kotlin/at/mocode/horses/domain/repository/HorseRepository.kt +++ /dev/null @@ -1,243 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.horses.domain.repository - -import at.mocode.horses.domain.model.DomPferd -import at.mocode.core.domain.model.PferdeGeschlechtE -import kotlin.uuid.Uuid - -/** - * Repository interface for DomPferd (Horse) domain operations. - * - * This interface defines the contract for horse data access operations - * without depending on specific implementation details (database, etc.). - * Following the hexagonal architecture pattern, this interface belongs - * to the domain layer and will be implemented in the infrastructure layer. - */ -interface HorseRepository { - - /** - * Finds a horse by its unique ID. - * - * @param id The unique identifier of the horse - * @return The horse if found, null otherwise - */ - suspend fun findById(id: Uuid): DomPferd? - - /** - * Finds a horse by its life number (Lebensnummer). - * - * @param lebensnummer The life number to search for - * @return The horse if found, null otherwise - */ - suspend fun findByLebensnummer(lebensnummer: String): DomPferd? - - /** - * Finds a horse by its chip number. - * - * @param chipNummer The chip number to search for - * @return The horse if found, null otherwise - */ - suspend fun findByChipNummer(chipNummer: String): DomPferd? - - /** - * Finds a horse by its passport number. - * - * @param passNummer The passport number to search for - * @return The horse if found, null otherwise - */ - suspend fun findByPassNummer(passNummer: String): DomPferd? - - /** - * Finds a horse by its OEPS number. - * - * @param oepsNummer The OEPS number to search for - * @return The horse if found, null otherwise - */ - suspend fun findByOepsNummer(oepsNummer: String): DomPferd? - - /** - * Finds a horse by its FEI number. - * - * @param feiNummer The FEI number to search for - * @return The horse if found, null otherwise - */ - suspend fun findByFeiNummer(feiNummer: String): DomPferd? - - /** - * Finds horses by name (partial match). - * - * @param searchTerm The search term to match against horse names - * @param limit Maximum number of results to return - * @return List of matching horses - */ - suspend fun findByName(searchTerm: String, limit: Int = 50): List - - /** - * Finds all horses owned by a specific person. - * - * @param ownerId The ID of the owner (from member-management context) - * @param activeOnly Whether to return only active horses - * @return List of horses owned by the person - */ - suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): List - - /** - * Finds all horses for which a person is responsible. - * - * @param responsiblePersonId The ID of the responsible person - * @param activeOnly Whether to return only active horses - * @return List of horses for which the person is responsible - */ - suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean = true): List - - /** - * Finds horses by gender. - * - * @param geschlecht The gender to filter by - * @param activeOnly Whether to return only active horses - * @param limit Maximum number of results to return - * @return List of horses with the specified gender - */ - suspend fun findByGeschlecht(geschlecht: PferdeGeschlechtE, activeOnly: Boolean = true, limit: Int = 100): List - - /** - * Finds horses by breed. - * - * @param rasse The breed to filter by - * @param activeOnly Whether to return only active horses - * @param limit Maximum number of results to return - * @return List of horses of the specified breed - */ - suspend fun findByRasse(rasse: String, activeOnly: Boolean = true, limit: Int = 100): List - - /** - * Finds horses by birth year. - * - * @param birthYear The birth year to filter by - * @param activeOnly Whether to return only active horses - * @return List of horses born in the specified year - */ - suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean = true): List - - /** - * Finds horses by birth year range. - * - * @param fromYear The start year (inclusive) - * @param toYear The end year (inclusive) - * @param activeOnly Whether to return only active horses - * @return List of horses born within the specified year range - */ - suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean = true): List - - /** - * Finds all active horses. - * - * @param limit Maximum number of results to return - * @return List of active horses - */ - suspend fun findAllActive(limit: Int = 1000): List - - /** - * Finds horses with OEPS registration. - * - * @param activeOnly Whether to return only active horses - * @return List of OEPS registered horses - */ - suspend fun findOepsRegistered(activeOnly: Boolean = true): List - - /** - * Finds horses with FEI registration. - * - * @param activeOnly Whether to return only active horses - * @return List of FEI registered horses - */ - suspend fun findFeiRegistered(activeOnly: Boolean = true): List - - /** - * Saves a horse (create or update). - * - * @param horse The horse to save - * @return The saved horse with updated timestamps - */ - suspend fun save(horse: DomPferd): DomPferd - - /** - * Deletes a horse by ID. - * - * @param id The unique identifier of the horse to delete - * @return true if the horse was deleted, false if not found - */ - suspend fun delete(id: Uuid): Boolean - - /** - * Checks if a horse with the given life number exists. - * - * @param lebensnummer The life number to check - * @return true if a horse with this life number exists, false otherwise - */ - suspend fun existsByLebensnummer(lebensnummer: String): Boolean - - /** - * Checks if a horse with the given chip number exists. - * - * @param chipNummer The chip number to check - * @return true if a horse with this chip number exists, false otherwise - */ - suspend fun existsByChipNummer(chipNummer: String): Boolean - - /** - * Checks if a horse with the given passport number exists. - * - * @param passNummer The passport number to check - * @return true if a horse with this passport number exists, false otherwise - */ - suspend fun existsByPassNummer(passNummer: String): Boolean - - /** - * Checks if a horse with the given OEPS number exists. - * - * @param oepsNummer The OEPS number to check - * @return true if a horse with this OEPS number exists, false otherwise - */ - suspend fun existsByOepsNummer(oepsNummer: String): Boolean - - /** - * Checks if a horse with the given FEI number exists. - * - * @param feiNummer The FEI number to check - * @return true if a horse with this FEI number exists, false otherwise - */ - suspend fun existsByFeiNummer(feiNummer: String): Boolean - - /** - * Counts the total number of active horses. - * - * @return The total count of active horses - */ - suspend fun countActive(): Long - - /** - * Counts horses by owner. - * - * @param ownerId The ID of the owner - * @param activeOnly Whether to count only active horses - * @return The count of horses owned by the person - */ - suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): Long - - /** - * Counts horses with OEPS registration. - * - * @param activeOnly Whether to count only active horses - * @return The count of OEPS registered horses - */ - suspend fun countOepsRegistered(activeOnly: Boolean = true): Long - - /** - * Counts horses with FEI registration. - * - * @param activeOnly Whether to count only active horses - * @return The count of FEI registered horses - */ - suspend fun countFeiRegistered(activeOnly: Boolean = true): Long -} diff --git a/backend/services/horses/horses-infrastructure/build.gradle.kts b/backend/services/horses/horses-infrastructure/build.gradle.kts deleted file mode 100644 index 1c0aa997..00000000 --- a/backend/services/horses/horses-infrastructure/build.gradle.kts +++ /dev/null @@ -1,30 +0,0 @@ -plugins { - alias(libs.plugins.kotlinJvm) - alias(libs.plugins.kotlinSpring) - alias(libs.plugins.kotlinSerialization) -} - -dependencies { - // Interne Module - implementation(projects.platform.platformDependencies) - implementation(projects.backend.services.horses.horsesDomain) - // horses-common: ON HOLD - // implementation(projects.backend.services.horses.horsesCommon) - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - - // Spring - implementation(libs.spring.boot.starter.web) - - // Datenbank via Exposed - implementation(libs.exposed.core) - implementation(libs.exposed.dao) - implementation(libs.exposed.jdbc) - implementation(libs.exposed.kotlin.datetime) - - // Datenbank-Treiber - runtimeOnly(libs.postgresql.driver) - - // Testing - testImplementation(projects.platform.platformTesting) -} diff --git a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt deleted file mode 100644 index 9b30d971..00000000 --- a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt +++ /dev/null @@ -1,266 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) - -package at.mocode.horses.infrastructure.persistence - -import at.mocode.core.domain.model.PferdeGeschlechtE -import at.mocode.horses.domain.model.DomPferd -import at.mocode.horses.domain.repository.HorseRepository -import org.jetbrains.exposed.v1.core.* -import org.jetbrains.exposed.v1.core.statements.UpdateBuilder -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.transactions.transaction -import org.jetbrains.exposed.v1.jdbc.update -import kotlin.time.Clock -import kotlin.uuid.Uuid -import kotlin.uuid.toJavaUuid -import kotlin.uuid.toKotlinUuid - -/** - * Exposed-basierte Implementierung des HorseRepository (v1-API). - */ -class HorseRepositoryImpl : HorseRepository { - - override suspend fun findById(id: Uuid): DomPferd? = transaction { - HorseTable.selectAll().where { HorseTable.id eq id.toJavaUuid() } - .map { rowToDomPferd(it) } - .singleOrNull() - } - - override suspend fun findByLebensnummer(lebensnummer: String): DomPferd? = transaction { - HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer } - .map { rowToDomPferd(it) } - .singleOrNull() - } - - override suspend fun findByChipNummer(chipNummer: String): DomPferd? = transaction { - HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer } - .map { rowToDomPferd(it) } - .singleOrNull() - } - - override suspend fun findByPassNummer(passNummer: String): DomPferd? = transaction { - HorseTable.selectAll().where { HorseTable.passNummer eq passNummer } - .map { rowToDomPferd(it) } - .singleOrNull() - } - - override suspend fun findByOepsNummer(oepsNummer: String): DomPferd? = transaction { - HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer } - .map { rowToDomPferd(it) } - .singleOrNull() - } - - override suspend fun findByFeiNummer(feiNummer: String): DomPferd? = transaction { - HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer } - .map { rowToDomPferd(it) } - .singleOrNull() - } - - override suspend fun findByName(searchTerm: String, limit: Int): List = transaction { - val pattern = "%$searchTerm%" - HorseTable.selectAll().where { HorseTable.pferdeName like pattern } - .limit(limit) - .map { rowToDomPferd(it) } - } - - override suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean): List = transaction { - HorseTable.selectAll().where { - (HorseTable.besitzerId eq ownerId.toJavaUuid()).let { - if (activeOnly) it and (HorseTable.istAktiv eq true) else it - } - }.map { rowToDomPferd(it) } - } - - override suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean): List = - transaction { - HorseTable.selectAll().where { - (HorseTable.verantwortlichePersonId eq responsiblePersonId.toJavaUuid()).let { - if (activeOnly) it and (HorseTable.istAktiv eq true) else it - } - }.map { rowToDomPferd(it) } - } - - override suspend fun findByGeschlecht( - geschlecht: PferdeGeschlechtE, - activeOnly: Boolean, - limit: Int - ): List = transaction { - HorseTable.selectAll().where { - (HorseTable.geschlecht eq geschlecht).let { - if (activeOnly) it and (HorseTable.istAktiv eq true) else it - } - }.limit(limit).map { rowToDomPferd(it) } - } - - override suspend fun findByRasse(rasse: String, activeOnly: Boolean, limit: Int): List = transaction { - HorseTable.selectAll().where { - (HorseTable.rasse eq rasse).let { - if (activeOnly) it and (HorseTable.istAktiv eq true) else it - } - }.limit(limit).map { rowToDomPferd(it) } - } - - override suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean): List = transaction { - HorseTable.selectAll() - .map { rowToDomPferd(it) } - .filter { it.geburtsdatum?.year == birthYear && (!activeOnly || it.istAktiv) } - } - - override suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean): List = - transaction { - HorseTable.selectAll() - .map { rowToDomPferd(it) } - .filter { - val year = it.geburtsdatum?.year - year != null && year in fromYear..toYear && (!activeOnly || it.istAktiv) - } - } - - override suspend fun findAllActive(limit: Int): List = transaction { - HorseTable.selectAll().where { HorseTable.istAktiv eq true } - .limit(limit) - .map { rowToDomPferd(it) } - } - - override suspend fun findOepsRegistered(activeOnly: Boolean): List = transaction { - HorseTable.selectAll().where { - HorseTable.oepsNummer.isNotNull().let { - if (activeOnly) it and (HorseTable.istAktiv eq true) else it - } - }.map { rowToDomPferd(it) } - } - - override suspend fun findFeiRegistered(activeOnly: Boolean): List = transaction { - HorseTable.selectAll().where { - HorseTable.feiNummer.isNotNull().let { - if (activeOnly) it and (HorseTable.istAktiv eq true) else it - } - }.map { rowToDomPferd(it) } - } - - override suspend fun save(horse: DomPferd): DomPferd = transaction { - val existing = HorseTable.selectAll() - .where { HorseTable.id eq horse.pferdId.toJavaUuid() } - .singleOrNull() - if (existing == null) { - HorseTable.insert { applyToStatement(it, horse) } - } else { - HorseTable.update({ HorseTable.id eq horse.pferdId.toJavaUuid() }) { - applyToStatement(it, horse.copy(updatedAt = Clock.System.now())) - } - } - horse - } - - override suspend fun delete(id: Uuid): Boolean = transaction { - HorseTable.deleteWhere { HorseTable.id eq id.toJavaUuid() } > 0 - } - - override suspend fun existsByLebensnummer(lebensnummer: String): Boolean = transaction { - HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }.count() > 0 - } - - override suspend fun existsByChipNummer(chipNummer: String): Boolean = transaction { - HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }.count() > 0 - } - - override suspend fun existsByPassNummer(passNummer: String): Boolean = transaction { - HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }.count() > 0 - } - - override suspend fun existsByOepsNummer(oepsNummer: String): Boolean = transaction { - HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }.count() > 0 - } - - override suspend fun existsByFeiNummer(feiNummer: String): Boolean = transaction { - HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }.count() > 0 - } - - override suspend fun countActive(): Long = transaction { - HorseTable.selectAll().where { HorseTable.istAktiv eq true }.count() - } - - override suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean): Long = transaction { - HorseTable.selectAll().where { - (HorseTable.besitzerId eq ownerId.toJavaUuid()).let { - if (activeOnly) it and (HorseTable.istAktiv eq true) else it - } - }.count() - } - - override suspend fun countOepsRegistered(activeOnly: Boolean): Long = transaction { - HorseTable.selectAll().where { - HorseTable.oepsNummer.isNotNull().let { - if (activeOnly) it and (HorseTable.istAktiv eq true) else it - } - }.count() - } - - override suspend fun countFeiRegistered(activeOnly: Boolean): Long = transaction { - HorseTable.selectAll().where { - HorseTable.feiNummer.isNotNull().let { - if (activeOnly) it and (HorseTable.istAktiv eq true) else it - } - }.count() - } - - // ------------------------------------------------------------------------- - // Hilfsmethoden - // ------------------------------------------------------------------------- - - private fun rowToDomPferd(row: ResultRow): DomPferd = DomPferd( - pferdId = row[HorseTable.id].value.toKotlinUuid(), - pferdeName = row[HorseTable.pferdeName], - geschlecht = row[HorseTable.geschlecht], - geburtsdatum = row[HorseTable.geburtsdatum], - rasse = row[HorseTable.rasse], - farbe = row[HorseTable.farbe], - besitzerId = row[HorseTable.besitzerId]?.toKotlinUuid(), - verantwortlichePersonId = row[HorseTable.verantwortlichePersonId]?.toKotlinUuid(), - zuechterName = row[HorseTable.zuechterName], - zuchtbuchNummer = row[HorseTable.zuchtbuchNummer], - lebensnummer = row[HorseTable.lebensnummer], - chipNummer = row[HorseTable.chipNummer], - passNummer = row[HorseTable.passNummer], - oepsNummer = row[HorseTable.oepsNummer], - feiNummer = row[HorseTable.feiNummer], - vaterName = row[HorseTable.vaterName], - mutterName = row[HorseTable.mutterName], - mutterVaterName = row[HorseTable.mutterVaterName], - stockmass = row[HorseTable.stockmass], - istAktiv = row[HorseTable.istAktiv], - bemerkungen = row[HorseTable.bemerkungen], - datenQuelle = row[HorseTable.datenQuelle], - createdAt = row[HorseTable.createdAt], - updatedAt = row[HorseTable.updatedAt] - ) - - private fun applyToStatement(statement: UpdateBuilder<*>, horse: DomPferd) { - statement[HorseTable.id] = horse.pferdId.toJavaUuid() - statement[HorseTable.pferdeName] = horse.pferdeName - statement[HorseTable.geschlecht] = horse.geschlecht - statement[HorseTable.geburtsdatum] = horse.geburtsdatum - statement[HorseTable.rasse] = horse.rasse - statement[HorseTable.farbe] = horse.farbe - statement[HorseTable.besitzerId] = horse.besitzerId?.toJavaUuid() - statement[HorseTable.verantwortlichePersonId] = horse.verantwortlichePersonId?.toJavaUuid() - statement[HorseTable.zuechterName] = horse.zuechterName - statement[HorseTable.zuchtbuchNummer] = horse.zuchtbuchNummer - statement[HorseTable.lebensnummer] = horse.lebensnummer - statement[HorseTable.chipNummer] = horse.chipNummer - statement[HorseTable.passNummer] = horse.passNummer - statement[HorseTable.oepsNummer] = horse.oepsNummer - statement[HorseTable.feiNummer] = horse.feiNummer - statement[HorseTable.vaterName] = horse.vaterName - statement[HorseTable.mutterName] = horse.mutterName - statement[HorseTable.mutterVaterName] = horse.mutterVaterName - statement[HorseTable.stockmass] = horse.stockmass - statement[HorseTable.istAktiv] = horse.istAktiv - statement[HorseTable.bemerkungen] = horse.bemerkungen - statement[HorseTable.datenQuelle] = horse.datenQuelle - statement[HorseTable.createdAt] = horse.createdAt - statement[HorseTable.updatedAt] = horse.updatedAt - } -} diff --git a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt.disabled b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt.disabled deleted file mode 100644 index a49d8514..00000000 --- a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseRepositoryImpl.kt.disabled +++ /dev/null @@ -1,336 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.horses.infrastructure.persistence - -import at.mocode.core.domain.model.PferdeGeschlechtE -import at.mocode.horses.domain.model.DomPferd -import at.mocode.horses.domain.repository.HorseRepository -import at.mocode.core.utils.database.DatabaseFactory -import kotlin.uuid.Uuid -import kotlin.time.Clock -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq -import org.jetbrains.exposed.sql.statements.UpdateBuilder -import org.springframework.stereotype.Repository - -/** - * PostgreSQL implementation of the HorseRepository using Exposed ORM. - * - * This implementation provides database operations for horse entities, - * mapping between the domain model (DomPferd) and the database table (HorseTable). - */ -@Repository -class HorseRepositoryImpl : HorseRepository { - - override suspend fun findById(id: Uuid): DomPferd? = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.id eq id } - .map { rowToDomPferd(it) } - .singleOrNull() - } - - override suspend fun findByLebensnummer(lebensnummer: String): DomPferd? = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer } - .map { rowToDomPferd(it) } - .singleOrNull() - } - - override suspend fun findByChipNummer(chipNummer: String): DomPferd? = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer } - .map { rowToDomPferd(it) } - .singleOrNull() - } - - override suspend fun findByPassNummer(passNummer: String): DomPferd? = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.passNummer eq passNummer } - .map { rowToDomPferd(it) } - .singleOrNull() - } - - override suspend fun findByOepsNummer(oepsNummer: String): DomPferd? = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer } - .map { rowToDomPferd(it) } - .singleOrNull() - } - - override suspend fun findByFeiNummer(feiNummer: String): DomPferd? = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer } - .map { rowToDomPferd(it) } - .singleOrNull() - } - - override suspend fun findByName(searchTerm: String, limit: Int): List = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.pferdeName like "%$searchTerm%" } - .orderBy(HorseTable.pferdeName to SortOrder.ASC) - .limit(limit) - .map { rowToDomPferd(it) } - } - - override suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean): List = DatabaseFactory.dbQuery { - val query = HorseTable.selectAll().where { HorseTable.besitzerId eq ownerId } - - if (activeOnly) { - query.andWhere { HorseTable.istAktiv eq true } - } else { - query - }.orderBy(HorseTable.pferdeName to SortOrder.ASC) - .map { rowToDomPferd(it) } - } - - override suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean): List = DatabaseFactory.dbQuery { - val query = HorseTable.selectAll().where { HorseTable.verantwortlichePersonId eq responsiblePersonId } - - if (activeOnly) { - query.andWhere { HorseTable.istAktiv eq true } - } else { - query - }.orderBy(HorseTable.pferdeName to SortOrder.ASC) - .map { rowToDomPferd(it) } - } - - override suspend fun findByGeschlecht(geschlecht: PferdeGeschlechtE, activeOnly: Boolean, limit: Int): List = DatabaseFactory.dbQuery { - val query = HorseTable.selectAll().where { HorseTable.geschlecht eq geschlecht } - - if (activeOnly) { - query.andWhere { HorseTable.istAktiv eq true } - } else { - query - }.orderBy(HorseTable.pferdeName to SortOrder.ASC) - .limit(limit) - .map { rowToDomPferd(it) } - } - - override suspend fun findByRasse(rasse: String, activeOnly: Boolean, limit: Int): List = DatabaseFactory.dbQuery { - val query = HorseTable.selectAll().where { HorseTable.rasse eq rasse } - - if (activeOnly) { - query.andWhere { HorseTable.istAktiv eq true } - } else { - query - }.orderBy(HorseTable.pferdeName to SortOrder.ASC) - .limit(limit) - .map { rowToDomPferd(it) } - } - - override suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean): List = DatabaseFactory.dbQuery { - val query = HorseTable.selectAll().where { - HorseTable.geburtsdatum.isNotNull() and - (CustomFunction( - "EXTRACT", - IntegerColumnType(), - stringLiteral("YEAR FROM "), - HorseTable.geburtsdatum - ) eq birthYear) - } - - if (activeOnly) { - query.andWhere { HorseTable.istAktiv eq true } - } else { - query - }.orderBy(HorseTable.pferdeName to SortOrder.ASC) - .map { rowToDomPferd(it) } - } - - override suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean): List = DatabaseFactory.dbQuery { - val query = HorseTable.selectAll().where { - HorseTable.geburtsdatum.isNotNull() and - (CustomFunction( - "EXTRACT", - IntegerColumnType(), - stringLiteral("YEAR FROM "), - HorseTable.geburtsdatum - ) greaterEq fromYear) and - (CustomFunction( - "EXTRACT", - IntegerColumnType(), - stringLiteral("YEAR FROM "), - HorseTable.geburtsdatum - ) lessEq toYear) - } - - if (activeOnly) { - query.andWhere { HorseTable.istAktiv eq true } - } else { - query - }.orderBy(HorseTable.geburtsdatum, SortOrder.DESC) - .map { rowToDomPferd(it) } - } - - override suspend fun findAllActive(limit: Int): List = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.istAktiv eq true } - .orderBy(HorseTable.pferdeName to SortOrder.ASC) - .limit(limit) - .map { rowToDomPferd(it) } - } - - override suspend fun findOepsRegistered(activeOnly: Boolean): List = DatabaseFactory.dbQuery { - val query = HorseTable.selectAll().where { HorseTable.oepsNummer.isNotNull() } - - if (activeOnly) { - query.andWhere { HorseTable.istAktiv eq true } - } else { - query - }.orderBy(HorseTable.pferdeName to SortOrder.ASC) - .map { rowToDomPferd(it) } - } - - override suspend fun findFeiRegistered(activeOnly: Boolean): List = DatabaseFactory.dbQuery { - val query = HorseTable.selectAll().where { HorseTable.feiNummer.isNotNull() } - - if (activeOnly) { - query.andWhere { HorseTable.istAktiv eq true } - } else { - query - }.orderBy(HorseTable.pferdeName to SortOrder.ASC) - .map { rowToDomPferd(it) } - } - - override suspend fun save(horse: DomPferd): DomPferd = DatabaseFactory.dbQuery { - val now = Clock.System.now() - val existingHorse = findById(horse.pferdId) - - if (existingHorse != null) { - // Update existing horse - val updatedHorse = horse.copy(updatedAt = now) - HorseTable.update({ HorseTable.id eq horse.pferdId }) { - domPferdToStatement(it, updatedHorse) - } - updatedHorse - } else { - // Insert a new horse - HorseTable.insert { - it[id] = horse.pferdId - domPferdToStatement(it, horse.copy(updatedAt = now)) - } - horse.copy(updatedAt = now) - } - } - - override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery { - val deletedRows = HorseTable.deleteWhere { HorseTable.id eq id } - deletedRows > 0 - } - - override suspend fun existsByLebensnummer(lebensnummer: String): Boolean = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer } - .count() > 0 - } - - override suspend fun existsByChipNummer(chipNummer: String): Boolean = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer } - .count() > 0 - } - - override suspend fun existsByPassNummer(passNummer: String): Boolean = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.passNummer eq passNummer } - .count() > 0 - } - - override suspend fun existsByOepsNummer(oepsNummer: String): Boolean = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer } - .count() > 0 - } - - override suspend fun existsByFeiNummer(feiNummer: String): Boolean = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer } - .count() > 0 - } - - override suspend fun countActive(): Long = DatabaseFactory.dbQuery { - HorseTable.selectAll().where { HorseTable.istAktiv eq true } - .count() - } - - override suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery { - val query = HorseTable.selectAll().where { HorseTable.besitzerId eq ownerId } - - if (activeOnly) { - query.andWhere { HorseTable.istAktiv eq true } - } else { - query - }.count() - } - - override suspend fun countOepsRegistered(activeOnly: Boolean): Long = DatabaseFactory.dbQuery { - val query = HorseTable.selectAll().where { - HorseTable.oepsNummer.isNotNull() and (HorseTable.oepsNummer neq "") - } - - if (activeOnly) { - query.andWhere { HorseTable.istAktiv eq true } - } else { - query - }.count() - } - - override suspend fun countFeiRegistered(activeOnly: Boolean): Long = DatabaseFactory.dbQuery { - val query = HorseTable.selectAll().where { - HorseTable.feiNummer.isNotNull() and (HorseTable.feiNummer neq "") - } - - if (activeOnly) { - query.andWhere { HorseTable.istAktiv eq true } - } else { - query - }.count() - } - - /** - * Maps a database row to a DomPferd domain object. - */ - private fun rowToDomPferd(row: ResultRow): DomPferd { - return DomPferd( - pferdId = row[HorseTable.id].value, - pferdeName = row[HorseTable.pferdeName], - geschlecht = row[HorseTable.geschlecht], - geburtsdatum = row[HorseTable.geburtsdatum], - rasse = row[HorseTable.rasse], - farbe = row[HorseTable.farbe], - besitzerId = row[HorseTable.besitzerId], - verantwortlichePersonId = row[HorseTable.verantwortlichePersonId], - zuechterName = row[HorseTable.zuechterName], - zuchtbuchNummer = row[HorseTable.zuchtbuchNummer], - lebensnummer = row[HorseTable.lebensnummer], - chipNummer = row[HorseTable.chipNummer], - passNummer = row[HorseTable.passNummer], - oepsNummer = row[HorseTable.oepsNummer], - feiNummer = row[HorseTable.feiNummer], - vaterName = row[HorseTable.vaterName], - mutterName = row[HorseTable.mutterName], - mutterVaterName = row[HorseTable.mutterVaterName], - stockmass = row[HorseTable.stockmass], - istAktiv = row[HorseTable.istAktiv], - bemerkungen = row[HorseTable.bemerkungen], - datenQuelle = row[HorseTable.datenQuelle], - createdAt = row[HorseTable.createdAt], - updatedAt = row[HorseTable.updatedAt] - ) - } - - /** - * Maps a DomPferd domain object to database statement values. - */ - private fun domPferdToStatement(statement: UpdateBuilder<*>, horse: DomPferd) { - statement[HorseTable.pferdeName] = horse.pferdeName - statement[HorseTable.geschlecht] = horse.geschlecht - statement[HorseTable.geburtsdatum] = horse.geburtsdatum - statement[HorseTable.rasse] = horse.rasse - statement[HorseTable.farbe] = horse.farbe - statement[HorseTable.besitzerId] = horse.besitzerId - statement[HorseTable.verantwortlichePersonId] = horse.verantwortlichePersonId - statement[HorseTable.zuechterName] = horse.zuechterName - statement[HorseTable.zuchtbuchNummer] = horse.zuchtbuchNummer - statement[HorseTable.lebensnummer] = horse.lebensnummer - statement[HorseTable.chipNummer] = horse.chipNummer - statement[HorseTable.passNummer] = horse.passNummer - statement[HorseTable.oepsNummer] = horse.oepsNummer - statement[HorseTable.feiNummer] = horse.feiNummer - statement[HorseTable.vaterName] = horse.vaterName - statement[HorseTable.mutterName] = horse.mutterName - statement[HorseTable.mutterVaterName] = horse.mutterVaterName - statement[HorseTable.stockmass] = horse.stockmass - statement[HorseTable.istAktiv] = horse.istAktiv - statement[HorseTable.bemerkungen] = horse.bemerkungen - statement[HorseTable.datenQuelle] = horse.datenQuelle - statement[HorseTable.createdAt] = horse.createdAt - statement[HorseTable.updatedAt] = horse.updatedAt - } -} diff --git a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseTable.kt b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseTable.kt deleted file mode 100644 index 33989387..00000000 --- a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/HorseTable.kt +++ /dev/null @@ -1,70 +0,0 @@ -package at.mocode.horses.infrastructure.persistence - -import at.mocode.core.domain.model.DatenQuelleE -import at.mocode.core.domain.model.PferdeGeschlechtE -import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable -import org.jetbrains.exposed.v1.core.java.javaUUID -import org.jetbrains.exposed.v1.datetime.date -import org.jetbrains.exposed.v1.datetime.timestamp - -/** - * Database table definition for horses in the horse-registry context. - * - * This table stores all horse information including identification, - * ownership, breeding data, and administrative information. - */ -object HorseTable : UUIDTable("zns_horses") { - // Basic Information - val pferdeName = varchar("pferde_name", 255) - val geschlecht = enumerationByName("geschlecht", 20) - val geburtsdatum = date("geburtsdatum").nullable() - val rasse = varchar("rasse", 100).nullable() - val farbe = varchar("farbe", 100).nullable() - - // Ownership and Responsibility - val besitzerId = javaUUID("besitzer_id").nullable() - val verantwortlichePersonId = javaUUID("verantwortliche_person_id").nullable() - - // Breeding Information - val zuechterName = varchar("zuechter_name", 255).nullable() - val zuchtbuchNummer = varchar("zuchtbuch_nummer", 100).nullable() - - // Identification Numbers - val lebensnummer = varchar("lebensnummer", 50).nullable() - val chipNummer = varchar("chip_nummer", 50).nullable() - val passNummer = varchar("pass_nummer", 50).nullable() - val oepsNummer = varchar("oeps_nummer", 50).nullable() - val feiNummer = varchar("fei_nummer", 50).nullable() - - // Pedigree Information - val vaterName = varchar("vater_name", 255).nullable() - val mutterName = varchar("mutter_name", 255).nullable() - val mutterVaterName = varchar("mutter_vater_name", 255).nullable() - - // Physical Characteristics - val stockmass = integer("stockmass").nullable() - - // Status and Administrative - val istAktiv = bool("ist_aktiv").default(true) - val bemerkungen = text("bemerkungen").nullable() - val datenQuelle = enumerationByName("daten_quelle", 20).default(DatenQuelleE.MANUELL) - - // Audit Fields - val createdAt = timestamp("created_at") - val updatedAt = timestamp("updated_at") - - init { - // Indexes for performance - index(false, pferdeName) - index(false, besitzerId) - index(false, istAktiv) - - // Unique constraints for identification numbers - // These ensure database-level uniqueness even under concurrent access - uniqueIndex(lebensnummer) - uniqueIndex(chipNummer) - uniqueIndex(passNummer) - uniqueIndex(oepsNummer) - uniqueIndex(feiNummer) - } -} diff --git a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsClubTable.kt b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsClubTable.kt deleted file mode 100644 index c10e3744..00000000 --- a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsClubTable.kt +++ /dev/null @@ -1,14 +0,0 @@ -package at.mocode.horses.infrastructure.persistence - -import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable -import org.jetbrains.exposed.v1.datetime.timestamp - -/** - * Tabelle für importierte Vereine aus dem ZNS-Datenbestand (VEREIN01.dat). - * Wird ausschließlich als Dev-Seed-Daten verwendet. - */ -object ZnsClubTable : UUIDTable("zns_clubs") { - val vereinsNummer = varchar("vereins_nummer", 4).uniqueIndex() - val name = varchar("name", 50) - val createdAt = timestamp("created_at") -} diff --git a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsPersonTable.kt b/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsPersonTable.kt deleted file mode 100644 index 83d234d9..00000000 --- a/backend/services/horses/horses-infrastructure/src/main/kotlin/at/mocode/horses/infrastructure/persistence/ZnsPersonTable.kt +++ /dev/null @@ -1,23 +0,0 @@ -package at.mocode.horses.infrastructure.persistence - -import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable -import org.jetbrains.exposed.v1.datetime.date -import org.jetbrains.exposed.v1.datetime.timestamp - -/** - * Tabelle für importierte Personen/Reiter aus dem ZNS-Datenbestand (LIZENZ01.dat). - * Wird ausschließlich als Dev-Seed-Daten verwendet. - */ -object ZnsPersonTable : UUIDTable("zns_persons") { - val lizenzNummer = varchar("lizenz_nummer", 10).uniqueIndex() - val nachname = varchar("nachname", 50) - val vorname = varchar("vorname", 25) - val vereinsNummer = varchar("vereins_nummer", 4).nullable() - val vereinsName = varchar("vereins_name", 50).nullable() - val nation = varchar("nation", 3).nullable() - val lizenzKlasse = varchar("lizenz_klasse", 6).nullable() - val mitgliedsNummer = varchar("mitglieds_nummer", 13).nullable() - val geschlecht = varchar("geschlecht", 1).nullable() - val geburtsdatum = date("geburtsdatum").nullable() - val createdAt = timestamp("created_at") -} diff --git a/backend/services/horses/horses-service/build.gradle.kts b/backend/services/horses/horses-service/build.gradle.kts deleted file mode 100644 index b5b6c301..00000000 --- a/backend/services/horses/horses-service/build.gradle.kts +++ /dev/null @@ -1,46 +0,0 @@ -plugins { - alias(libs.plugins.kotlinJvm) - alias(libs.plugins.kotlinSpring) - alias(libs.plugins.spring.boot) - alias(libs.plugins.spring.dependencyManagement) -} - -springBoot { - mainClass.set("at.mocode.horses.service.HorsesServiceApplicationKt") -} - -dependencies { - // Interne Module - implementation(projects.platform.platformDependencies) - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - implementation(projects.backend.services.horses.horsesDomain) - // horses-common: ON HOLD – veraltete API-Referenzen - // implementation(projects.backend.services.horses.horsesCommon) - implementation(projects.backend.services.horses.horsesInfrastructure) - - // Spring Boot Starters - implementation(libs.spring.boot.starter.web) - implementation(libs.spring.boot.starter.validation) - implementation(libs.spring.boot.starter.actuator) - - // Datenbank-Abhängigkeiten - implementation(libs.exposed.core) - implementation(libs.exposed.dao) - implementation(libs.exposed.jdbc) - implementation(libs.exposed.migration.jdbc) - implementation(libs.exposed.kotlin.datetime) - implementation(libs.hikari.cp) - testImplementation(project(":backend:infrastructure:messaging:messaging-client")) - runtimeOnly(libs.postgresql.driver) - testRuntimeOnly(libs.h2.driver) - - // Testing - testImplementation(projects.platform.platformTesting) - testImplementation(libs.spring.boot.starter.test) - testImplementation(libs.logback.classic) -} - -tasks.test { - useJUnitPlatform() -} diff --git a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/HorsesServiceApplication.kt b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/HorsesServiceApplication.kt deleted file mode 100644 index 28915d6b..00000000 --- a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/HorsesServiceApplication.kt +++ /dev/null @@ -1,26 +0,0 @@ -package at.mocode.horses.service - -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.runApplication -import org.springframework.context.annotation.ComponentScan - -/** - * Main application class for the Horses Service. - * - * This service provides APIs for managing horses and their data. - */ -@SpringBootApplication -@ComponentScan(basePackages = [ - "at.mocode.horses.service", - "at.mocode.horses.api", - "at.mocode.horses.infrastructure", - "at.mocode.infrastructure.messaging" -]) -class HorsesServiceApplication - -/** - * Main entry point for the Horses Service application. - */ -fun main(args: Array) { - runApplication(*args) -} diff --git a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/ApplicationConfiguration.kt.disabled b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/ApplicationConfiguration.kt.disabled deleted file mode 100644 index 4605095f..00000000 --- a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/ApplicationConfiguration.kt.disabled +++ /dev/null @@ -1,60 +0,0 @@ -package at.mocode.horses.service.config - -import at.mocode.horses.application.usecase.CreateHorseUseCase -import at.mocode.horses.application.usecase.TransactionalCreateHorseUseCase -import at.mocode.horses.application.usecase.UpdateHorseUseCase -import at.mocode.horses.application.usecase.DeleteHorseUseCase -import at.mocode.horses.application.usecase.GetHorseUseCase -import at.mocode.horses.domain.repository.HorseRepository -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration - -/** - * Application configuration for the Horses Service. - * - * This configuration wires the use cases as Spring beans. - */ -@Configuration -class ApplicationConfiguration { - - /** - * Creates the CreateHorseUseCase as a Spring bean. - */ - @Bean - fun createHorseUseCase(horseRepository: HorseRepository): CreateHorseUseCase { - return CreateHorseUseCase(horseRepository) - } - - /** - * Creates the TransactionalCreateHorseUseCase as a Spring bean. - * This version ensures all database operations run within a single transaction. - */ - @Bean - fun transactionalCreateHorseUseCase(horseRepository: HorseRepository): TransactionalCreateHorseUseCase { - return TransactionalCreateHorseUseCase(horseRepository) - } - - /** - * Creates the UpdateHorseUseCase as a Spring bean. - */ - @Bean - fun updateHorseUseCase(horseRepository: HorseRepository): UpdateHorseUseCase { - return UpdateHorseUseCase(horseRepository) - } - - /** - * Creates the DeleteHorseUseCase as a Spring bean. - */ - @Bean - fun deleteHorseUseCase(horseRepository: HorseRepository): DeleteHorseUseCase { - return DeleteHorseUseCase(horseRepository) - } - - /** - * Creates the GetHorseUseCase as a Spring bean. - */ - @Bean - fun getHorseUseCase(horseRepository: HorseRepository): GetHorseUseCase { - return GetHorseUseCase(horseRepository) - } -} diff --git a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt deleted file mode 100644 index 9894862a..00000000 --- a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt +++ /dev/null @@ -1,38 +0,0 @@ -package at.mocode.horses.service.config - -import at.mocode.horses.infrastructure.persistence.HorseTable -import at.mocode.horses.infrastructure.persistence.ZnsClubTable -import at.mocode.horses.infrastructure.persistence.ZnsPersonTable -import jakarta.annotation.PostConstruct -import org.jetbrains.exposed.v1.jdbc.Database -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Profile - -/** - * Minimale Datenbank-Konfiguration für den Horses-Service (Dev-Profil). - * Verbindet sich direkt via JDBC und legt die Tabellen an. - */ -@Configuration -@Profile("dev") -class HorsesDatabaseConfiguration( - @Value("\${spring.datasource.url}") private val jdbcUrl: String, - @Value("\${spring.datasource.username}") private val username: String, - @Value("\${spring.datasource.password}") private val password: String -) { - private val log = LoggerFactory.getLogger(HorsesDatabaseConfiguration::class.java) - - @PostConstruct - fun initializeDatabase() { - log.info("Initialisiere Datenbank-Schema für Horses-Service...") - Database.connect(jdbcUrl, user = username, password = password) - transaction { - val statements = MigrationUtils.statementsRequiredForDatabaseMigration(HorseTable, ZnsPersonTable, ZnsClubTable) - statements.forEach { exec(it) } - log.info("Datenbank-Schema erfolgreich initialisiert") - } - } -} diff --git a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt.disabled b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt.disabled deleted file mode 100644 index 971a9a50..00000000 --- a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/config/DatabaseConfiguration.kt.disabled +++ /dev/null @@ -1,108 +0,0 @@ -package at.mocode.horses.service.config - -import at.mocode.core.utils.database.DatabaseConfig -import at.mocode.core.utils.database.DatabaseFactory -import at.mocode.horses.infrastructure.persistence.HorseTable -import at.mocode.horses.infrastructure.persistence.ZnsPersonTable -import at.mocode.horses.infrastructure.persistence.ZnsClubTable -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Component -import jakarta.annotation.PostConstruct -import jakarta.annotation.PreDestroy -import org.slf4j.LoggerFactory -import org.jetbrains.exposed.v1.core.SchemaUtils -import org.jetbrains.exposed.v1.jdbc.transactions.transaction - -/** - * Database configuration for the Horses Service. - * - * This configuration ensures that Database.connect() is called properly - * before any Exposed operations are performed. - */ -@Configuration -@Profile("!test") -class HorsesDatabaseConfiguration { - - private val log = LoggerFactory.getLogger(HorsesDatabaseConfiguration::class.java) - - @PostConstruct - fun initializeDatabase() { - log.info("Initializing database schema for Horses Service...") - - try { - // Database connection is already initialized by the gateway - // Only initialize the schema for this service - transaction { - SchemaUtils.createMissingTablesAndColumns(HorseTable) - log.info("Horse database schema initialized successfully") - } - } catch (e: Exception) { - log.error("Failed to initialize database schema", e) - throw e - } - } - - @PreDestroy - fun closeDatabase() { - log.info("Closing database connection for Horses Service...") - try { - DatabaseFactory.close() - log.info("Database connection closed successfully") - } catch (e: Exception) { - log.error("Error closing database connection", e) - } - } -} - -/** - * Test-specific database configuration. - */ -@Configuration -@Profile("test") -class HorsesTestDatabaseConfiguration { - - private val log = LoggerFactory.getLogger(HorsesTestDatabaseConfiguration::class.java) - - @PostConstruct - fun initializeTestDatabase() { - log.info("Initializing test database connection for Horses Service...") - - try { - // Use H2 in-memory database for tests - val testConfig = DatabaseConfig( - jdbcUrl = "jdbc:h2:mem:horses_test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", - username = "sa", - password = "", - driverClassName = "org.h2.Driver", - maxPoolSize = 5, - minPoolSize = 1, - autoMigrate = true - ) - - DatabaseFactory.init(testConfig) - log.info("Test database connection initialized successfully") - - // Initialize database schema for tests - transaction { - SchemaUtils.createMissingTablesAndColumns(HorseTable) - log.info("Test horse database schema initialized successfully") - } - } catch (e: Exception) { - log.error("Failed to initialize test database connection", e) - throw e - } - } - - @PreDestroy - fun closeTestDatabase() { - log.info("Closing test database connection for Horses Service...") - try { - DatabaseFactory.close() - log.info("Test database connection closed successfully") - } catch (e: Exception) { - log.error("Error closing test database connection", e) - } - } -} diff --git a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/dev/ZnsDataSeeder.kt b/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/dev/ZnsDataSeeder.kt deleted file mode 100644 index 8d83eee0..00000000 --- a/backend/services/horses/horses-service/src/main/kotlin/at/mocode/horses/service/dev/ZnsDataSeeder.kt +++ /dev/null @@ -1,277 +0,0 @@ -package at.mocode.horses.service.dev - -import at.mocode.core.domain.model.DatenQuelleE -import at.mocode.core.domain.model.PferdeGeschlechtE -import at.mocode.horses.infrastructure.persistence.HorseTable -import at.mocode.horses.infrastructure.persistence.ZnsClubTable -import at.mocode.horses.infrastructure.persistence.ZnsPersonTable -import org.jetbrains.exposed.v1.jdbc.batchInsert -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.CommandLineRunner -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Component -import java.io.File -import kotlin.time.Clock - -/** - * Dev-only Seeder: Importiert ZNS-Rohdaten (Fixbreiten-Flat-Files) in die lokale Entwicklungs-DB. - * - * Aktivierung: Spring-Profil "dev" + Umgebungsvariable ZNS_DATA_DIR (Pfad zum ZNS-Verzeichnis). - * Beispiel: ZNS_DATA_DIR=/pfad/zu/ZNS ./gradlew :horses-service:bootRun --args='--spring.profiles.active=dev' - * - * ACHTUNG: Kein Produktions-Feature – nur für lokale Entwicklung gedacht. - */ -@Component -@Profile("dev") -class ZnsDataSeeder( - @Value("\${zns.data.dir:#{null}}") private val znsDataDir: String? -) : CommandLineRunner { - - private val log = LoggerFactory.getLogger(ZnsDataSeeder::class.java) - - override fun run(vararg args: String) { - if (znsDataDir == null) { - log.warn("ZNS_DATA_DIR nicht gesetzt – ZnsDataSeeder wird übersprungen.") - log.warn("Setze die Umgebungsvariable ZNS_DATA_DIR=/pfad/zu/ZNS um den Seeder zu aktivieren.") - return - } - - val dir = File(znsDataDir) - if (!dir.exists() || !dir.isDirectory) { - log.error("ZNS_DATA_DIR '{}' existiert nicht oder ist kein Verzeichnis.", znsDataDir) - return - } - - log.info("=== ZnsDataSeeder gestartet – Quelle: {} ===", znsDataDir) - - transaction { - MigrationUtils.statementsRequiredForDatabaseMigration(ZnsClubTable, ZnsPersonTable, HorseTable) - .forEach { exec(it) } - } - - seedClubs(dir) - seedPersons(dir) - seedHorses(dir) - - log.info("=== ZnsDataSeeder abgeschlossen ===") - } - - // ------------------------------------------------------------------------- - // VEREIN01.dat → zns_clubs - // Format: [0-3] VereinsNr, [4-53] Name (54 Zeichen pro Zeile) - // ------------------------------------------------------------------------- - private fun seedClubs(dir: File) { - val file = File(dir, "VEREIN01.dat") - if (!file.exists()) { - log.warn("VEREIN01.dat nicht gefunden – Vereine werden übersprungen."); return - } - - data class ClubRow(val nr: String, val name: String) - - val rows = file.readLines(Charsets.ISO_8859_1) - .filter { it.length >= 4 } - .map { line -> - ClubRow( - nr = line.substring(0, 4).trim(), - name = line.substring(4).trim().take(50) - ) - } - .filter { it.nr.isNotBlank() } - - val now = Clock.System.now() - transaction { - ZnsClubTable.batchInsert(rows, ignore = true) { row -> - this[ZnsClubTable.vereinsNummer] = row.nr - this[ZnsClubTable.name] = row.name - this[ZnsClubTable.createdAt] = now - } - } - log.info("Vereine importiert: {} Datensätze", rows.size) - } - - // ------------------------------------------------------------------------- - // LIZENZ01.dat → zns_persons - // Format (220 Zeichen): - // [0-5] LizenzNr - // [6-55] Nachname - // [56-80] Vorname - // [81-83] VereinsNr (2-stellig, rechtsbündig) - // [84-133] VereinsName - // [134-136] Nation - // [137-143] LizenzKlasse (z.B. "R1", "R2", "RD2") - // [144-156] MitgliedsNr - // [157-165] Telefon (nicht gespeichert) - // [166-169] Jahr - // [170] Geschlecht (M/W) - // [171-178] Geburtsdatum (YYYYMMDD) - // ------------------------------------------------------------------------- - private fun seedPersons(dir: File) { - val file = File(dir, "LIZENZ01.dat") - if (!file.exists()) { - log.warn("LIZENZ01.dat nicht gefunden – Personen werden übersprungen."); return - } - - data class PersonRow( - val lizenzNr: String, - val nachname: String, - val vorname: String, - val vereinsNr: String?, - val vereinsName: String?, - val nation: String?, - val lizenzKlasse: String?, - val mitgliedsNr: String?, - val geschlecht: String?, - val geburtsdatum: java.time.LocalDate? - ) - - val rows = file.readLines(Charsets.ISO_8859_1) - .filter { it.length >= 6 } - .mapNotNull { line -> - val lizenzNr = line.substring(0, 6).trim() - if (lizenzNr.isBlank()) return@mapNotNull null - - val nachname = line.safeSubstring(6, 56).trim() - val vorname = line.safeSubstring(56, 81).trim() - val vereinsNr = line.safeSubstring(81, 84).trim().takeIf { it.isNotBlank() } - val vereinsName = line.safeSubstring(84, 134).trim().takeIf { it.isNotBlank() } - val nation = line.safeSubstring(134, 137).trim().takeIf { it.isNotBlank() } - val lizenzKlasse = line.safeSubstring(137, 144).trim().takeIf { it.isNotBlank() } - val mitgliedsNr = line.safeSubstring(144, 157).trim().takeIf { it.isNotBlank() } - val geschlecht = line.safeSubstring(170, 171).trim().takeIf { it.isNotBlank() } - val gebDatStr = line.safeSubstring(171, 179).trim() - val gebDat = parseDate(gebDatStr) - - PersonRow( - lizenzNr, nachname, vorname, vereinsNr, vereinsName, - nation, lizenzKlasse, mitgliedsNr, geschlecht, gebDat - ) - } - - val now = Clock.System.now() - transaction { - ZnsPersonTable.batchInsert(rows, ignore = true) { row -> - this[ZnsPersonTable.lizenzNummer] = row.lizenzNr - this[ZnsPersonTable.nachname] = row.nachname - this[ZnsPersonTable.vorname] = row.vorname - this[ZnsPersonTable.vereinsNummer] = row.vereinsNr - this[ZnsPersonTable.vereinsName] = row.vereinsName - this[ZnsPersonTable.nation] = row.nation - this[ZnsPersonTable.lizenzKlasse] = row.lizenzKlasse - this[ZnsPersonTable.mitgliedsNummer] = row.mitgliedsNr - this[ZnsPersonTable.geschlecht] = row.geschlecht - this[ZnsPersonTable.geburtsdatum] = row.geburtsdatum?.let { - kotlinx.datetime.LocalDate(it.year, it.monthValue, it.dayOfMonth) - } - this[ZnsPersonTable.createdAt] = now - } - } - log.info("Personen importiert: {} Datensätze", rows.size) - } - - // ------------------------------------------------------------------------- - // PFERDE01.dat → horses - // Format (211 Zeichen): - // [0-3] OepsNr (4 Zeichen) - // [4-33] Name (30 Zeichen) - // [34-42] ZnsNr / interne Nummer (9 Zeichen) - // [43] Geschlecht (S=Stute, W=Wallach, H=Hengst) - // [44-47] Geburtsjahr (4 Zeichen) - // [48-62] Farbe (15 Zeichen) - // [63-77] Rasse (15 Zeichen) - // [78-81] VereinsNr (4 Zeichen) - // [82-85] Jahr (4 Zeichen) - // [86-135] BesitzerName (50 Zeichen) - // [136-185] VaterName (50 Zeichen) - // [186-210] ChipNr (25 Zeichen) - // ------------------------------------------------------------------------- - private fun seedHorses(dir: File) { - val file = File(dir, "PFERDE01.dat") - if (!file.exists()) { - log.warn("PFERDE01.dat nicht gefunden – Pferde werden übersprungen."); return - } - - data class HorseRow( - val oepsNr: String, - val name: String, - val geschlecht: PferdeGeschlechtE, - val geburtsjahr: Int?, - val farbe: String?, - val rasse: String?, - val besitzerName: String?, - val vaterName: String?, - val chipNr: String? - ) - - val rows = file.readLines(Charsets.ISO_8859_1) - .filter { it.length >= 5 } - .mapNotNull { line -> - val oepsNr = line.safeSubstring(0, 4).trim() - val name = line.safeSubstring(4, 34).trim() - if (name.isBlank()) return@mapNotNull null - - val geschlechtChar = line.safeSubstring(43, 44).trim() - val geschlecht = when (geschlechtChar.uppercase()) { - "S" -> PferdeGeschlechtE.STUTE - "W" -> PferdeGeschlechtE.WALLACH - "H" -> PferdeGeschlechtE.HENGST - else -> PferdeGeschlechtE.UNBEKANNT - } - val gebjahr = line.safeSubstring(44, 48).trim().toIntOrNull() - val farbe = line.safeSubstring(48, 63).trim().takeIf { it.isNotBlank() } - val rasse = line.safeSubstring(63, 78).trim().takeIf { it.isNotBlank() } - val besitzer = line.safeSubstring(86, 136).trim().takeIf { it.isNotBlank() } - val vater = line.safeSubstring(136, 186).trim().takeIf { it.isNotBlank() } - val chip = line.safeSubstring(186, 211).trim().takeIf { it.isNotBlank() } - - HorseRow(oepsNr, name, geschlecht, gebjahr, farbe, rasse, besitzer, vater, chip) - } - - val now = Clock.System.now() - transaction { - HorseTable.batchInsert(rows, ignore = true) { row -> - this[HorseTable.pferdeName] = row.name - this[HorseTable.geschlecht] = row.geschlecht - this[HorseTable.geburtsdatum] = row.geburtsjahr?.let { - kotlinx.datetime.LocalDate(it, 1, 1) - } - this[HorseTable.farbe] = row.farbe - this[HorseTable.rasse] = row.rasse - this[HorseTable.oepsNummer] = row.oepsNr.takeIf { it.isNotBlank() } - this[HorseTable.chipNummer] = row.chipNr - this[HorseTable.vaterName] = row.vaterName - this[HorseTable.zuechterName] = row.besitzerName - this[HorseTable.datenQuelle] = DatenQuelleE.IMPORT_ZNS - this[HorseTable.istAktiv] = true - this[HorseTable.createdAt] = now - this[HorseTable.updatedAt] = now - } - } - log.info("Pferde importiert: {} Datensätze", rows.size) - } - - // ------------------------------------------------------------------------- - // Hilfsfunktionen - // ------------------------------------------------------------------------- - - private fun String.safeSubstring(start: Int, end: Int): String { - if (start >= this.length) return "" - return this.substring(start, minOf(end, this.length)) - } - - private fun parseDate(s: String): java.time.LocalDate? { - if (s.length < 8) return null - return try { - java.time.LocalDate.of( - s.substring(0, 4).toInt(), - s.substring(4, 6).toInt(), - s.substring(6, 8).toInt() - ) - } catch (_: Exception) { - null - } - } - -} diff --git a/backend/services/identity/identity-domain/build.gradle.kts b/backend/services/identity/identity-domain/build.gradle.kts new file mode 100644 index 00000000..b08f9ed9 --- /dev/null +++ b/backend/services/identity/identity-domain/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.kotlinSerialization) +} + +dependencies { + implementation(projects.platform.platformDependencies) + implementation(projects.core.coreDomain) + + testImplementation(projects.platform.platformTesting) + testImplementation(libs.mockk) + testImplementation(libs.kotlin.test) +} diff --git a/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/model/DomProfil.kt b/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/model/DomProfil.kt new file mode 100644 index 00000000..2245f71a --- /dev/null +++ b/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/model/DomProfil.kt @@ -0,0 +1,41 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.identity.domain.model + +import at.mocode.core.domain.serialization.InstantSerializer +import at.mocode.core.domain.serialization.UuidSerializer +import kotlinx.serialization.Serializable +import kotlin.time.Clock +import kotlin.time.Instant +import kotlin.uuid.Uuid + +/** + * Domain model representing an extended profile of a user. + * This links a Keycloak User ID with an official ZNS Satznummer. + * + * @property profileId Unique internal identifier. + * @property userId The Keycloak User ID (UUID string). + * @property satznummer The official ZNS Satznummer (link to master-data-context). + * @property logoUrl Optional URL to a logo or profile picture. + * @property bio Optional short biography or description. + * @property contactEmail Optional contact email (might differ from account email). + * @property createdAt Timestamp of creation. + * @property updatedAt Timestamp of the last update. + */ +@Serializable +data class DomProfil( + @Serializable(with = UuidSerializer::class) + val profileId: Uuid = Uuid.random(), + + val userId: String, + val satznummer: String, + + val logoUrl: String? = null, + val bio: String? = null, + val contactEmail: String? = null, + + @Serializable(with = InstantSerializer::class) + val createdAt: Instant = Clock.System.now(), + @Serializable(with = InstantSerializer::class) + var updatedAt: Instant = Clock.System.now() +) diff --git a/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/repository/ProfileRepository.kt b/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/repository/ProfileRepository.kt new file mode 100644 index 00000000..d79b8e6e --- /dev/null +++ b/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/repository/ProfileRepository.kt @@ -0,0 +1,14 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.identity.domain.repository + +import at.mocode.identity.domain.model.DomProfil +import kotlin.uuid.Uuid + +interface ProfileRepository { + suspend fun findById(id: Uuid): DomProfil? + suspend fun findByUserId(userId: String): DomProfil? + suspend fun findBySatznummer(satznummer: String): List + suspend fun save(profil: DomProfil): DomProfil + suspend fun delete(id: Uuid): Boolean +} diff --git a/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/service/ProfileService.kt b/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/service/ProfileService.kt new file mode 100644 index 00000000..29323fac --- /dev/null +++ b/backend/services/identity/identity-domain/src/main/kotlin/at/mocode/identity/domain/service/ProfileService.kt @@ -0,0 +1,44 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.identity.domain.service + +import at.mocode.identity.domain.model.DomProfil +import at.mocode.identity.domain.repository.ProfileRepository +import kotlin.uuid.Uuid + +/** + * Domain service for managing user profiles and ZNS links. + */ +class ProfileService( + private val profileRepository: ProfileRepository +) { + suspend fun getProfileByUserId(userId: String): DomProfil? { + return profileRepository.findByUserId(userId) + } + + suspend fun linkUserToZns(userId: String, satznummer: String): DomProfil { + val existing = profileRepository.findByUserId(userId) + val profil = if (existing != null) { + existing.copy(satznummer = satznummer) + } else { + DomProfil(userId = userId, satznummer = satznummer) + } + return profileRepository.save(profil) + } + + suspend fun updateProfile(userId: String, logoUrl: String?, bio: String?, contactEmail: String?): DomProfil { + val profil = profileRepository.findByUserId(userId) + ?: throw IllegalStateException("Profile for user $userId not found. Link to ZNS first.") + + val updated = profil.copy( + logoUrl = logoUrl ?: profil.logoUrl, + bio = bio ?: profil.bio, + contactEmail = contactEmail ?: profil.contactEmail + ) + return profileRepository.save(updated) + } + + suspend fun deleteProfile(profileId: Uuid): Boolean { + return profileRepository.delete(profileId) + } +} diff --git a/backend/services/identity/identity-domain/src/test/kotlin/at/mocode/identity/domain/service/ProfileServiceTest.kt b/backend/services/identity/identity-domain/src/test/kotlin/at/mocode/identity/domain/service/ProfileServiceTest.kt new file mode 100644 index 00000000..e24cd8c1 --- /dev/null +++ b/backend/services/identity/identity-domain/src/test/kotlin/at/mocode/identity/domain/service/ProfileServiceTest.kt @@ -0,0 +1,51 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.identity.domain.service + +import at.mocode.identity.domain.model.DomProfil +import at.mocode.identity.domain.repository.ProfileRepository +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class ProfileServiceTest { + + private val profileRepository = mockk() + private val profileService = ProfileService(profileRepository) + + @Test + fun `linkUserToZns should create new profile if none exists`() = runBlocking { + val userId = "user-123" + val satznummer = "0000123456" + + coEvery { profileRepository.findByUserId(userId) } returns null + coEvery { profileRepository.save(any()) } answers { it.invocation.args[0] as DomProfil } + + val result = profileService.linkUserToZns(userId, satznummer) + + assertNotNull(result) + assertEquals(userId, result.userId) + assertEquals(satznummer, result.satznummer) + coVerify { profileRepository.save(match { it.userId == userId && it.satznummer == satznummer }) } + } + + @Test + fun `linkUserToZns should update existing profile`() = runBlocking { + val userId = "user-123" + val oldSatz = "old-123" + val newSatz = "new-456" + val existing = DomProfil(userId = userId, satznummer = oldSatz) + + coEvery { profileRepository.findByUserId(userId) } returns existing + coEvery { profileRepository.save(any()) } answers { it.invocation.args[0] as DomProfil } + + val result = profileService.linkUserToZns(userId, newSatz) + + assertEquals(newSatz, result.satznummer) + coVerify { profileRepository.save(match { it.userId == userId && it.satznummer == newSatz }) } + } +} diff --git a/backend/services/clubs/clubs-infrastructure/build.gradle.kts b/backend/services/identity/identity-infrastructure/build.gradle.kts similarity index 61% rename from backend/services/clubs/clubs-infrastructure/build.gradle.kts rename to backend/services/identity/identity-infrastructure/build.gradle.kts index 0956a0b8..7a608a0c 100644 --- a/backend/services/clubs/clubs-infrastructure/build.gradle.kts +++ b/backend/services/identity/identity-infrastructure/build.gradle.kts @@ -1,23 +1,15 @@ plugins { alias(libs.plugins.kotlinJvm) alias(libs.plugins.kotlinSpring) - alias(libs.plugins.kotlinSerialization) } dependencies { implementation(projects.platform.platformDependencies) - implementation(projects.backend.services.clubs.clubsDomain) + implementation(projects.backend.services.identity.identityDomain) implementation(projects.core.coreDomain) implementation(projects.core.coreUtils) - - implementation(libs.spring.boot.starter.web) - implementation(libs.exposed.core) implementation(libs.exposed.dao) implementation(libs.exposed.jdbc) implementation(libs.exposed.kotlin.datetime) - - runtimeOnly(libs.postgresql.driver) - - testImplementation(projects.platform.platformTesting) } diff --git a/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/ExposedProfileRepository.kt b/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/ExposedProfileRepository.kt new file mode 100644 index 00000000..8c4ab041 --- /dev/null +++ b/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/ExposedProfileRepository.kt @@ -0,0 +1,80 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.identity.infrastructure.persistence + +import at.mocode.identity.domain.model.DomProfil +import at.mocode.identity.domain.repository.ProfileRepository +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.statements.UpdateBuilder +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.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.update +import kotlin.time.Clock +import kotlin.uuid.Uuid +import kotlin.uuid.toJavaUuid +import kotlin.uuid.toKotlinUuid + +class ExposedProfileRepository : ProfileRepository { + + override suspend fun findById(id: Uuid): DomProfil? = transaction { + ProfileTable.selectAll().where { ProfileTable.id eq id.toJavaUuid() } + .map { rowToProfile(it) } + .singleOrNull() + } + + override suspend fun findByUserId(userId: String): DomProfil? = transaction { + ProfileTable.selectAll().where { ProfileTable.userId eq userId } + .map { rowToProfile(it) } + .singleOrNull() + } + + override suspend fun findBySatznummer(satznummer: String): List = transaction { + ProfileTable.selectAll().where { ProfileTable.satznummer eq satznummer } + .map { rowToProfile(it) } + } + + override suspend fun save(profil: DomProfil): DomProfil = transaction { + val now = Clock.System.now() + val updated = profil.copy(updatedAt = now) + val javaId = profil.profileId.toJavaUuid() + + val existing = ProfileTable.selectAll().where { ProfileTable.id eq javaId }.singleOrNull() + if (existing != null) { + ProfileTable.update({ ProfileTable.id eq javaId }) { profileToStatement(it, updated) } + } else { + ProfileTable.insert { + it[id] = javaId + profileToStatement(it, updated) + } + } + updated + } + + override suspend fun delete(id: Uuid): Boolean = transaction { + ProfileTable.deleteWhere { ProfileTable.id eq id.toJavaUuid() } > 0 + } + + private fun rowToProfile(row: ResultRow): DomProfil = DomProfil( + profileId = row[ProfileTable.id].toKotlinUuid(), + userId = row[ProfileTable.userId], + satznummer = row[ProfileTable.satznummer], + logoUrl = row[ProfileTable.logoUrl], + bio = row[ProfileTable.bio], + contactEmail = row[ProfileTable.contactEmail], + createdAt = row[ProfileTable.createdAt], + updatedAt = row[ProfileTable.updatedAt] + ) + + private fun profileToStatement(stmt: UpdateBuilder<*>, p: DomProfil) { + stmt[ProfileTable.userId] = p.userId + stmt[ProfileTable.satznummer] = p.satznummer + stmt[ProfileTable.logoUrl] = p.logoUrl + stmt[ProfileTable.bio] = p.bio + stmt[ProfileTable.contactEmail] = p.contactEmail + stmt[ProfileTable.createdAt] = p.createdAt + stmt[ProfileTable.updatedAt] = p.updatedAt + } +} diff --git a/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/ProfileTable.kt b/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/ProfileTable.kt new file mode 100644 index 00000000..52c0f79a --- /dev/null +++ b/backend/services/identity/identity-infrastructure/src/main/kotlin/at/mocode/identity/infrastructure/persistence/ProfileTable.kt @@ -0,0 +1,29 @@ +package at.mocode.identity.infrastructure.persistence + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.java.javaUUID +import org.jetbrains.exposed.v1.datetime.timestamp + +/** + * Exposed Table definition for user profiles. + * Links Keycloak user IDs to official ZNS satznummer. + */ +object ProfileTable : Table("identity_profiles") { + val id = javaUUID("id").autoGenerate() + override val primaryKey = PrimaryKey(id) + + val userId = varchar("user_id", 100) + val satznummer = varchar("satznummer", 20) + + val logoUrl = text("logo_url").nullable() + val bio = text("bio").nullable() + val contactEmail = varchar("contact_email", 200).nullable() + + val createdAt = timestamp("created_at") + val updatedAt = timestamp("updated_at") + + init { + index(true, userId) + index(false, satznummer) + } +} diff --git a/backend/services/identity/identity-service/build.gradle.kts b/backend/services/identity/identity-service/build.gradle.kts new file mode 100644 index 00000000..2f2dc665 --- /dev/null +++ b/backend/services/identity/identity-service/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.kotlinSpring) + alias(libs.plugins.spring.boot) + alias(libs.plugins.spring.dependencyManagement) +} + +springBoot { + mainClass.set("at.mocode.identity.service.IdentityServiceApplicationKt") +} + +dependencies { + implementation(projects.platform.platformDependencies) + implementation(projects.core.coreDomain) + implementation(projects.core.coreUtils) + implementation(projects.backend.services.identity.identityDomain) + implementation(projects.backend.services.identity.identityInfrastructure) + + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.validation) + implementation(libs.spring.boot.starter.security) + implementation(libs.spring.boot.starter.oauth2.resource.server) + + implementation(libs.exposed.core) + implementation(libs.exposed.jdbc) + implementation(libs.hikari.cp) + runtimeOnly(libs.postgresql.driver) +} diff --git a/backend/services/identity/identity-service/src/main/kotlin/at/mocode/identity/service/IdentityServiceApplication.kt b/backend/services/identity/identity-service/src/main/kotlin/at/mocode/identity/service/IdentityServiceApplication.kt new file mode 100644 index 00000000..6b61988a --- /dev/null +++ b/backend/services/identity/identity-service/src/main/kotlin/at/mocode/identity/service/IdentityServiceApplication.kt @@ -0,0 +1,11 @@ +package at.mocode.identity.service + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication(scanBasePackages = ["at.mocode.identity", "at.mocode.infrastructure.security"]) +class IdentityServiceApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/backend/services/identity/identity-service/src/main/kotlin/at/mocode/identity/service/config/IdentityConfig.kt b/backend/services/identity/identity-service/src/main/kotlin/at/mocode/identity/service/config/IdentityConfig.kt new file mode 100644 index 00000000..af4ebbdb --- /dev/null +++ b/backend/services/identity/identity-service/src/main/kotlin/at/mocode/identity/service/config/IdentityConfig.kt @@ -0,0 +1,18 @@ +package at.mocode.identity.service.config + +import at.mocode.identity.domain.repository.ProfileRepository +import at.mocode.identity.domain.service.ProfileService +import at.mocode.identity.infrastructure.persistence.ExposedProfileRepository +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class IdentityConfig { + + @Bean + fun profileRepository(): ProfileRepository = ExposedProfileRepository() + + @Bean + fun profileService(profileRepository: ProfileRepository): ProfileService = + ProfileService(profileRepository) +} diff --git a/backend/services/identity/identity-service/src/main/kotlin/at/mocode/identity/service/web/ProfileController.kt b/backend/services/identity/identity-service/src/main/kotlin/at/mocode/identity/service/web/ProfileController.kt new file mode 100644 index 00000000..6aa3a658 --- /dev/null +++ b/backend/services/identity/identity-service/src/main/kotlin/at/mocode/identity/service/web/ProfileController.kt @@ -0,0 +1,46 @@ +package at.mocode.identity.service.web + +import at.mocode.identity.domain.model.DomProfil +import at.mocode.identity.domain.service.ProfileService +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.bind.annotation.* + +@RestController +@RequestMapping("/api/v1/profiles") +class ProfileController( + private val profileService: ProfileService +) { + + @GetMapping("/me") + suspend fun getMyProfile(@AuthenticationPrincipal jwt: Jwt): DomProfil? { + return profileService.getProfileByUserId(jwt.subject) + } + + @PostMapping("/link/{satznummer}") + suspend fun linkToZns( + @AuthenticationPrincipal jwt: Jwt, + @PathVariable satznummer: String + ): DomProfil { + return profileService.linkUserToZns(jwt.subject, satznummer) + } + + @PutMapping("/me") + suspend fun updateMyProfile( + @AuthenticationPrincipal jwt: Jwt, + @RequestBody request: ProfileUpdateRequest + ): DomProfil { + return profileService.updateProfile( + userId = jwt.subject, + logoUrl = request.logoUrl, + bio = request.bio, + contactEmail = request.contactEmail + ) + } +} + +data class ProfileUpdateRequest( + val logoUrl: String? = null, + val bio: String? = null, + val contactEmail: String? = null +) diff --git a/backend/services/masterdata/masterdata-api/build.gradle.kts b/backend/services/masterdata/masterdata-api/build.gradle.kts index 74e2be31..a58859af 100644 --- a/backend/services/masterdata/masterdata-api/build.gradle.kts +++ b/backend/services/masterdata/masterdata-api/build.gradle.kts @@ -1,11 +1,7 @@ plugins { - // KORREKTUR: Alle Plugins werden jetzt konsistent über den Version Catalog geladen. - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.kotlin.spring) - alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.ktor) - application - alias(libs.plugins.spring.dependencyManagement) + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.kotlinSerialization) + id("application") } application { @@ -16,8 +12,8 @@ dependencies { api(platform(libs.spring.boot.dependencies)) // Interne Module implementation(projects.platform.platformDependencies) - implementation(projects.masterdata.masterdataDomain) - implementation(projects.masterdata.masterdataApplication) + implementation(projects.backend.services.masterdata.masterdataDomain) + implementation(projects.backend.services.masterdata.masterdataCommon) implementation(projects.core.coreDomain) implementation(projects.core.coreUtils) diff --git a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/StatusPages.kt b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/StatusPages.kt index 75022656..58e22b19 100644 --- a/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/StatusPages.kt +++ b/backend/services/masterdata/masterdata-api/src/main/kotlin/at/mocode/masterdata/api/StatusPages.kt @@ -1,10 +1,14 @@ package at.mocode.masterdata.api import at.mocode.core.domain.model.ApiResponse +import at.mocode.core.domain.model.ErrorCode import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.response.* +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger("at.mocode.masterdata.api.StatusPages") // Eine einfache, eigene Exception, um "Nicht gefunden"-Fälle klarer zu machen. class NotFoundException(message: String) : RuntimeException(message) @@ -15,10 +19,10 @@ fun Application.configureStatusPages() { // Regel 1: Fange alle "IllegalArgumentException" ab. // Das passiert bei ungültigen Eingaben, z.B. ein falsches UUID-Format. exception { call, cause -> - log.warn("Bad Request: ${cause.message}") - val errorResponse = ApiResponse( - message = cause.message ?: "Invalid input provided.", - errors = listOf("BAD_REQUEST") + logger.warn("Bad Request: ${cause.message}") + val errorResponse = ApiResponse.error( + code = ErrorCode("BAD_REQUEST"), + message = cause.message ?: "Invalid input provided." ) call.respond(HttpStatusCode.BadRequest, errorResponse) } @@ -26,10 +30,10 @@ fun Application.configureStatusPages() { // Regel 2: Fange unsere eigene "NotFoundException" ab. // Diese werfen wir, wenn eine Entität nicht in der DB gefunden wurde. exception { call, cause -> - log.info("Resource not found: ${cause.message}") - val errorResponse = ApiResponse( - message = cause.message ?: "The requested resource was not found.", - errors = listOf("NOT_FOUND") + logger.info("Resource not found: ${cause.message}") + val errorResponse = ApiResponse.error( + code = ErrorCode("NOT_FOUND"), + message = cause.message ?: "The requested resource was not found." ) call.respond(HttpStatusCode.NotFound, errorResponse) } @@ -37,10 +41,10 @@ fun Application.configureStatusPages() { // Regel 3: Fange alle anderen, unerwarteten Fehler ab. // Das ist unser Sicherheitsnetz für alles, was wir nicht vorhergesehen haben. exception { call, cause -> - log.error("Internal Server Error", cause) // Logge den kompletten Stacktrace - val errorResponse = ApiResponse( - message = "An unexpected internal server error occurred.", - errors = listOf("INTERNAL_SERVER_ERROR") + logger.error("Internal Server Error", cause) // Logge den kompletten Stacktrace + val errorResponse = ApiResponse.error( + code = ErrorCode("INTERNAL_SERVER_ERROR"), + message = "An unexpected internal server error occurred." ) call.respond(HttpStatusCode.InternalServerError, errorResponse) } 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 743c78e4..f3aa2ea2 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 @@ -1,4 +1,5 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + package at.mocode.masterdata.api.rest import at.mocode.core.domain.model.ApiResponse @@ -6,7 +7,6 @@ import at.mocode.core.domain.model.SparteE import at.mocode.masterdata.application.usecase.CreateAltersklasseUseCase import at.mocode.masterdata.application.usecase.GetAltersklasseUseCase import at.mocode.masterdata.domain.model.AltersklasseDefinition -import at.mocode.core.utils.validation.ApiValidationUtils import kotlin.uuid.Uuid import io.ktor.http.* import io.ktor.server.request.* @@ -16,449 +16,220 @@ import kotlinx.serialization.Serializable /** * REST API controller for age class management operations. - * - * This controller provides HTTP endpoints for the master-data context's - * age class functionality, following REST conventions and proper error handling. */ class AltersklasseController( - private val getAltersklasseUseCase: GetAltersklasseUseCase, - private val createAltersklasseUseCase: CreateAltersklasseUseCase + private val getAltersklasseUseCase: GetAltersklasseUseCase, + private val createAltersklasseUseCase: CreateAltersklasseUseCase ) { - /** - * DTO for age class API responses. - */ - @Serializable - data class AltersklasseDto( - val altersklasseId: String, - val altersklasseCode: String, - val bezeichnung: String, - val minAlter: Int? = null, - val maxAlter: Int? = null, - val stichtagRegelText: String? = null, - val sparteFilter: String? = null, - val geschlechtFilter: String? = null, - val oetoRegelReferenzId: String? = null, - val istAktiv: Boolean = true, - val createdAt: String, - val updatedAt: String - ) + @Serializable + data class AltersklasseDto( + val altersklasseId: String, + val altersklasseCode: String, + val bezeichnung: String, + val minAlter: Int? = null, + val maxAlter: Int? = null, + val stichtagRegelText: String? = null, + val sparteFilter: String? = null, + val geschlechtFilter: String? = null, + val oetoRegelReferenzId: String? = null, + val istAktiv: Boolean = true, + val createdAt: String, + val updatedAt: String + ) - /** - * DTO for creating a new age class. - */ - @Serializable - data class CreateAltersklasseDto( - val altersklasseCode: String, - val bezeichnung: String, - val minAlter: Int? = null, - val maxAlter: Int? = null, - val stichtagRegelText: String? = "31.12. des laufenden Kalenderjahres", - val sparteFilter: String? = null, - val geschlechtFilter: String? = null, - val oetoRegelReferenzId: String? = null, - val istAktiv: Boolean = true - ) + @Serializable + data class CreateAltersklasseDto( + val altersklasseCode: String, + val bezeichnung: String, + val minAlter: Int? = null, + val maxAlter: Int? = null, + val stichtagRegelText: String? = "31.12. des laufenden Kalenderjahres", + val sparteFilter: String? = null, + val geschlechtFilter: String? = null, + val oetoRegelReferenzId: String? = null, + val istAktiv: Boolean = true + ) - /** - * DTO for updating an existing age class. - */ - @Serializable - data class UpdateAltersklasseDto( - val altersklasseCode: String, - val bezeichnung: String, - val minAlter: Int? = null, - val maxAlter: Int? = null, - val stichtagRegelText: String? = "31.12. des laufenden Kalenderjahres", - val sparteFilter: String? = null, - val geschlechtFilter: String? = null, - val oetoRegelReferenzId: String? = null, - val istAktiv: Boolean = true - ) + @Serializable + data class UpdateAltersklasseDto( + val altersklasseCode: String, + val bezeichnung: String, + val minAlter: Int? = null, + val maxAlter: Int? = null, + val stichtagRegelText: String? = null, + val sparteFilter: String? = null, + val geschlechtFilter: String? = null, + val oetoRegelReferenzId: String? = null, + val istAktiv: Boolean = true + ) - /** - * Configures the routing for age class endpoints. - */ - fun configureRouting(routing: Routing) { - routing.route("/api/masterdata/altersklassen") { - - // GET /api/masterdata/altersklassen - Get all active age classes - get { - try { - val sparteFilterParam = call.request.queryParameters["sparte"] - val sparteFilter = sparteFilterParam?.let { - try { - SparteE.valueOf(it.uppercase()) - } catch (_: Exception) { - return@get call.respond( - HttpStatusCode.BadRequest, - ApiResponse>("Invalid sparte parameter: $it") - ) - } - } - - val geschlechtFilterParam = call.request.queryParameters["geschlecht"] - val geschlechtFilter = geschlechtFilterParam?.let { gender -> - if (gender.length == 1 && (gender == "M" || gender == "W")) { - gender[0] - } else { - return@get call.respond( - HttpStatusCode.BadRequest, - ApiResponse>("Invalid geschlecht parameter. Must be 'M' or 'W'") - ) - } - } - - val altersklassen = getAltersklasseUseCase.getAllActive(sparteFilter, geschlechtFilter) - val altersklasseDtos = altersklassen.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(altersklasseDtos)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve age classes: ${e.message}")) - } - } - - // GET /api/masterdata/altersklassen/{id} - Get age class by ID - get("/{id}") { - try { - val altersklasseId = call.parameters["id"]?.let { Uuid.parse(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid age class ID")) - - val altersklasse = getAltersklasseUseCase.getById(altersklasseId) - if (altersklasse != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(altersklasse.toDto())) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Age class not found")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve age class: ${e.message}")) - } - } - - // GET /api/masterdata/altersklassen/code/{code} - Get age class by code - get("/code/{code}") { - try { - val altersklasseCode = call.parameters["code"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Age class code is required")) - - val altersklasse = getAltersklasseUseCase.getByCode(altersklasseCode) - if (altersklasse != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(altersklasse.toDto())) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Age class not found")) - } - } catch (e: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error(e.message ?: "Invalid age class code")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve age class: ${e.message}")) - } - } - - // GET /api/masterdata/altersklassen/search - Search age classes by name - get("/search") { - try { - val validationErrors = ApiValidationUtils.validateQueryParameters( - limit = call.request.queryParameters["limit"], - q = call.request.queryParameters["q"] - ) - - if (!ApiValidationUtils.isValid(validationErrors)) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error>(ApiValidationUtils.createErrorMessage(validationErrors)) - ) - return@get - } - - val searchTerm = call.request.queryParameters["q"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Search term 'q' is required")) - - val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50 - - val altersklassen = getAltersklasseUseCase.searchByName(searchTerm, limit) - val altersklasseDtos = altersklassen.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(altersklasseDtos)) - } catch (e: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error>(e.message ?: "Invalid search parameters")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to search age classes: ${e.message}")) - } - } - - // GET /api/masterdata/altersklassen/age/{age} - Get age classes applicable for specific age - get("/age/{age}") { - try { - val age = call.parameters["age"]?.toIntOrNull() - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Invalid age parameter")) - - if (age < 0) { - return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Age must be non-negative")) - } - - val sparteFilterParam = call.request.queryParameters["sparte"] - val sparteFilter = sparteFilterParam?.let { SparteE.valueOf(it.uppercase()) } - - val geschlechtFilterParam = call.request.queryParameters["geschlecht"] - val geschlechtFilter = geschlechtFilterParam?.let { gender -> - if (gender.length == 1 && (gender == "M" || gender == "W")) { - gender[0] - } else { - return@get call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error>("Invalid geschlecht parameter. Must be 'M' or 'W'") - ) - } - } - - val altersklassen = getAltersklasseUseCase.getApplicableForAge(age, sparteFilter, geschlechtFilter) - val altersklasseDtos = altersklassen.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(altersklasseDtos)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve age classes: ${e.message}")) - } - } - - // GET /api/masterdata/altersklassen/sparte/{sparte} - Get age classes by sport type - get("/sparte/{sparte}") { - try { - val sparteParam = call.parameters["sparte"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Sport type is required")) - - val sparte = try { - SparteE.valueOf(sparteParam.uppercase()) - } catch (_: Exception) { - return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Invalid sport type: $sparteParam")) - } - - val activeOnlyParam = call.request.queryParameters["activeOnly"] - val activeOnly = activeOnlyParam?.toBoolean() ?: true - - val altersklassen = getAltersklasseUseCase.getBySparte(sparte, activeOnly) - val altersklasseDtos = altersklassen.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(altersklasseDtos)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve age classes: ${e.message}")) - } - } - - // POST /api/masterdata/altersklassen - Create new age class - post { - try { - val createDto = call.receive() - - // Basic validation - if (createDto.altersklasseCode.isBlank()) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Age class code is required") - ) - return@post - } - - if (createDto.bezeichnung.isBlank()) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Bezeichnung is required") - ) - return@post - } - - val sparteFilter = createDto.sparteFilter?.let { - try { - SparteE.valueOf(it.uppercase()) - } catch (_: Exception) { - return@post call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid sparte filter: $it") - ) - } - } - - val geschlechtFilter = createDto.geschlechtFilter?.let { gender -> - if (gender.length == 1 && (gender == "M" || gender == "W")) { - gender[0] - } else { - return@post call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid geschlecht filter. Must be 'M' or 'W'") - ) - } - } - - val oetoRegelReferenzId = createDto.oetoRegelReferenzId?.let { - try { - Uuid.parse(it) - } catch (_: Exception) { - return@post call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid OETO regel referenz ID format") - ) - } - } - - val request = CreateAltersklasseUseCase.CreateAltersklasseRequest( - altersklasseCode = createDto.altersklasseCode, - bezeichnung = createDto.bezeichnung, - minAlter = createDto.minAlter, - maxAlter = createDto.maxAlter, - stichtagRegelText = createDto.stichtagRegelText, - sparteFilter = sparteFilter, - geschlechtFilter = geschlechtFilter, - oetoRegelReferenzId = oetoRegelReferenzId, - istAktiv = createDto.istAktiv - ) - - val result = createAltersklasseUseCase.createAltersklasse(request) - if (result.success) { - call.respond(HttpStatusCode.Created, ApiResponse.success(result.altersklasse!!.toDto())) - } else { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Validation failed: ${result.errors.joinToString(", ")}")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to create age class: ${e.message}")) - } - } - - // PUT /api/masterdata/altersklassen/{id} - Update existing age class - put("/{id}") { - try { - val altersklasseId = call.parameters["id"]?.let { Uuid.parse(it) } - ?: return@put call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid age class ID")) - - val updateDto = call.receive() - - // Basic validation - if (updateDto.altersklasseCode.isBlank()) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Age class code is required") - ) - return@put - } - - if (updateDto.bezeichnung.isBlank()) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Bezeichnung is required") - ) - return@put - } - - val sparteFilter = updateDto.sparteFilter?.let { - try { - SparteE.valueOf(it.uppercase()) - } catch (_: Exception) { - return@put call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid sparte filter: $it") - ) - } - } - - val geschlechtFilter = updateDto.geschlechtFilter?.let { gender -> - if (gender.length == 1 && (gender == "M" || gender == "W")) { - gender[0] - } else { - return@put call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid geschlecht filter. Must be 'M' or 'W'") - ) - } - } - - val oetoRegelReferenzId = updateDto.oetoRegelReferenzId?.let { - try { - Uuid.parse(it) - } catch (_: Exception) { - return@put call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid OETO regel referenz ID format") - ) - } - } - - val request = CreateAltersklasseUseCase.UpdateAltersklasseRequest( - altersklasseId = altersklasseId, - altersklasseCode = updateDto.altersklasseCode, - bezeichnung = updateDto.bezeichnung, - minAlter = updateDto.minAlter, - maxAlter = updateDto.maxAlter, - stichtagRegelText = updateDto.stichtagRegelText, - sparteFilter = sparteFilter, - geschlechtFilter = geschlechtFilter, - oetoRegelReferenzId = oetoRegelReferenzId, - istAktiv = updateDto.istAktiv - ) - - val result = createAltersklasseUseCase.updateAltersklasse(request) - if (result.success) { - call.respond(HttpStatusCode.OK, ApiResponse.success(result.altersklasse!!.toDto())) - } else { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Validation failed: ${result.errors.joinToString(", ")}")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to update age class: ${e.message}")) - } - } - - // DELETE /api/masterdata/altersklassen/{id} - Delete age class - delete("/{id}") { - try { - val altersklasseId = call.parameters["id"]?.let { Uuid.parse(it) } - ?: return@delete call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid age class ID")) - - val result = createAltersklasseUseCase.deleteAltersklasse(altersklasseId) - if (result.success) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Age class not found: ${result.errors.joinToString(", ")}")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to delete age class: ${e.message}")) - } - } - - // GET /api/masterdata/altersklassen/eligible/{id} - Check eligibility for age class - get("/eligible/{id}") { - try { - val altersklasseId = call.parameters["id"]?.let { Uuid.parse(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid age class ID")) - - val ageParam = call.request.queryParameters["age"]?.toIntOrNull() - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Age parameter is required")) - - val geschlechtParam = call.request.queryParameters["geschlecht"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Gender parameter is required")) - - if (geschlechtParam.length != 1 || (geschlechtParam != "M" && geschlechtParam != "W")) { - return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Gender must be 'M' or 'W'")) - } - - val isEligible = getAltersklasseUseCase.isEligible(altersklasseId, ageParam, geschlechtParam[0]) - call.respond(HttpStatusCode.OK, ApiResponse.success(isEligible)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to check eligibility: ${e.message}")) - } - } + fun Route.registerRoutes() { + route("/altersklassen") { + get { + val sparte = call.request.queryParameters["sparte"]?.let { + try { + SparteE.valueOf(it) + } catch (e: Exception) { + null + } } - } + val geschlecht = call.request.queryParameters["geschlecht"]?.getOrNull(0) - /** - * Extension function to convert AltersklasseDefinition domain object to AltersklasseDto. - */ - private fun AltersklasseDefinition.toDto(): AltersklasseDto { - return AltersklasseDto( - altersklasseId = this.altersklasseId.toString(), - altersklasseCode = this.altersklasseCode, - bezeichnung = this.bezeichnung, - minAlter = this.minAlter, - maxAlter = this.maxAlter, - stichtagRegelText = this.stichtagRegelText, - sparteFilter = this.sparteFilter?.name, - geschlechtFilter = this.geschlechtFilter?.toString(), - oetoRegelReferenzId = this.oetoRegelReferenzId?.toString(), - istAktiv = this.istAktiv, - createdAt = this.createdAt.toString(), - updatedAt = this.updatedAt.toString() + val response = getAltersklasseUseCase.getAllActive(sparte, geschlecht) + val dtos = response.altersklassen.map { it.toDto() } + call.respond(ApiResponse.success(dtos)) + } + + get("/{id}") { + val idStr = call.parameters["id"] + val id = idStr?.let { + try { + Uuid.parse(it) + } catch (e: Exception) { + null + } + } + ?: return@get call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("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")) + } + + post { + val dto = call.receive() + val request = CreateAltersklasseUseCase.CreateAltersklasseRequest( + altersklasseCode = dto.altersklasseCode, + bezeichnung = dto.bezeichnung, + minAlter = dto.minAlter, + maxAlter = dto.maxAlter, + stichtagRegelText = dto.stichtagRegelText, + sparteFilter = dto.sparteFilter?.let { + try { + SparteE.valueOf(it) + } catch (e: Exception) { + null + } + }, + geschlechtFilter = dto.geschlechtFilter?.getOrNull(0), + oetoRegelReferenzId = dto.oetoRegelReferenzId?.let { + try { + Uuid.parse(it) + } catch (e: Exception) { + null + } + }, + istAktiv = dto.istAktiv ) + + val response = createAltersklasseUseCase.createAltersklasse(request) + val altersklasse = response.altersklasse + if (response.success && altersklasse != null) { + call.respond(HttpStatusCode.Created, ApiResponse.success(altersklasse.toDto())) + } else { + call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("CREATION_FAILED", response.errors.joinToString()) + ) + } + } + + put("/{id}") { + val idStr = call.parameters["id"] + val id = idStr?.let { + try { + Uuid.parse(it) + } catch (e: Exception) { + null + } + } + ?: return@put call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("INVALID_ID", "Missing or invalid ID") + ) + + val dto = call.receive() + val request = CreateAltersklasseUseCase.UpdateAltersklasseRequest( + altersklasseId = id, + altersklasseCode = dto.altersklasseCode, + bezeichnung = dto.bezeichnung, + minAlter = dto.minAlter, + maxAlter = dto.maxAlter, + stichtagRegelText = dto.stichtagRegelText, + sparteFilter = dto.sparteFilter?.let { + try { + SparteE.valueOf(it) + } catch (e: Exception) { + null + } + }, + geschlechtFilter = dto.geschlechtFilter?.getOrNull(0), + oetoRegelReferenzId = dto.oetoRegelReferenzId?.let { + try { + Uuid.parse(it) + } catch (e: Exception) { + null + } + }, + istAktiv = dto.istAktiv + ) + + val response = createAltersklasseUseCase.updateAltersklasse(request) + val altersklasse = response.altersklasse + if (response.success && altersklasse != null) { + call.respond(ApiResponse.success(altersklasse.toDto())) + } else { + call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("UPDATE_FAILED", response.errors.joinToString()) + ) + } + } + + delete("/{id}") { + val idStr = call.parameters["id"] + val id = idStr?.let { + try { + Uuid.parse(it) + } catch (e: Exception) { + null + } + } + ?: return@delete call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("INVALID_ID", "Missing or invalid ID") + ) + + val response = createAltersklasseUseCase.deleteAltersklasse(id) + if (response.success) { + call.respond(ApiResponse.success(Unit)) + } else { + call.respond( + HttpStatusCode.NotFound, + ApiResponse.error("DELETE_FAILED", response.errors.joinToString()) + ) + } + } } + } + + private fun AltersklasseDefinition.toDto() = AltersklasseDto( + altersklasseId = altersklasseId.toString(), + altersklasseCode = altersklasseCode, + bezeichnung = bezeichnung, + minAlter = minAlter, + maxAlter = maxAlter, + stichtagRegelText = stichtagRegelText, + sparteFilter = sparteFilter?.name, + geschlechtFilter = geschlechtFilter?.toString(), + oetoRegelReferenzId = oetoRegelReferenzId?.toString(), + istAktiv = istAktiv, + createdAt = createdAt.toString(), + updatedAt = updatedAt.toString() + ) } 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 0a6b9834..660bcb0b 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 @@ -5,7 +5,6 @@ import at.mocode.core.domain.model.ApiResponse import at.mocode.masterdata.application.usecase.CreateBundeslandUseCase import at.mocode.masterdata.application.usecase.GetBundeslandUseCase import at.mocode.masterdata.domain.model.BundeslandDefinition -import at.mocode.core.utils.validation.ApiValidationUtils import kotlin.uuid.Uuid import io.ktor.http.* import io.ktor.server.request.* @@ -14,19 +13,13 @@ import io.ktor.server.routing.* import kotlinx.serialization.Serializable /** - * REST API controller for federal state management operations. - * - * This controller provides HTTP endpoints for the master-data context's - * federal state functionality, following REST conventions and proper error handling. + * REST API controller for federal state (Bundesland) management. */ class BundeslandController( private val getBundeslandUseCase: GetBundeslandUseCase, private val createBundeslandUseCase: CreateBundeslandUseCase ) { - /** - * DTO for federal state API responses. - */ @Serializable data class BundeslandDto( val bundeslandId: String, @@ -42,9 +35,6 @@ class BundeslandController( val updatedAt: String ) - /** - * DTO for creating a new federal state. - */ @Serializable data class CreateBundeslandDto( val landId: String, @@ -57,313 +47,97 @@ class BundeslandController( val sortierReihenfolge: Int? = null ) - /** - * DTO for updating an existing federal state. - */ - @Serializable - data class UpdateBundeslandDto( - val landId: String, - 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 - ) - - /** - * Configures the routing for federal state endpoints. - */ - fun configureRouting(routing: Routing) { - routing.route("/api/masterdata/bundeslaender") { - - // GET /api/masterdata/bundeslaender - Get all active federal states + fun Route.registerRoutes() { + route("/bundeslaender") { get { + val landId = call.request.queryParameters["landId"]?.let { try { - val orderBySortierungParam = call.request.queryParameters["orderBySortierung"] - val orderBySortierung = if (orderBySortierungParam != null) { - try { - orderBySortierungParam.toBoolean() - } catch (_: Exception) { - return@get call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error>("Invalid orderBySortierung parameter. Must be true or false") - ) - } - } else { - true - } - - val bundeslaender = getBundeslandUseCase.getAllActive(orderBySortierung) - val bundeslandDtos = bundeslaender.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(bundeslandDtos)) + Uuid.parse(it) } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve federal states: ${e.message}")) + null } + } + + val response = if (landId != null) { + getBundeslandUseCase.getByCountry(landId) + } else { + getBundeslandUseCase.getAllActive() + } + + val dtos = response.bundeslaender.map { it.toDto() } + call.respond(ApiResponse.success(dtos)) } - // GET /api/masterdata/bundeslaender/{id} - Get federal state by ID get("/{id}") { + val idStr = call.parameters["id"] + val id = idStr?.let { try { - val bundeslandId = call.parameters["id"]?.let { Uuid.parse(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid federal state ID")) - - val bundesland = getBundeslandUseCase.getById(bundeslandId) - if (bundesland != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(bundesland.toDto())) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Federal state not found")) - } + Uuid.parse(it) } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve federal state: ${e.message}")) + null } + } + ?: return@get call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("INVALID_ID", "Missing or invalid ID") + ) + + val response = getBundeslandUseCase.getById(id) + response.bundesland?.let { + call.respond(ApiResponse.success(it.toDto())) + } ?: call.respond( + HttpStatusCode.NotFound, + ApiResponse.error("NOT_FOUND", "Federal state not found") + ) } - // GET /api/masterdata/bundeslaender/oeps/{code} - Get federal state by OEPS code - get("/oeps/{code}") { - try { - val oepsCode = call.parameters["code"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("OEPS code is required")) - - val landIdParam = call.request.queryParameters["landId"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Country ID (landId) is required")) - - val landId = try { - Uuid.parse(landIdParam) - } catch (_: Exception) { - return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid country ID format")) - } - - val bundesland = getBundeslandUseCase.getByOepsCode(oepsCode, landId) - if (bundesland != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(bundesland.toDto())) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Federal state not found")) - } - } catch (e: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error(e.message ?: "Invalid OEPS code")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve federal state: ${e.message}")) - } - } - - // GET /api/masterdata/bundeslaender/iso/{code} - Get federal state by ISO 3166-2 code - get("/iso/{code}") { - try { - val isoCode = call.parameters["code"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("ISO 3166-2 code is required")) - - val bundesland = getBundeslandUseCase.getByIso3166_2_Code(isoCode) - if (bundesland != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(bundesland.toDto())) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Federal state not found")) - } - } catch (e: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error(e.message ?: "Invalid ISO code")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve federal state: ${e.message}")) - } - } - - // GET /api/masterdata/bundeslaender/country/{countryId} - Get federal states by country - get("/country/{countryId}") { - try { - val landId = call.parameters["countryId"]?.let { Uuid.parse(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Invalid country ID")) - - val activeOnlyParam = call.request.queryParameters["activeOnly"] - val activeOnly = activeOnlyParam?.toBoolean() ?: true - - val orderBySortierungParam = call.request.queryParameters["orderBySortierung"] - val orderBySortierung = orderBySortierungParam?.toBoolean() ?: true - - val bundeslaender = getBundeslandUseCase.getByCountry(landId, activeOnly, orderBySortierung) - val bundeslandDtos = bundeslaender.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(bundeslandDtos)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve federal states: ${e.message}")) - } - } - - // GET /api/masterdata/bundeslaender/search - Search federal states by name - get("/search") { - try { - val validationErrors = ApiValidationUtils.validateQueryParameters( - limit = call.request.queryParameters["limit"], - q = call.request.queryParameters["q"] - ) - - if (!ApiValidationUtils.isValid(validationErrors)) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error>(ApiValidationUtils.createErrorMessage(validationErrors)) - ) - return@get - } - - val searchTerm = call.request.queryParameters["q"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Search term 'q' is required")) - - val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50 - val landIdParam = call.request.queryParameters["landId"] - val landId = landIdParam?.let { Uuid.parse(it) } - - val bundeslaender = getBundeslandUseCase.searchByName(searchTerm, landId, limit) - val bundeslandDtos = bundeslaender.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(bundeslandDtos)) - } catch (e: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error>(e.message ?: "Invalid search parameters")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to search federal states: ${e.message}")) - } - } - - // POST /api/masterdata/bundeslaender - Create new federal state post { - try { - val createDto = call.receive() + val dto = call.receive() + val landId = try { + Uuid.parse(dto.landId) + } catch (e: Exception) { + return@post call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("INVALID_LAND_ID", "Invalid landId format") + ) + } - // Basic validation - if (createDto.name.isBlank()) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Name is required") - ) - return@post - } + val request = CreateBundeslandUseCase.CreateBundeslandRequest( + landId = landId, + oepsCode = dto.oepsCode, + iso3166_2_Code = dto.iso3166_2_Code, + name = dto.name, + kuerzel = dto.kuerzel, + wappenUrl = dto.wappenUrl, + istAktiv = dto.istAktiv, + sortierReihenfolge = dto.sortierReihenfolge + ) - try { - uuidFrom(createDto.landId) - } catch (_: Exception) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid country ID format") - ) - return@post - } - - val request = CreateBundeslandUseCase.CreateBundeslandRequest( - landId = uuidFrom(createDto.landId), - oepsCode = createDto.oepsCode, - iso3166_2_Code = createDto.iso3166_2_Code, - name = createDto.name, - kuerzel = createDto.kuerzel, - wappenUrl = createDto.wappenUrl, - istAktiv = createDto.istAktiv, - sortierReihenfolge = createDto.sortierReihenfolge - ) - - val result = createBundeslandUseCase.createBundesland(request) - if (result.success) { - call.respond(HttpStatusCode.Created, ApiResponse.success(result.bundesland!!.toDto())) - } else { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Validation failed: ${result.errors.joinToString(", ")}")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to create federal state: ${e.message}")) - } + val response = createBundeslandUseCase.createBundesland(request) + val bundesland = response.bundesland + if (response.success && bundesland != null) { + call.respond(HttpStatusCode.Created, ApiResponse.success(bundesland.toDto())) + } else { + call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("CREATION_FAILED", response.errors.joinToString()) + ) + } } - - // PUT /api/masterdata/bundeslaender/{id} - Update existing federal state - put("/{id}") { - try { - val bundeslandId = call.parameters["id"]?.let { uuidFrom(it) } - ?: return@put call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid federal state ID")) - - val updateDto = call.receive() - - // Basic validation - if (updateDto.name.isBlank()) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Name is required") - ) - return@put - } - - try { - uuidFrom(updateDto.landId) - } catch (_: Exception) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid country ID format") - ) - return@put - } - - val request = CreateBundeslandUseCase.UpdateBundeslandRequest( - bundeslandId = bundeslandId, - landId = uuidFrom(updateDto.landId), - oepsCode = updateDto.oepsCode, - iso3166_2_Code = updateDto.iso3166_2_Code, - name = updateDto.name, - kuerzel = updateDto.kuerzel, - wappenUrl = updateDto.wappenUrl, - istAktiv = updateDto.istAktiv, - sortierReihenfolge = updateDto.sortierReihenfolge - ) - - val result = createBundeslandUseCase.updateBundesland(request) - if (result.success) { - call.respond(HttpStatusCode.OK, ApiResponse.success(result.bundesland!!.toDto())) - } else { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Validation failed: ${result.errors.joinToString(", ")}")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to update federal state: ${e.message}")) - } - } - - // DELETE /api/masterdata/bundeslaender/{id} - Delete federal state - delete("/{id}") { - try { - val bundeslandId = call.parameters["id"]?.let { uuidFrom(it) } - ?: return@delete call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid federal state ID")) - - val result = createBundeslandUseCase.deleteBundesland(bundeslandId) - if (result.success) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Federal state not found: ${result.errors.joinToString(", ")}")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to delete federal state: ${e.message}")) - } - } - - // GET /api/masterdata/bundeslaender/count/{countryId} - Count active federal states by country - get("/count/{countryId}") { - try { - val landId = call.parameters["countryId"]?.let { uuidFrom(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid country ID")) - - val count = getBundeslandUseCase.countActiveByCountry(landId) - call.respond(HttpStatusCode.OK, ApiResponse.success(count)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to count federal states: ${e.message}")) - } - } - } } + } - /** - * Extension function to convert BundeslandDefinition domain object to BundeslandDto. - */ - private fun BundeslandDefinition.toDto(): BundeslandDto { - return BundeslandDto( - bundeslandId = this.bundeslandId.toString(), - landId = this.landId.toString(), - oepsCode = this.oepsCode, - iso3166_2_Code = this.iso3166_2_Code, - name = this.name, - kuerzel = this.kuerzel, - wappenUrl = this.wappenUrl, - istAktiv = this.istAktiv, - sortierReihenfolge = this.sortierReihenfolge, - createdAt = this.createdAt.toString(), - updatedAt = this.updatedAt.toString() - ) - } + private fun BundeslandDefinition.toDto() = BundeslandDto( + bundeslandId = bundeslandId.toString(), + landId = landId.toString(), + oepsCode = oepsCode, + iso3166_2_Code = iso3166_2_Code, + name = name, + kuerzel = kuerzel, + wappenUrl = wappenUrl, + istAktiv = istAktiv, + sortierReihenfolge = sortierReihenfolge, + createdAt = createdAt.toString(), + updatedAt = updatedAt.toString() + ) } 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 cf06d0dc..94df67fc 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 @@ -5,7 +5,6 @@ import at.mocode.core.domain.model.ApiResponse import at.mocode.masterdata.application.usecase.CreateCountryUseCase import at.mocode.masterdata.application.usecase.GetCountryUseCase import at.mocode.masterdata.domain.model.LandDefinition -import at.mocode.core.utils.validation.ApiValidationUtils import kotlin.uuid.Uuid import io.ktor.http.* import io.ktor.server.request.* @@ -14,19 +13,13 @@ import io.ktor.server.routing.* import kotlinx.serialization.Serializable /** - * REST API controller for country management operations. - * - * This controller provides HTTP endpoints for the master-data context's - * country functionality, following REST conventions and proper error handling. + * REST API controller for country (Land) management. */ class CountryController( private val getCountryUseCase: GetCountryUseCase, private val createCountryUseCase: CreateCountryUseCase ) { - /** - * DTO for country API responses. - */ @Serializable data class CountryDto( val landId: String, @@ -44,9 +37,6 @@ class CountryController( val updatedAt: String ) - /** - * DTO for creating a new country. - */ @Serializable data class CreateCountryDto( val isoAlpha2Code: String, @@ -61,294 +51,76 @@ class CountryController( val sortierReihenfolge: Int? = null ) - /** - * DTO for updating an existing country. - */ - @Serializable - data class UpdateCountryDto( - val isoAlpha2Code: String, - val isoAlpha3Code: String, - val isoNumerischerCode: String? = null, - val nameDeutsch: String, - val nameEnglisch: String? = null, - val wappenUrl: String? = null, - val istEuMitglied: Boolean? = null, - val istEwrMitglied: Boolean? = null, - val istAktiv: Boolean = true, - val sortierReihenfolge: Int? = null - ) - - /** - * Configures the routing for country endpoints. - */ - fun configureRouting(routing: Routing) { - routing.route("/api/masterdata/countries") { - - // GET /api/masterdata/countries - Get all active countries + fun Route.registerRoutes() { + route("/countries") { get { - try { - // Validate orderBySortierung parameter if provided - val orderBySortierungParam = call.request.queryParameters["orderBySortierung"] - val orderBySortierung = if (orderBySortierungParam != null) { - try { - orderBySortierungParam.toBoolean() - } catch (_: Exception) { - return@get call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error>("Invalid orderBySortierung parameter. Must be true or false") - ) - } - } else { - true - } - val countries = getCountryUseCase.getAllActive(orderBySortierung) - val countryDtos = countries.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(countryDtos)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve countries: ${e.message}")) - } + val response = getCountryUseCase.getAllActive() + val dtos = response.countries.map { it.toDto() } + call.respond(ApiResponse.success(dtos)) } - // GET /api/masterdata/countries/{id} - Get country by ID get("/{id}") { + val idStr = call.parameters["id"] + val id = idStr?.let { try { - val countryId = call.parameters["id"]?.let { Uuid.parse(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid country ID")) - - val country = getCountryUseCase.getById(countryId) - if (country != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(country.toDto())) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Country not found")) - } + Uuid.parse(it) } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve country: ${e.message}")) + null } + } + ?: return@get call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("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")) } - // GET /api/masterdata/countries/iso2/{code} - Get country by ISO Alpha-2 code - get("/iso2/{code}") { - try { - val isoCode = call.parameters["code"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("ISO code is required")) - - val country = getCountryUseCase.getByIsoAlpha2Code(isoCode) - if (country != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(country.toDto())) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Country not found")) - } - } catch (e: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error(e.message ?: "Invalid ISO code")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve country: ${e.message}")) - } - } - - // GET /api/masterdata/countries/iso3/{code} - Get country by ISO Alpha-3 code - get("/iso3/{code}") { - try { - val isoCode = call.parameters["code"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("ISO code is required")) - - val country = getCountryUseCase.getByIsoAlpha3Code(isoCode) - if (country != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(country.toDto())) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Country not found")) - } - } catch (e: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error(e.message ?: "Invalid ISO code")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve country: ${e.message}")) - } - } - - // GET /api/masterdata/countries/search - Search countries by name - get("/search") { - try { - // Validate query parameters - val validationErrors = ApiValidationUtils.validateQueryParameters( - limit = call.request.queryParameters["limit"], - q = call.request.queryParameters["q"] - ) - - if (!ApiValidationUtils.isValid(validationErrors)) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error>(ApiValidationUtils.createErrorMessage(validationErrors)) - ) - return@get - } - - val searchTerm = call.request.queryParameters["q"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Search term 'q' is required")) - - val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50 - - val countries = getCountryUseCase.searchByName(searchTerm, limit) - val countryDtos = countries.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(countryDtos)) - } catch (e: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error>(e.message ?: "Invalid search parameters")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to search countries: ${e.message}")) - } - } - - // GET /api/masterdata/countries/eu - Get EU member countries - get("/eu") { - try { - val countries = getCountryUseCase.getEuMembers() - val countryDtos = countries.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(countryDtos)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve EU countries: ${e.message}")) - } - } - - // GET /api/masterdata/countries/ewr - Get EWR member countries - get("/ewr") { - try { - val countries = getCountryUseCase.getEwrMembers() - val countryDtos = countries.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(countryDtos)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve EWR countries: ${e.message}")) - } - } - - // POST /api/masterdata/countries - Create new country post { - try { - val createDto = call.receive() + val dto = call.receive() + val request = CreateCountryUseCase.CreateCountryRequest( + isoAlpha2Code = dto.isoAlpha2Code, + isoAlpha3Code = dto.isoAlpha3Code, + isoNumerischerCode = dto.isoNumerischerCode, + nameDeutsch = dto.nameDeutsch, + nameEnglisch = dto.nameEnglisch, + wappenUrl = dto.wappenUrl, + istEuMitglied = dto.istEuMitglied, + istEwrMitglied = dto.istEwrMitglied, + istAktiv = dto.istAktiv, + sortierReihenfolge = dto.sortierReihenfolge + ) - // Validate input using shared validation utilities - val validationErrors = ApiValidationUtils.validateCountryRequest( - isoAlpha2Code = createDto.isoAlpha2Code, - isoAlpha3Code = createDto.isoAlpha3Code, - nameDeutsch = createDto.nameDeutsch, - nameEnglisch = createDto.nameEnglisch - ) - - if (!ApiValidationUtils.isValid(validationErrors)) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error(ApiValidationUtils.createErrorMessage(validationErrors)) - ) - return@post - } - - val request = CreateCountryUseCase.CreateCountryRequest( - isoAlpha2Code = createDto.isoAlpha2Code, - isoAlpha3Code = createDto.isoAlpha3Code, - isoNumerischerCode = createDto.isoNumerischerCode, - nameDeutsch = createDto.nameDeutsch, - nameEnglisch = createDto.nameEnglisch, - wappenUrl = createDto.wappenUrl, - istEuMitglied = createDto.istEuMitglied, - istEwrMitglied = createDto.istEwrMitglied, - istAktiv = createDto.istAktiv, - sortierReihenfolge = createDto.sortierReihenfolge - ) - - val result = createCountryUseCase.createCountry(request) - if (result.success) { - call.respond(HttpStatusCode.Created, ApiResponse.success(result.country!!.toDto())) - } else { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Validation failed: ${result.errors.joinToString(", ")}")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to create country: ${e.message}")) - } - } - - // PUT /api/masterdata/countries/{id} - Update existing country - put("/{id}") { - try { - val countryId = call.parameters["id"]?.let { uuidFrom(it) } - ?: return@put call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid country ID")) - - val updateDto = call.receive() - - // Validate input using shared validation utilities - val validationErrors = ApiValidationUtils.validateCountryRequest( - isoAlpha2Code = updateDto.isoAlpha2Code, - isoAlpha3Code = updateDto.isoAlpha3Code, - nameDeutsch = updateDto.nameDeutsch, - nameEnglisch = updateDto.nameEnglisch - ) - - if (!ApiValidationUtils.isValid(validationErrors)) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error(ApiValidationUtils.createErrorMessage(validationErrors)) - ) - return@put - } - - val request = CreateCountryUseCase.UpdateCountryRequest( - landId = countryId, - isoAlpha2Code = updateDto.isoAlpha2Code, - isoAlpha3Code = updateDto.isoAlpha3Code, - isoNumerischerCode = updateDto.isoNumerischerCode, - nameDeutsch = updateDto.nameDeutsch, - nameEnglisch = updateDto.nameEnglisch, - wappenUrl = updateDto.wappenUrl, - istEuMitglied = updateDto.istEuMitglied, - istEwrMitglied = updateDto.istEwrMitglied, - istAktiv = updateDto.istAktiv, - sortierReihenfolge = updateDto.sortierReihenfolge - ) - - val result = createCountryUseCase.updateCountry(request) - if (result.success) { - call.respond(HttpStatusCode.OK, ApiResponse.success(result.country!!.toDto())) - } else { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Validation failed: ${result.errors.joinToString(", ")}")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to update country: ${e.message}")) - } - } - - // DELETE /api/masterdata/countries/{id} - Delete country - delete("/{id}") { - try { - val countryId = call.parameters["id"]?.let { uuidFrom(it) } - ?: return@delete call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid country ID")) - - val result = createCountryUseCase.deleteCountry(countryId) - if (result.success) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Country not found: ${result.errors.joinToString(", ")}")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to delete country: ${e.message}")) + val response = createCountryUseCase.createCountry(request) + val country = response.country + if (response.success && country != null) { + call.respond(HttpStatusCode.Created, ApiResponse.success(country.toDto())) + } else { + call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("CREATION_FAILED", response.errors.joinToString()) + ) } } } } - /** - * Extension function to convert LandDefinition domain object to CountryDto. - */ - private fun LandDefinition.toDto(): CountryDto { - return CountryDto( - landId = this.landId.toString(), - isoAlpha2Code = this.isoAlpha2Code, - isoAlpha3Code = this.isoAlpha3Code, - isoNumerischerCode = this.isoNumerischerCode, - nameDeutsch = this.nameDeutsch, - nameEnglisch = this.nameEnglisch, - wappenUrl = this.wappenUrl, - istEuMitglied = this.istEuMitglied, - istEwrMitglied = this.istEwrMitglied, - istAktiv = this.istAktiv, - sortierReihenfolge = this.sortierReihenfolge, - createdAt = this.createdAt.toString(), - updatedAt = this.updatedAt.toString() - ) - } + private fun LandDefinition.toDto() = CountryDto( + landId = landId.toString(), + isoAlpha2Code = isoAlpha2Code, + isoAlpha3Code = isoAlpha3Code, + isoNumerischerCode = isoNumerischerCode, + nameDeutsch = nameDeutsch, + nameEnglisch = nameEnglisch, + wappenUrl = wappenUrl, + istEuMitglied = istEuMitglied, + istEwrMitglied = istEwrMitglied, + istAktiv = istAktiv, + sortierReihenfolge = sortierReihenfolge, + createdAt = createdAt.toString(), + updatedAt = updatedAt.toString() + ) } 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 d38daed0..01136844 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 @@ -6,7 +6,6 @@ import at.mocode.core.domain.model.PlatzTypE import at.mocode.masterdata.application.usecase.CreatePlatzUseCase import at.mocode.masterdata.application.usecase.GetPlatzUseCase import at.mocode.masterdata.domain.model.Platz -import at.mocode.core.utils.validation.ApiValidationUtils import kotlin.uuid.Uuid import io.ktor.http.* import io.ktor.server.request.* @@ -15,22 +14,16 @@ import io.ktor.server.routing.* import kotlinx.serialization.Serializable /** - * REST API controller for venue/arena management operations. - * - * This controller provides HTTP endpoints for the master-data context's - * venue functionality, following REST conventions and proper error handling. + * REST API controller for venue/arena (Platz) management. */ class PlatzController( private val getPlatzUseCase: GetPlatzUseCase, private val createPlatzUseCase: CreatePlatzUseCase ) { - /** - * DTO for venue API responses. - */ @Serializable data class PlatzDto( - val id: String, + val platzId: String, val turnierId: String, val name: String, val dimension: String? = null, @@ -42,9 +35,6 @@ class PlatzController( val updatedAt: String ) - /** - * DTO for creating a new venue. - */ @Serializable data class CreatePlatzDto( val turnierId: String, @@ -56,420 +46,95 @@ class PlatzController( val sortierReihenfolge: Int? = null ) - /** - * DTO for updating an existing venue. - */ - @Serializable - data class UpdatePlatzDto( - val turnierId: String, - val name: String, - val dimension: String? = null, - val boden: String? = null, - val typ: String, - val istAktiv: Boolean = true, - val sortierReihenfolge: Int? = null - ) - - /** - * Configures the routing for venue endpoints. - */ - fun configureRouting(routing: Routing) { - routing.route("/api/masterdata/plaetze") { - - // GET /api/masterdata/plaetze/{id} - Get venue by ID - get("/{id}") { - try { - val platzId = call.parameters["id"]?.let { Uuid.parse(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid venue ID")) - - val platz = getPlatzUseCase.getById(platzId) - if (platz != null) { - call.respond(HttpStatusCode.OK, ApiResponse.success(platz.toDto())) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Venue not found")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to retrieve venue: ${e.message}")) - } - } - - // GET /api/masterdata/plaetze/tournament/{turnierId} - Get venues by tournament - get("/tournament/{turnierId}") { - try { - val turnierId = call.parameters["turnierId"]?.let { Uuid.parse(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Invalid tournament ID")) - - val activeOnlyParam = call.request.queryParameters["activeOnly"] - val activeOnly = activeOnlyParam?.toBoolean() ?: true - - val orderBySortierungParam = call.request.queryParameters["orderBySortierung"] - val orderBySortierung = orderBySortierungParam?.toBoolean() ?: true - - val plaetze = getPlatzUseCase.getByTournament(turnierId, activeOnly, orderBySortierung) - val platzDtos = plaetze.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(platzDtos)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve venues: ${e.message}")) - } - } - - // GET /api/masterdata/plaetze/search - Search venues by name - get("/search") { - try { - val validationErrors = ApiValidationUtils.validateQueryParameters( - limit = call.request.queryParameters["limit"], - q = call.request.queryParameters["q"] - ) - - if (!ApiValidationUtils.isValid(validationErrors)) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error>(ApiValidationUtils.createErrorMessage(validationErrors)) - ) - return@get - } - - val searchTerm = call.request.queryParameters["q"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Search term 'q' is required")) - - val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50 - val turnierIdParam = call.request.queryParameters["turnierId"] - val turnierId = turnierIdParam?.let { uuidFrom(it) } - - val plaetze = getPlatzUseCase.searchByName(searchTerm, turnierId, limit) - val platzDtos = plaetze.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(platzDtos)) - } catch (e: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error>(e.message ?: "Invalid search parameters")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to search venues: ${e.message}")) - } - } - - // GET /api/masterdata/plaetze/type/{typ} - Get venues by type - get("/type/{typ}") { - try { - val typParam = call.parameters["typ"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Venue type is required")) - - val typ = try { - PlatzTypE.valueOf(typParam.uppercase()) - } catch (_: Exception) { - return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Invalid venue type: $typParam")) - } - - val turnierIdParam = call.request.queryParameters["turnierId"] - val turnierId = turnierIdParam?.let { uuidFrom(it) } - - val activeOnlyParam = call.request.queryParameters["activeOnly"] - val activeOnly = activeOnlyParam?.toBoolean() ?: true - - val plaetze = getPlatzUseCase.getByType(typ, turnierId, activeOnly) - val platzDtos = plaetze.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(platzDtos)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve venues: ${e.message}")) - } - } - - // GET /api/masterdata/plaetze/ground/{boden} - Get venues by ground type - get("/ground/{boden}") { - try { - val boden = call.parameters["boden"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Ground type is required")) - - val turnierIdParam = call.request.queryParameters["turnierId"] - val turnierId = turnierIdParam?.let { uuidFrom(it) } - - val activeOnlyParam = call.request.queryParameters["activeOnly"] - val activeOnly = activeOnlyParam?.toBoolean() ?: true - - val plaetze = getPlatzUseCase.getByGroundType(boden, turnierId, activeOnly) - val platzDtos = plaetze.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(platzDtos)) - } catch (e: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error>(e.message ?: "Invalid ground type")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve venues: ${e.message}")) - } - } - - // GET /api/masterdata/plaetze/dimension/{dimension} - Get venues by dimensions - get("/dimension/{dimension}") { - try { - val dimension = call.parameters["dimension"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Dimension is required")) - - val turnierIdParam = call.request.queryParameters["turnierId"] - val turnierId = turnierIdParam?.let { uuidFrom(it) } - - val activeOnlyParam = call.request.queryParameters["activeOnly"] - val activeOnly = activeOnlyParam?.toBoolean() ?: true - - val plaetze = getPlatzUseCase.getByDimensions(dimension, turnierId, activeOnly) - val platzDtos = plaetze.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(platzDtos)) - } catch (e: IllegalArgumentException) { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error>(e.message ?: "Invalid dimension")) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve venues: ${e.message}")) - } - } - - // GET /api/masterdata/plaetze/suitable - Get venues suitable for discipline - get("/suitable") { - try { - val typParam = call.request.queryParameters["typ"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Required venue type parameter is missing")) - - val requiredType = try { - PlatzTypE.valueOf(typParam.uppercase()) - } catch (_: Exception) { - return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Invalid venue type: $typParam")) - } - - val requiredDimensions = call.request.queryParameters["dimension"] - val turnierIdParam = call.request.queryParameters["turnierId"] - val turnierId = turnierIdParam?.let { uuidFrom(it) } - - val plaetze = getPlatzUseCase.getSuitableForDiscipline(requiredType, requiredDimensions, turnierId) - val platzDtos = plaetze.map { it.toDto() } - call.respond(HttpStatusCode.OK, ApiResponse.success(platzDtos)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to retrieve suitable venues: ${e.message}")) - } - } - - // POST /api/masterdata/plaetze - Create new venue - post { - try { - val createDto = call.receive() - - // Basic validation - if (createDto.name.isBlank()) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Name is required") - ) - return@post - } - - val turnierId = try { - uuidFrom(createDto.turnierId) - } catch (_: Exception) { - return@post call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid tournament ID format") - ) - } - - val typ = try { - PlatzTypE.valueOf(createDto.typ.uppercase()) - } catch (_: Exception) { - return@post call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid venue type: ${createDto.typ}") - ) - } - - val request = CreatePlatzUseCase.CreatePlatzRequest( - turnierId = turnierId, - name = createDto.name, - dimension = createDto.dimension, - boden = createDto.boden, - typ = typ, - istAktiv = createDto.istAktiv, - sortierReihenfolge = createDto.sortierReihenfolge - ) - - val result = createPlatzUseCase.createPlatz(request) - if (result.success) { - call.respond(HttpStatusCode.Created, ApiResponse.success(result.platz!!.toDto())) - } else { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Validation failed: ${result.errors.joinToString(", ")}")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to create venue: ${e.message}")) - } - } - - // PUT /api/masterdata/plaetze/{id} - Update existing venue - put("/{id}") { - try { - val platzId = call.parameters["id"]?.let { uuidFrom(it) } - ?: return@put call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid venue ID")) - - val updateDto = call.receive() - - // Basic validation - if (updateDto.name.isBlank()) { - call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Name is required") - ) - return@put - } - - val turnierId = try { - uuidFrom(updateDto.turnierId) - } catch (_: Exception) { - return@put call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid tournament ID format") - ) - } - - val typ = try { - PlatzTypE.valueOf(updateDto.typ.uppercase()) - } catch (_: Exception) { - return@put call.respond( - HttpStatusCode.BadRequest, - ApiResponse.error("Invalid venue type: ${updateDto.typ}") - ) - } - - val request = CreatePlatzUseCase.UpdatePlatzRequest( - platzId = platzId, - turnierId = turnierId, - name = updateDto.name, - dimension = updateDto.dimension, - boden = updateDto.boden, - typ = typ, - istAktiv = updateDto.istAktiv, - sortierReihenfolge = updateDto.sortierReihenfolge - ) - - val result = createPlatzUseCase.updatePlatz(request) - if (result.success) { - call.respond(HttpStatusCode.OK, ApiResponse.success(result.platz!!.toDto())) - } else { - call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Validation failed: ${result.errors.joinToString(", ")}")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to update venue: ${e.message}")) - } - } - - // DELETE /api/masterdata/plaetze/{id} - Delete venue - delete("/{id}") { - try { - val platzId = call.parameters["id"]?.let { uuidFrom(it) } - ?: return@delete call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid venue ID")) - - val result = createPlatzUseCase.deletePlatz(platzId) - if (result.success) { - call.respond(HttpStatusCode.NoContent) - } else { - call.respond(HttpStatusCode.NotFound, ApiResponse.error("Venue not found: ${result.errors.joinToString(", ")}")) - } - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to delete venue: ${e.message}")) - } - } - - // GET /api/masterdata/plaetze/count/tournament/{turnierId} - Count venues by tournament - get("/count/tournament/{turnierId}") { - try { - val turnierId = call.parameters["turnierId"]?.let { uuidFrom(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid tournament ID")) - - val count = getPlatzUseCase.countActiveByTournament(turnierId) - call.respond(HttpStatusCode.OK, ApiResponse.success(count)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to count venues: ${e.message}")) - } - } - - // GET /api/masterdata/plaetze/count/type/{typ}/tournament/{turnierId} - Count venues by type and tournament - get("/count/type/{typ}/tournament/{turnierId}") { - try { - val typParam = call.parameters["typ"] - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Venue type is required")) - - val typ = try { - PlatzTypE.valueOf(typParam.uppercase()) - } catch (_: Exception) { - return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid venue type: $typParam")) - } - - val turnierId = call.parameters["turnierId"]?.let { uuidFrom(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error("Invalid tournament ID")) - - val activeOnlyParam = call.request.queryParameters["activeOnly"] - val activeOnly = activeOnlyParam?.toBoolean() ?: true - - val count = getPlatzUseCase.countByTypeAndTournament(typ, turnierId, activeOnly) - call.respond(HttpStatusCode.OK, ApiResponse.success(count)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error("Failed to count venues: ${e.message}")) - } - } - - // GET /api/masterdata/plaetze/grouped/tournament/{turnierId} - Get venues grouped by type - get("/grouped/tournament/{turnierId}") { - try { - val turnierId = call.parameters["turnierId"]?.let { uuidFrom(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>>("Invalid tournament ID")) - - val activeOnlyParam = call.request.queryParameters["activeOnly"] - val activeOnly = activeOnlyParam?.toBoolean() ?: true - - val groupedVenues = getPlatzUseCase.getGroupedByTypeForTournament(turnierId, activeOnly) - val groupedDtos = groupedVenues.mapKeys { it.key.name }.mapValues { entry -> - entry.value.map { it.toDto() } - } - call.respond(HttpStatusCode.OK, ApiResponse.success(groupedDtos)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>>("Failed to retrieve grouped venues: ${e.message}")) - } - } - - // GET /api/masterdata/plaetze/validate/{id} - Validate venue suitability - get("/validate/{id}") { - try { - val platzId = call.parameters["id"]?.let { uuidFrom(it) } - ?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Invalid venue ID")) - - val requiredTypeParam = call.request.queryParameters["requiredType"] - val requiredType = requiredTypeParam?.let { - try { - PlatzTypE.valueOf(it.uppercase()) - } catch (_: Exception) { - return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error>("Invalid required type: $it")) - } - } - - val requiredDimensions = call.request.queryParameters["requiredDimensions"] - val requiredGroundType = call.request.queryParameters["requiredGroundType"] - - val (isValid, reasons) = getPlatzUseCase.validateVenueSuitability(platzId, requiredType, requiredDimensions, requiredGroundType) - val response = mapOf( - "isValid" to isValid, - "reasons" to reasons - ) - call.respond(HttpStatusCode.OK, ApiResponse.success(response)) - } catch (e: Exception) { - call.respond(HttpStatusCode.InternalServerError, ApiResponse.error>("Failed to validate venue: ${e.message}")) - } - } + fun Route.registerRoutes() { + route("/plaetze") { + get { + val turnierId = call.request.queryParameters["turnierId"]?.let { + try { + Uuid.parse(it) + } catch (e: Exception) { + null + } } - } + ?: return@get call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("MISSING_TURNIER_ID", "Query parameter turnierId is required") + ) - /** - * Extension function to convert Platz domain object to PlatzDto. - */ - private fun Platz.toDto(): PlatzDto { - return PlatzDto( - id = this.id.toString(), - turnierId = this.turnierId.toString(), - name = this.name, - dimension = this.dimension, - boden = this.boden, - typ = this.typ.name, - istAktiv = this.istAktiv, - sortierReihenfolge = this.sortierReihenfolge, - createdAt = this.createdAt.toString(), - updatedAt = this.updatedAt.toString() - ) + val response = getPlatzUseCase.getByTournament(turnierId) + val dtos = response.plaetze.map { it.toDto() } + call.respond(ApiResponse.success(dtos)) + } + + get("/{id}") { + val idStr = call.parameters["id"] + val id = idStr?.let { + try { + Uuid.parse(it) + } catch (e: Exception) { + null + } + } + ?: return@get call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("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")) + } + + post { + val dto = call.receive() + val turnierId = try { + Uuid.parse(dto.turnierId) + } catch (e: Exception) { + return@post call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("INVALID_TURNIER_ID", "Invalid turnierId format") + ) + } + + val request = CreatePlatzUseCase.CreatePlatzRequest( + turnierId = turnierId, + name = dto.name, + dimension = dto.dimension, + boden = dto.boden, + typ = try { + PlatzTypE.valueOf(dto.typ) + } catch (e: Exception) { + PlatzTypE.SONSTIGE + }, + istAktiv = dto.istAktiv, + sortierReihenfolge = dto.sortierReihenfolge + ) + + val response = createPlatzUseCase.createPlatz(request) + val platz = response.platz + if (response.success && platz != null) { + call.respond(HttpStatusCode.Created, ApiResponse.success(platz.toDto())) + } else { + call.respond( + HttpStatusCode.BadRequest, + ApiResponse.error("CREATION_FAILED", response.errors.joinToString()) + ) + } + } } + } + + private fun Platz.toDto() = PlatzDto( + platzId = id.toString(), + turnierId = turnierId.toString(), + name = name, + dimension = dimension, + boden = boden, + typ = typ.name, + istAktiv = istAktiv, + sortierReihenfolge = sortierReihenfolge, + createdAt = createdAt.toString(), + updatedAt = updatedAt.toString() + ) } diff --git a/backend/services/masterdata/masterdata-common/build.gradle.kts b/backend/services/masterdata/masterdata-common/build.gradle.kts index 1660e8ba..dc4d791c 100644 --- a/backend/services/masterdata/masterdata-common/build.gradle.kts +++ b/backend/services/masterdata/masterdata-common/build.gradle.kts @@ -3,8 +3,9 @@ plugins { } dependencies { - implementation(projects.masterdata.masterdataDomain) + implementation(projects.backend.services.masterdata.masterdataDomain) implementation(projects.core.coreDomain) implementation(projects.core.coreUtils) + implementation(libs.kotlinx.datetime) testImplementation(projects.platform.platformTesting) } 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 fc8d477d..6b90d5df 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 @@ -7,7 +7,7 @@ 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 kotlinx.datetime.Clock +import kotlin.time.Clock /** * Use case for creating and updating age class information. @@ -209,7 +209,7 @@ class CreateAltersklasseUseCase( } /** - * Validates a create age class request. + * Validates a creation age class request. */ private fun validateCreateRequest(request: CreateAltersklasseRequest): ValidationResult { val errors = mutableListOf() 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 8b5fb74f..43c70d14 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 @@ -6,7 +6,7 @@ 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 kotlinx.datetime.Clock +import kotlin.time.Clock /** * Use case for creating and updating federal state information. 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 34100d3b..e6e44efc 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 @@ -6,7 +6,7 @@ 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 kotlinx.datetime.Clock +import kotlin.time.Clock /** * Use case for creating and updating country information. 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 ead10eab..895d91fe 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 @@ -7,7 +7,7 @@ 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 kotlinx.datetime.Clock +import kotlin.time.Clock /** * Use case for creating and updating venue/arena information. 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 5797a564..c6d968d5 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 @@ -22,10 +22,13 @@ class GetAltersklasseUseCase( * @param altersklasseId The unique identifier of the age class * @return The age class if found, null otherwise */ - suspend fun getById(altersklasseId: Uuid): AltersklasseDefinition? { - return altersklasseRepository.findById(altersklasseId) + suspend fun getById(altersklasseId: Uuid): GetAltersklasseResponse { + val altersklasse = altersklasseRepository.findById(altersklasseId) + return GetAltersklasseResponse(altersklasse = altersklasse) } + data class GetAltersklasseResponse(val altersklasse: AltersklasseDefinition?) + /** * Retrieves an age class by its code. * @@ -57,13 +60,16 @@ class GetAltersklasseUseCase( * @param geschlechtFilter Optional filter by gender ('M', 'W') * @return List of active age classes */ - suspend fun getAllActive(sparteFilter: SparteE? = null, geschlechtFilter: Char? = null): List { + suspend fun getAllActive(sparteFilter: SparteE? = null, geschlechtFilter: Char? = null): GetAltersklassenResponse { geschlechtFilter?.let { gender -> require(gender == 'M' || gender == 'W') { "Gender filter must be 'M' or 'W'" } } - return altersklasseRepository.findAllActive(sparteFilter, geschlechtFilter) + val altersklassen = altersklasseRepository.findAllActive(sparteFilter, geschlechtFilter) + return GetAltersklassenResponse(altersklassen = altersklassen) } + data class GetAltersklassenResponse(val altersklassen: List) + /** * Finds age classes applicable for a specific age. * diff --git a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetBundeslandUseCase.kt b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetBundeslandUseCase.kt index 55a2e20e..885885b1 100644 --- a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetBundeslandUseCase.kt +++ b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetBundeslandUseCase.kt @@ -21,10 +21,13 @@ class GetBundeslandUseCase( * @param bundeslandId The unique identifier of the federal state * @return The federal state if found, null otherwise */ - suspend fun getById(bundeslandId: Uuid): BundeslandDefinition? { - return bundeslandRepository.findById(bundeslandId) + suspend fun getById(bundeslandId: Uuid): GetBundeslandResponse { + val bundesland = bundeslandRepository.findById(bundeslandId) + return GetBundeslandResponse(bundesland = bundesland) } + data class GetBundeslandResponse(val bundesland: BundeslandDefinition?) + /** * Retrieves a federal state by its OEPS code for a specific country. * @@ -56,22 +59,13 @@ class GetBundeslandUseCase( * @param orderBySortierung Whether to order by sortierReihenfolge field (default: true) * @return List of federal states for the country */ - suspend fun getByCountry(landId: Uuid, activeOnly: Boolean = true, orderBySortierung: Boolean = true): List { - return bundeslandRepository.findByCountry(landId, activeOnly, orderBySortierung) - } - - /** - * Searches for federal states by name (partial match). - * - * @param searchTerm The search term to match against federal state names - * @param landId Optional country ID to limit search - * @param limit Maximum number of results to return (default: 50) - * @return List of matching federal states - */ - suspend fun searchByName(searchTerm: String, landId: Uuid? = null, limit: Int = 50): List { - require(searchTerm.isNotBlank()) { "Search term cannot be blank" } - require(limit > 0) { "Limit must be positive" } - return bundeslandRepository.findByName(searchTerm.trim(), landId, limit) + suspend fun getByCountry( + landId: Uuid, + activeOnly: Boolean = true, + orderBySortierung: Boolean = true + ): GetBundeslaenderResponse { + val bundeslaender = bundeslandRepository.findByCountry(landId, activeOnly, orderBySortierung) + return GetBundeslaenderResponse(bundeslaender = bundeslaender) } /** @@ -80,10 +74,13 @@ class GetBundeslandUseCase( * @param orderBySortierung Whether to order by sortierReihenfolge field (default: true) * @return List of active federal states */ - suspend fun getAllActive(orderBySortierung: Boolean = true): List { - return bundeslandRepository.findAllActive(orderBySortierung) + suspend fun getAllActive(orderBySortierung: Boolean = true): GetBundeslaenderResponse { + val bundeslaender = bundeslandRepository.findAllActive(orderBySortierung) + return GetBundeslaenderResponse(bundeslaender = bundeslaender) } + data class GetBundeslaenderResponse(val bundeslaender: List) + /** * Checks if a federal state with the given OEPS code exists for a country. * diff --git a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetCountryUseCase.kt b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetCountryUseCase.kt index 10a233fe..5aceeb06 100644 --- a/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetCountryUseCase.kt +++ b/backend/services/masterdata/masterdata-common/src/main/kotlin/at/mocode/masterdata/application/usecase/GetCountryUseCase.kt @@ -21,10 +21,13 @@ class GetCountryUseCase( * @param countryId The unique identifier of the country * @return The country if found, null otherwise */ - suspend fun getById(countryId: Uuid): LandDefinition? { - return landRepository.findById(countryId) + suspend fun getById(countryId: Uuid): GetCountryResponse { + val country = landRepository.findById(countryId) + return GetCountryResponse(country = country) } + data class GetCountryResponse(val country: LandDefinition?) + /** * Retrieves a country by its ISO Alpha-2 code. * @@ -66,10 +69,13 @@ class GetCountryUseCase( * @param orderBySortierung Whether to order by sortierReihenfolge field (default: true) * @return List of active countries */ - suspend fun getAllActive(orderBySortierung: Boolean = true): List { - return landRepository.findAllActive(orderBySortierung) + suspend fun getAllActive(orderBySortierung: Boolean = true): GetCountriesResponse { + val countries = landRepository.findAllActive(orderBySortierung) + return GetCountriesResponse(countries = countries) } + data class GetCountriesResponse(val countries: List) + /** * Retrieves all EU member countries. * 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 20984763..af5c8ed0 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 @@ -22,10 +22,13 @@ class GetPlatzUseCase( * @param platzId The unique identifier of the venue * @return The venue if found, null otherwise */ - suspend fun getById(platzId: Uuid): Platz? { - return platzRepository.findById(platzId) + suspend fun getById(platzId: Uuid): GetPlatzResponse { + val platz = platzRepository.findById(platzId) + return GetPlatzResponse(platz = platz) } + data class GetPlatzResponse(val platz: Platz?) + /** * Retrieves all venues for a specific tournament. * @@ -34,10 +37,17 @@ class GetPlatzUseCase( * @param orderBySortierung Whether to order by sortierReihenfolge field (default: true) * @return List of venues for the tournament */ - suspend fun getByTournament(turnierId: Uuid, activeOnly: Boolean = true, orderBySortierung: Boolean = true): List { - return platzRepository.findByTournament(turnierId, activeOnly, orderBySortierung) + suspend fun getByTournament( + turnierId: Uuid, + activeOnly: Boolean = true, + orderBySortierung: Boolean = true + ): GetPlaetzeResponse { + val plaetze = platzRepository.findByTournament(turnierId, activeOnly, orderBySortierung) + return GetPlaetzeResponse(plaetze = plaetze) } + data class GetPlaetzeResponse(val plaetze: List) + /** * Searches for venues by name (partial match). * diff --git a/backend/services/masterdata/masterdata-domain/build.gradle.kts b/backend/services/masterdata/masterdata-domain/build.gradle.kts index 89790406..f1be904c 100644 --- a/backend/services/masterdata/masterdata-domain/build.gradle.kts +++ b/backend/services/masterdata/masterdata-domain/build.gradle.kts @@ -1,6 +1,6 @@ plugins { - alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) } kotlin { @@ -20,6 +20,10 @@ kotlin { val commonTest by getting { dependencies { implementation(kotlin("test")) + } + } + val jvmTest by getting { + dependencies { implementation(projects.platform.platformTesting) } } diff --git a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/model/AltersklasseDefinition.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/AltersklasseDefinition.kt similarity index 86% rename from backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/model/AltersklasseDefinition.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/AltersklasseDefinition.kt index a55b5948..f2e2724f 100644 --- a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/model/AltersklasseDefinition.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/AltersklasseDefinition.kt @@ -1,13 +1,12 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) package at.mocode.masterdata.domain.model -import at.mocode.core.domain.model.SparteE // Optional, falls Altersklassen stark spartenspezifisch sind -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.model.SparteE +import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.UuidSerializer -import kotlin.uuid.Uuid -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +import kotlin.time.Instant +import kotlin.uuid.Uuid /** * Definiert eine spezifische Altersklasse für Teilnehmer (Reiter, Fahrer, Voltigierer) @@ -52,8 +51,8 @@ data class AltersklasseDefinition( var istAktiv: Boolean = true, - @Serializable(with = KotlinInstantSerializer::class) - val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) - var updatedAt: Instant = Clock.System.now() + @Serializable(with = InstantSerializer::class) + val createdAt: Instant, + @Serializable(with = InstantSerializer::class) + var updatedAt: Instant ) diff --git a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/model/BundeslandDefinition.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/BundeslandDefinition.kt similarity index 87% rename from backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/model/BundeslandDefinition.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/BundeslandDefinition.kt index 35a2b0bb..cf3664f2 100644 --- a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/model/BundeslandDefinition.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/BundeslandDefinition.kt @@ -1,12 +1,11 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) package at.mocode.masterdata.domain.model -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.UuidSerializer -import kotlin.uuid.Uuid -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +import kotlin.time.Instant +import kotlin.uuid.Uuid /** * Definiert ein Bundesland oder eine vergleichbare subnationale Verwaltungseinheit. @@ -44,8 +43,8 @@ data class BundeslandDefinition( var istAktiv: Boolean = true, var sortierReihenfolge: Int? = null, - @Serializable(with = KotlinInstantSerializer::class) - val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) - var updatedAt: Instant = Clock.System.now() + @Serializable(with = InstantSerializer::class) + val createdAt: Instant, + @Serializable(with = InstantSerializer::class) + var updatedAt: Instant ) diff --git a/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/model/DomFunktionaer.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomFunktionaer.kt similarity index 95% rename from backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/model/DomFunktionaer.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomFunktionaer.kt index e5548224..ea0fb884 100644 --- a/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/model/DomFunktionaer.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomFunktionaer.kt @@ -1,12 +1,12 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.officials.domain.model +package at.mocode.masterdata.domain.model import at.mocode.core.domain.model.DatenQuelleE import at.mocode.core.domain.model.FunktionaerRolleE import at.mocode.core.domain.model.RichterQualifikationE import at.mocode.core.domain.model.SparteE -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable @@ -70,9 +70,9 @@ data class DomFunktionaer( var datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS, // Audit - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) var updatedAt: Instant = Clock.System.now() ) { /** diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomPferd.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomPferd.kt new file mode 100644 index 00000000..b1a3505d --- /dev/null +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomPferd.kt @@ -0,0 +1,175 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.domain.model + +import at.mocode.core.domain.model.DatenQuelleE +import at.mocode.core.domain.model.PferdeGeschlechtE +import at.mocode.core.domain.serialization.InstantSerializer +import at.mocode.core.domain.serialization.UuidSerializer +import kotlinx.datetime.LocalDate +import kotlinx.datetime.number +import kotlinx.datetime.todayIn +import kotlinx.serialization.Serializable +import kotlin.time.Clock +import kotlin.time.Instant +import kotlin.uuid.Uuid + +/** + * Domain model representing a horse in the registry system. + * + * This entity contains all essential information about a horse including + * identification, ownership, breeding information, and administrative data. + * It serves as the core aggregate root for the horse-registry bounded context. + * + * @property pferdId Unique internal identifier for this horse (UUID). + * @property pferdeName Name of the horse. + * @property geschlecht Gender of the horse (Hengst, Stute, Wallach). + * @property geburtsdatum Birthdate of the horse. + * @property rasse Breed of the horse. + * @property farbe Color/coat of the horse. + * @property besitzerId ID of the current owner (Person from member-management context). + * @property verantwortlichePersonId ID of the responsible person (trainer, rider, etc.). + * @property zuechterName Name of the breeder. + * @property zuchtbuchNummer Studbook number if registered. + * @property lebensnummer Life number (unique identification number). + * @property chipNummer Microchip number for identification. + * @property passNummer Passport number. + * @property oepsNummer OEPS (Austrian Equestrian Federation) number. + * @property feiNummer FEI (International Equestrian Federation) number. + * @property vaterName Name of the sire (father). + * @property mutterName Name of the dam (mother). + * @property mutterVaterName Name of the maternal grandsire. + * @property stockmass Height of the horse in cm. + * @property istAktiv Whether the horse is currently active in the system. + * @property bemerkungen Additional notes or comments. + * @property datenQuelle Source of the data (manual entry, import, etc.). + * @property createdAt Timestamp when this record was created. + * @property updatedAt Timestamp when this record was last updated. + */ +@Serializable +data class DomPferd( + @Serializable(with = UuidSerializer::class) + val pferdId: Uuid = Uuid.random(), + + // Basic Information + var pferdeName: String, + var geschlecht: PferdeGeschlechtE, + var geburtsdatum: LocalDate? = null, + var rasse: String? = null, + var farbe: String? = null, + + // Ownership and Responsibility + @Serializable(with = UuidSerializer::class) + var besitzerId: Uuid? = null, + @Serializable(with = UuidSerializer::class) + var verantwortlichePersonId: Uuid? = null, + + // Breeding Information + var zuechterName: String? = null, + var zuchtbuchNummer: String? = null, + + // Identification Numbers + var lebensnummer: String? = null, + var chipNummer: String? = null, + var passNummer: String? = null, + var oepsNummer: String? = null, + var feiNummer: String? = null, + + // Pedigree Information + var vaterName: String? = null, + var mutterName: String? = null, + var mutterVaterName: String? = null, + + // Physical Characteristics + var stockmass: Int? = null, // Height in cm + + // Status and Administrative + var istAktiv: Boolean = true, + var bemerkungen: String? = null, + var datenQuelle: DatenQuelleE = DatenQuelleE.MANUELL, + + // Audit Fields + @Serializable(with = InstantSerializer::class) + val createdAt: Instant = Clock.System.now(), + @Serializable(with = InstantSerializer::class) + var updatedAt: Instant = Clock.System.now() +) { + /** + * Returns the display name for the horse, combining name and birth year if available. + */ + fun getDisplayName(): String { + return geburtsdatum?.let { birthDate -> + "$pferdeName (${birthDate.year})" + } ?: pferdeName + } + + /** + * Checks if the horse has complete identification information. + */ + fun hasCompleteIdentification(): Boolean { + return !lebensnummer.isNullOrBlank() || + !chipNummer.isNullOrBlank() || + !passNummer.isNullOrBlank() + } + + /** + * Checks if the horse is registered with OEPS. + */ + fun isOepsRegistered(): Boolean { + return !oepsNummer.isNullOrBlank() + } + + /** + * Checks if the horse is registered with FEI. + */ + fun isFeiRegistered(): Boolean { + return !feiNummer.isNullOrBlank() + } + + /** + * Returns the age of the horse in years, or null if birth date is unknown. + */ + fun getAge(): Int? { + return geburtsdatum?.let { birthDate -> + val today = Clock.System.todayIn(kotlinx.datetime.TimeZone.currentSystemDefault()) + var age = today.year - birthDate.year + + // Check if a birthday has occurred this year + if (today.month.number < birthDate.month.number || + (today.month.number == birthDate.month.number && today.day < birthDate.day) + ) { + age-- + } + + age + } + } + + /** + * Validates that required fields are present for horse registration. + */ + fun validateForRegistration(): List { + val errors = mutableListOf() + + if (pferdeName.isBlank()) { + errors.add("Horse name is required") + } + + if (!hasCompleteIdentification()) { + errors.add("At least one identification number (life number, chip number, or passport number) is required") + } + + if (besitzerId == null) { + errors.add("Owner is required") + } + + return errors + } + + /** + * Creates a copy of this horse with an updated timestamp. + */ + fun withUpdatedTimestamp(): DomPferd { + return this.copy(updatedAt = Clock.System.now()) + } +} diff --git a/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/model/DomReiter.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomReiter.kt similarity index 93% rename from backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/model/DomReiter.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomReiter.kt index b83a645d..b147e68e 100644 --- a/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/model/DomReiter.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomReiter.kt @@ -1,12 +1,12 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.persons.domain.model +package at.mocode.masterdata.domain.model import at.mocode.core.domain.model.DatenQuelleE import at.mocode.core.domain.model.LizenzKlasseE import at.mocode.core.domain.model.SparteE -import at.mocode.core.domain.serialization.KotlinInstantSerializer -import at.mocode.core.domain.serialization.KotlinLocalDateSerializer +import at.mocode.core.domain.serialization.InstantSerializer +import at.mocode.core.domain.serialization.LocalDateSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable @@ -74,7 +74,7 @@ data class DomReiter( // Personal Data (denormalized from DomPerson for performance) val nachname: String, val vorname: String, - @Serializable(with = KotlinLocalDateSerializer::class) + @Serializable(with = LocalDateSerializer::class) val geburtsdatum: LocalDate? = null, // Club Affiliation @@ -87,9 +87,9 @@ data class DomReiter( val datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS, // Audit Fields - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) var updatedAt: Instant = Clock.System.now() ) { /** diff --git a/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/model/DomVerein.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomVerein.kt similarity index 94% rename from backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/model/DomVerein.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomVerein.kt index 32497bd4..ecefa688 100644 --- a/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/model/DomVerein.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/DomVerein.kt @@ -1,9 +1,9 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.clubs.domain.model +package at.mocode.masterdata.domain.model import at.mocode.core.domain.model.DatenQuelleE -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.UuidSerializer import kotlinx.serialization.Serializable import kotlin.time.Clock @@ -71,9 +71,9 @@ data class DomVerein( var datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS, // Audit - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) + @Serializable(with = InstantSerializer::class) var updatedAt: Instant = Clock.System.now() ) { /** diff --git a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/model/LandDefinition.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/LandDefinition.kt similarity index 87% rename from backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/model/LandDefinition.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/LandDefinition.kt index 67bb9efc..cf97cc3b 100644 --- a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/model/LandDefinition.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/LandDefinition.kt @@ -1,12 +1,11 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) package at.mocode.masterdata.domain.model -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.UuidSerializer -import kotlin.uuid.Uuid -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +import kotlin.time.Instant +import kotlin.uuid.Uuid /** * Definiert ein Land/eine Nation mit seinen offiziellen Codes und Bezeichnungen. @@ -44,8 +43,8 @@ data class LandDefinition( var istAktiv: Boolean = true, var sortierReihenfolge: Int? = null, - @Serializable(with = KotlinInstantSerializer::class) - val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) - var updatedAt: Instant = Clock.System.now() + @Serializable(with = InstantSerializer::class) + val createdAt: Instant, + @Serializable(with = InstantSerializer::class) + var updatedAt: Instant ) diff --git a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/model/Platz.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/Platz.kt similarity index 82% rename from backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/model/Platz.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/Platz.kt index e7d3a425..f011e8b5 100644 --- a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/model/Platz.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/model/Platz.kt @@ -2,12 +2,11 @@ package at.mocode.masterdata.domain.model import at.mocode.core.domain.model.PlatzTypE -import at.mocode.core.domain.serialization.KotlinInstantSerializer +import at.mocode.core.domain.serialization.InstantSerializer import at.mocode.core.domain.serialization.UuidSerializer -import kotlin.uuid.Uuid -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +import kotlin.time.Instant +import kotlin.uuid.Uuid /** * Definiert einen Turnierplatz oder eine Wettkampfstätte. @@ -41,8 +40,8 @@ data class Platz( var istAktiv: Boolean = true, var sortierReihenfolge: Int? = null, - @Serializable(with = KotlinInstantSerializer::class) - val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) - var updatedAt: Instant = Clock.System.now() + @Serializable(with = InstantSerializer::class) + val createdAt: Instant, + @Serializable(with = InstantSerializer::class) + var updatedAt: Instant ) diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/AltersklasseRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/AltersklasseRepository.kt new file mode 100644 index 00000000..b7dcd1fc --- /dev/null +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/AltersklasseRepository.kt @@ -0,0 +1,144 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.domain.repository + +import at.mocode.core.domain.model.SparteE +import at.mocode.masterdata.domain.model.AltersklasseDefinition +import kotlin.uuid.Uuid + +/** + * Repository interface for AltersklasseDefinition (Age Class) domain operations. + * + * This interface defines the contract for age class data access operations + * without depending on specific implementation details (database, etc.). + * Following the hexagonal architecture pattern, this interface belongs + * to the domain layer and will be implemented in the infrastructure layer. + */ +interface AltersklasseRepository { + + /** + * Finds an age class by its unique ID. + * + * @param id The unique identifier of the age class + * @return The age class if found, null otherwise + */ + suspend fun findById(id: Uuid): AltersklasseDefinition? + + /** + * Finds an age class by its code. + * + * @param altersklasseCode The age class code (e.g., "JGD_U16", "JUN_U18") + * @return The age class if found, null otherwise + */ + suspend fun findByCode(altersklasseCode: String): AltersklasseDefinition? + + /** + * Finds age classes by name (partial match). + * + * @param searchTerm The search term to match against age class names + * @param limit Maximum number of results to return + * @return List of matching age classes + */ + suspend fun findByName(searchTerm: String, limit: Int = 50): List + + /** + * Finds all active age classes. + * + * @param sparteFilter Optional filter by sport type + * @param geschlechtFilter Optional filter by gender ('M', 'W') + * @return List of active age classes + */ + suspend fun findAllActive(sparteFilter: SparteE? = null, geschlechtFilter: Char? = null): List + + /** + * Finds age classes applicable for a specific age. + * + * @param age The age to check + * @param sparteFilter Optional filter by sport type + * @param geschlechtFilter Optional filter by gender ('M', 'W') + * @return List of applicable age classes + */ + suspend fun findApplicableForAge( + age: Int, + sparteFilter: SparteE? = null, + geschlechtFilter: Char? = null + ): List + + /** + * Finds age classes by sport type. + * + * @param sparte The sport type + * @param activeOnly Whether to return only active age classes + * @return List of age classes for the sport type + */ + suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean = true): List + + /** + * Finds age classes by gender filter. + * + * @param geschlecht The gender ('M', 'W') + * @param activeOnly Whether to return only active age classes + * @return List of age classes for the gender + */ + suspend fun findByGeschlecht(geschlecht: Char, activeOnly: Boolean = true): List + + /** + * Finds age classes by age range. + * + * @param minAge Minimum age (inclusive) + * @param maxAge Maximum age (inclusive) + * @param activeOnly Whether to return only active age classes + * @return List of age classes within the age range + */ + suspend fun findByAgeRange(minAge: Int?, maxAge: Int?, activeOnly: Boolean = true): List + + /** + * Finds age classes by OETO rule reference. + * + * @param oetoRegelReferenzId The OETO rule reference ID + * @return List of age classes linked to the rule + */ + suspend fun findByOetoRegelReferenz(oetoRegelReferenzId: Uuid): List + + /** + * Saves an age class (create or update). + * + * @param altersklasse The age class to save + * @return The saved age class with updated timestamps + */ + suspend fun save(altersklasse: AltersklasseDefinition): AltersklasseDefinition + + /** + * Deletes an age class by ID. + * + * @param id The unique identifier of the age class to delete + * @return true if the age class was deleted, false if not found + */ + suspend fun delete(id: Uuid): Boolean + + /** + * Checks if an age class with the given code exists. + * + * @param altersklasseCode The age class code to check + * @return true if an age class with this code exists, false otherwise + */ + suspend fun existsByCode(altersklasseCode: String): Boolean + + /** + * Counts the total number of active age classes. + * + * @param sparteFilter Optional filter by sport type + * @return The total count of active age classes + */ + suspend fun countActive(sparteFilter: SparteE? = null): Long + + /** + * Validates if a person with given age and gender can participate in an age class. + * + * @param altersklasseId The age class ID + * @param age The person's age + * @param geschlecht The person's gender ('M', 'W') + * @return true if the person can participate, false otherwise + */ + suspend fun isEligible(altersklasseId: Uuid, age: Int, geschlecht: Char): Boolean +} diff --git a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/repository/BundeslandRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/BundeslandRepository.kt similarity index 100% rename from backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/repository/BundeslandRepository.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/BundeslandRepository.kt diff --git a/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/repository/FunktionaerRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/FunktionaerRepository.kt similarity index 96% rename from backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/repository/FunktionaerRepository.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/FunktionaerRepository.kt index 7fe82228..88840f57 100644 --- a/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/repository/FunktionaerRepository.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/FunktionaerRepository.kt @@ -1,11 +1,11 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.officials.domain.repository +package at.mocode.masterdata.domain.repository import at.mocode.core.domain.model.FunktionaerRolleE import at.mocode.core.domain.model.RichterQualifikationE import at.mocode.core.domain.model.SparteE -import at.mocode.officials.domain.model.DomFunktionaer +import at.mocode.masterdata.domain.model.DomFunktionaer import kotlin.uuid.Uuid /** diff --git a/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/HorseRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/HorseRepository.kt new file mode 100644 index 00000000..fa50dbfd --- /dev/null +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/HorseRepository.kt @@ -0,0 +1,248 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.domain.repository + +import at.mocode.masterdata.domain.model.DomPferd +import at.mocode.core.domain.model.PferdeGeschlechtE +import kotlin.uuid.Uuid + +/** + * Repository interface for DomPferd (Horse) domain operations. + * + * This interface defines the contract for horse data access operations + * without depending on specific implementation details (database, etc.). + * Following the hexagonal architecture pattern, this interface belongs + * to the domain layer and will be implemented in the infrastructure layer. + */ +interface HorseRepository { + + /** + * Finds a horse by its unique ID. + * + * @param id The unique identifier of the horse + * @return The horse if found, null otherwise + */ + suspend fun findById(id: Uuid): DomPferd? + + /** + * Finds a horse by its life number (Lebensnummer). + * + * @param lebensnummer The life number to search for + * @return The horse if found, null otherwise + */ + suspend fun findByLebensnummer(lebensnummer: String): DomPferd? + + /** + * Finds a horse by its chip number. + * + * @param chipNummer The chip number to search for + * @return The horse if found, null otherwise + */ + suspend fun findByChipNummer(chipNummer: String): DomPferd? + + /** + * Finds a horse by its passport number. + * + * @param passNummer The passport number to search for + * @return The horse if found, null otherwise + */ + suspend fun findByPassNummer(passNummer: String): DomPferd? + + /** + * Finds a horse by its OEPS number. + * + * @param oepsNummer The OEPS number to search for + * @return The horse if found, null otherwise + */ + suspend fun findByOepsNummer(oepsNummer: String): DomPferd? + + /** + * Finds a horse by its FEI number. + * + * @param feiNummer The FEI number to search for + * @return The horse if found, null otherwise + */ + suspend fun findByFeiNummer(feiNummer: String): DomPferd? + + /** + * Finds horses by name (partial match). + * + * @param searchTerm The search term to match against horse names + * @param limit Maximum number of results to return + * @return List of matching horses + */ + suspend fun findByName(searchTerm: String, limit: Int = 50): List + + /** + * Finds all horses owned by a specific person. + * + * @param ownerId The ID of the owner (from member-management context) + * @param activeOnly Whether to return only active horses + * @return List of horses owned by the person + */ + suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): List + + /** + * Finds all horses for which a person is responsible. + * + * @param responsiblePersonId The ID of the responsible person + * @param activeOnly Whether to return only active horses + * @return List of horses for which the person is responsible + */ + suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean = true): List + + /** + * Finds horses by gender. + * + * @param geschlecht The gender to filter by + * @param activeOnly Whether to return only active horses + * @param limit Maximum number of results to return + * @return List of horses with the specified gender + */ + suspend fun findByGeschlecht( + geschlecht: PferdeGeschlechtE, + activeOnly: Boolean = true, + limit: Int = 100 + ): List + + /** + * Finds horses by breed. + * + * @param rasse The breed to filter by + * @param activeOnly Whether to return only active horses + * @param limit Maximum number of results to return + * @return List of horses of the specified breed + */ + suspend fun findByRasse(rasse: String, activeOnly: Boolean = true, limit: Int = 100): List + + /** + * Finds horses by birth year. + * + * @param birthYear The birth year to filter by + * @param activeOnly Whether to return only active horses + * @return List of horses born in the specified year + */ + suspend fun findByBirthYear(birthYear: Int, activeOnly: Boolean = true): List + + /** + * Finds horses by birth year range. + * + * @param fromYear The start year (inclusive) + * @param toYear The end year (inclusive) + * @param activeOnly Whether to return only active horses + * @return List of horses born within the specified year range + */ + suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean = true): List + + /** + * Finds all active horses. + * + * @param limit Maximum number of results to return + * @return List of active horses + */ + suspend fun findAllActive(limit: Int = 1000): List + + /** + * Finds horses with OEPS registration. + * + * @param activeOnly Whether to return only active horses + * @return List of OEPS registered horses + */ + suspend fun findOepsRegistered(activeOnly: Boolean = true): List + + /** + * Finds horses with FEI registration. + * + * @param activeOnly Whether to return only active horses + * @return List of FEI registered horses + */ + suspend fun findFeiRegistered(activeOnly: Boolean = true): List + + /** + * Saves a horse (create or update). + * + * @param horse The horse to save + * @return The saved horse with updated timestamps + */ + suspend fun save(horse: DomPferd): DomPferd + + /** + * Deletes a horse by ID. + * + * @param id The unique identifier of the horse to delete + * @return true if the horse was deleted, false if not found + */ + suspend fun delete(id: Uuid): Boolean + + /** + * Checks if a horse with the given life number exists. + * + * @param lebensnummer The life number to check + * @return true if a horse with this life number exists, false otherwise + */ + suspend fun existsByLebensnummer(lebensnummer: String): Boolean + + /** + * Checks if a horse with the given chip number exists. + * + * @param chipNummer The chip number to check + * @return true if a horse with this chip number exists, false otherwise + */ + suspend fun existsByChipNummer(chipNummer: String): Boolean + + /** + * Checks if a horse with the given passport number exists. + * + * @param passNummer The passport number to check + * @return true if a horse with this passport number exists, false otherwise + */ + suspend fun existsByPassNummer(passNummer: String): Boolean + + /** + * Checks if a horse with the given OEPS number exists. + * + * @param oepsNummer The OEPS number to check + * @return true if a horse with this OEPS number exists, false otherwise + */ + suspend fun existsByOepsNummer(oepsNummer: String): Boolean + + /** + * Checks if a horse with the given FEI number exists. + * + * @param feiNummer The FEI number to check + * @return true if a horse with this FEI number exists, false otherwise + */ + suspend fun existsByFeiNummer(feiNummer: String): Boolean + + /** + * Counts the total number of active horses. + * + * @return The total count of active horses + */ + suspend fun countActive(): Long + + /** + * Counts horses by owner. + * + * @param ownerId The ID of the owner + * @param activeOnly Whether to count only active horses + * @return The count of horses owned by the person + */ + suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean = true): Long + + /** + * Counts horses with OEPS registration. + * + * @param activeOnly Whether to count only active horses + * @return The count of OEPS registered horses + */ + suspend fun countOepsRegistered(activeOnly: Boolean = true): Long + + /** + * Counts horses with FEI registration. + * + * @param activeOnly Whether to count only active horses + * @return The count of FEI registered horses + */ + suspend fun countFeiRegistered(activeOnly: Boolean = true): Long +} diff --git a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/repository/LandRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/LandRepository.kt similarity index 100% rename from backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/repository/LandRepository.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/LandRepository.kt diff --git a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/repository/PlatzRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/PlatzRepository.kt similarity index 100% rename from backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/repository/PlatzRepository.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/PlatzRepository.kt diff --git a/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/repository/ReiterRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/ReiterRepository.kt similarity index 96% rename from backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/repository/ReiterRepository.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/ReiterRepository.kt index 46a12ff6..ce5b79e9 100644 --- a/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/repository/ReiterRepository.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/ReiterRepository.kt @@ -1,10 +1,10 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.persons.domain.repository +package at.mocode.masterdata.domain.repository import at.mocode.core.domain.model.LizenzKlasseE import at.mocode.core.domain.model.SparteE -import at.mocode.persons.domain.model.DomReiter +import at.mocode.masterdata.domain.model.DomReiter import kotlin.uuid.Uuid /** diff --git a/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/repository/VereinRepository.kt b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/VereinRepository.kt similarity index 91% rename from backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/repository/VereinRepository.kt rename to backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/VereinRepository.kt index 73b3e95c..51b33185 100644 --- a/backend/services/clubs/clubs-domain/src/main/kotlin/at/mocode/clubs/domain/repository/VereinRepository.kt +++ b/backend/services/masterdata/masterdata-domain/src/commonMain/kotlin/at/mocode/masterdata/domain/repository/VereinRepository.kt @@ -1,15 +1,15 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.clubs.domain.repository +package at.mocode.masterdata.domain.repository -import at.mocode.clubs.domain.model.DomVerein +import at.mocode.masterdata.domain.model.DomVerein import kotlin.uuid.Uuid /** * Repository-Interface für DomVerein (Verein) Domain-Operationen. * * Definiert den Vertrag für Datenzugriffs-Operationen ohne Abhängigkeit - * von konkreten Implementierungsdetails (Datenbank, etc.). + * von konkreten Implementierungsdetails (Datenbank etc.). */ interface VereinRepository { diff --git a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/repository/AltersklasseRepository.kt b/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/repository/AltersklasseRepository.kt deleted file mode 100644 index a18ec34d..00000000 --- a/backend/services/masterdata/masterdata-domain/src/main/kotlin/at/mocode/masterdata/domain/repository/AltersklasseRepository.kt +++ /dev/null @@ -1,139 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) -package at.mocode.masterdata.domain.repository - -import at.mocode.core.domain.model.SparteE -import at.mocode.masterdata.domain.model.AltersklasseDefinition -import kotlin.uuid.Uuid - -/** - * Repository interface for AltersklasseDefinition (Age Class) domain operations. - * - * This interface defines the contract for age class data access operations - * without depending on specific implementation details (database, etc.). - * Following the hexagonal architecture pattern, this interface belongs - * to the domain layer and will be implemented in the infrastructure layer. - */ -interface AltersklasseRepository { - - /** - * Finds an age class by its unique ID. - * - * @param id The unique identifier of the age class - * @return The age class if found, null otherwise - */ - suspend fun findById(id: Uuid): AltersklasseDefinition? - - /** - * Finds an age class by its code. - * - * @param altersklasseCode The age class code (e.g., "JGD_U16", "JUN_U18") - * @return The age class if found, null otherwise - */ - suspend fun findByCode(altersklasseCode: String): AltersklasseDefinition? - - /** - * Finds age classes by name (partial match). - * - * @param searchTerm The search term to match against age class names - * @param limit Maximum number of results to return - * @return List of matching age classes - */ - suspend fun findByName(searchTerm: String, limit: Int = 50): List - - /** - * Finds all active age classes. - * - * @param sparteFilter Optional filter by sport type - * @param geschlechtFilter Optional filter by gender ('M', 'W') - * @return List of active age classes - */ - suspend fun findAllActive(sparteFilter: SparteE? = null, geschlechtFilter: Char? = null): List - - /** - * Finds age classes applicable for a specific age. - * - * @param age The age to check - * @param sparteFilter Optional filter by sport type - * @param geschlechtFilter Optional filter by gender ('M', 'W') - * @return List of applicable age classes - */ - suspend fun findApplicableForAge(age: Int, sparteFilter: SparteE? = null, geschlechtFilter: Char? = null): List - - /** - * Finds age classes by sport type. - * - * @param sparte The sport type - * @param activeOnly Whether to return only active age classes - * @return List of age classes for the sport type - */ - suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean = true): List - - /** - * Finds age classes by gender filter. - * - * @param geschlecht The gender ('M', 'W') - * @param activeOnly Whether to return only active age classes - * @return List of age classes for the gender - */ - suspend fun findByGeschlecht(geschlecht: Char, activeOnly: Boolean = true): List - - /** - * Finds age classes by age range. - * - * @param minAge Minimum age (inclusive) - * @param maxAge Maximum age (inclusive) - * @param activeOnly Whether to return only active age classes - * @return List of age classes within the age range - */ - suspend fun findByAgeRange(minAge: Int?, maxAge: Int?, activeOnly: Boolean = true): List - - /** - * Finds age classes by OETO rule reference. - * - * @param oetoRegelReferenzId The OETO rule reference ID - * @return List of age classes linked to the rule - */ - suspend fun findByOetoRegelReferenz(oetoRegelReferenzId: Uuid): List - - /** - * Saves an age class (create or update). - * - * @param altersklasse The age class to save - * @return The saved age class with updated timestamps - */ - suspend fun save(altersklasse: AltersklasseDefinition): AltersklasseDefinition - - /** - * Deletes an age class by ID. - * - * @param id The unique identifier of the age class to delete - * @return true if the age class was deleted, false if not found - */ - suspend fun delete(id: Uuid): Boolean - - /** - * Checks if an age class with the given code exists. - * - * @param altersklasseCode The age class code to check - * @return true if an age class with this code exists, false otherwise - */ - suspend fun existsByCode(altersklasseCode: String): Boolean - - /** - * Counts the total number of active age classes. - * - * @param sparteFilter Optional filter by sport type - * @return The total count of active age classes - */ - suspend fun countActive(sparteFilter: SparteE? = null): Long - - /** - * Validates if a person with given age and gender can participate in an age class. - * - * @param altersklasseId The age class ID - * @param age The person's age - * @param geschlecht The person's gender ('M', 'W') - * @return true if the person can participate, false otherwise - */ - suspend fun isEligible(altersklasseId: Uuid, age: Int, geschlecht: Char): Boolean -} diff --git a/backend/services/masterdata/masterdata-infrastructure/build.gradle.kts b/backend/services/masterdata/masterdata-infrastructure/build.gradle.kts index 3a007665..25be836f 100644 --- a/backend/services/masterdata/masterdata-infrastructure/build.gradle.kts +++ b/backend/services/masterdata/masterdata-infrastructure/build.gradle.kts @@ -1,27 +1,26 @@ plugins { - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.kotlin.spring) - - // KORREKTUR: Dieses Plugin ist entscheidend. Es schaltet den `springBoot`-Block - // und alle Spring-Boot-spezifischen Gradle-Tasks frei. - alias(libs.plugins.spring.boot) - - // Dependency Management für konsistente Spring-Versionen + kotlin("jvm") + alias(libs.plugins.spring.boot) apply false alias(libs.plugins.spring.dependencyManagement) + alias(libs.plugins.kotlinSpring) } dependencies { implementation(projects.platform.platformDependencies) - implementation(projects.masterdata.masterdataDomain) - implementation(projects.masterdata.masterdataApplication) + implementation(projects.backend.services.masterdata.masterdataDomain) implementation(projects.core.coreDomain) implementation(projects.core.coreUtils) - implementation(projects.infrastructure.cache.cacheApi) - implementation(projects.infrastructure.eventStore.eventStoreApi) - implementation(projects.infrastructure.messaging.messagingClient) + implementation(projects.backend.infrastructure.cache.cacheApi) + implementation(projects.backend.infrastructure.eventStore.eventStoreApi) + implementation(projects.backend.infrastructure.messaging.messagingClient) + + // Exposed + implementation(libs.exposed.core) + implementation(libs.exposed.dao) + implementation(libs.exposed.jdbc) + implementation(libs.exposed.kotlin.datetime) - implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.postgresql:postgresql") testImplementation(projects.platform.platformTesting) 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 1fb3465d..4c1bcffe 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 @@ -1,240 +1,220 @@ @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + package at.mocode.masterdata.infrastructure.persistence import at.mocode.core.domain.model.SparteE +import at.mocode.core.utils.database.DatabaseFactory import at.mocode.masterdata.domain.model.AltersklasseDefinition import at.mocode.masterdata.domain.repository.AltersklasseRepository -import at.mocode.core.utils.database.DatabaseFactory +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.* import kotlin.uuid.Uuid -import kotlinx.datetime.Clock -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq /** * Implementierung des AltersklasseRepository für die Datenbankzugriffe. - * - * Diese Implementierung verwendet Exposed SQL für den Datenbankzugriff - * und mappt zwischen der AltersklasseDefinition Domain-Entität und der AltersklasseTable. */ class AltersklasseRepositoryImpl : AltersklasseRepository { - /** - * Konvertiert eine Datenbankzeile in ein Domain-Objekt. - */ - private fun rowToAltersklasseDefinition(row: ResultRow): AltersklasseDefinition { - return AltersklasseDefinition( - altersklasseId = row[AltersklasseTable.id], - altersklasseCode = row[AltersklasseTable.altersklasseCode], - bezeichnung = row[AltersklasseTable.bezeichnung], - minAlter = row[AltersklasseTable.minAlter], - maxAlter = row[AltersklasseTable.maxAlter], - stichtagRegelText = row[AltersklasseTable.stichtagRegelText], - sparteFilter = row[AltersklasseTable.sparteFilter]?.let { SparteE.valueOf(it) }, - geschlechtFilter = row[AltersklasseTable.geschlechtFilter], - oetoRegelReferenzId = row[AltersklasseTable.oetoRegelReferenzId], - istAktiv = row[AltersklasseTable.istAktiv], - createdAt = row[AltersklasseTable.createdAt].toInstant(TimeZone.UTC), - updatedAt = row[AltersklasseTable.updatedAt].toInstant(TimeZone.UTC) - ) + private fun rowToAltersklasseDefinition(row: ResultRow): AltersklasseDefinition { + return AltersklasseDefinition( + altersklasseId = row[AltersklasseTable.id], + altersklasseCode = row[AltersklasseTable.altersklasseCode], + bezeichnung = row[AltersklasseTable.bezeichnung], + minAlter = row[AltersklasseTable.minAlter], + maxAlter = row[AltersklasseTable.maxAlter], + stichtagRegelText = row[AltersklasseTable.stichtagRegelText], + sparteFilter = row[AltersklasseTable.sparteFilter]?.let { SparteE.valueOf(it) }, + geschlechtFilter = row[AltersklasseTable.geschlechtFilter], + oetoRegelReferenzId = row[AltersklasseTable.oetoRegelReferenzId], + istAktiv = row[AltersklasseTable.istAktiv], + createdAt = row[AltersklasseTable.createdAt], + updatedAt = row[AltersklasseTable.updatedAt] + ) + } + + override suspend fun findById(id: Uuid): AltersklasseDefinition? = DatabaseFactory.dbQuery { + AltersklasseTable.selectAll().where { AltersklasseTable.id eq id } + .map(::rowToAltersklasseDefinition) + .singleOrNull() + } + + override suspend fun findByCode(altersklasseCode: String): AltersklasseDefinition? = DatabaseFactory.dbQuery { + AltersklasseTable.selectAll().where { AltersklasseTable.altersklasseCode eq altersklasseCode } + .map(::rowToAltersklasseDefinition) + .singleOrNull() + } + + override suspend fun findByName(searchTerm: String, limit: Int): List = + DatabaseFactory.dbQuery { + val pattern = "%$searchTerm%" + AltersklasseTable.selectAll().where { AltersklasseTable.bezeichnung like pattern } + .limit(limit) + .map(::rowToAltersklasseDefinition) } - override suspend fun findById(id: Uuid): AltersklasseDefinition? = DatabaseFactory.dbQuery { - AltersklasseTable.selectAll().where { AltersklasseTable.id eq id } - .map(::rowToAltersklasseDefinition) - .singleOrNull() - } + override suspend fun findAllActive(sparteFilter: SparteE?, geschlechtFilter: Char?): List = + DatabaseFactory.dbQuery { + val query = AltersklasseTable.selectAll().where { AltersklasseTable.istAktiv eq true } - override suspend fun findByCode(altersklasseCode: String): AltersklasseDefinition? = DatabaseFactory.dbQuery { - AltersklasseTable.selectAll().where { AltersklasseTable.altersklasseCode eq altersklasseCode } - .map(::rowToAltersklasseDefinition) - .singleOrNull() - } - - override suspend fun findByName(searchTerm: String, limit: Int): List = DatabaseFactory.dbQuery { - val pattern = "%$searchTerm%" - AltersklasseTable.selectAll().where { AltersklasseTable.bezeichnung like pattern } - .limit(limit) - .map(::rowToAltersklasseDefinition) - } - - override suspend fun findAllActive(sparteFilter: SparteE?, geschlechtFilter: Char?): List = DatabaseFactory.dbQuery { - val query = AltersklasseTable.selectAll().where { AltersklasseTable.istAktiv eq true } - - sparteFilter?.let { sparte -> - query.andWhere { - (AltersklasseTable.sparteFilter eq sparte.name) or (AltersklasseTable.sparteFilter.isNull()) - } - } - - geschlechtFilter?.let { geschlecht -> - query.andWhere { - (AltersklasseTable.geschlechtFilter eq geschlecht) or (AltersklasseTable.geschlechtFilter.isNull()) - } - } - - query.orderBy(AltersklasseTable.bezeichnung to SortOrder.ASC) - .map(::rowToAltersklasseDefinition) - } - - override suspend fun findApplicableForAge(age: Int, sparteFilter: SparteE?, geschlechtFilter: Char?): List = DatabaseFactory.dbQuery { - val query = AltersklasseTable.selectAll().where { AltersklasseTable.istAktiv eq true } - - // Age range filter + sparteFilter?.let { sparte -> query.andWhere { - (AltersklasseTable.minAlter.isNull() or (AltersklasseTable.minAlter lessEq age)) and - (AltersklasseTable.maxAlter.isNull() or (AltersklasseTable.maxAlter greaterEq age)) + (AltersklasseTable.sparteFilter eq sparte.name) or (AltersklasseTable.sparteFilter.isNull()) } + } - sparteFilter?.let { sparte -> - query.andWhere { - (AltersklasseTable.sparteFilter eq sparte.name) or (AltersklasseTable.sparteFilter.isNull()) - } + geschlechtFilter?.let { geschlecht -> + query.andWhere { + (AltersklasseTable.geschlechtFilter eq geschlecht) or (AltersklasseTable.geschlechtFilter.isNull()) } + } - geschlechtFilter?.let { geschlecht -> - query.andWhere { - (AltersklasseTable.geschlechtFilter eq geschlecht) or (AltersklasseTable.geschlechtFilter.isNull()) - } - } - - query.orderBy(AltersklasseTable.bezeichnung to SortOrder.ASC) - .map(::rowToAltersklasseDefinition) + query.orderBy(AltersklasseTable.bezeichnung to SortOrder.ASC) + .map(::rowToAltersklasseDefinition) } - override suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean): List = DatabaseFactory.dbQuery { - val query = AltersklasseTable.selectAll().where { - (AltersklasseTable.sparteFilter eq sparte.name) or (AltersklasseTable.sparteFilter.isNull()) - } + override suspend fun findApplicableForAge( + age: Int, + sparteFilter: SparteE?, + geschlechtFilter: Char? + ): List = DatabaseFactory.dbQuery { + val query = AltersklasseTable.selectAll().where { AltersklasseTable.istAktiv eq true } - if (activeOnly) { - query.andWhere { AltersklasseTable.istAktiv eq true } - } - - query.orderBy(AltersklasseTable.bezeichnung to SortOrder.ASC) - .map(::rowToAltersklasseDefinition) + query.andWhere { + (AltersklasseTable.minAlter.isNull() or (AltersklasseTable.minAlter lessEq age)) and + (AltersklasseTable.maxAlter.isNull() or (AltersklasseTable.maxAlter greaterEq age)) } - override suspend fun findByGeschlecht(geschlecht: Char, activeOnly: Boolean): List = DatabaseFactory.dbQuery { - val query = AltersklasseTable.selectAll().where { - (AltersklasseTable.geschlechtFilter eq geschlecht) or (AltersklasseTable.geschlechtFilter.isNull()) - } - - if (activeOnly) { - query.andWhere { AltersklasseTable.istAktiv eq true } - } - - query.orderBy(AltersklasseTable.bezeichnung to SortOrder.ASC) - .map(::rowToAltersklasseDefinition) + sparteFilter?.let { sparte -> + query.andWhere { + (AltersklasseTable.sparteFilter eq sparte.name) or (AltersklasseTable.sparteFilter.isNull()) + } } - override suspend fun findByAgeRange(minAge: Int?, maxAge: Int?, activeOnly: Boolean): List = DatabaseFactory.dbQuery { - val query = AltersklasseTable.selectAll() - - minAge?.let { min -> - query.andWhere { - (AltersklasseTable.maxAlter.isNull()) or (AltersklasseTable.maxAlter greaterEq min) - } - } - - maxAge?.let { max -> - query.andWhere { - (AltersklasseTable.minAlter.isNull()) or (AltersklasseTable.minAlter lessEq max) - } - } - - if (activeOnly) { - query.andWhere { AltersklasseTable.istAktiv eq true } - } - - query.orderBy(AltersklasseTable.bezeichnung to SortOrder.ASC) - .map(::rowToAltersklasseDefinition) + geschlechtFilter?.let { geschlecht -> + query.andWhere { + (AltersklasseTable.geschlechtFilter eq geschlecht) or (AltersklasseTable.geschlechtFilter.isNull()) + } } - override suspend fun findByOetoRegelReferenz(oetoRegelReferenzId: Uuid): List = DatabaseFactory.dbQuery { - AltersklasseTable.selectAll().where { AltersklasseTable.oetoRegelReferenzId eq oetoRegelReferenzId } - .orderBy(AltersklasseTable.bezeichnung to SortOrder.ASC) - .map(::rowToAltersklasseDefinition) + query.orderBy(AltersklasseTable.bezeichnung to SortOrder.ASC) + .map(::rowToAltersklasseDefinition) + } + + override suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean): List = + DatabaseFactory.dbQuery { + val query = AltersklasseTable.selectAll().where { + (AltersklasseTable.sparteFilter eq sparte.name) or (AltersklasseTable.sparteFilter.isNull()) + } + + if (activeOnly) { + query.andWhere { AltersklasseTable.istAktiv eq true } + } + + query.orderBy(AltersklasseTable.bezeichnung to SortOrder.ASC) + .map(::rowToAltersklasseDefinition) } - override suspend fun save(altersklasse: AltersklasseDefinition): AltersklasseDefinition = DatabaseFactory.dbQuery { - val now = Clock.System.now() - val existingAltersklasse = AltersklasseTable.selectAll().where { AltersklasseTable.id eq altersklasse.altersklasseId }.singleOrNull() + override suspend fun findByGeschlecht(geschlecht: Char, activeOnly: Boolean): List = + DatabaseFactory.dbQuery { + val query = AltersklasseTable.selectAll().where { + (AltersklasseTable.geschlechtFilter eq geschlecht) or (AltersklasseTable.geschlechtFilter.isNull()) + } - if (existingAltersklasse == null) { - // Insert a new age class - AltersklasseTable.insert { stmt -> - stmt[id] = altersklasse.altersklasseId - stmt[altersklasseCode] = altersklasse.altersklasseCode - stmt[bezeichnung] = altersklasse.bezeichnung - stmt[minAlter] = altersklasse.minAlter - stmt[maxAlter] = altersklasse.maxAlter - stmt[stichtagRegelText] = altersklasse.stichtagRegelText - stmt[sparteFilter] = altersklasse.sparteFilter?.name - stmt[geschlechtFilter] = altersklasse.geschlechtFilter - stmt[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId - stmt[istAktiv] = altersklasse.istAktiv - stmt[createdAt] = altersklasse.createdAt.toLocalDateTime(TimeZone.UTC) - stmt[updatedAt] = now.toLocalDateTime(TimeZone.UTC) - } - } else { - // Update existing age class - AltersklasseTable.update({ AltersklasseTable.id eq altersklasse.altersklasseId }) { stmt -> - stmt[altersklasseCode] = altersklasse.altersklasseCode - stmt[bezeichnung] = altersklasse.bezeichnung - stmt[minAlter] = altersklasse.minAlter - stmt[maxAlter] = altersklasse.maxAlter - stmt[stichtagRegelText] = altersklasse.stichtagRegelText - stmt[sparteFilter] = altersklasse.sparteFilter?.name - stmt[geschlechtFilter] = altersklasse.geschlechtFilter - stmt[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId - stmt[istAktiv] = altersklasse.istAktiv - stmt[updatedAt] = now.toLocalDateTime(TimeZone.UTC) - } - } + if (activeOnly) { + query.andWhere { AltersklasseTable.istAktiv eq true } + } - altersklasse.copy(updatedAt = now) + query.orderBy(AltersklasseTable.bezeichnung to SortOrder.ASC) + .map(::rowToAltersklasseDefinition) } - override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery { - AltersklasseTable.deleteWhere { AltersklasseTable.id eq id } > 0 + override suspend fun findByAgeRange(minAge: Int?, maxAge: Int?, activeOnly: Boolean): List = + DatabaseFactory.dbQuery { + val query = AltersklasseTable.selectAll() + + if (minAge != null) { + query.andWhere { AltersklasseTable.minAlter greaterEq minAge } + } + if (maxAge != null) { + query.andWhere { AltersklasseTable.maxAlter lessEq maxAge } + } + if (activeOnly) { + query.andWhere { AltersklasseTable.istAktiv eq true } + } + + query.orderBy(AltersklasseTable.bezeichnung to SortOrder.ASC) + .map(::rowToAltersklasseDefinition) } - override suspend fun existsByCode(altersklasseCode: String): Boolean = DatabaseFactory.dbQuery { - AltersklasseTable.selectAll().where { AltersklasseTable.altersklasseCode eq altersklasseCode } - .count() > 0 + override suspend fun findByOetoRegelReferenz(oetoRegelReferenzId: Uuid): List = + DatabaseFactory.dbQuery { + AltersklasseTable.selectAll().where { AltersklasseTable.oetoRegelReferenzId eq oetoRegelReferenzId } + .map(::rowToAltersklasseDefinition) } - override suspend fun countActive(sparteFilter: SparteE?): Long = DatabaseFactory.dbQuery { - val query = AltersklasseTable.selectAll().where { AltersklasseTable.istAktiv eq true } + override suspend fun save(altersklasse: AltersklasseDefinition): AltersklasseDefinition = DatabaseFactory.dbQuery { + val exists = AltersklasseTable.selectAll().where { AltersklasseTable.id eq altersklasse.altersklasseId }.any() - sparteFilter?.let { sparte -> - query.andWhere { - (AltersklasseTable.sparteFilter eq sparte.name) or (AltersklasseTable.sparteFilter.isNull()) - } - } - - query.count() + if (exists) { + AltersklasseTable.update({ AltersklasseTable.id eq altersklasse.altersklasseId }) { + it[altersklasseCode] = altersklasse.altersklasseCode + it[bezeichnung] = altersklasse.bezeichnung + it[minAlter] = altersklasse.minAlter + it[maxAlter] = altersklasse.maxAlter + it[stichtagRegelText] = altersklasse.stichtagRegelText + it[sparteFilter] = altersklasse.sparteFilter?.name + it[geschlechtFilter] = altersklasse.geschlechtFilter + it[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId + it[istAktiv] = altersklasse.istAktiv + it[updatedAt] = altersklasse.updatedAt + } + altersklasse + } else { + AltersklasseTable.insert { + it[id] = altersklasse.altersklasseId + it[altersklasseCode] = altersklasse.altersklasseCode + it[bezeichnung] = altersklasse.bezeichnung + it[minAlter] = altersklasse.minAlter + it[maxAlter] = altersklasse.maxAlter + it[stichtagRegelText] = altersklasse.stichtagRegelText + it[sparteFilter] = altersklasse.sparteFilter?.name + it[geschlechtFilter] = altersklasse.geschlechtFilter + it[oetoRegelReferenzId] = altersklasse.oetoRegelReferenzId + it[istAktiv] = altersklasse.istAktiv + it[createdAt] = altersklasse.createdAt + it[updatedAt] = altersklasse.updatedAt + } + altersklasse } + } - override suspend fun isEligible(altersklasseId: Uuid, age: Int, geschlecht: Char): Boolean = DatabaseFactory.dbQuery { - val altersklasse = AltersklasseTable.selectAll().where { - (AltersklasseTable.id eq altersklasseId) and (AltersklasseTable.istAktiv eq true) - }.singleOrNull() + override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery { + AltersklasseTable.deleteWhere { AltersklasseTable.id eq id } > 0 + } - if (altersklasse == null) return@dbQuery false + override suspend fun existsByCode(altersklasseCode: String): Boolean = DatabaseFactory.dbQuery { + AltersklasseTable.selectAll().where { AltersklasseTable.altersklasseCode eq altersklasseCode }.any() + } - // Check age eligibility - val minAlter = altersklasse[AltersklasseTable.minAlter] - val maxAlter = altersklasse[AltersklasseTable.maxAlter] - val ageEligible = (minAlter == null || age >= minAlter) && (maxAlter == null || age <= maxAlter) - - // Check gender eligibility - val geschlechtFilter = altersklasse[AltersklasseTable.geschlechtFilter] - val genderEligible = geschlechtFilter == null || geschlechtFilter == geschlecht - - ageEligible && genderEligible + override suspend fun countActive(sparteFilter: SparteE?): Long = DatabaseFactory.dbQuery { + val query = AltersklasseTable.selectAll().where { AltersklasseTable.istAktiv eq true } + sparteFilter?.let { sparte -> + query.andWhere { (AltersklasseTable.sparteFilter eq sparte.name) or (AltersklasseTable.sparteFilter.isNull()) } } + query.count() + } + + override suspend fun isEligible(altersklasseId: Uuid, age: Int, geschlecht: Char): Boolean = DatabaseFactory.dbQuery { + AltersklasseTable.selectAll().where { AltersklasseTable.id eq altersklasseId } + .map { + val min = it[AltersklasseTable.minAlter] + val max = it[AltersklasseTable.maxAlter] + val g = it[AltersklasseTable.geschlechtFilter] + + val ageOk = (min == null || age >= min) && (max == null || age <= max) + val geschlechtOk = (g == null || g == geschlecht) + + ageOk && geschlechtOk + }.singleOrNull() ?: false + } } diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/AltersklasseTable.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/AltersklasseTable.kt index 4c316708..44867b35 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/AltersklasseTable.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/AltersklasseTable.kt @@ -1,37 +1,38 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + package at.mocode.masterdata.infrastructure.persistence import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.core.kotlin.datetime.datetime -import org.jetbrains.exposed.v1.core.kotlin.datetime.CurrentDateTime -import org.jetbrains.exposed.v1.core.javaUUID +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.datetime.timestamp /** - * Exposed-Tabellendefinition für die Altersklasse-Entität (Altersklassendefinitionen). + * Exposed-Tabellendefinition für die Altersklasse-Entität (Altersklassendefinition). * * Diese Tabelle speichert alle Informationen zu Altersklassen für Teilnehmer * entsprechend der AltersklasseDefinition Domain-Entität. */ object AltersklasseTable : Table("altersklasse") { - val id = javaUUID("id").autoGenerate() - val altersklasseCode = varchar("altersklasse_code", 50).uniqueIndex() - val bezeichnung = varchar("bezeichnung", 200) - val minAlter = integer("min_alter").nullable() - val maxAlter = integer("max_alter").nullable() - val stichtagRegelText = varchar("stichtag_regel_text", 500).nullable() - val sparteFilter = varchar("sparte_filter", 50).nullable() // Enum as string - val geschlechtFilter = char("geschlecht_filter").nullable() - val oetoRegelReferenzId = javaUUID("oeto_regel_referenz_id").nullable() - val istAktiv = bool("ist_aktiv").default(true) - val createdAt = datetime("created_at").defaultExpression(CurrentDateTime) - val updatedAt = datetime("updated_at").defaultExpression(CurrentDateTime) + val id = uuid("id") + val altersklasseCode = varchar("altersklasse_code", 50).uniqueIndex() + val bezeichnung = varchar("bezeichnung", 200) + val minAlter = integer("min_alter").nullable() + val maxAlter = integer("max_alter").nullable() + val stichtagRegelText = varchar("stichtag_regel_text", 500).nullable() + val sparteFilter = varchar("sparte_filter", 50).nullable() // Enum as string + val geschlechtFilter = char("geschlecht_filter").nullable() + val oetoRegelReferenzId = uuid("oeto_regel_referenz_id").nullable() + val istAktiv = bool("ist_aktiv").default(true) + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp) - override val primaryKey = PrimaryKey(id) + override val primaryKey = PrimaryKey(id) - init { - // Index for performance on common queries - index(customIndexName = "idx_altersklasse_aktiv", columns = arrayOf(istAktiv)) - index(customIndexName = "idx_altersklasse_sparte", columns = arrayOf(sparteFilter)) - index(customIndexName = "idx_altersklasse_geschlecht", columns = arrayOf(geschlechtFilter)) - index(customIndexName = "idx_altersklasse_alter", columns = arrayOf(minAlter, maxAlter)) - } + init { + // Index for performance on common queries + index(customIndexName = "idx_altersklasse_aktiv", columns = arrayOf(istAktiv)) + index(customIndexName = "idx_altersklasse_sparte", columns = arrayOf(sparteFilter)) + index(customIndexName = "idx_altersklasse_geschlecht", columns = arrayOf(geschlechtFilter)) + index(customIndexName = "idx_altersklasse_alter", columns = arrayOf(minAlter, maxAlter)) + } } 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 f38213db..0b588ac0 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 @@ -1,158 +1,136 @@ @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.BundeslandDefinition import at.mocode.masterdata.domain.repository.BundeslandRepository -import at.mocode.core.utils.database.DatabaseFactory +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.* import kotlin.uuid.Uuid -import kotlinx.datetime.Clock -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq /** * Implementierung des BundeslandRepository für die Datenbankzugriffe. - * - * Diese Implementierung verwendet Exposed SQL für den Datenbankzugriff - * und mappt zwischen der BundeslandDefinition Domain-Entität und der BundeslandTable. */ class BundeslandRepositoryImpl : BundeslandRepository { - /** - * Konvertiert eine Datenbankzeile in ein Domain-Objekt. - */ - private fun rowToBundeslandDefinition(row: ResultRow): BundeslandDefinition { - return BundeslandDefinition( - bundeslandId = row[BundeslandTable.id], - landId = row[BundeslandTable.landId], - oepsCode = row[BundeslandTable.oepsCode], - iso3166_2_Code = row[BundeslandTable.iso3166_2_Code], - name = row[BundeslandTable.name], - kuerzel = row[BundeslandTable.kuerzel], - wappenUrl = row[BundeslandTable.wappenUrl], - istAktiv = row[BundeslandTable.istAktiv], - sortierReihenfolge = row[BundeslandTable.sortierReihenfolge], - createdAt = row[BundeslandTable.createdAt].toInstant(TimeZone.UTC), - updatedAt = row[BundeslandTable.updatedAt].toInstant(TimeZone.UTC) - ) + private fun rowToBundeslandDefinition(row: ResultRow): BundeslandDefinition { + return BundeslandDefinition( + bundeslandId = row[BundeslandTable.id], + landId = row[BundeslandTable.landId], + oepsCode = row[BundeslandTable.oepsCode], + iso3166_2_Code = row[BundeslandTable.iso3166_2_Code], + name = row[BundeslandTable.name], + kuerzel = row[BundeslandTable.kuerzel], + wappenUrl = row[BundeslandTable.wappenUrl], + istAktiv = row[BundeslandTable.istAktiv], + sortierReihenfolge = row[BundeslandTable.sortierReihenfolge], + createdAt = row[BundeslandTable.createdAt], + updatedAt = row[BundeslandTable.updatedAt] + ) + } + + override suspend fun findById(id: Uuid): BundeslandDefinition? = DatabaseFactory.dbQuery { + BundeslandTable.selectAll().where { BundeslandTable.id eq id } + .map(::rowToBundeslandDefinition) + .singleOrNull() + } + + override suspend fun findByOepsCode(oepsCode: String, landId: Uuid): BundeslandDefinition? = DatabaseFactory.dbQuery { + BundeslandTable.selectAll().where { (BundeslandTable.oepsCode eq oepsCode) and (BundeslandTable.landId eq landId) } + .map(::rowToBundeslandDefinition) + .singleOrNull() + } + + override suspend fun findByIso3166_2_Code(iso3166_2_Code: String): BundeslandDefinition? = DatabaseFactory.dbQuery { + BundeslandTable.selectAll().where { BundeslandTable.iso3166_2_Code eq iso3166_2_Code } + .map(::rowToBundeslandDefinition) + .singleOrNull() + } + + override suspend fun findByCountry( + landId: Uuid, + activeOnly: Boolean, + orderBySortierung: Boolean + ): List = DatabaseFactory.dbQuery { + val query = BundeslandTable.selectAll().where { BundeslandTable.landId eq landId } + if (activeOnly) { + query.andWhere { BundeslandTable.istAktiv eq true } + } + if (orderBySortierung) { + query.orderBy(BundeslandTable.sortierReihenfolge to SortOrder.ASC, BundeslandTable.name to SortOrder.ASC) + } else { + query.orderBy(BundeslandTable.name to SortOrder.ASC) + } + query.map(::rowToBundeslandDefinition) + } + + override suspend fun findByName(searchTerm: String, landId: Uuid?, limit: Int): List = + DatabaseFactory.dbQuery { + val pattern = "%$searchTerm%" + val query = BundeslandTable.selectAll().where { BundeslandTable.name like pattern } + landId?.let { query.andWhere { BundeslandTable.landId eq it } } + query.limit(limit).map(::rowToBundeslandDefinition) } - override suspend fun findById(id: Uuid): BundeslandDefinition? = DatabaseFactory.dbQuery { - BundeslandTable.selectAll().where { BundeslandTable.id eq id } - .map(::rowToBundeslandDefinition) - .singleOrNull() + override suspend fun findAllActive(orderBySortierung: Boolean): List = DatabaseFactory.dbQuery { + val query = BundeslandTable.selectAll().where { BundeslandTable.istAktiv eq true } + if (orderBySortierung) { + query.orderBy(BundeslandTable.sortierReihenfolge to SortOrder.ASC, BundeslandTable.name to SortOrder.ASC) + } else { + query.orderBy(BundeslandTable.name to SortOrder.ASC) } + query.map(::rowToBundeslandDefinition) + } - override suspend fun findByOepsCode(oepsCode: String, landId: Uuid): BundeslandDefinition? = DatabaseFactory.dbQuery { - BundeslandTable.selectAll().where { - (BundeslandTable.oepsCode eq oepsCode) and (BundeslandTable.landId eq landId) - } - .map(::rowToBundeslandDefinition) - .singleOrNull() + override suspend fun save(bundesland: BundeslandDefinition): BundeslandDefinition = DatabaseFactory.dbQuery { + val exists = BundeslandTable.selectAll().where { BundeslandTable.id eq bundesland.bundeslandId }.any() + if (exists) { + BundeslandTable.update({ BundeslandTable.id eq bundesland.bundeslandId }) { + it[landId] = bundesland.landId + it[oepsCode] = bundesland.oepsCode + it[iso3166_2_Code] = bundesland.iso3166_2_Code + it[name] = bundesland.name + it[kuerzel] = bundesland.kuerzel + it[wappenUrl] = bundesland.wappenUrl + it[istAktiv] = bundesland.istAktiv + it[sortierReihenfolge] = bundesland.sortierReihenfolge + it[updatedAt] = bundesland.updatedAt + } + bundesland + } else { + BundeslandTable.insert { + it[id] = bundesland.bundeslandId + it[landId] = bundesland.landId + it[oepsCode] = bundesland.oepsCode + it[iso3166_2_Code] = bundesland.iso3166_2_Code + it[name] = bundesland.name + it[kuerzel] = bundesland.kuerzel + it[wappenUrl] = bundesland.wappenUrl + it[istAktiv] = bundesland.istAktiv + it[sortierReihenfolge] = bundesland.sortierReihenfolge + it[createdAt] = bundesland.createdAt + it[updatedAt] = bundesland.updatedAt + } + bundesland } + } - override suspend fun findByIso3166_2_Code(iso3166_2_Code: String): BundeslandDefinition? = DatabaseFactory.dbQuery { - BundeslandTable.selectAll().where { BundeslandTable.iso3166_2_Code eq iso3166_2_Code } - .map(::rowToBundeslandDefinition) - .singleOrNull() - } + override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery { + BundeslandTable.deleteWhere { BundeslandTable.id eq id } > 0 + } - override suspend fun findByCountry(landId: Uuid, activeOnly: Boolean, orderBySortierung: Boolean): List = DatabaseFactory.dbQuery { - val query = BundeslandTable.selectAll().where { BundeslandTable.landId eq landId } + override suspend fun existsByOepsCode(oepsCode: String, landId: Uuid): Boolean = DatabaseFactory.dbQuery { + BundeslandTable.selectAll().where { (BundeslandTable.oepsCode eq oepsCode) and (BundeslandTable.landId eq landId) } + .any() + } - if (activeOnly) { - query.andWhere { BundeslandTable.istAktiv eq true } - } + override suspend fun existsByIso3166_2_Code(iso3166_2_Code: String): Boolean = DatabaseFactory.dbQuery { + BundeslandTable.selectAll().where { BundeslandTable.iso3166_2_Code eq iso3166_2_Code }.any() + } - if (orderBySortierung) { - query.orderBy(BundeslandTable.sortierReihenfolge to SortOrder.ASC, BundeslandTable.name to SortOrder.ASC) - } else { - query.orderBy(BundeslandTable.name to SortOrder.ASC) - } - - query.map(::rowToBundeslandDefinition) - } - - override suspend fun findByName(searchTerm: String, landId: Uuid?, limit: Int): List = DatabaseFactory.dbQuery { - val pattern = "%$searchTerm%" - val query = BundeslandTable.selectAll().where { BundeslandTable.name like pattern } - - landId?.let { - query.andWhere { BundeslandTable.landId eq it } - } - - query.limit(limit).map(::rowToBundeslandDefinition) - } - - override suspend fun findAllActive(orderBySortierung: Boolean): List = DatabaseFactory.dbQuery { - val query = BundeslandTable.selectAll().where { BundeslandTable.istAktiv eq true } - - if (orderBySortierung) { - query.orderBy(BundeslandTable.sortierReihenfolge to SortOrder.ASC, BundeslandTable.name to SortOrder.ASC) - } else { - query.orderBy(BundeslandTable.name to SortOrder.ASC) - } - - query.map(::rowToBundeslandDefinition) - } - - override suspend fun save(bundesland: BundeslandDefinition): BundeslandDefinition = DatabaseFactory.dbQuery { - val now = Clock.System.now() - val existingBundesland = BundeslandTable.selectAll().where { BundeslandTable.id eq bundesland.bundeslandId }.singleOrNull() - - if (existingBundesland == null) { - // Insert a new federal state - BundeslandTable.insert { stmt -> - stmt[id] = bundesland.bundeslandId - stmt[landId] = bundesland.landId - stmt[oepsCode] = bundesland.oepsCode - stmt[iso3166_2_Code] = bundesland.iso3166_2_Code - stmt[name] = bundesland.name - stmt[kuerzel] = bundesland.kuerzel - stmt[wappenUrl] = bundesland.wappenUrl - stmt[istAktiv] = bundesland.istAktiv - stmt[sortierReihenfolge] = bundesland.sortierReihenfolge - stmt[createdAt] = bundesland.createdAt.toLocalDateTime(TimeZone.UTC) - stmt[updatedAt] = now.toLocalDateTime(TimeZone.UTC) - } - } else { - // Update existing federal state - BundeslandTable.update({ BundeslandTable.id eq bundesland.bundeslandId }) { stmt -> - stmt[landId] = bundesland.landId - stmt[oepsCode] = bundesland.oepsCode - stmt[iso3166_2_Code] = bundesland.iso3166_2_Code - stmt[name] = bundesland.name - stmt[kuerzel] = bundesland.kuerzel - stmt[wappenUrl] = bundesland.wappenUrl - stmt[istAktiv] = bundesland.istAktiv - stmt[sortierReihenfolge] = bundesland.sortierReihenfolge - stmt[updatedAt] = now.toLocalDateTime(TimeZone.UTC) - } - } - - bundesland.copy(updatedAt = now) - } - - override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery { - BundeslandTable.deleteWhere { BundeslandTable.id eq id } > 0 - } - - override suspend fun existsByOepsCode(oepsCode: String, landId: Uuid): Boolean = DatabaseFactory.dbQuery { - BundeslandTable.selectAll().where { - (BundeslandTable.oepsCode eq oepsCode) and (BundeslandTable.landId eq landId) - }.count() > 0 - } - - override suspend fun existsByIso3166_2_Code(iso3166_2_Code: String): Boolean = DatabaseFactory.dbQuery { - BundeslandTable.selectAll().where { BundeslandTable.iso3166_2_Code eq iso3166_2_Code } - .count() > 0 - } - - override suspend fun countActiveByCountry(landId: Uuid): Long = DatabaseFactory.dbQuery { - BundeslandTable.selectAll().where { - (BundeslandTable.landId eq landId) and (BundeslandTable.istAktiv eq true) - }.count() - } + override suspend fun countActiveByCountry(landId: Uuid): Long = DatabaseFactory.dbQuery { + BundeslandTable.selectAll().where { (BundeslandTable.landId eq landId) and (BundeslandTable.istAktiv eq true) } + .count() + } } diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/BundeslandTable.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/BundeslandTable.kt index 4246bc37..079e137f 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/BundeslandTable.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/BundeslandTable.kt @@ -1,35 +1,31 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + package at.mocode.masterdata.infrastructure.persistence import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.core.kotlin.datetime.datetime -import org.jetbrains.exposed.v1.core.kotlin.datetime.CurrentDateTime -import org.jetbrains.exposed.v1.core.javaUUID +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.datetime.timestamp /** - * Exposed-Tabellendefinition für die Bundesland-Entität (Bundesländer/Regionen). - * - * Diese Tabelle speichert alle Informationen zu Bundesländern und subnationalen - * Verwaltungseinheiten entsprechend der BundeslandDefinition Domain-Entität. + * Exposed-Tabellendefinition für die Bundesland-Entität. */ object BundeslandTable : Table("bundesland") { - val id = javaUUID("id").autoGenerate() - val landId = javaUUID("land_id").references(LandTable.id) - val oepsCode = varchar("oeps_code", 10).nullable() - val iso3166_2_Code = varchar("iso_3166_2_code", 10).nullable() - val name = varchar("name", 100) - val kuerzel = varchar("kuerzel", 10).nullable() - val wappenUrl = varchar("wappen_url", 500).nullable() - val istAktiv = bool("ist_aktiv").default(true) - val sortierReihenfolge = integer("sortier_reihenfolge").nullable() - val createdAt = datetime("created_at").defaultExpression(CurrentDateTime) - val updatedAt = datetime("updated_at").defaultExpression(CurrentDateTime) + val id = uuid("bundesland_id") + val landId = uuid("land_id") + val oepsCode = varchar("oeps_code", 10).nullable() + val iso3166_2_Code = varchar("iso_3166_2_code", 10).nullable() + val name = varchar("name", 100) + val kuerzel = varchar("kuerzel", 10).nullable() + val wappenUrl = varchar("wappen_url", 255).nullable() + val istAktiv = bool("ist_aktiv").default(true) + val sortierReihenfolge = integer("sortier_reihenfolge").nullable() + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp) - override val primaryKey = PrimaryKey(id) + override val primaryKey = PrimaryKey(id) - init { - // Unique constraint for OEPS code per country - uniqueIndex("uk_bundesland_oeps_land", oepsCode, landId) - // Unique constraint for ISO 3166-2 code globally - uniqueIndex("uk_bundesland_iso3166_2", iso3166_2_Code) - } + init { + uniqueIndex("idx_bundesland_oeps", oepsCode, landId) + uniqueIndex("idx_bundesland_iso", iso3166_2_Code) + } } diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedFunktionaerRepository.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedFunktionaerRepository.kt new file mode 100644 index 00000000..f51df332 --- /dev/null +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedFunktionaerRepository.kt @@ -0,0 +1,157 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.infrastructure.persistence + +import at.mocode.core.domain.model.DatenQuelleE +import at.mocode.core.domain.model.FunktionaerRolleE +import at.mocode.core.domain.model.RichterQualifikationE +import at.mocode.core.domain.model.SparteE +import at.mocode.core.utils.database.DatabaseFactory +import at.mocode.masterdata.domain.model.DomFunktionaer +import at.mocode.masterdata.domain.repository.FunktionaerRepository +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.like +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.jdbc.* +import kotlin.uuid.Uuid + +/** + * Exposed-basierte Implementierung des Funktionaer-Repositorys. + */ +class ExposedFunktionaerRepository : FunktionaerRepository { + + private fun rowToDomFunktionaer(row: ResultRow): DomFunktionaer { + return DomFunktionaer( + funktionaerId = row[FunktionaerTable.id], + richterNummer = row[FunktionaerTable.richterNummer], + vorname = row[FunktionaerTable.vorname], + nachname = row[FunktionaerTable.nachname], + geburtsdatum = row[FunktionaerTable.geburtsdatum], + email = row[FunktionaerTable.email], + telefon = row[FunktionaerTable.telefon], + vereinsNummer = row[FunktionaerTable.vereinsNummer], + istAktiv = row[FunktionaerTable.istAktiv], + bemerkungen = row[FunktionaerTable.bemerkungen], + datenQuelle = DatenQuelleE.valueOf(row[FunktionaerTable.datenQuelle]), + createdAt = row[FunktionaerTable.createdAt], + updatedAt = row[FunktionaerTable.updatedAt] + ) + } + + override suspend fun findById(id: Uuid): DomFunktionaer? = DatabaseFactory.dbQuery { + FunktionaerTable.selectAll().where { FunktionaerTable.id eq id } + .map(::rowToDomFunktionaer) + .singleOrNull() + } + + override suspend fun findByRichterNummer(richterNummer: String): DomFunktionaer? = DatabaseFactory.dbQuery { + FunktionaerTable.selectAll().where { FunktionaerTable.richterNummer eq richterNummer } + .map(::rowToDomFunktionaer) + .singleOrNull() + } + + override suspend fun findByName(searchTerm: String, limit: Int): List = DatabaseFactory.dbQuery { + val pattern = "%$searchTerm%" + FunktionaerTable.selectAll() + .where { (FunktionaerTable.nachname like pattern) or (FunktionaerTable.vorname like pattern) } + .limit(limit) + .map(::rowToDomFunktionaer) + } + + override suspend fun findByRolle(rolle: FunktionaerRolleE, activeOnly: Boolean): List = + DatabaseFactory.dbQuery { + // Rolle wird aktuell nicht in FunktionaerTable gespeichert. + // Falls benötigt, muss die Tabelle erweitert werden. + emptyList() + } + + override suspend fun findByRichterQualifikation( + qualifikation: RichterQualifikationE, + activeOnly: Boolean + ): List = DatabaseFactory.dbQuery { + // Qualifikationen werden aktuell nicht in FunktionaerTable gespeichert. + emptyList() + } + + override suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean): List = + DatabaseFactory.dbQuery { + emptyList() + } + + override suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean): List = + DatabaseFactory.dbQuery { + val query = FunktionaerTable.selectAll().where { FunktionaerTable.vereinsNummer eq vereinsNummer } + if (activeOnly) { + query.andWhere { FunktionaerTable.istAktiv eq true } + } + query.map(::rowToDomFunktionaer) + } + + override suspend fun findAllActive(limit: Int, offset: Int): List = DatabaseFactory.dbQuery { + FunktionaerTable.selectAll().where { FunktionaerTable.istAktiv eq true } + .limit(limit).offset(offset.toLong()) + .map(::rowToDomFunktionaer) + } + + override suspend fun findAll(limit: Int, offset: Int): List = DatabaseFactory.dbQuery { + FunktionaerTable.selectAll() + .limit(limit).offset(offset.toLong()) + .map(::rowToDomFunktionaer) + } + + override suspend fun save(funktionaer: DomFunktionaer): DomFunktionaer = DatabaseFactory.dbQuery { + val exists = FunktionaerTable.selectAll().where { FunktionaerTable.id eq funktionaer.funktionaerId }.any() + if (exists) { + FunktionaerTable.update({ FunktionaerTable.id eq funktionaer.funktionaerId }) { + it[richterNummer] = funktionaer.richterNummer + it[vorname] = funktionaer.vorname + it[nachname] = funktionaer.nachname + it[geburtsdatum] = funktionaer.geburtsdatum + it[email] = funktionaer.email + it[telefon] = funktionaer.telefon + it[vereinsNummer] = funktionaer.vereinsNummer + it[istAktiv] = funktionaer.istAktiv + it[bemerkungen] = funktionaer.bemerkungen + it[datenQuelle] = funktionaer.datenQuelle.name + it[updatedAt] = funktionaer.updatedAt + } + funktionaer + } else { + FunktionaerTable.insert { + it[id] = funktionaer.funktionaerId + it[richterNummer] = funktionaer.richterNummer + it[vorname] = funktionaer.vorname + it[nachname] = funktionaer.nachname + it[geburtsdatum] = funktionaer.geburtsdatum + it[email] = funktionaer.email + it[telefon] = funktionaer.telefon + it[vereinsNummer] = funktionaer.vereinsNummer + it[istAktiv] = funktionaer.istAktiv + it[bemerkungen] = funktionaer.bemerkungen + it[datenQuelle] = funktionaer.datenQuelle.name + it[createdAt] = funktionaer.createdAt + it[updatedAt] = funktionaer.updatedAt + } + funktionaer + } + } + + override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery { + FunktionaerTable.deleteWhere { FunktionaerTable.id eq id } > 0 + } + + override suspend fun countActive(): Long = DatabaseFactory.dbQuery { + FunktionaerTable.selectAll().where { FunktionaerTable.istAktiv eq true }.count() + } + + override suspend fun countByRichterQualifikation(qualifikation: RichterQualifikationE, activeOnly: Boolean): Long = + DatabaseFactory.dbQuery { + // Aktuell keine Qualifikations-Speicherung + 0L + } + + override suspend fun existsByRichterNummer(richterNummer: String): Boolean = DatabaseFactory.dbQuery { + FunktionaerTable.selectAll().where { FunktionaerTable.richterNummer eq richterNummer }.any() + } +} diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedReiterRepository.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedReiterRepository.kt new file mode 100644 index 00000000..20fcda14 --- /dev/null +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedReiterRepository.kt @@ -0,0 +1,177 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.infrastructure.persistence + +import at.mocode.core.domain.model.DatenQuelleE +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.DomReiter +import at.mocode.masterdata.domain.repository.ReiterRepository +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.like +import org.jetbrains.exposed.v1.core.or +import org.jetbrains.exposed.v1.jdbc.* +import kotlin.uuid.Uuid + +/** + * Exposed-basierte Implementierung des Reiter-Repositorys. + */ +class ExposedReiterRepository : ReiterRepository { + + private fun rowToDomReiter(row: ResultRow): DomReiter { + return DomReiter( + reiterId = row[ReiterTable.id], + personId = row[ReiterTable.personId], + satznummer = row[ReiterTable.satznummer], + nachname = row[ReiterTable.nachname], + vorname = row[ReiterTable.vorname], + geburtsdatum = row[ReiterTable.geburtsdatum], + lizenzNummer = row[ReiterTable.lizenzNummer], + lizenzKlasse = LizenzKlasseE.valueOf(row[ReiterTable.lizenzKlasse]), + startkartAktiv = row[ReiterTable.startkartAktiv], + startkartSaison = row[ReiterTable.startkartSaison], + feiId = row[ReiterTable.feiId], + nation = row[ReiterTable.nation], + vereinsNummer = row[ReiterTable.vereinsNummer], + vereinsName = row[ReiterTable.vereinsName], + istGastreiter = row[ReiterTable.istGastreiter], + istAktiv = row[ReiterTable.istAktiv], + datenQuelle = DatenQuelleE.valueOf(row[ReiterTable.datenQuelle]), + createdAt = row[ReiterTable.createdAt], + updatedAt = row[ReiterTable.updatedAt] + ) + } + + override suspend fun findById(id: Uuid): DomReiter? = DatabaseFactory.dbQuery { + ReiterTable.selectAll().where { ReiterTable.id eq id } + .map(::rowToDomReiter) + .singleOrNull() + } + + override suspend fun findBySatznummer(satznummer: String): DomReiter? = DatabaseFactory.dbQuery { + ReiterTable.selectAll().where { ReiterTable.satznummer eq satznummer } + .map(::rowToDomReiter) + .singleOrNull() + } + + override suspend fun findByFeiId(feiId: String): DomReiter? = DatabaseFactory.dbQuery { + ReiterTable.selectAll().where { ReiterTable.feiId eq feiId } + .map(::rowToDomReiter) + .singleOrNull() + } + + override suspend fun findByName(searchTerm: String, limit: Int): List = DatabaseFactory.dbQuery { + val pattern = "%$searchTerm%" + ReiterTable.selectAll().where { (ReiterTable.nachname like pattern) or (ReiterTable.vorname like pattern) } + .limit(limit) + .map(::rowToDomReiter) + } + + override suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean): List = + DatabaseFactory.dbQuery { + val query = ReiterTable.selectAll().where { ReiterTable.vereinsNummer eq vereinsNummer } + if (activeOnly) { + query.andWhere { ReiterTable.istAktiv eq true } + } + query.map(::rowToDomReiter) + } + + override suspend fun findByLizenzKlasse(lizenzKlasse: LizenzKlasseE, activeOnly: Boolean): List = + DatabaseFactory.dbQuery { + val query = ReiterTable.selectAll().where { ReiterTable.lizenzKlasse eq lizenzKlasse.name } + if (activeOnly) { + query.andWhere { ReiterTable.istAktiv eq true } + } + query.map(::rowToDomReiter) + } + + override suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean): List = DatabaseFactory.dbQuery { + // Da wir in ReiterTable keinen sparteFilter haben, müssen wir ggf. über eine andere Tabelle gehen + // oder die Logik anpassen. Fürs erste geben wir eine leere Liste zurück oder suchen nach Name in Lizenz? + // TODO: Implementierung prüfen, falls Sparten-Lizenzierung in eigener Tabelle liegt. + emptyList() + } + + override suspend fun findGastreiter(activeOnly: Boolean): List = DatabaseFactory.dbQuery { + val query = ReiterTable.selectAll().where { ReiterTable.istGastreiter eq true } + if (activeOnly) { + query.andWhere { ReiterTable.istAktiv eq true } + } + query.map(::rowToDomReiter) + } + + override suspend fun findAllActive(limit: Int, offset: Int): List = DatabaseFactory.dbQuery { + ReiterTable.selectAll().where { ReiterTable.istAktiv eq true } + .limit(limit).offset(offset.toLong()) + .map(::rowToDomReiter) + } + + override suspend fun findAll(limit: Int, offset: Int): List = DatabaseFactory.dbQuery { + ReiterTable.selectAll() + .limit(limit).offset(offset.toLong()) + .map(::rowToDomReiter) + } + + override suspend fun save(reiter: DomReiter): DomReiter = DatabaseFactory.dbQuery { + val exists = ReiterTable.selectAll().where { ReiterTable.id eq reiter.reiterId }.any() + if (exists) { + ReiterTable.update({ ReiterTable.id eq reiter.reiterId }) { + it[personId] = reiter.personId + it[satznummer] = reiter.satznummer + it[nachname] = reiter.nachname + it[vorname] = reiter.vorname + it[geburtsdatum] = reiter.geburtsdatum + it[lizenzNummer] = reiter.lizenzNummer + it[lizenzKlasse] = reiter.lizenzKlasse.name + it[startkartAktiv] = reiter.startkartAktiv + it[startkartSaison] = reiter.startkartSaison + it[feiId] = reiter.feiId + it[nation] = reiter.nation + it[vereinsNummer] = reiter.vereinsNummer + it[vereinsName] = reiter.vereinsName + it[istGastreiter] = reiter.istGastreiter + it[istAktiv] = reiter.istAktiv + it[datenQuelle] = reiter.datenQuelle.name + it[updatedAt] = reiter.updatedAt + } + reiter + } else { + ReiterTable.insert { + it[id] = reiter.reiterId + it[personId] = reiter.personId + it[satznummer] = reiter.satznummer + it[nachname] = reiter.nachname + it[vorname] = reiter.vorname + it[geburtsdatum] = reiter.geburtsdatum + it[lizenzNummer] = reiter.lizenzNummer + it[lizenzKlasse] = reiter.lizenzKlasse.name + it[startkartAktiv] = reiter.startkartAktiv + it[startkartSaison] = reiter.startkartSaison + it[feiId] = reiter.feiId + it[nation] = reiter.nation + it[vereinsNummer] = reiter.vereinsNummer + it[vereinsName] = reiter.vereinsName + it[istGastreiter] = reiter.istGastreiter + it[istAktiv] = reiter.istAktiv + it[datenQuelle] = reiter.datenQuelle.name + it[createdAt] = reiter.createdAt + it[updatedAt] = reiter.updatedAt + } + reiter + } + } + + override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery { + ReiterTable.deleteWhere { ReiterTable.id eq id } > 0 + } + + override suspend fun countActive(): Long = DatabaseFactory.dbQuery { + ReiterTable.selectAll().where { ReiterTable.istAktiv eq true }.count() + } + + override suspend fun existsBySatznummer(satznummer: String): Boolean = DatabaseFactory.dbQuery { + ReiterTable.selectAll().where { ReiterTable.satznummer eq satznummer }.any() + } +} diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedVereinRepository.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedVereinRepository.kt new file mode 100644 index 00000000..005c73c8 --- /dev/null +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ExposedVereinRepository.kt @@ -0,0 +1,154 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.infrastructure.persistence + +import at.mocode.core.domain.model.DatenQuelleE +import at.mocode.core.utils.database.DatabaseFactory +import at.mocode.masterdata.domain.model.DomVerein +import at.mocode.masterdata.domain.repository.VereinRepository +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.or +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 org.jetbrains.exposed.v1.jdbc.andWhere +import kotlin.uuid.Uuid + +/** + * Exposed-basierte Implementierung des Verein-Repositorys. + */ +class ExposedVereinRepository : VereinRepository { + + private fun rowToDomVerein(row: ResultRow): DomVerein { + return DomVerein( + vereinId = row[VereinTable.id], + vereinsNummer = row[VereinTable.vereinsNummer], + name = row[VereinTable.name], + kurzname = row[VereinTable.kurzname], + bundesland = row[VereinTable.bundesland], + ort = row[VereinTable.ort], + plz = row[VereinTable.plz], + strasse = row[VereinTable.strasse], + email = row[VereinTable.email], + telefon = row[VereinTable.telefon], + website = row[VereinTable.website], + oepsRegionNummer = row[VereinTable.oepsRegionNummer], + istVeranstalter = row[VereinTable.istVeranstalter], + istAktiv = row[VereinTable.istAktiv], + bemerkungen = row[VereinTable.bemerkungen], + datenQuelle = DatenQuelleE.valueOf(row[VereinTable.datenQuelle]), + createdAt = row[VereinTable.createdAt], + updatedAt = row[VereinTable.updatedAt] + ) + } + + override suspend fun findById(id: Uuid): DomVerein? = DatabaseFactory.dbQuery { + VereinTable.selectAll().where { VereinTable.id eq id } + .map(::rowToDomVerein) + .singleOrNull() + } + + override suspend fun findByVereinsNummer(vereinsNummer: String): DomVerein? = DatabaseFactory.dbQuery { + VereinTable.selectAll().where { VereinTable.vereinsNummer eq vereinsNummer } + .map(::rowToDomVerein) + .singleOrNull() + } + + override suspend fun findByName(searchTerm: String, limit: Int): List = DatabaseFactory.dbQuery { + val pattern = "%$searchTerm%" + VereinTable.selectAll().where { (VereinTable.name like pattern) or (VereinTable.kurzname like pattern) } + .limit(limit) + .map(::rowToDomVerein) + } + + override suspend fun findByBundesland(bundesland: String, activeOnly: Boolean): List = + DatabaseFactory.dbQuery { + val query = VereinTable.selectAll().where { VereinTable.bundesland eq bundesland } + if (activeOnly) { + query.andWhere { VereinTable.istAktiv eq true } + } + query.map(::rowToDomVerein) + } + + override suspend fun findVeranstalter(activeOnly: Boolean): List = DatabaseFactory.dbQuery { + val query = VereinTable.selectAll().where { VereinTable.istVeranstalter eq true } + if (activeOnly) { + query.andWhere { VereinTable.istAktiv eq true } + } + query.map(::rowToDomVerein) + } + + override suspend fun findAllActive(limit: Int, offset: Int): List = DatabaseFactory.dbQuery { + VereinTable.selectAll().where { VereinTable.istAktiv eq true } + .limit(limit).offset(offset.toLong()) + .map(::rowToDomVerein) + } + + override suspend fun findAll(limit: Int, offset: Int): List = DatabaseFactory.dbQuery { + VereinTable.selectAll() + .limit(limit).offset(offset.toLong()) + .map(::rowToDomVerein) + } + + override suspend fun save(verein: DomVerein): DomVerein = DatabaseFactory.dbQuery { + val exists = VereinTable.selectAll().where { VereinTable.id eq verein.vereinId }.any() + if (exists) { + VereinTable.update({ VereinTable.id eq verein.vereinId }) { + it[vereinsNummer] = verein.vereinsNummer + it[name] = verein.name + it[kurzname] = verein.kurzname + it[bundesland] = verein.bundesland + it[ort] = verein.ort + it[plz] = verein.plz + it[strasse] = verein.strasse + it[email] = verein.email + it[telefon] = verein.telefon + it[website] = verein.website + it[oepsRegionNummer] = verein.oepsRegionNummer + it[istVeranstalter] = verein.istVeranstalter + it[istAktiv] = verein.istAktiv + it[bemerkungen] = verein.bemerkungen + it[datenQuelle] = verein.datenQuelle.name + it[updatedAt] = verein.updatedAt + } + verein + } else { + VereinTable.insert { + it[id] = verein.vereinId + it[vereinsNummer] = verein.vereinsNummer + it[name] = verein.name + it[kurzname] = verein.kurzname + it[bundesland] = verein.bundesland + it[ort] = verein.ort + it[plz] = verein.plz + it[strasse] = verein.strasse + it[email] = verein.email + it[telefon] = verein.telefon + it[website] = verein.website + it[oepsRegionNummer] = verein.oepsRegionNummer + it[istVeranstalter] = verein.istVeranstalter + it[istAktiv] = verein.istAktiv + it[bemerkungen] = verein.bemerkungen + it[datenQuelle] = verein.datenQuelle.name + it[createdAt] = verein.createdAt + it[updatedAt] = verein.updatedAt + } + verein + } + } + + override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery { + VereinTable.deleteWhere { VereinTable.id eq id } > 0 + } + + override suspend fun countActive(): Long = DatabaseFactory.dbQuery { + VereinTable.selectAll().where { VereinTable.istAktiv eq true }.count() + } + + override suspend fun existsByVereinsNummer(vereinsNummer: String): Boolean = DatabaseFactory.dbQuery { + VereinTable.selectAll().where { VereinTable.vereinsNummer eq vereinsNummer }.any() + } +} diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/FunktionaerTable.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/FunktionaerTable.kt new file mode 100644 index 00000000..b6cd7097 --- /dev/null +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/FunktionaerTable.kt @@ -0,0 +1,30 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.infrastructure.persistence + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.datetime.date +import org.jetbrains.exposed.v1.datetime.timestamp + + +/** + * Exposed-Tabellendefinition für die Funktionär-Entität. + */ +object FunktionaerTable : Table("funktionaer") { + val id = uuid("funktionaer_id") + val richterNummer = varchar("richter_nummer", 10).nullable().uniqueIndex() + val vorname = varchar("vorname", 100) + val nachname = varchar("nachname", 100) + val geburtsdatum = date("geburtsdatum").nullable() + val email = varchar("email", 200).nullable() + val telefon = varchar("telefon", 50).nullable() + val vereinsNummer = varchar("vereins_nummer", 10).nullable() + val istAktiv = bool("ist_aktiv").default(true) + val bemerkungen = text("bemerkungen").nullable() + val datenQuelle = varchar("daten_quelle", 50) + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp) + + override val primaryKey = PrimaryKey(id) +} 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 new file mode 100644 index 00000000..ebd100bb --- /dev/null +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/HorseRepositoryImpl.kt @@ -0,0 +1,286 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.infrastructure.persistence + +import at.mocode.core.domain.model.DatenQuelleE +import at.mocode.core.domain.model.PferdeGeschlechtE +import at.mocode.core.utils.database.DatabaseFactory +import at.mocode.masterdata.domain.model.DomPferd +import at.mocode.masterdata.domain.repository.HorseRepository +import org.jetbrains.exposed.v1.core.* +import org.jetbrains.exposed.v1.jdbc.* +import kotlin.uuid.Uuid + +/** + * Exposed-basierte Implementierung des Horse-Repositorys. + */ +class HorseRepositoryImpl : HorseRepository { + + private fun rowToDomPferd(row: ResultRow): DomPferd { + return DomPferd( + pferdId = row[HorseTable.id], + pferdeName = row[HorseTable.pferdeName], + geschlecht = PferdeGeschlechtE.valueOf(row[HorseTable.geschlecht]), + geburtsdatum = row[HorseTable.geburtsdatum], + rasse = row[HorseTable.rasse], + farbe = row[HorseTable.farbe], + besitzerId = row[HorseTable.besitzerId], + verantwortlichePersonId = row[HorseTable.verantwortlichePersonId], + zuechterName = row[HorseTable.zuechterName], + zuchtbuchNummer = row[HorseTable.zuchtbuchNummer], + lebensnummer = row[HorseTable.lebensnummer], + chipNummer = row[HorseTable.chipNummer], + passNummer = row[HorseTable.passNummer], + oepsNummer = row[HorseTable.oepsNummer], + feiNummer = row[HorseTable.feiNummer], + vaterName = row[HorseTable.vaterName], + mutterName = row[HorseTable.mutterName], + mutterVaterName = row[HorseTable.mutterVaterName], + stockmass = row[HorseTable.stockmass], + istAktiv = row[HorseTable.istAktiv], + bemerkungen = row[HorseTable.bemerkungen], + datenQuelle = DatenQuelleE.valueOf(row[HorseTable.datenQuelle]), + createdAt = row[HorseTable.createdAt], + updatedAt = row[HorseTable.updatedAt] + ) + } + + override suspend fun findById(id: Uuid): DomPferd? = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.id eq id } + .map(::rowToDomPferd) + .singleOrNull() + } + + override suspend fun findByLebensnummer(lebensnummer: String): DomPferd? = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer } + .map(::rowToDomPferd) + .singleOrNull() + } + + override suspend fun findByChipNummer(chipNummer: String): DomPferd? = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer } + .map(::rowToDomPferd) + .singleOrNull() + } + + override suspend fun findByPassNummer(passNummer: String): DomPferd? = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.passNummer eq passNummer } + .map(::rowToDomPferd) + .singleOrNull() + } + + override suspend fun findByOepsNummer(oepsNummer: String): DomPferd? = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer } + .map(::rowToDomPferd) + .singleOrNull() + } + + override suspend fun findByFeiNummer(feiNummer: String): DomPferd? = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer } + .map(::rowToDomPferd) + .singleOrNull() + } + + override suspend fun findByName(searchTerm: String, limit: Int): List = DatabaseFactory.dbQuery { + val pattern = "%$searchTerm%" + HorseTable.selectAll().where { HorseTable.pferdeName like pattern } + .limit(limit) + .map(::rowToDomPferd) + } + + override suspend fun findByOwnerId(ownerId: Uuid, activeOnly: Boolean): List = DatabaseFactory.dbQuery { + val query = HorseTable.selectAll().where { HorseTable.besitzerId eq ownerId } + if (activeOnly) { + query.andWhere { HorseTable.istAktiv eq true } + } + query.map(::rowToDomPferd) + } + + override suspend fun findByResponsiblePersonId(responsiblePersonId: Uuid, activeOnly: Boolean): List = + DatabaseFactory.dbQuery { + val query = HorseTable.selectAll().where { HorseTable.verantwortlichePersonId eq responsiblePersonId } + if (activeOnly) { + query.andWhere { HorseTable.istAktiv eq true } + } + query.map(::rowToDomPferd) + } + + override suspend fun findByGeschlecht( + geschlecht: PferdeGeschlechtE, + activeOnly: Boolean, + limit: Int + ): List = DatabaseFactory.dbQuery { + val query = HorseTable.selectAll().where { HorseTable.geschlecht eq geschlecht.name } + if (activeOnly) { + query.andWhere { HorseTable.istAktiv eq true } + } + query.limit(limit).map(::rowToDomPferd) + } + + override suspend fun findByRasse(rasse: String, activeOnly: Boolean, limit: Int): List = + DatabaseFactory.dbQuery { + val query = HorseTable.selectAll().where { HorseTable.rasse eq rasse } + if (activeOnly) { + query.andWhere { HorseTable.istAktiv eq true } + } + query.limit(limit).map(::rowToDomPferd) + } + + 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. + val startDate = kotlinx.datetime.LocalDate(birthYear, 1, 1) + val endDate = kotlinx.datetime.LocalDate(birthYear, 12, 31) + val query = HorseTable.selectAll() + .where { (HorseTable.geburtsdatum greaterEq startDate) and (HorseTable.geburtsdatum lessEq endDate) } + if (activeOnly) { + query.andWhere { HorseTable.istAktiv eq true } + } + query.map(::rowToDomPferd) + } + + override suspend fun findByBirthYearRange(fromYear: Int, toYear: Int, activeOnly: Boolean): List = + DatabaseFactory.dbQuery { + val startDate = kotlinx.datetime.LocalDate(fromYear, 1, 1) + val endDate = kotlinx.datetime.LocalDate(toYear, 12, 31) + val query = HorseTable.selectAll() + .where { (HorseTable.geburtsdatum greaterEq startDate) and (HorseTable.geburtsdatum lessEq endDate) } + if (activeOnly) { + query.andWhere { HorseTable.istAktiv eq true } + } + query.map(::rowToDomPferd) + } + + override suspend fun findAllActive(limit: Int): List = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.istAktiv eq true } + .limit(limit) + .map(::rowToDomPferd) + } + + override suspend fun findOepsRegistered(activeOnly: Boolean): List = DatabaseFactory.dbQuery { + val query = HorseTable.selectAll().where { HorseTable.oepsNummer.isNotNull() } + if (activeOnly) { + query.andWhere { HorseTable.istAktiv eq true } + } + query.map(::rowToDomPferd) + } + + override suspend fun findFeiRegistered(activeOnly: Boolean): List = DatabaseFactory.dbQuery { + val query = HorseTable.selectAll().where { HorseTable.feiNummer.isNotNull() } + if (activeOnly) { + query.andWhere { HorseTable.istAktiv eq true } + } + query.map(::rowToDomPferd) + } + + override suspend fun save(horse: DomPferd): DomPferd = DatabaseFactory.dbQuery { + val exists = HorseTable.selectAll().where { HorseTable.id eq horse.pferdId }.any() + if (exists) { + HorseTable.update({ HorseTable.id eq horse.pferdId }) { + it[pferdeName] = horse.pferdeName + it[geschlecht] = horse.geschlecht.name + it[geburtsdatum] = horse.geburtsdatum + it[rasse] = horse.rasse + it[farbe] = horse.farbe + it[besitzerId] = horse.besitzerId + it[verantwortlichePersonId] = horse.verantwortlichePersonId + it[zuechterName] = horse.zuechterName + it[zuchtbuchNummer] = horse.zuchtbuchNummer + it[lebensnummer] = horse.lebensnummer + it[chipNummer] = horse.chipNummer + it[passNummer] = horse.passNummer + it[oepsNummer] = horse.oepsNummer + it[feiNummer] = horse.feiNummer + it[vaterName] = horse.vaterName + it[mutterName] = horse.mutterName + it[mutterVaterName] = horse.mutterVaterName + it[stockmass] = horse.stockmass + it[istAktiv] = horse.istAktiv + it[bemerkungen] = horse.bemerkungen + it[datenQuelle] = horse.datenQuelle.name + it[updatedAt] = horse.updatedAt + } + horse + } else { + HorseTable.insert { + it[id] = horse.pferdId + it[pferdeName] = horse.pferdeName + it[geschlecht] = horse.geschlecht.name + it[geburtsdatum] = horse.geburtsdatum + it[rasse] = horse.rasse + it[farbe] = horse.farbe + it[besitzerId] = horse.besitzerId + it[verantwortlichePersonId] = horse.verantwortlichePersonId + it[zuechterName] = horse.zuechterName + it[zuchtbuchNummer] = horse.zuchtbuchNummer + it[lebensnummer] = horse.lebensnummer + it[chipNummer] = horse.chipNummer + it[passNummer] = horse.passNummer + it[oepsNummer] = horse.oepsNummer + it[feiNummer] = horse.feiNummer + it[vaterName] = horse.vaterName + it[mutterName] = horse.mutterName + it[mutterVaterName] = horse.mutterVaterName + it[stockmass] = horse.stockmass + it[istAktiv] = horse.istAktiv + it[bemerkungen] = horse.bemerkungen + it[datenQuelle] = horse.datenQuelle.name + it[createdAt] = horse.createdAt + it[updatedAt] = horse.updatedAt + } + horse + } + } + + override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery { + HorseTable.deleteWhere { HorseTable.id eq id } > 0 + } + + override suspend fun existsByLebensnummer(lebensnummer: String): Boolean = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.lebensnummer eq lebensnummer }.any() + } + + override suspend fun existsByChipNummer(chipNummer: String): Boolean = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.chipNummer eq chipNummer }.any() + } + + override suspend fun existsByPassNummer(passNummer: String): Boolean = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.passNummer eq passNummer }.any() + } + + override suspend fun existsByOepsNummer(oepsNummer: String): Boolean = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.oepsNummer eq oepsNummer }.any() + } + + override suspend fun existsByFeiNummer(feiNummer: String): Boolean = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.feiNummer eq feiNummer }.any() + } + + override suspend fun countActive(): Long = DatabaseFactory.dbQuery { + HorseTable.selectAll().where { HorseTable.istAktiv eq true }.count() + } + + override suspend fun countByOwnerId(ownerId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery { + val query = HorseTable.selectAll().where { HorseTable.besitzerId eq ownerId } + if (activeOnly) { + query.andWhere { HorseTable.istAktiv eq true } + } + query.count() + } + + override suspend fun countOepsRegistered(activeOnly: Boolean): Long = DatabaseFactory.dbQuery { + val query = HorseTable.selectAll().where { HorseTable.oepsNummer.isNotNull() } + if (activeOnly) { + query.andWhere { HorseTable.istAktiv eq true } + } + query.count() + } + + override suspend fun countFeiRegistered(activeOnly: Boolean): Long = DatabaseFactory.dbQuery { + val query = HorseTable.selectAll().where { HorseTable.feiNummer.isNotNull() } + if (activeOnly) { + query.andWhere { HorseTable.istAktiv eq true } + } + query.count() + } +} diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/HorseTable.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/HorseTable.kt new file mode 100644 index 00000000..889ad672 --- /dev/null +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/HorseTable.kt @@ -0,0 +1,45 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.infrastructure.persistence + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.datetime.date +import org.jetbrains.exposed.v1.datetime.timestamp + +/** + * Exposed-Tabellendefinition für die Pferd-Entität. + */ +object HorseTable : Table("horse") { + val id = uuid("horse_id") + val pferdeName = varchar("pferde_name", 200) + val geschlecht = varchar("geschlecht", 20) + val geburtsdatum = date("geburtsdatum").nullable() + val rasse = varchar("rasse", 100).nullable() + val farbe = varchar("farbe", 100).nullable() + val besitzerId = uuid("besitzer_id").nullable() + val verantwortlichePersonId = uuid("verantwortliche_person_id").nullable() + val zuechterName = varchar("zuechter_name", 200).nullable() + val zuchtbuchNummer = varchar("zuchtbuch_nummer", 50).nullable() + val lebensnummer = varchar("lebensnummer", 50).nullable() + val chipNummer = varchar("chip_nummer", 50).nullable() + val passNummer = varchar("pass_nummer", 50).nullable() + val oepsNummer = varchar("oeps_nummer", 50).nullable() + val feiNummer = varchar("fei_nummer", 50).nullable() + val vaterName = varchar("vater_name", 200).nullable() + val mutterName = varchar("mutter_name", 200).nullable() + val mutterVaterName = varchar("mutter_vater_name", 200).nullable() + val stockmass = integer("stockmass").nullable() + val istAktiv = bool("ist_aktiv").default(true) + val bemerkungen = text("bemerkungen").nullable() + val datenQuelle = varchar("daten_quelle", 50) + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp) + + override val primaryKey = PrimaryKey(id) + + init { + index("idx_horse_lebensnummer", isUnique = false, lebensnummer) + index("idx_horse_name", isUnique = false, pferdeName) + } +} 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 fe9fde45..cd118248 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,28 +1,25 @@ @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 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.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 kotlin.uuid.Uuid -import kotlinx.datetime.Clock -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq /** * Implementierung des LandRepository für die Datenbankzugriffe. - * - * Diese Implementierung verwendet Exposed SQL für den Datenbankzugriff - * und mappt zwischen der LandDefinition Domain-Entität und der LandTable. */ class LandRepositoryImpl : LandRepository { - /** - * Konvertiert eine Datenbankzeile in ein Domain-Objekt. - */ private fun rowToLandDefinition(row: ResultRow): LandDefinition { return LandDefinition( landId = row[LandTable.id], @@ -36,8 +33,8 @@ class LandRepositoryImpl : LandRepository { istEwrMitglied = row[LandTable.istEwrMitglied], istAktiv = row[LandTable.istAktiv], sortierReihenfolge = row[LandTable.sortierReihenfolge], - createdAt = row[LandTable.createdAt].toInstant(TimeZone.UTC), - updatedAt = row[LandTable.updatedAt].toInstant(TimeZone.UTC) + createdAt = row[LandTable.createdAt], + updatedAt = row[LandTable.updatedAt] ) } @@ -48,90 +45,82 @@ class LandRepositoryImpl : LandRepository { } override suspend fun findByIsoAlpha2Code(isoAlpha2Code: String): LandDefinition? = DatabaseFactory.dbQuery { - LandTable.selectAll().where { LandTable.isoAlpha2Code eq isoAlpha2Code } + LandTable.selectAll().where { LandTable.isoAlpha2Code eq isoAlpha2Code.uppercase() } .map(::rowToLandDefinition) .singleOrNull() } override suspend fun findByIsoAlpha3Code(isoAlpha3Code: String): LandDefinition? = DatabaseFactory.dbQuery { - LandTable.selectAll().where { LandTable.isoAlpha3Code eq isoAlpha3Code } + LandTable.selectAll().where { LandTable.isoAlpha3Code eq isoAlpha3Code.uppercase() } .map(::rowToLandDefinition) .singleOrNull() } - override suspend fun findByName(searchTerm: String, limit: Int): List = DatabaseFactory.dbQuery { - val pattern = "%$searchTerm%" - LandTable.selectAll().where { - (LandTable.nameDeutsch like pattern) or - (LandTable.nameEnglisch like pattern) - } - .limit(limit) - .map(::rowToLandDefinition) - } - override suspend fun findAllActive(orderBySortierung: Boolean): List = DatabaseFactory.dbQuery { val query = LandTable.selectAll().where { LandTable.istAktiv eq true } - if (orderBySortierung) { query.orderBy(LandTable.sortierReihenfolge to SortOrder.ASC, LandTable.nameDeutsch to SortOrder.ASC) } else { query.orderBy(LandTable.nameDeutsch to SortOrder.ASC) } - query.map(::rowToLandDefinition) } + override suspend fun findByName(searchTerm: String, limit: Int): List = DatabaseFactory.dbQuery { + val pattern = "%$searchTerm%" + LandTable.selectAll() + .where { (LandTable.nameDeutsch like pattern) or (LandTable.nameEnglisch like pattern) } + .limit(limit) + .map(::rowToLandDefinition) + } + override suspend fun findEuMembers(): List = DatabaseFactory.dbQuery { - LandTable.selectAll().where { (LandTable.istEuMitglied eq true) and (LandTable.istAktiv eq true) } - .orderBy(LandTable.sortierReihenfolge to SortOrder.ASC, LandTable.nameDeutsch to SortOrder.ASC) + LandTable.selectAll().where { LandTable.istEuMitglied eq true } + .orderBy(LandTable.nameDeutsch to SortOrder.ASC) .map(::rowToLandDefinition) } override suspend fun findEwrMembers(): List = DatabaseFactory.dbQuery { - LandTable.selectAll().where { (LandTable.istEwrMitglied eq true) and (LandTable.istAktiv eq true) } - .orderBy(LandTable.sortierReihenfolge to SortOrder.ASC, LandTable.nameDeutsch to SortOrder.ASC) + LandTable.selectAll().where { LandTable.istEwrMitglied eq true } + .orderBy(LandTable.nameDeutsch to SortOrder.ASC) .map(::rowToLandDefinition) } override suspend fun save(land: LandDefinition): LandDefinition = DatabaseFactory.dbQuery { - val now = Clock.System.now() - val existingLand = LandTable.selectAll().where { LandTable.id eq land.landId }.singleOrNull() - - if (existingLand == null) { - // Insert a new country - LandTable.insert { stmt -> - stmt[id] = land.landId - stmt[isoAlpha2Code] = land.isoAlpha2Code - stmt[isoAlpha3Code] = land.isoAlpha3Code - stmt[isoNumerischerCode] = land.isoNumerischerCode - stmt[nameDeutsch] = land.nameDeutsch - stmt[nameEnglisch] = land.nameEnglisch - stmt[wappenUrl] = land.wappenUrl - stmt[istEuMitglied] = land.istEuMitglied - stmt[istEwrMitglied] = land.istEwrMitglied - stmt[istAktiv] = land.istAktiv - stmt[sortierReihenfolge] = land.sortierReihenfolge - stmt[createdAt] = land.createdAt.toLocalDateTime(TimeZone.UTC) - stmt[updatedAt] = now.toLocalDateTime(TimeZone.UTC) + val exists = LandTable.selectAll().where { LandTable.id eq land.landId }.any() + if (exists) { + LandTable.update({ LandTable.id eq land.landId }) { + it[isoAlpha2Code] = land.isoAlpha2Code.uppercase() + it[isoAlpha3Code] = land.isoAlpha3Code.uppercase() + it[isoNumerischerCode] = land.isoNumerischerCode + it[nameDeutsch] = land.nameDeutsch + it[nameEnglisch] = land.nameEnglisch + it[wappenUrl] = land.wappenUrl + it[istEuMitglied] = land.istEuMitglied + it[istEwrMitglied] = land.istEwrMitglied + it[istAktiv] = land.istAktiv + it[sortierReihenfolge] = land.sortierReihenfolge + it[updatedAt] = land.updatedAt } + land } else { - // Update existing country - LandTable.update({ LandTable.id eq land.landId }) { stmt -> - stmt[isoAlpha2Code] = land.isoAlpha2Code - stmt[isoAlpha3Code] = land.isoAlpha3Code - stmt[isoNumerischerCode] = land.isoNumerischerCode - stmt[nameDeutsch] = land.nameDeutsch - stmt[nameEnglisch] = land.nameEnglisch - stmt[wappenUrl] = land.wappenUrl - stmt[istEuMitglied] = land.istEuMitglied - stmt[istEwrMitglied] = land.istEwrMitglied - stmt[istAktiv] = land.istAktiv - stmt[sortierReihenfolge] = land.sortierReihenfolge - stmt[updatedAt] = now.toLocalDateTime(TimeZone.UTC) + LandTable.insert { + it[id] = land.landId + it[isoAlpha2Code] = land.isoAlpha2Code.uppercase() + it[isoAlpha3Code] = land.isoAlpha3Code.uppercase() + it[isoNumerischerCode] = land.isoNumerischerCode + it[nameDeutsch] = land.nameDeutsch + it[nameEnglisch] = land.nameEnglisch + it[wappenUrl] = land.wappenUrl + it[istEuMitglied] = land.istEuMitglied + it[istEwrMitglied] = land.istEwrMitglied + it[istAktiv] = land.istAktiv + it[sortierReihenfolge] = land.sortierReihenfolge + it[createdAt] = land.createdAt + it[updatedAt] = land.updatedAt } + land } - - land.copy(updatedAt = now) } override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery { @@ -139,13 +128,11 @@ class LandRepositoryImpl : LandRepository { } override suspend fun existsByIsoAlpha2Code(isoAlpha2Code: String): Boolean = DatabaseFactory.dbQuery { - LandTable.selectAll().where { LandTable.isoAlpha2Code eq isoAlpha2Code } - .count() > 0 + LandTable.selectAll().where { LandTable.isoAlpha2Code eq isoAlpha2Code.uppercase() }.any() } override suspend fun existsByIsoAlpha3Code(isoAlpha3Code: String): Boolean = DatabaseFactory.dbQuery { - LandTable.selectAll().where { LandTable.isoAlpha3Code eq isoAlpha3Code } - .count() > 0 + LandTable.selectAll().where { LandTable.isoAlpha3Code eq isoAlpha3Code.uppercase() }.any() } override suspend fun countActive(): Long = DatabaseFactory.dbQuery { diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/LandTable.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/LandTable.kt index fdad0fe5..eb5b1420 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/LandTable.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/LandTable.kt @@ -1,30 +1,28 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + package at.mocode.masterdata.infrastructure.persistence import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.core.kotlin.datetime.datetime -import org.jetbrains.exposed.v1.core.kotlin.datetime.CurrentDateTime -import org.jetbrains.exposed.v1.core.javaUUID +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.datetime.timestamp /** - * Exposed-Tabellendefinition für die Land-Entität (Länderstammdaten). - * - * Diese Tabelle speichert alle Informationen zu Ländern/Nationen entsprechend - * der LandDefinition Domain-Entität. + * Exposed-Tabellendefinition für die Land-Entität. */ object LandTable : Table("land") { - val id = javaUUID("id").autoGenerate() - val isoAlpha2Code = varchar("iso_alpha2_code", 2).uniqueIndex() - val isoAlpha3Code = varchar("iso_alpha3_code", 3).uniqueIndex() - val isoNumerischerCode = varchar("iso_numerischer_code", 3).nullable() - val nameDeutsch = varchar("name_deutsch", 100) - val nameEnglisch = varchar("name_englisch", 100).nullable() - val wappenUrl = varchar("wappen_url", 500).nullable() - val istEuMitglied = bool("ist_eu_mitglied").nullable() - val istEwrMitglied = bool("ist_ewr_mitglied").nullable() - val istAktiv = bool("ist_aktiv").default(true) - val sortierReihenfolge = integer("sortier_reihenfolge").nullable() - val createdAt = datetime("created_at").defaultExpression(CurrentDateTime) - val updatedAt = datetime("updated_at").defaultExpression(CurrentDateTime) + val id = uuid("land_id") + val isoAlpha2Code = varchar("iso_alpha2_code", 2).uniqueIndex() + val isoAlpha3Code = varchar("iso_alpha3_code", 3).uniqueIndex() + val isoNumerischerCode = varchar("iso_numerischer_code", 3).nullable() + val nameDeutsch = varchar("name_deutsch", 100) + val nameEnglisch = varchar("name_englisch", 100).nullable() + val wappenUrl = varchar("wappen_url", 255).nullable() + val istEuMitglied = bool("ist_eu_mitglied").nullable() + val istEwrMitglied = bool("ist_ewr_mitglied").nullable() + val istAktiv = bool("ist_aktiv").default(true) + val sortierReihenfolge = integer("sortier_reihenfolge").nullable() + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp) - override val primaryKey = PrimaryKey(id) + override val primaryKey = PrimaryKey(id) } diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/PlatzRepositoryImpl.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/PlatzRepositoryImpl.kt index 9770fa4b..fdb46b85 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/PlatzRepositoryImpl.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/PlatzRepositoryImpl.kt @@ -2,28 +2,26 @@ package at.mocode.masterdata.infrastructure.persistence import at.mocode.core.domain.model.PlatzTypE +import at.mocode.core.utils.database.DatabaseFactory import at.mocode.masterdata.domain.model.Platz import at.mocode.masterdata.domain.repository.PlatzRepository -import at.mocode.core.utils.database.DatabaseFactory +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.like +import org.jetbrains.exposed.v1.jdbc.andWhere +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 kotlin.uuid.Uuid -import kotlinx.datetime.Clock -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toInstant -import kotlinx.datetime.toLocalDateTime -import org.jetbrains.exposed.sql.* -import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq /** - * Implementierung des PlatzRepository für die Datenbankzugriffe. - * - * Diese Implementierung verwendet Exposed SQL für den Datenbankzugriff - * und mappt zwischen der Platz Domain-Entität und der PlatzTable. + * Exposed-basierte Implementierung des Platz-Repositorys. */ class PlatzRepositoryImpl : PlatzRepository { - /** - * Konvertiert eine Datenbankzeile in ein Domain-Objekt. - */ private fun rowToPlatz(row: ResultRow): Platz { return Platz( id = row[PlatzTable.id], @@ -34,8 +32,8 @@ class PlatzRepositoryImpl : PlatzRepository { typ = PlatzTypE.valueOf(row[PlatzTable.typ]), istAktiv = row[PlatzTable.istAktiv], sortierReihenfolge = row[PlatzTable.sortierReihenfolge], - createdAt = row[PlatzTable.createdAt].toInstant(TimeZone.UTC), - updatedAt = row[PlatzTable.updatedAt].toInstant(TimeZone.UTC) + createdAt = row[PlatzTable.createdAt], + updatedAt = row[PlatzTable.updatedAt] ) } @@ -47,144 +45,102 @@ class PlatzRepositoryImpl : PlatzRepository { override suspend fun findByTournament(turnierId: Uuid, activeOnly: Boolean, orderBySortierung: Boolean): List = DatabaseFactory.dbQuery { val query = PlatzTable.selectAll().where { PlatzTable.turnierId eq turnierId } - if (activeOnly) { query.andWhere { PlatzTable.istAktiv eq true } } - if (orderBySortierung) { query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC) } else { query.orderBy(PlatzTable.name to SortOrder.ASC) } - query.map(::rowToPlatz) } override suspend fun findByName(searchTerm: String, turnierId: Uuid?, limit: Int): List = DatabaseFactory.dbQuery { val pattern = "%$searchTerm%" val query = PlatzTable.selectAll().where { PlatzTable.name like pattern } - - turnierId?.let { - query.andWhere { PlatzTable.turnierId eq it } - } - - query.limit(limit) - .orderBy(PlatzTable.name to SortOrder.ASC) - .map(::rowToPlatz) + turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } } + query.limit(limit).map(::rowToPlatz) } override suspend fun findByType(typ: PlatzTypE, turnierId: Uuid?, activeOnly: Boolean): List = DatabaseFactory.dbQuery { val query = PlatzTable.selectAll().where { PlatzTable.typ eq typ.name } - - turnierId?.let { - query.andWhere { PlatzTable.turnierId eq it } - } - + turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } } if (activeOnly) { query.andWhere { PlatzTable.istAktiv eq true } } - - query.orderBy(PlatzTable.name to SortOrder.ASC) - .map(::rowToPlatz) + query.map(::rowToPlatz) } override suspend fun findByGroundType(boden: String, turnierId: Uuid?, activeOnly: Boolean): List = DatabaseFactory.dbQuery { val query = PlatzTable.selectAll().where { PlatzTable.boden eq boden } - - turnierId?.let { - query.andWhere { PlatzTable.turnierId eq it } - } - + turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } } if (activeOnly) { query.andWhere { PlatzTable.istAktiv eq true } } - - query.orderBy(PlatzTable.name to SortOrder.ASC) - .map(::rowToPlatz) + query.map(::rowToPlatz) } override suspend fun findByDimensions(dimension: String, turnierId: Uuid?, activeOnly: Boolean): List = DatabaseFactory.dbQuery { val query = PlatzTable.selectAll().where { PlatzTable.dimension eq dimension } - - turnierId?.let { - query.andWhere { PlatzTable.turnierId eq it } - } - + turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } } if (activeOnly) { query.andWhere { PlatzTable.istAktiv eq true } } - - query.orderBy(PlatzTable.name to SortOrder.ASC) - .map(::rowToPlatz) + query.map(::rowToPlatz) } override suspend fun findAllActive(orderBySortierung: Boolean): List = DatabaseFactory.dbQuery { val query = PlatzTable.selectAll().where { PlatzTable.istAktiv eq true } - if (orderBySortierung) { query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC) } else { query.orderBy(PlatzTable.name to SortOrder.ASC) } - query.map(::rowToPlatz) } - override suspend fun findSuitableForDiscipline( - requiredType: PlatzTypE, - requiredDimensions: String?, - turnierId: Uuid? - ): List = DatabaseFactory.dbQuery { - val query = PlatzTable.selectAll().where { - (PlatzTable.typ eq requiredType.name) and (PlatzTable.istAktiv eq true) - } - - requiredDimensions?.let { dimensions -> - query.andWhere { PlatzTable.dimension eq dimensions } - } - - turnierId?.let { - query.andWhere { PlatzTable.turnierId eq it } - } - - query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC) - .map(::rowToPlatz) + override suspend fun findSuitableForDiscipline( + requiredType: PlatzTypE, + requiredDimensions: String?, + turnierId: Uuid? + ): List = DatabaseFactory.dbQuery { + val query = PlatzTable.selectAll().where { PlatzTable.typ eq requiredType.name } + requiredDimensions?.let { dim -> query.andWhere { PlatzTable.dimension eq dim } } + turnierId?.let { tid -> query.andWhere { PlatzTable.turnierId eq tid } } + query.andWhere { PlatzTable.istAktiv eq true } + query.map(::rowToPlatz) } override suspend fun save(platz: Platz): Platz = DatabaseFactory.dbQuery { - val now = Clock.System.now() - val existingPlatz = PlatzTable.selectAll().where { PlatzTable.id eq platz.id }.singleOrNull() - - if (existingPlatz == null) { - // Insert a new venue - PlatzTable.insert { stmt -> - stmt[id] = platz.id - stmt[turnierId] = platz.turnierId - stmt[name] = platz.name - stmt[dimension] = platz.dimension - stmt[boden] = platz.boden - stmt[typ] = platz.typ.name - stmt[istAktiv] = platz.istAktiv - stmt[sortierReihenfolge] = platz.sortierReihenfolge - stmt[createdAt] = platz.createdAt.toLocalDateTime(TimeZone.UTC) - stmt[updatedAt] = now.toLocalDateTime(TimeZone.UTC) + val exists = PlatzTable.selectAll().where { PlatzTable.id eq platz.id }.any() + if (exists) { + PlatzTable.update({ PlatzTable.id eq platz.id }) { + it[turnierId] = platz.turnierId + it[name] = platz.name + it[dimension] = platz.dimension + it[boden] = platz.boden + it[typ] = platz.typ.name + it[istAktiv] = platz.istAktiv + it[sortierReihenfolge] = platz.sortierReihenfolge + it[updatedAt] = platz.updatedAt } + platz } else { - // Update existing venue - PlatzTable.update({ PlatzTable.id eq platz.id }) { stmt -> - stmt[turnierId] = platz.turnierId - stmt[name] = platz.name - stmt[dimension] = platz.dimension - stmt[boden] = platz.boden - stmt[typ] = platz.typ.name - stmt[istAktiv] = platz.istAktiv - stmt[sortierReihenfolge] = platz.sortierReihenfolge - stmt[updatedAt] = now.toLocalDateTime(TimeZone.UTC) + PlatzTable.insert { + it[id] = platz.id + it[turnierId] = platz.turnierId + it[name] = platz.name + it[dimension] = platz.dimension + it[boden] = platz.boden + it[typ] = platz.typ.name + it[istAktiv] = platz.istAktiv + it[sortierReihenfolge] = platz.sortierReihenfolge + it[createdAt] = platz.createdAt + it[updatedAt] = platz.updatedAt } + platz } - - platz.copy(updatedAt = now) } override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery { @@ -192,40 +148,24 @@ class PlatzRepositoryImpl : PlatzRepository { } override suspend fun existsByNameAndTournament(name: String, turnierId: Uuid): Boolean = DatabaseFactory.dbQuery { - PlatzTable.selectAll().where { - (PlatzTable.name eq name) and (PlatzTable.turnierId eq turnierId) - }.count() > 0 + PlatzTable.selectAll().where { (PlatzTable.name eq name) and (PlatzTable.turnierId eq turnierId) }.any() } override suspend fun countActiveByTournament(turnierId: Uuid): Long = DatabaseFactory.dbQuery { - PlatzTable.selectAll().where { - (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) - }.count() + PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) }.count() } override suspend fun countByTypeAndTournament(typ: PlatzTypE, turnierId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery { - val query = PlatzTable.selectAll().where { - (PlatzTable.typ eq typ.name) and (PlatzTable.turnierId eq turnierId) - } - + val query = PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.typ eq typ.name) } if (activeOnly) { query.andWhere { PlatzTable.istAktiv eq true } } - query.count() } override suspend fun findAvailableForTimeSlot(turnierId: Uuid, startTime: String?, endTime: String?): List = DatabaseFactory.dbQuery { - // For now, this returns all active venues for the tournament - // This can be extended when venue scheduling functionality is implemented - val query = PlatzTable.selectAll().where { - (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) - } - - // TODO: Add time slot availability logic when scheduling is implemented - // This would involve joining with a scheduling/booking table to check availability - - query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC) + // Derzeit gibt die Methode einfach alle aktiven Plätze des Turniers zurück. + PlatzTable.selectAll().where { (PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true) } .map(::rowToPlatz) } } diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/PlatzTable.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/PlatzTable.kt index 54e313f4..169adb84 100644 --- a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/PlatzTable.kt +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/PlatzTable.kt @@ -1,38 +1,29 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + package at.mocode.masterdata.infrastructure.persistence import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.core.kotlin.datetime.datetime -import org.jetbrains.exposed.v1.core.kotlin.datetime.CurrentDateTime -import org.jetbrains.exposed.v1.core.javaUUID +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.datetime.timestamp /** - * Exposed-Tabellendefinition für die Platz-Entität (Turnierplätze/Wettkampfstätten). - * - * Diese Tabelle speichert alle Informationen zu Plätzen und Arenen - * entsprechend der Platz Domain-Entität. + * Exposed-Tabellendefinition für die Platz-Entität. */ object PlatzTable : Table("platz") { - val id = javaUUID("id").autoGenerate() - val turnierId = javaUUID("turnier_id") // Foreign key to tournament (not enforced here as tournament might be in different module) - val name = varchar("name", 200) - val dimension = varchar("dimension", 50).nullable() - val boden = varchar("boden", 100).nullable() - val typ = varchar("typ", 50) // Enum as string - val istAktiv = bool("ist_aktiv").default(true) - val sortierReihenfolge = integer("sortier_reihenfolge").nullable() - val createdAt = datetime("created_at").defaultExpression(CurrentDateTime) - val updatedAt = datetime("updated_at").defaultExpression(CurrentDateTime) + val id = uuid("platz_id") + val turnierId = uuid("turnier_id") + val name = varchar("name", 100) + val dimension = varchar("dimension", 50).nullable() + val boden = varchar("boden", 50).nullable() + val typ = varchar("typ", 50) + val istAktiv = bool("ist_aktiv").default(true) + val sortierReihenfolge = integer("sortier_reihenfolge").nullable() + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp) - override val primaryKey = PrimaryKey(id) + override val primaryKey = PrimaryKey(id) - init { - // Index for performance on common queries - index(customIndexName = "idx_platz_turnier", columns = arrayOf(turnierId)) - index(customIndexName = "idx_platz_aktiv", columns = arrayOf(istAktiv)) - index(customIndexName = "idx_platz_typ", columns = arrayOf(typ)) - index(customIndexName = "idx_platz_turnier_aktiv", columns = arrayOf(turnierId, istAktiv)) - - // Unique constraint for name per tournament - uniqueIndex("uk_platz_name_turnier", name, turnierId) - } + init { + index("idx_platz_turnier", isUnique = false, turnierId) + } } diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ReiterTable.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ReiterTable.kt new file mode 100644 index 00000000..90bca385 --- /dev/null +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/ReiterTable.kt @@ -0,0 +1,40 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.infrastructure.persistence + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.datetime.date +import org.jetbrains.exposed.v1.datetime.timestamp + +/** + * Exposed-Tabellendefinition für die Reiter-Entität. + */ +object ReiterTable : Table("reiter") { + val id = uuid("reiter_id") + val personId = uuid("person_id") + val satznummer = varchar("satznummer", 10).uniqueIndex() + val lizenzNummer = varchar("lizenz_nummer", 20).nullable() + val lizenzKlasse = varchar("lizenz_klasse", 20) + val startkartAktiv = bool("startkart_aktiv").default(false) + val startkartSaison = integer("startkart_saison").nullable() + val feiId = varchar("fei_id", 20).nullable() + val nation = varchar("nation", 3).nullable() + val nachname = varchar("nachname", 100) + val vorname = varchar("vorname", 100) + val geburtsdatum = date("geburtsdatum").nullable() + val vereinsNummer = varchar("vereins_nummer", 10).nullable() + val vereinsName = varchar("vereins_name", 200).nullable() + val istGastreiter = bool("ist_gastreiter").default(false) + val istAktiv = bool("ist_aktiv").default(true) + val datenQuelle = varchar("daten_quelle", 50) + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp) + + override val primaryKey = PrimaryKey(id) + + init { + index("idx_reiter_satznummer", isUnique = true, satznummer) + index("idx_reiter_name", isUnique = false, nachname, vorname) + } +} diff --git a/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/VereinTable.kt b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/VereinTable.kt new file mode 100644 index 00000000..4da8570e --- /dev/null +++ b/backend/services/masterdata/masterdata-infrastructure/src/main/kotlin/at/mocode/masterdata/infrastructure/persistence/VereinTable.kt @@ -0,0 +1,33 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) + +package at.mocode.masterdata.infrastructure.persistence + +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.datetime.CurrentTimestamp +import org.jetbrains.exposed.v1.datetime.timestamp + +/** + * Exposed-Tabellendefinition für die Verein-Entität. + */ +object VereinTable : Table("verein") { + val id = uuid("verein_id") + val vereinsNummer = varchar("vereins_nummer", 10).uniqueIndex() + val name = varchar("name", 200) + val kurzname = varchar("kurzname", 100).nullable() + val bundesland = varchar("bundesland", 100).nullable() + val ort = varchar("ort", 100).nullable() + val plz = varchar("plz", 10).nullable() + val strasse = varchar("strasse", 200).nullable() + val email = varchar("email", 200).nullable() + val telefon = varchar("telefon", 50).nullable() + val website = varchar("website", 255).nullable() + val oepsRegionNummer = varchar("oeps_region_nummer", 10).nullable() + val istVeranstalter = bool("ist_veranstalter").default(false) + val istAktiv = bool("ist_aktiv").default(true) + val bemerkungen = text("bemerkungen").nullable() + val datenQuelle = varchar("daten_quelle", 50) + val createdAt = timestamp("created_at").defaultExpression(CurrentTimestamp) + val updatedAt = timestamp("updated_at").defaultExpression(CurrentTimestamp) + + override val primaryKey = PrimaryKey(id) +} diff --git a/backend/services/masterdata/masterdata-service/build.gradle.kts b/backend/services/masterdata/masterdata-service/build.gradle.kts index be5e1e18..3f05e49a 100644 --- a/backend/services/masterdata/masterdata-service/build.gradle.kts +++ b/backend/services/masterdata/masterdata-service/build.gradle.kts @@ -1,57 +1,53 @@ plugins { - alias(libs.plugins.kotlin.jvm) - alias(libs.plugins.kotlin.spring) - - // KORREKTUR: Dieses Plugin ist entscheidend. Es schaltet den `springBoot`-Block - // und alle Spring-Boot-spezifischen Gradle-Tasks frei. - alias(libs.plugins.spring.boot) - - // Dependency Management für konsistente Spring-Versionen - alias(libs.plugins.spring.dependencyManagement) + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.spring.boot) + alias(libs.plugins.spring.dependencyManagement) + alias(libs.plugins.kotlinSpring) } // Dieser Block funktioniert jetzt, weil das `springBoot`-Plugin oben aktiviert ist. springBoot { - mainClass.set("at.mocode.masterdata.service.MasterdataServiceApplicationKt") + mainClass.set("at.mocode.masterdata.service.MasterdataServiceApplicationKt") } dependencies { - // Interne Module - implementation(projects.platform.platformDependencies) - implementation(projects.core.coreUtils) - implementation(projects.masterdata.masterdataDomain) - implementation(projects.masterdata.masterdataApplication) - implementation(projects.masterdata.masterdataInfrastructure) - implementation(projects.masterdata.masterdataApi) + // Interne Module + implementation(projects.platform.platformDependencies) + implementation(projects.core.coreUtils) + implementation(projects.backend.services.masterdata.masterdataDomain) + implementation(projects.backend.services.masterdata.masterdataInfrastructure) + implementation(projects.backend.services.masterdata.masterdataCommon) + implementation(projects.backend.services.masterdata.masterdataApi) - // Infrastruktur-Clients - implementation(projects.infrastructure.cache.redisCache) - implementation(projects.infrastructure.messaging.messagingClient) - implementation(projects.infrastructure.monitoring.monitoringClient) + // Infrastruktur-Clients + implementation(projects.backend.infrastructure.cache.valkeyCache) + implementation(projects.backend.infrastructure.messaging.messagingClient) + implementation(projects.backend.infrastructure.monitoring.monitoringClient) - // KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen. + // KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen. - // Spring Boot Starters - implementation(libs.spring.boot.starter.web) - implementation(libs.spring.boot.starter.validation) - implementation(libs.spring.boot.starter.actuator) - //implementation(libs.springdoc.openapi.starter.webmvc.ui) + // Spring Boot Starters + implementation(libs.spring.boot.starter.web) + implementation(libs.spring.boot.starter.validation) + implementation(libs.spring.boot.starter.actuator) + //implementation(libs.springdoc.openapi.starter.webmvc.ui) - // Datenbank-Abhängigkeiten - implementation(libs.exposed.core) - implementation(libs.exposed.dao) - implementation(libs.exposed.jdbc) - implementation(libs.exposed.kotlin.datetime) - implementation(libs.hikari.cp) - runtimeOnly(libs.postgresql.driver) - testRuntimeOnly(libs.h2.driver) + // Datenbank-Abhängigkeiten + implementation(libs.exposed.core) + implementation(libs.exposed.dao) + implementation(libs.exposed.jdbc) + implementation(libs.exposed.kotlin.datetime) + implementation(libs.hikari.cp) + // implementation(libs.firebase.database.ktx) // Firebase removed + runtimeOnly(libs.postgresql.driver) + testRuntimeOnly(libs.h2.driver) - // Testing - testImplementation(projects.platform.platformTesting) - testImplementation(libs.spring.boot.starter.test) - testImplementation(libs.logback.classic) // SLF4J provider for tests + // Testing + testImplementation(projects.platform.platformTesting) + testImplementation(libs.spring.boot.starter.test) + testImplementation(libs.logback.classic) // SLF4J provider for tests } tasks.test { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/MasterdataServiceApplication.kt b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/MasterdataServiceApplication.kt index 35cfeea2..d0d20624 100644 --- a/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/MasterdataServiceApplication.kt +++ b/backend/services/masterdata/masterdata-service/src/main/kotlin/at/mocode/masterdata/service/MasterdataServiceApplication.kt @@ -1,7 +1,5 @@ package at.mocode.masterdata.service -import at.mocode.core.utils.config.AppConfig -import at.mocode.core.utils.database.DatabaseFactory import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication @@ -14,17 +12,6 @@ import org.springframework.boot.runApplication class MasterdataServiceApplication fun main(args: Array) { - // 1. Lade die Konfiguration explizit, genau einmal beim Start. - val appConfig = AppConfig.load() - println("Konfiguration für Umgebung '${appConfig.environment}' geladen.") - - // 2. Initialisiere die Datenbank mit der geladenen Konfiguration. - // Flyway-Migrationen werden hier automatisch ausgeführt. - DatabaseFactory.init(appConfig.database) - println("Datenbank initialisiert und migriert.") - - // 3. Starte die Spring Boot / Ktor Anwendung. - // Der appConfig-Wert kann hier an die Anwendung übergeben werden, - // um ihn später per Dependency Injection zu nutzen. - runApplication(*args) + // Starte die Spring Boot Anwendung. + runApplication(*args) } 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 0cdb25b5..51b824ae 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 @@ -17,103 +17,123 @@ import org.springframework.context.annotation.Profile @Configuration class MasterdataConfiguration { - // Repository Implementations - @Bean - fun landRepository(): LandRepository { - return LandRepositoryImpl() - } + // Repository Implementations + @Bean + fun landRepository(): LandRepository { + return LandRepositoryImpl() + } - @Bean - fun bundeslandRepository(): BundeslandRepository { - return BundeslandRepositoryImpl() - } + @Bean + fun bundeslandRepository(): BundeslandRepository { + return BundeslandRepositoryImpl() + } - @Bean - fun altersklasseRepository(): AltersklasseRepository { - return AltersklasseRepositoryImpl() - } + @Bean + fun altersklasseRepository(): AltersklasseRepository { + return AltersklasseRepositoryImpl() + } - @Bean - fun platzRepository(): PlatzRepository { - return PlatzRepositoryImpl() - } + @Bean + fun platzRepository(): PlatzRepository { + return PlatzRepositoryImpl() + } - // Use Cases - Country/Land - @Bean - fun getCountryUseCase(landRepository: LandRepository): GetCountryUseCase { - return GetCountryUseCase(landRepository) - } + @Bean + fun reiterRepository(): ReiterRepository { + return ExposedReiterRepository() + } - @Bean - fun createCountryUseCase(landRepository: LandRepository): CreateCountryUseCase { - return CreateCountryUseCase(landRepository) - } + @Bean + fun vereinRepository(): VereinRepository { + return ExposedVereinRepository() + } - // Use Cases - Federal State/Bundesland - @Bean - fun getBundeslandUseCase(bundeslandRepository: BundeslandRepository): GetBundeslandUseCase { - return GetBundeslandUseCase(bundeslandRepository) - } + @Bean + fun horseRepository(): HorseRepository { + return HorseRepositoryImpl() + } - @Bean - fun createBundeslandUseCase(bundeslandRepository: BundeslandRepository): CreateBundeslandUseCase { - return CreateBundeslandUseCase(bundeslandRepository) - } + @Bean + fun funktionaerRepository(): FunktionaerRepository { + return ExposedFunktionaerRepository() + } - // Use Cases - Age Class/Altersklasse - @Bean - fun getAltersklasseUseCase(altersklasseRepository: AltersklasseRepository): GetAltersklasseUseCase { - return GetAltersklasseUseCase(altersklasseRepository) - } + // Use Cases - Country/Land + @Bean + fun getCountryUseCase(landRepository: LandRepository): GetCountryUseCase { + return GetCountryUseCase(landRepository) + } - @Bean - fun createAltersklasseUseCase(altersklasseRepository: AltersklasseRepository): CreateAltersklasseUseCase { - return CreateAltersklasseUseCase(altersklasseRepository) - } + @Bean + fun createCountryUseCase(landRepository: LandRepository): CreateCountryUseCase { + return CreateCountryUseCase(landRepository) + } - // Use Cases - Venue/Platz - @Bean - fun getPlatzUseCase(platzRepository: PlatzRepository): GetPlatzUseCase { - return GetPlatzUseCase(platzRepository) - } + // Use Cases - Federal State/Bundesland + @Bean + fun getBundeslandUseCase(bundeslandRepository: BundeslandRepository): GetBundeslandUseCase { + return GetBundeslandUseCase(bundeslandRepository) + } - @Bean - fun createPlatzUseCase(platzRepository: PlatzRepository): CreatePlatzUseCase { - return CreatePlatzUseCase(platzRepository) - } + @Bean + fun createBundeslandUseCase(bundeslandRepository: BundeslandRepository): CreateBundeslandUseCase { + return CreateBundeslandUseCase(bundeslandRepository) + } - // API Controllers - @Bean - fun countryController( - getCountryUseCase: GetCountryUseCase, - createCountryUseCase: CreateCountryUseCase - ): CountryController { - return CountryController(getCountryUseCase, createCountryUseCase) - } + // Use Cases - Age Class/Altersklasse + @Bean + fun getAltersklasseUseCase(altersklasseRepository: AltersklasseRepository): GetAltersklasseUseCase { + return GetAltersklasseUseCase(altersklasseRepository) + } - @Bean - fun bundeslandController( - getBundeslandUseCase: GetBundeslandUseCase, - createBundeslandUseCase: CreateBundeslandUseCase - ): BundeslandController { - return BundeslandController(getBundeslandUseCase, createBundeslandUseCase) - } + @Bean + fun createAltersklasseUseCase(altersklasseRepository: AltersklasseRepository): CreateAltersklasseUseCase { + return CreateAltersklasseUseCase(altersklasseRepository) + } - @Bean - fun altersklasseController( - getAltersklasseUseCase: GetAltersklasseUseCase, - createAltersklasseUseCase: CreateAltersklasseUseCase - ): AltersklasseController { - return AltersklasseController(getAltersklasseUseCase, createAltersklasseUseCase) - } + // Use Cases - Venue/Platz + @Bean + fun getPlatzUseCase(platzRepository: PlatzRepository): GetPlatzUseCase { + return GetPlatzUseCase(platzRepository) + } - @Bean - fun platzController( - getPlatzUseCase: GetPlatzUseCase, - createPlatzUseCase: CreatePlatzUseCase - ): PlatzController { - return PlatzController(getPlatzUseCase, createPlatzUseCase) - } + @Bean + fun createPlatzUseCase(platzRepository: PlatzRepository): CreatePlatzUseCase { + return CreatePlatzUseCase(platzRepository) + } + + // API Controllers + @Bean + fun countryController( + getCountryUseCase: GetCountryUseCase, + createCountryUseCase: CreateCountryUseCase + ): CountryController { + return CountryController(getCountryUseCase, createCountryUseCase) + } + + @Bean + fun bundeslandController( + getBundeslandUseCase: GetBundeslandUseCase, + createBundeslandUseCase: CreateBundeslandUseCase + ): BundeslandController { + return BundeslandController(getBundeslandUseCase, createBundeslandUseCase) + } + + @Bean + fun altersklasseController( + getAltersklasseUseCase: GetAltersklasseUseCase, + createAltersklasseUseCase: CreateAltersklasseUseCase + ): AltersklasseController { + return AltersklasseController(getAltersklasseUseCase, createAltersklasseUseCase) + } + + @Bean + fun platzController( + getPlatzUseCase: GetPlatzUseCase, + createPlatzUseCase: CreatePlatzUseCase + ): PlatzController { + return PlatzController(getPlatzUseCase, createPlatzUseCase) + } } /** @@ -122,33 +142,33 @@ class MasterdataConfiguration { @Configuration class DatabaseConfiguration { - /** - * Development database configuration. - */ - @Configuration - @Profile("dev", "development") - class DevelopmentDatabaseConfig { - // Development-specific database configuration - // This would typically include H2 or local PostgreSQL setup - } + /** + * Development database configuration. + */ + @Configuration + @Profile("dev", "development") + class DevelopmentDatabaseConfig { + // Development-specific database configuration + // This would typically include H2 or local PostgreSQL setup + } - /** - * Production database configuration. - */ - @Configuration - @Profile("prod", "production") - class ProductionDatabaseConfig { - // Production-specific database configuration - // This would include production PostgreSQL setup with connection pooling - } + /** + * Production database configuration. + */ + @Configuration + @Profile("prod", "production") + class ProductionDatabaseConfig { + // Production-specific database configuration + // This would include production PostgreSQL setup with connection pooling + } - /** - * Test database configuration. - */ - @Configuration - @Profile("test") - class TestDatabaseConfig { - // Test-specific database configuration - // This would typically include in-memory H2 database - } + /** + * Test database configuration. + */ + @Configuration + @Profile("test") + class TestDatabaseConfig { + // Test-specific database configuration + // This would typically include in-memory H2 database + } } 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 734dc5d6..b48dfa37 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 @@ -1,18 +1,17 @@ package at.mocode.masterdata.service.config -import at.mocode.core.utils.database.DatabaseConfig -import at.mocode.core.utils.database.DatabaseFactory -import at.mocode.masterdata.infrastructure.persistence.LandTable -import at.mocode.masterdata.infrastructure.persistence.BundeslandTable + import at.mocode.masterdata.infrastructure.persistence.AltersklasseTable +import at.mocode.masterdata.infrastructure.persistence.BundeslandTable +import at.mocode.masterdata.infrastructure.persistence.LandTable import at.mocode.masterdata.infrastructure.persistence.PlatzTable -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Profile import jakarta.annotation.PostConstruct import jakarta.annotation.PreDestroy +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.slf4j.LoggerFactory -import org.jetbrains.exposed.sql.SchemaUtils -import org.jetbrains.exposed.sql.transactions.transaction +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Profile /** * Database configuration for the Masterdata Service. @@ -24,40 +23,33 @@ import org.jetbrains.exposed.sql.transactions.transaction @Profile("!test") class MasterdataDatabaseConfiguration { - private val log = LoggerFactory.getLogger(MasterdataDatabaseConfiguration::class.java) + private val log = LoggerFactory.getLogger(MasterdataDatabaseConfiguration::class.java) - @PostConstruct - fun initializeDatabase() { - log.info("Initializing database schema for Masterdata Service...") + @PostConstruct + fun initializeDatabase() { + log.info("Initializing database schema for Masterdata Service...") - try { - // Database connection is already initialized by the gateway - // Only initialize the schema for this service - transaction { - SchemaUtils.create( - LandTable, - BundeslandTable, - AltersklasseTable, - PlatzTable - ) - log.info("Masterdata database schema initialized successfully") - } - } catch (e: Exception) { - log.error("Failed to initialize database schema", e) - throw e - } + try { + // Database connection should be initialized by Spring Boot + transaction { + SchemaUtils.create( + LandTable, + BundeslandTable, + AltersklasseTable, + PlatzTable + ) + log.info("Masterdata database schema initialized successfully") + } + } catch (e: Exception) { + log.error("Failed to initialize database schema", e) + throw e } + } - @PreDestroy - fun closeDatabase() { - log.info("Closing database connection for Masterdata Service...") - try { - DatabaseFactory.close() - log.info("Database connection closed successfully") - } catch (e: Exception) { - log.error("Error closing database connection", e) - } - } + @PreDestroy + fun closeDatabase() { + log.info("Closing Masterdata Service database configuration...") + } } /** @@ -67,51 +59,31 @@ class MasterdataDatabaseConfiguration { @Profile("test") class MasterdataTestDatabaseConfiguration { - private val log = LoggerFactory.getLogger(MasterdataTestDatabaseConfiguration::class.java) + private val log = LoggerFactory.getLogger(MasterdataTestDatabaseConfiguration::class.java) - @PostConstruct - fun initializeTestDatabase() { - log.info("Initializing test database connection for Masterdata Service...") + @PostConstruct + fun initializeTestDatabase() { + log.info("Initializing test database schema for Masterdata Service...") - try { - // Use H2 in-memory database for tests - val testConfig = DatabaseConfig( - jdbcUrl = "jdbc:h2:mem:masterdata_test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE", - username = "sa", - password = "", - driverClassName = "org.h2.Driver", - maxPoolSize = 5, - minPoolSize = 1, - autoMigrate = true - ) - - DatabaseFactory.init(testConfig) - log.info("Test database connection initialized successfully") - - // Initialize database schema for tests - transaction { - SchemaUtils.create( - LandTable, - BundeslandTable, - AltersklasseTable, - PlatzTable - ) - log.info("Test masterdata database schema initialized successfully") - } - } catch (e: Exception) { - log.error("Failed to initialize test database connection", e) - throw e - } + try { + // Initialize database schema for tests + transaction { + SchemaUtils.create( + LandTable, + BundeslandTable, + AltersklasseTable, + PlatzTable + ) + log.info("Test masterdata database schema initialized successfully") + } + } catch (e: Exception) { + log.error("Failed to initialize test database schema", e) + throw e } + } - @PreDestroy - fun closeTestDatabase() { - log.info("Closing test database connection for Masterdata Service...") - try { - DatabaseFactory.close() - log.info("Test database connection closed successfully") - } catch (e: Exception) { - log.error("Error closing test database connection", e) - } - } + @PreDestroy + fun closeTestDatabase() { + log.info("Closing test database configuration for Masterdata Service...") + } } diff --git a/backend/services/officials/officials-domain/build.gradle.kts b/backend/services/officials/officials-domain/build.gradle.kts deleted file mode 100644 index 80e97efb..00000000 --- a/backend/services/officials/officials-domain/build.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.kotlinSerialization) -} - -kotlin { - jvm() - - sourceSets { - commonMain { - kotlin.srcDir("src/main/kotlin") - dependencies { - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - implementation(libs.kotlinx.datetime) - implementation(libs.kotlinx.serialization.json) - } - } - commonTest { - kotlin.srcDir("src/test/kotlin") - dependencies { - implementation(projects.platform.platformTesting) - } - } - } -} diff --git a/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/model/DomOfficial.kt b/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/model/DomOfficial.kt deleted file mode 100644 index fdcfe7a1..00000000 --- a/backend/services/officials/officials-domain/src/main/kotlin/at/mocode/officials/domain/model/DomOfficial.kt +++ /dev/null @@ -1,40 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) - -package at.mocode.officials.domain.model - -import at.mocode.core.domain.model.DatenQuelleE -import at.mocode.core.domain.serialization.KotlinInstantSerializer -import at.mocode.core.domain.serialization.UuidSerializer -import kotlinx.serialization.Serializable -import kotlin.time.Clock -import kotlin.time.Instant -import kotlin.uuid.Uuid - -/** - * Domain model representing an official (Richter) in the registry system. - * - * @property officialId Unique internal identifier (UUID). - * @property richterNummer ÖPS official number (from ZNS RICHT01.dat). - * @property name Full name of the official. - * @property qualifikation Qualification/class code (e.g. "GA", "G3"). - * @property datenQuelle Source of the data. - * @property createdAt Timestamp when this record was created. - * @property updatedAt Timestamp when this record was last updated. - */ -@Serializable -data class DomOfficial( - @Serializable(with = UuidSerializer::class) - val officialId: Uuid = Uuid.random(), - - val richterNummer: String, - val name: String, - val qualifikation: String? = null, - - val istAktiv: Boolean = true, - val datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS, - - @Serializable(with = KotlinInstantSerializer::class) - val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) - var updatedAt: Instant = Clock.System.now() -) diff --git a/backend/services/officials/officials-infrastructure/build.gradle.kts b/backend/services/officials/officials-infrastructure/build.gradle.kts deleted file mode 100644 index a91c977a..00000000 --- a/backend/services/officials/officials-infrastructure/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -plugins { - alias(libs.plugins.kotlinJvm) - alias(libs.plugins.kotlinSpring) - alias(libs.plugins.kotlinSerialization) -} - -dependencies { - implementation(projects.platform.platformDependencies) - implementation(projects.backend.services.officials.officialsDomain) - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - - implementation(libs.spring.boot.starter.web) - - implementation(libs.exposed.core) - implementation(libs.exposed.dao) - implementation(libs.exposed.jdbc) - implementation(libs.exposed.kotlin.datetime) - - runtimeOnly(libs.postgresql.driver) - - testImplementation(projects.platform.platformTesting) -} diff --git a/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/ExposedFunktionaerRepository.kt b/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/ExposedFunktionaerRepository.kt deleted file mode 100644 index 80977258..00000000 --- a/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/ExposedFunktionaerRepository.kt +++ /dev/null @@ -1,185 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) - -package at.mocode.officials.infrastructure.persistence - -import at.mocode.core.domain.model.DatenQuelleE -import at.mocode.core.domain.model.FunktionaerRolleE -import at.mocode.core.domain.model.RichterQualifikationE -import at.mocode.core.domain.model.SparteE -import at.mocode.officials.domain.model.DomFunktionaer -import at.mocode.officials.domain.repository.FunktionaerRepository -import kotlinx.serialization.json.Json -import org.jetbrains.exposed.v1.core.* -import org.jetbrains.exposed.v1.core.statements.UpdateBuilder -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.transactions.transaction -import org.jetbrains.exposed.v1.jdbc.update -import kotlin.time.Clock -import kotlin.uuid.Uuid -import kotlin.uuid.toJavaUuid -import kotlin.uuid.toKotlinUuid - -/** - * Exposed-basierte Implementierung des FunktionaerRepository. - */ -class ExposedFunktionaerRepository : FunktionaerRepository { - - override suspend fun findById(id: Uuid): DomFunktionaer? = transaction { - FunktionaerTable.selectAll().where { FunktionaerTable.id eq id.toJavaUuid() } - .map { rowToFunktionaer(it) } - .singleOrNull() - } - - override suspend fun findByRichterNummer(richterNummer: String): DomFunktionaer? = transaction { - FunktionaerTable.selectAll().where { FunktionaerTable.richterNummer eq richterNummer } - .map { rowToFunktionaer(it) } - .singleOrNull() - } - - override suspend fun findByName(searchTerm: String, limit: Int): List = transaction { - val pattern = "%$searchTerm%" - FunktionaerTable.selectAll().where { - (FunktionaerTable.nachname like pattern) or (FunktionaerTable.vorname like pattern) - }.limit(limit).map { rowToFunktionaer(it) } - } - - override suspend fun findByRolle(rolle: FunktionaerRolleE, activeOnly: Boolean): List = transaction { - FunktionaerTable.selectAll().where { - (FunktionaerTable.rollen like "%${rolle.name}%").let { - if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it - } - }.map { rowToFunktionaer(it) } - } - - override suspend fun findByRichterQualifikation( - qualifikation: RichterQualifikationE, - activeOnly: Boolean - ): List = transaction { - FunktionaerTable.selectAll().where { - (FunktionaerTable.richterQualifikation eq qualifikation.name).let { - if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it - } - }.map { rowToFunktionaer(it) } - } - - override suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean): List = transaction { - FunktionaerTable.selectAll().where { - (FunktionaerTable.qualifiziertFuerSparten like "%${sparte.name}%").let { - if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it - } - }.map { rowToFunktionaer(it) } - } - - override suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean): List = - transaction { - FunktionaerTable.selectAll().where { - (FunktionaerTable.vereinsNummer eq vereinsNummer).let { - if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it - } - }.map { rowToFunktionaer(it) } - } - - override suspend fun findAllActive(limit: Int, offset: Int): List = transaction { - FunktionaerTable.selectAll().where { FunktionaerTable.istAktiv eq true } - .limit(limit).offset(offset.toLong()) - .map { rowToFunktionaer(it) } - } - - override suspend fun findAll(limit: Int, offset: Int): List = transaction { - FunktionaerTable.selectAll() - .limit(limit).offset(offset.toLong()) - .map { rowToFunktionaer(it) } - } - - override suspend fun save(funktionaer: DomFunktionaer): DomFunktionaer = transaction { - val now = Clock.System.now() - val updated = funktionaer.copy(updatedAt = now) - val javaId = funktionaer.funktionaerId.toJavaUuid() - val existing = FunktionaerTable.selectAll().where { FunktionaerTable.id eq javaId }.singleOrNull() - if (existing != null) { - FunktionaerTable.update({ FunktionaerTable.id eq javaId }) { funktionaerToStatement(it, updated) } - } else { - FunktionaerTable.insert { - it[id] = javaId - funktionaerToStatement(it, updated) - } - } - updated - } - - override suspend fun delete(id: Uuid): Boolean = transaction { - FunktionaerTable.deleteWhere { FunktionaerTable.id eq id.toJavaUuid() } > 0 - } - - override suspend fun countActive(): Long = transaction { - FunktionaerTable.selectAll().where { FunktionaerTable.istAktiv eq true }.count() - } - - override suspend fun countByRichterQualifikation(qualifikation: RichterQualifikationE, activeOnly: Boolean): Long = - transaction { - FunktionaerTable.selectAll().where { - (FunktionaerTable.richterQualifikation eq qualifikation.name).let { - if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it - } - }.count() - } - - override suspend fun existsByRichterNummer(richterNummer: String): Boolean = transaction { - FunktionaerTable.selectAll().where { FunktionaerTable.richterNummer eq richterNummer }.count() > 0 - } - - private fun rowToFunktionaer(row: ResultRow): DomFunktionaer { - val rollen = try { - Json.decodeFromString>(row[FunktionaerTable.rollen]).toSet() - } catch (_: Exception) { - emptySet() - } - - val sparten = try { - Json.decodeFromString>(row[FunktionaerTable.qualifiziertFuerSparten]).toSet() - } catch (_: Exception) { - emptySet() - } - - return DomFunktionaer( - funktionaerId = row[FunktionaerTable.id].toKotlinUuid(), - richterNummer = row[FunktionaerTable.richterNummer], - vorname = row[FunktionaerTable.vorname], - nachname = row[FunktionaerTable.nachname], - geburtsdatum = row[FunktionaerTable.geburtsdatum], - rollen = rollen, - richterQualifikation = row[FunktionaerTable.richterQualifikation]?.let { - runCatching { RichterQualifikationE.valueOf(it) }.getOrNull() - }, - qualifiziertFuerSparten = sparten, - email = row[FunktionaerTable.email], - telefon = row[FunktionaerTable.telefon], - vereinsNummer = row[FunktionaerTable.vereinsNummer], - istAktiv = row[FunktionaerTable.istAktiv], - bemerkungen = row[FunktionaerTable.bemerkungen], - datenQuelle = runCatching { DatenQuelleE.valueOf(row[FunktionaerTable.datenQuelle]) }.getOrDefault(DatenQuelleE.IMPORT_ZNS), - createdAt = row[FunktionaerTable.createdAt], - updatedAt = row[FunktionaerTable.updatedAt] - ) - } - - private fun funktionaerToStatement(stmt: UpdateBuilder<*>, f: DomFunktionaer) { - stmt[FunktionaerTable.richterNummer] = f.richterNummer - stmt[FunktionaerTable.vorname] = f.vorname - stmt[FunktionaerTable.nachname] = f.nachname - stmt[FunktionaerTable.geburtsdatum] = f.geburtsdatum - stmt[FunktionaerTable.rollen] = Json.encodeToString(f.rollen.toList()) - stmt[FunktionaerTable.richterQualifikation] = f.richterQualifikation?.name - stmt[FunktionaerTable.qualifiziertFuerSparten] = Json.encodeToString(f.qualifiziertFuerSparten.toList()) - stmt[FunktionaerTable.email] = f.email - stmt[FunktionaerTable.telefon] = f.telefon - stmt[FunktionaerTable.vereinsNummer] = f.vereinsNummer - stmt[FunktionaerTable.istAktiv] = f.istAktiv - stmt[FunktionaerTable.bemerkungen] = f.bemerkungen - stmt[FunktionaerTable.datenQuelle] = f.datenQuelle.name - stmt[FunktionaerTable.createdAt] = f.createdAt - stmt[FunktionaerTable.updatedAt] = f.updatedAt - } -} diff --git a/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/FunktionaerTable.kt b/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/FunktionaerTable.kt deleted file mode 100644 index caa7e05d..00000000 --- a/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/FunktionaerTable.kt +++ /dev/null @@ -1,53 +0,0 @@ -package at.mocode.officials.infrastructure.persistence - -import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.core.java.javaUUID -import org.jetbrains.exposed.v1.datetime.date -import org.jetbrains.exposed.v1.datetime.timestamp - -/** - * Exposed-Tabellendefinition für Funktionäre (DomFunktionaer). - * - * Speichert alle Funktionärs-Daten inkl. Rollen (JSON), Richterqualifikation - * und Sparten-Qualifikationen (JSON) gemäß ÖTO-Anforderungen. - */ -object FunktionaerTable : Table("funktionaere") { - - val id = javaUUID("id").autoGenerate() - override val primaryKey = PrimaryKey(id) - - // Identifikation - val richterNummer = varchar("richter_nummer", 50).nullable() - - // Persönliche Daten - val vorname = varchar("vorname", 100) - val nachname = varchar("nachname", 100) - val geburtsdatum = date("geburtsdatum").nullable() - - // Rollen & Qualifikationen (als JSON-Arrays gespeichert) - val rollen = text("rollen") // JSON array of FunktionaerRolleE - val richterQualifikation = varchar("richter_qualifikation", 50).nullable() - val qualifiziertFuerSparten = text("qualifiziert_fuer_sparten") // JSON array of SparteE - - // Kontaktdaten - val email = varchar("email", 255).nullable() - val telefon = varchar("telefon", 50).nullable() - val vereinsNummer = varchar("vereins_nummer", 20).nullable() - - // Status & Verwaltung - val istAktiv = bool("ist_aktiv").default(true) - val bemerkungen = text("bemerkungen").nullable() - val datenQuelle = varchar("daten_quelle", 50) - - // Audit-Felder - val createdAt = timestamp("created_at") - val updatedAt = timestamp("updated_at") - - init { - index(false, nachname) - index(false, vorname) - index(false, vereinsNummer) - index(false, istAktiv) - index(false, richterQualifikation) - } -} diff --git a/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/ZnsOfficialTable.kt b/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/ZnsOfficialTable.kt deleted file mode 100644 index ce63c2b0..00000000 --- a/backend/services/officials/officials-infrastructure/src/main/kotlin/at/mocode/officials/infrastructure/persistence/ZnsOfficialTable.kt +++ /dev/null @@ -1,15 +0,0 @@ -package at.mocode.officials.infrastructure.persistence - -import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable -import org.jetbrains.exposed.v1.datetime.timestamp - -/** - * Tabelle für importierte Richter aus dem ZNS-Datenbestand (RICHT01.dat). - * Wird ausschließlich als Dev-Seed-Daten verwendet. - */ -object ZnsOfficialTable : UUIDTable("zns_officials") { - val richterNummer = varchar("richter_nummer", 6).uniqueIndex() - val name = varchar("name", 80) - val qualifikation = varchar("qualifikation", 5).nullable() - val createdAt = timestamp("created_at") -} diff --git a/backend/services/officials/officials-service/build.gradle.kts b/backend/services/officials/officials-service/build.gradle.kts deleted file mode 100644 index ffe6187a..00000000 --- a/backend/services/officials/officials-service/build.gradle.kts +++ /dev/null @@ -1,39 +0,0 @@ -plugins { - alias(libs.plugins.kotlinJvm) - alias(libs.plugins.kotlinSpring) - alias(libs.plugins.spring.boot) - alias(libs.plugins.spring.dependencyManagement) -} - -springBoot { - mainClass.set("at.mocode.officials.service.OfficialsServiceApplicationKt") -} - -dependencies { - implementation(projects.platform.platformDependencies) - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - implementation(projects.backend.services.officials.officialsDomain) - implementation(projects.backend.services.officials.officialsInfrastructure) - - implementation(libs.spring.boot.starter.web) - implementation(libs.spring.boot.starter.validation) - implementation(libs.spring.boot.starter.actuator) - - implementation(libs.exposed.core) - implementation(libs.exposed.dao) - implementation(libs.exposed.jdbc) - implementation(libs.exposed.migration.jdbc) - implementation(libs.exposed.kotlin.datetime) - implementation(libs.hikari.cp) - runtimeOnly(libs.postgresql.driver) - testRuntimeOnly(libs.h2.driver) - - testImplementation(projects.platform.platformTesting) - testImplementation(libs.spring.boot.starter.test) - testImplementation(libs.logback.classic) -} - -tasks.test { - useJUnitPlatform() -} diff --git a/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/OfficialsServiceApplication.kt b/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/OfficialsServiceApplication.kt deleted file mode 100644 index 74a03553..00000000 --- a/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/OfficialsServiceApplication.kt +++ /dev/null @@ -1,18 +0,0 @@ -package at.mocode.officials.service - -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.runApplication -import org.springframework.context.annotation.ComponentScan - -@SpringBootApplication -@ComponentScan( - basePackages = [ - "at.mocode.officials.service", - "at.mocode.officials.infrastructure" - ] -) -class OfficialsServiceApplication - -fun main(args: Array) { - runApplication(*args) -} diff --git a/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/config/DatabaseConfiguration.kt b/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/config/DatabaseConfiguration.kt deleted file mode 100644 index 158d6d8f..00000000 --- a/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/config/DatabaseConfiguration.kt +++ /dev/null @@ -1,32 +0,0 @@ -package at.mocode.officials.service.config - -import at.mocode.officials.infrastructure.persistence.ZnsOfficialTable -import jakarta.annotation.PostConstruct -import org.jetbrains.exposed.v1.jdbc.Database -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Profile - -@Configuration -@Profile("dev") -class OfficialsDatabaseConfiguration( - @Value("\${spring.datasource.url}") private val jdbcUrl: String, - @Value("\${spring.datasource.username}") private val username: String, - @Value("\${spring.datasource.password}") private val password: String -) { - private val log = LoggerFactory.getLogger(OfficialsDatabaseConfiguration::class.java) - - @PostConstruct - fun initializeDatabase() { - log.info("Initialisiere Datenbank-Schema für Officials-Service...") - Database.connect(jdbcUrl, user = username, password = password) - transaction { - val statements = MigrationUtils.statementsRequiredForDatabaseMigration(ZnsOfficialTable) - statements.forEach { exec(it) } - log.info("Datenbank-Schema erfolgreich initialisiert") - } - } -} diff --git a/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/dev/ZnsOfficialSeeder.kt b/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/dev/ZnsOfficialSeeder.kt deleted file mode 100644 index 56594e53..00000000 --- a/backend/services/officials/officials-service/src/main/kotlin/at/mocode/officials/service/dev/ZnsOfficialSeeder.kt +++ /dev/null @@ -1,91 +0,0 @@ -package at.mocode.officials.service.dev - -import at.mocode.officials.infrastructure.persistence.ZnsOfficialTable -import org.jetbrains.exposed.v1.jdbc.batchInsert -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.CommandLineRunner -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Component -import java.io.File -import kotlin.time.Clock - -/** - * Dev-only Seeder: Importiert RICHT01.dat (ZNS) → zns_officials. - * Aktivierung: Umgebungsvariable ZNS_DATA_DIR setzen + Profil "dev". - */ -@Component -@Profile("dev") -class ZnsOfficialSeeder( - @Value("\${zns.data.dir:#{null}}") private val znsDataDir: String? -) : CommandLineRunner { - - private val log = LoggerFactory.getLogger(ZnsOfficialSeeder::class.java) - - override fun run(vararg args: String) { - if (znsDataDir == null) { - log.info("ZNS_DATA_DIR nicht gesetzt – ZnsOfficialSeeder wird übersprungen.") - return - } - val dir = File(znsDataDir) - if (!dir.exists() || !dir.isDirectory) { - log.warn("ZNS_DATA_DIR '{}' existiert nicht oder ist kein Verzeichnis.", znsDataDir) - return - } - log.info("Starte ZNS-Richter-Import aus: {}", dir.absolutePath) - transaction { - val statements = MigrationUtils.statementsRequiredForDatabaseMigration(ZnsOfficialTable) - statements.forEach { exec(it) } - } - seedOfficials(dir) - log.info("ZNS-Richter-Import abgeschlossen.") - } - - // ------------------------------------------------------------------------- - // RICHT01.dat → zns_officials - // Format (~109 Zeichen): - // [0] 'Y' (Kennzeichen) - // [1-7] RichterNr (6 Zeichen) - // [7-87] Name (80 Zeichen) - // [87-89] Qualifikation (z.B. "GA", "G3") - // ------------------------------------------------------------------------- - private fun seedOfficials(dir: File) { - val file = File(dir, "RICHT01.dat") - if (!file.exists()) { - log.warn("RICHT01.dat nicht gefunden – Richter werden übersprungen.") - return - } - - data class OfficialRow( - val richterNr: String, - val name: String, - val qualifikation: String? - ) - - val rows = file.readLines(Charsets.ISO_8859_1) - .filter { it.length >= 7 && it.startsWith("Y") } - .mapNotNull { line -> - val richterNr = line.substring(1, 7).trim() - if (richterNr.isBlank()) return@mapNotNull null - val name = line.safeSubstring(7, 87).trim() - val qualifikation = line.safeSubstring(87, 92).trim().takeIf { it.isNotBlank() } - OfficialRow(richterNr, name, qualifikation) - } - - val now = Clock.System.now() - transaction { - ZnsOfficialTable.batchInsert(rows, ignore = true) { row -> - this[ZnsOfficialTable.richterNummer] = row.richterNr - this[ZnsOfficialTable.name] = row.name - this[ZnsOfficialTable.qualifikation] = row.qualifikation - this[ZnsOfficialTable.createdAt] = now - } - } - log.info("Richter importiert: {} Datensätze", rows.size) - } - - private fun String.safeSubstring(start: Int, end: Int): String = - if (this.length >= end) this.substring(start, end) else if (this.length > start) this.substring(start) else "" -} diff --git a/backend/services/officials/officials-service/src/main/resources/db/migration/V001__Create_Funktionaere_Table.sql b/backend/services/officials/officials-service/src/main/resources/db/migration/V001__Create_Funktionaere_Table.sql deleted file mode 100644 index dc99dbd3..00000000 --- a/backend/services/officials/officials-service/src/main/resources/db/migration/V001__Create_Funktionaere_Table.sql +++ /dev/null @@ -1,82 +0,0 @@ --- Migration V001: Create Funktionaere (Officials) table --- Speichert alle Funktionärs-Daten inkl. Rollen (JSON), Richterqualifikation --- und Sparten-Qualifikationen (JSON) gemäß ÖTO-Anforderungen. - -CREATE TABLE IF NOT EXISTS funktionaere -( - id - UUID - PRIMARY - KEY - DEFAULT - gen_random_uuid -( -), - richter_nummer VARCHAR -( - 50 -), - vorname VARCHAR -( - 100 -) NOT NULL, - nachname VARCHAR -( - 100 -) NOT NULL, - geburtsdatum DATE, - rollen TEXT NOT NULL DEFAULT '[]', - richter_qualifikation VARCHAR -( - 50 -), - qualifiziert_fuer_sparten TEXT NOT NULL DEFAULT '[]', - email VARCHAR -( - 255 -), - telefon VARCHAR -( - 50 -), - vereins_nummer VARCHAR -( - 20 -), - ist_aktiv BOOLEAN NOT NULL DEFAULT true, - bemerkungen TEXT, - daten_quelle VARCHAR -( - 50 -) NOT NULL DEFAULT 'MANUELL', - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - --- Unique index für Richternummer (wenn gesetzt) -CREATE UNIQUE INDEX IF NOT EXISTS uk_funktionaere_richter_nummer - ON funktionaere(richter_nummer) - WHERE richter_nummer IS NOT NULL; - --- Performance-Indizes -CREATE INDEX IF NOT EXISTS idx_funktionaere_nachname ON funktionaere(nachname); -CREATE INDEX IF NOT EXISTS idx_funktionaere_vorname ON funktionaere(vorname); -CREATE INDEX IF NOT EXISTS idx_funktionaere_vereins_nummer ON funktionaere(vereins_nummer); -CREATE INDEX IF NOT EXISTS idx_funktionaere_ist_aktiv ON funktionaere(ist_aktiv); -CREATE INDEX IF NOT EXISTS idx_funktionaere_richter_qual ON funktionaere(richter_qualifikation); - --- Dokumentation -COMMENT -ON TABLE funktionaere IS 'Funktionäre (Richter, Parcoursbauer, TBA, etc.) gemäß ÖTO-Regelwerk'; -COMMENT -ON COLUMN funktionaere.id IS 'Eindeutige interne ID (UUID)'; -COMMENT -ON COLUMN funktionaere.richter_nummer IS 'Offizielle OEPS-Richternummer (eindeutig, optional)'; -COMMENT -ON COLUMN funktionaere.rollen IS 'JSON-Array der Funktionärs-Rollen (FunktionaerRolleE)'; -COMMENT -ON COLUMN funktionaere.richter_qualifikation IS 'Richter-Qualifikationsstufe (RichterQualifikationE): GA, G1, G2, G3, INTERNATIONAL'; -COMMENT -ON COLUMN funktionaere.qualifiziert_fuer_sparten IS 'JSON-Array der Sparten-Qualifikationen (SparteE)'; -COMMENT -ON COLUMN funktionaere.daten_quelle IS 'Datenherkunft: MANUELL, IMPORT_ZNS, IMPORT_FEI'; diff --git a/backend/services/persons/persons-domain/build.gradle.kts b/backend/services/persons/persons-domain/build.gradle.kts deleted file mode 100644 index 80e97efb..00000000 --- a/backend/services/persons/persons-domain/build.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.kotlinSerialization) -} - -kotlin { - jvm() - - sourceSets { - commonMain { - kotlin.srcDir("src/main/kotlin") - dependencies { - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - implementation(libs.kotlinx.datetime) - implementation(libs.kotlinx.serialization.json) - } - } - commonTest { - kotlin.srcDir("src/test/kotlin") - dependencies { - implementation(projects.platform.platformTesting) - } - } - } -} diff --git a/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/model/DomPerson.kt b/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/model/DomPerson.kt deleted file mode 100644 index f4215016..00000000 --- a/backend/services/persons/persons-domain/src/main/kotlin/at/mocode/persons/domain/model/DomPerson.kt +++ /dev/null @@ -1,58 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) - -package at.mocode.persons.domain.model - -import at.mocode.core.domain.model.DatenQuelleE -import at.mocode.core.domain.serialization.KotlinInstantSerializer -import at.mocode.core.domain.serialization.UuidSerializer -import kotlinx.datetime.LocalDate -import kotlinx.serialization.Serializable -import kotlin.time.Clock -import kotlin.time.Instant -import kotlin.uuid.Uuid - -/** - * Domain model representing a person (rider/member) in the registry system. - * - * @property personId Unique internal identifier (UUID). - * @property lizenzNummer ÖPS license number (from ZNS LIZENZ01.dat). - * @property nachname Last name. - * @property vorname First name. - * @property geschlecht Gender code (M/W). - * @property geburtsdatum Date of birth. - * @property nation Nation code (e.g. AUT). - * @property lizenzKlasse License class (e.g. R1, R2, RD2). - * @property mitgliedsNummer Membership number. - * @property vereinsNummer Club number. - * @property vereinsName Club name. - * @property datenQuelle Source of the data. - * @property createdAt Timestamp when this record was created. - * @property updatedAt Timestamp when this record was last updated. - */ -@Serializable -data class DomPerson( - @Serializable(with = UuidSerializer::class) - val personId: Uuid = Uuid.random(), - - val lizenzNummer: String, - val nachname: String, - val vorname: String, - - val geschlecht: String? = null, - val geburtsdatum: LocalDate? = null, - val nation: String? = null, - val lizenzKlasse: String? = null, - val mitgliedsNummer: String? = null, - val vereinsNummer: String? = null, - val vereinsName: String? = null, - - val istAktiv: Boolean = true, - val datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS, - - @Serializable(with = KotlinInstantSerializer::class) - val createdAt: Instant = Clock.System.now(), - @Serializable(with = KotlinInstantSerializer::class) - var updatedAt: Instant = Clock.System.now() -) { - fun getDisplayName(): String = "$vorname $nachname" -} diff --git a/backend/services/persons/persons-infrastructure/build.gradle.kts b/backend/services/persons/persons-infrastructure/build.gradle.kts deleted file mode 100644 index 3306c3bf..00000000 --- a/backend/services/persons/persons-infrastructure/build.gradle.kts +++ /dev/null @@ -1,23 +0,0 @@ -plugins { - alias(libs.plugins.kotlinJvm) - alias(libs.plugins.kotlinSpring) - alias(libs.plugins.kotlinSerialization) -} - -dependencies { - implementation(projects.platform.platformDependencies) - implementation(projects.backend.services.persons.personsDomain) - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - - implementation(libs.spring.boot.starter.web) - - implementation(libs.exposed.core) - implementation(libs.exposed.dao) - implementation(libs.exposed.jdbc) - implementation(libs.exposed.kotlin.datetime) - - runtimeOnly(libs.postgresql.driver) - - testImplementation(projects.platform.platformTesting) -} diff --git a/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ExposedReiterRepository.kt b/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ExposedReiterRepository.kt deleted file mode 100644 index 8d50b761..00000000 --- a/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ExposedReiterRepository.kt +++ /dev/null @@ -1,173 +0,0 @@ -@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) - -package at.mocode.persons.infrastructure.persistence - -import at.mocode.core.domain.model.DatenQuelleE -import at.mocode.core.domain.model.LizenzKlasseE -import at.mocode.core.domain.model.SparteE -import at.mocode.persons.domain.model.DomReiter -import at.mocode.persons.domain.repository.ReiterRepository -import kotlinx.serialization.json.Json -import org.jetbrains.exposed.v1.core.* -import org.jetbrains.exposed.v1.core.statements.UpdateBuilder -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.transactions.transaction -import org.jetbrains.exposed.v1.jdbc.update -import kotlin.time.Clock -import kotlin.uuid.Uuid -import kotlin.uuid.toJavaUuid -import kotlin.uuid.toKotlinUuid - -/** - * Exposed-basierte Implementierung des ReiterRepository. - */ -class ExposedReiterRepository : ReiterRepository { - - override suspend fun findById(id: Uuid): DomReiter? = transaction { - ReiterTable.selectAll().where { ReiterTable.id eq id.toJavaUuid() } - .map { rowToReiter(it) } - .singleOrNull() - } - - override suspend fun findBySatznummer(satznummer: String): DomReiter? = transaction { - ReiterTable.selectAll().where { ReiterTable.satznummer eq satznummer } - .map { rowToReiter(it) } - .singleOrNull() - } - - override suspend fun findByFeiId(feiId: String): DomReiter? = transaction { - ReiterTable.selectAll().where { ReiterTable.feiId eq feiId } - .map { rowToReiter(it) } - .singleOrNull() - } - - override suspend fun findByName(searchTerm: String, limit: Int): List = transaction { - val pattern = "%$searchTerm%" - ReiterTable.selectAll().where { - (ReiterTable.nachname like pattern) or (ReiterTable.vorname like pattern) - }.limit(limit).map { rowToReiter(it) } - } - - override suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean): List = transaction { - ReiterTable.selectAll().where { - (ReiterTable.vereinsNummer eq vereinsNummer).let { - if (activeOnly) it and (ReiterTable.istAktiv eq true) else it - } - }.map { rowToReiter(it) } - } - - override suspend fun findByLizenzKlasse(lizenzKlasse: LizenzKlasseE, activeOnly: Boolean): List = - transaction { - ReiterTable.selectAll().where { - (ReiterTable.lizenzKlasse eq lizenzKlasse.name).let { - if (activeOnly) it and (ReiterTable.istAktiv eq true) else it - } - }.map { rowToReiter(it) } - } - - override suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean): List = transaction { - ReiterTable.selectAll().where { - (ReiterTable.lizenziertFuerSparten like "%${sparte.name}%").let { - if (activeOnly) it and (ReiterTable.istAktiv eq true) else it - } - }.map { rowToReiter(it) } - } - - override suspend fun findGastreiter(activeOnly: Boolean): List = transaction { - ReiterTable.selectAll().where { - (ReiterTable.istGastreiter eq true).let { - if (activeOnly) it and (ReiterTable.istAktiv eq true) else it - } - }.map { rowToReiter(it) } - } - - override suspend fun findAllActive(limit: Int, offset: Int): List = transaction { - ReiterTable.selectAll().where { ReiterTable.istAktiv eq true } - .limit(limit).offset(offset.toLong()) - .map { rowToReiter(it) } - } - - override suspend fun findAll(limit: Int, offset: Int): List = transaction { - ReiterTable.selectAll() - .limit(limit).offset(offset.toLong()) - .map { rowToReiter(it) } - } - - override suspend fun save(reiter: DomReiter): DomReiter = transaction { - val now = Clock.System.now() - val updated = reiter.copy(updatedAt = now) - val javaId = reiter.reiterId.toJavaUuid() - val existing = ReiterTable.selectAll().where { ReiterTable.id eq javaId }.singleOrNull() - if (existing != null) { - ReiterTable.update({ ReiterTable.id eq javaId }) { reiterToStatement(it, updated) } - } else { - ReiterTable.insert { - it[id] = javaId - reiterToStatement(it, updated) - } - } - updated - } - - override suspend fun delete(id: Uuid): Boolean = transaction { - ReiterTable.deleteWhere { ReiterTable.id eq id.toJavaUuid() } > 0 - } - - override suspend fun countActive(): Long = transaction { - ReiterTable.selectAll().where { ReiterTable.istAktiv eq true }.count() - } - - override suspend fun existsBySatznummer(satznummer: String): Boolean = transaction { - ReiterTable.selectAll().where { ReiterTable.satznummer eq satznummer }.count() > 0 - } - - private fun rowToReiter(row: ResultRow): DomReiter { - val sparten = try { - Json.decodeFromString>(row[ReiterTable.lizenziertFuerSparten]) - } catch (_: Exception) { - emptyList() - } - - return DomReiter( - reiterId = row[ReiterTable.id].toKotlinUuid(), - personId = row[ReiterTable.id].toKotlinUuid(), // same as reiterId for now - satznummer = row[ReiterTable.satznummer] ?: "", - feiId = row[ReiterTable.feiId], - nation = row[ReiterTable.nation], - vorname = row[ReiterTable.vorname], - nachname = row[ReiterTable.nachname], - geburtsdatum = row[ReiterTable.geburtsdatum], - vereinsNummer = row[ReiterTable.vereinsNummer], - vereinsName = row[ReiterTable.vereinsName], - lizenzKlasse = runCatching { LizenzKlasseE.valueOf(row[ReiterTable.lizenzKlasse] ?: "") }.getOrDefault( - LizenzKlasseE.LIZENZFREI - ), - lizenzSparten = sparten, - istGastreiter = row[ReiterTable.istGastreiter], - istAktiv = row[ReiterTable.istAktiv], - datenQuelle = runCatching { DatenQuelleE.valueOf(row[ReiterTable.datenQuelle]) }.getOrDefault(DatenQuelleE.IMPORT_ZNS), - createdAt = row[ReiterTable.createdAt], - updatedAt = row[ReiterTable.updatedAt] - ) - } - - private fun reiterToStatement(stmt: UpdateBuilder<*>, r: DomReiter) { - stmt[ReiterTable.satznummer] = r.satznummer - stmt[ReiterTable.feiId] = r.feiId - stmt[ReiterTable.vorname] = r.vorname - stmt[ReiterTable.nachname] = r.nachname - stmt[ReiterTable.geburtsdatum] = r.geburtsdatum - stmt[ReiterTable.nation] = r.nation - stmt[ReiterTable.vereinsNummer] = r.vereinsNummer - stmt[ReiterTable.vereinsName] = r.vereinsName - stmt[ReiterTable.lizenzKlasse] = r.lizenzKlasse.name - stmt[ReiterTable.lizenziertFuerSparten] = Json.encodeToString(r.lizenzSparten) - stmt[ReiterTable.istGastreiter] = r.istGastreiter - stmt[ReiterTable.istAktiv] = r.istAktiv - stmt[ReiterTable.datenQuelle] = r.datenQuelle.name - stmt[ReiterTable.createdAt] = r.createdAt - stmt[ReiterTable.updatedAt] = r.updatedAt - } -} diff --git a/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ReiterTable.kt b/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ReiterTable.kt deleted file mode 100644 index 61f3fac2..00000000 --- a/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ReiterTable.kt +++ /dev/null @@ -1,55 +0,0 @@ -package at.mocode.persons.infrastructure.persistence - -import org.jetbrains.exposed.v1.core.Table -import org.jetbrains.exposed.v1.core.java.javaUUID -import org.jetbrains.exposed.v1.datetime.date -import org.jetbrains.exposed.v1.datetime.timestamp - -/** - * Exposed-Tabellendefinition für Reiter (DomReiter). - * - * Speichert alle Reiter-Daten inkl. Lizenz, Sparten (JSON) und ZNS-Identifikation. - */ -object ReiterTable : Table("reiter") { - - val id = javaUUID("id").autoGenerate() - override val primaryKey = PrimaryKey(id) - - // Identifikation - val satznummer = varchar("satznummer", 20).nullable() - val feiId = varchar("fei_id", 20).nullable() - - // Persönliche Daten - val vorname = varchar("vorname", 100) - val nachname = varchar("nachname", 100) - val geburtsdatum = date("geburtsdatum").nullable() - val nation = varchar("nation", 3).nullable().default("AUT") - - // Vereinsdaten - val vereinsNummer = varchar("vereins_nummer", 20).nullable() - val vereinsName = varchar("vereins_name", 200).nullable() - - // Lizenz & Qualifikation - val lizenzKlasse = varchar("lizenz_klasse", 50).nullable() - val lizenziertFuerSparten = text("lizenziert_fuer_sparten") // JSON array of SparteE - - // Status & Verwaltung - val istAktiv = bool("ist_aktiv").default(true) - val istGastreiter = bool("ist_gastreiter").default(false) - val bemerkungen = text("bemerkungen").nullable() - val datenQuelle = varchar("daten_quelle", 50) - - // Audit-Felder - val createdAt = timestamp("created_at") - val updatedAt = timestamp("updated_at") - - init { - index(false, nachname) - index(false, vorname) - index(true, satznummer) - index(true, feiId) - index(false, vereinsNummer) - index(false, istAktiv) - index(false, lizenzKlasse) - } -} diff --git a/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ZnsPersonTable.kt b/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ZnsPersonTable.kt deleted file mode 100644 index 94d11d88..00000000 --- a/backend/services/persons/persons-infrastructure/src/main/kotlin/at/mocode/persons/infrastructure/persistence/ZnsPersonTable.kt +++ /dev/null @@ -1,23 +0,0 @@ -package at.mocode.persons.infrastructure.persistence - -import org.jetbrains.exposed.v1.core.dao.id.java.UUIDTable -import org.jetbrains.exposed.v1.datetime.date -import org.jetbrains.exposed.v1.datetime.timestamp - -/** - * Tabelle für importierte Personen/Reiter aus dem ZNS-Datenbestand (LIZENZ01.dat). - * Wird ausschließlich als Dev-Seed-Daten verwendet. - */ -object ZnsPersonTable : UUIDTable("zns_persons") { - val lizenzNummer = varchar("lizenz_nummer", 10).uniqueIndex() - val nachname = varchar("nachname", 50) - val vorname = varchar("vorname", 25) - val vereinsNummer = varchar("vereins_nummer", 4).nullable() - val vereinsName = varchar("vereins_name", 50).nullable() - val nation = varchar("nation", 3).nullable() - val lizenzKlasse = varchar("lizenz_klasse", 6).nullable() - val mitgliedsNummer = varchar("mitglieds_nummer", 13).nullable() - val geschlecht = varchar("geschlecht", 1).nullable() - val geburtsdatum = date("geburtsdatum").nullable() - val createdAt = timestamp("created_at") -} diff --git a/backend/services/persons/persons-service/build.gradle.kts b/backend/services/persons/persons-service/build.gradle.kts deleted file mode 100644 index 6d3271c8..00000000 --- a/backend/services/persons/persons-service/build.gradle.kts +++ /dev/null @@ -1,39 +0,0 @@ -plugins { - alias(libs.plugins.kotlinJvm) - alias(libs.plugins.kotlinSpring) - alias(libs.plugins.spring.boot) - alias(libs.plugins.spring.dependencyManagement) -} - -springBoot { - mainClass.set("at.mocode.persons.service.PersonsServiceApplicationKt") -} - -dependencies { - implementation(projects.platform.platformDependencies) - implementation(projects.core.coreDomain) - implementation(projects.core.coreUtils) - implementation(projects.backend.services.persons.personsDomain) - implementation(projects.backend.services.persons.personsInfrastructure) - - implementation(libs.spring.boot.starter.web) - implementation(libs.spring.boot.starter.validation) - implementation(libs.spring.boot.starter.actuator) - - implementation(libs.exposed.core) - implementation(libs.exposed.dao) - implementation(libs.exposed.jdbc) - implementation(libs.exposed.migration.jdbc) - implementation(libs.exposed.kotlin.datetime) - implementation(libs.hikari.cp) - runtimeOnly(libs.postgresql.driver) - testRuntimeOnly(libs.h2.driver) - - testImplementation(projects.platform.platformTesting) - testImplementation(libs.spring.boot.starter.test) - testImplementation(libs.logback.classic) -} - -tasks.test { - useJUnitPlatform() -} diff --git a/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/PersonsServiceApplication.kt b/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/PersonsServiceApplication.kt deleted file mode 100644 index cbee2365..00000000 --- a/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/PersonsServiceApplication.kt +++ /dev/null @@ -1,18 +0,0 @@ -package at.mocode.persons.service - -import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.runApplication -import org.springframework.context.annotation.ComponentScan - -@SpringBootApplication -@ComponentScan( - basePackages = [ - "at.mocode.persons.service", - "at.mocode.persons.infrastructure" - ] -) -class PersonsServiceApplication - -fun main(args: Array) { - runApplication(*args) -} diff --git a/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/config/DatabaseConfiguration.kt b/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/config/DatabaseConfiguration.kt deleted file mode 100644 index 62d6ae30..00000000 --- a/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/config/DatabaseConfiguration.kt +++ /dev/null @@ -1,32 +0,0 @@ -package at.mocode.persons.service.config - -import at.mocode.persons.infrastructure.persistence.ZnsPersonTable -import jakarta.annotation.PostConstruct -import org.jetbrains.exposed.v1.jdbc.Database -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.context.annotation.Configuration -import org.springframework.context.annotation.Profile - -@Configuration -@Profile("dev") -class PersonsDatabaseConfiguration( - @Value("\${spring.datasource.url}") private val jdbcUrl: String, - @Value("\${spring.datasource.username}") private val username: String, - @Value("\${spring.datasource.password}") private val password: String -) { - private val log = LoggerFactory.getLogger(PersonsDatabaseConfiguration::class.java) - - @PostConstruct - fun initializeDatabase() { - log.info("Initialisiere Datenbank-Schema für Persons-Service...") - Database.connect(jdbcUrl, user = username, password = password) - transaction { - val statements = MigrationUtils.statementsRequiredForDatabaseMigration(ZnsPersonTable) - statements.forEach { exec(it) } - log.info("Datenbank-Schema erfolgreich initialisiert") - } - } -} diff --git a/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/dev/ZnsPersonSeeder.kt b/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/dev/ZnsPersonSeeder.kt deleted file mode 100644 index d0852e58..00000000 --- a/backend/services/persons/persons-service/src/main/kotlin/at/mocode/persons/service/dev/ZnsPersonSeeder.kt +++ /dev/null @@ -1,137 +0,0 @@ -package at.mocode.persons.service.dev - -import at.mocode.persons.infrastructure.persistence.ZnsPersonTable -import org.jetbrains.exposed.v1.jdbc.batchInsert -import org.jetbrains.exposed.v1.jdbc.transactions.transaction -import org.jetbrains.exposed.v1.migration.jdbc.MigrationUtils -import org.slf4j.LoggerFactory -import org.springframework.beans.factory.annotation.Value -import org.springframework.boot.CommandLineRunner -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Component -import java.io.File -import kotlin.time.Clock - -/** - * Dev-only Seeder: Importiert LIZENZ01.dat (ZNS) → zns_persons. - * Aktivierung: Umgebungsvariable ZNS_DATA_DIR setzen + Profil "dev". - */ -@Component -@Profile("dev") -class ZnsPersonSeeder( - @Value("\${zns.data.dir:#{null}}") private val znsDataDir: String? -) : CommandLineRunner { - - private val log = LoggerFactory.getLogger(ZnsPersonSeeder::class.java) - - override fun run(vararg args: String) { - if (znsDataDir == null) { - log.info("ZNS_DATA_DIR nicht gesetzt – ZnsPersonSeeder wird übersprungen.") - return - } - val dir = File(znsDataDir) - if (!dir.exists() || !dir.isDirectory) { - log.warn("ZNS_DATA_DIR '{}' existiert nicht oder ist kein Verzeichnis.", znsDataDir) - return - } - log.info("Starte ZNS-Personen-Import aus: {}", dir.absolutePath) - transaction { - val statements = MigrationUtils.statementsRequiredForDatabaseMigration(ZnsPersonTable) - statements.forEach { exec(it) } - } - seedPersons(dir) - log.info("ZNS-Personen-Import abgeschlossen.") - } - - // ------------------------------------------------------------------------- - // LIZENZ01.dat → zns_persons - // Format (220 Zeichen): - // [0-5] LizenzNr - // [6-55] Nachname - // [56-80] Vorname - // [81-83] VereinsNr - // [84-133] VereinsName - // [134-136] Nation - // [137-143] LizenzKlasse (z.B. "R1", "R2", "RD2") - // [144-156] MitgliedsNr - // [157-165] Telefon (nicht gespeichert) - // [166-169] Jahr - // [170] Geschlecht (M/W) - // [171-178] Geburtsdatum (YYYYMMDD) - // ------------------------------------------------------------------------- - private fun seedPersons(dir: File) { - val file = File(dir, "LIZENZ01.dat") - if (!file.exists()) { - log.warn("LIZENZ01.dat nicht gefunden – Personen werden übersprungen.") - return - } - - data class PersonRow( - val lizenzNr: String, - val nachname: String, - val vorname: String, - val vereinsNr: String?, - val vereinsName: String?, - val nation: String?, - val lizenzKlasse: String?, - val mitgliedsNr: String?, - val geschlecht: String?, - val geburtsdatum: java.time.LocalDate? - ) - - val rows = file.readLines(Charsets.ISO_8859_1) - .filter { it.length >= 6 } - .mapNotNull { line -> - val lizenzNr = line.substring(0, 6).trim() - if (lizenzNr.isBlank()) return@mapNotNull null - - val nachname = line.safeSubstring(6, 56).trim() - val vorname = line.safeSubstring(56, 81).trim() - val vereinsNr = line.safeSubstring(81, 84).trim().takeIf { it.isNotBlank() } - val vereinsName = line.safeSubstring(84, 134).trim().takeIf { it.isNotBlank() } - val nation = line.safeSubstring(134, 137).trim().takeIf { it.isNotBlank() } - val lizenzKlasse = line.safeSubstring(137, 144).trim().takeIf { it.isNotBlank() } - val mitgliedsNr = line.safeSubstring(144, 157).trim().takeIf { it.isNotBlank() } - val geschlecht = line.safeSubstring(170, 171).trim().takeIf { it.isNotBlank() } - val gebDatStr = line.safeSubstring(171, 179).trim() - val gebDat = parseDate(gebDatStr) - - PersonRow( - lizenzNr, nachname, vorname, vereinsNr, vereinsName, - nation, lizenzKlasse, mitgliedsNr, geschlecht, gebDat - ) - } - - val now = Clock.System.now() - transaction { - ZnsPersonTable.batchInsert(rows, ignore = true) { row -> - this[ZnsPersonTable.lizenzNummer] = row.lizenzNr - this[ZnsPersonTable.nachname] = row.nachname - this[ZnsPersonTable.vorname] = row.vorname - this[ZnsPersonTable.vereinsNummer] = row.vereinsNr - this[ZnsPersonTable.vereinsName] = row.vereinsName - this[ZnsPersonTable.nation] = row.nation - this[ZnsPersonTable.lizenzKlasse] = row.lizenzKlasse - this[ZnsPersonTable.mitgliedsNummer] = row.mitgliedsNr - this[ZnsPersonTable.geschlecht] = row.geschlecht - this[ZnsPersonTable.geburtsdatum] = row.geburtsdatum?.let { - kotlinx.datetime.LocalDate(it.year, it.monthValue, it.dayOfMonth) - } - this[ZnsPersonTable.createdAt] = now - } - } - log.info("Personen importiert: {} Datensätze", rows.size) - } - - private fun String.safeSubstring(start: Int, end: Int): String = - if (this.length >= end) this.substring(start, end) else if (this.length > start) this.substring(start) else "" - - private fun parseDate(s: String): java.time.LocalDate? { - if (s.length < 8 || s.all { it == '0' }) return null - return try { - java.time.LocalDate.of(s.substring(0, 4).toInt(), s.substring(4, 6).toInt(), s.substring(6, 8).toInt()) - } catch (_: Exception) { - null - } - } -} diff --git a/backend/services/persons/persons-service/src/main/resources/db/migration/V001__Create_Reiter_Table.sql b/backend/services/persons/persons-service/src/main/resources/db/migration/V001__Create_Reiter_Table.sql deleted file mode 100644 index dc9b3a53..00000000 --- a/backend/services/persons/persons-service/src/main/resources/db/migration/V001__Create_Reiter_Table.sql +++ /dev/null @@ -1,87 +0,0 @@ --- Migration V001: Create Reiter (Riders) table --- Speichert alle Reiter-Daten inkl. Lizenz, Sparten (JSON) und ZNS-Identifikation. - -CREATE TABLE IF NOT EXISTS reiter -( - id - UUID - PRIMARY - KEY - DEFAULT - gen_random_uuid -( -), - satznummer VARCHAR -( - 20 -), - fei_id VARCHAR -( - 20 -), - vorname VARCHAR -( - 100 -) NOT NULL, - nachname VARCHAR -( - 100 -) NOT NULL, - geburtsdatum DATE, - nation VARCHAR -( - 3 -) DEFAULT 'AUT', - vereins_nummer VARCHAR -( - 20 -), - vereins_name VARCHAR -( - 200 -), - lizenz_klasse VARCHAR -( - 50 -), - lizenziert_fuer_sparten TEXT NOT NULL DEFAULT '[]', - ist_aktiv BOOLEAN NOT NULL DEFAULT true, - ist_gastreiter BOOLEAN NOT NULL DEFAULT false, - bemerkungen TEXT, - daten_quelle VARCHAR -( - 50 -) NOT NULL DEFAULT 'IMPORT_ZNS', - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP - ); - --- Unique Indizes für ZNS-Identifikation -CREATE UNIQUE INDEX IF NOT EXISTS uk_reiter_satznummer ON reiter(satznummer) WHERE satznummer IS NOT NULL; -CREATE UNIQUE INDEX IF NOT EXISTS uk_reiter_fei_id ON reiter(fei_id) WHERE fei_id IS NOT NULL; - --- Performance-Indizes -CREATE INDEX IF NOT EXISTS idx_reiter_nachname ON reiter(nachname); -CREATE INDEX IF NOT EXISTS idx_reiter_vorname ON reiter(vorname); -CREATE INDEX IF NOT EXISTS idx_reiter_vereins_nummer ON reiter(vereins_nummer); -CREATE INDEX IF NOT EXISTS idx_reiter_ist_aktiv ON reiter(ist_aktiv); -CREATE INDEX IF NOT EXISTS idx_reiter_lizenz_klasse ON reiter(lizenz_klasse); -CREATE INDEX IF NOT EXISTS idx_reiter_ist_gastreiter ON reiter(ist_gastreiter); - --- Dokumentation -COMMENT -ON TABLE reiter IS 'Reiter/Teilnehmer gemäß OEPS-Mitgliederregister (ZNS)'; -COMMENT -ON COLUMN reiter.id IS 'Eindeutige interne ID (UUID)'; -COMMENT -ON COLUMN reiter.satznummer IS 'OEPS-Satznummer (Mitgliedsnummer, eindeutig)'; -COMMENT -ON COLUMN reiter.fei_id IS 'FEI-ID für internationale Starts (eindeutig)'; -COMMENT -ON COLUMN reiter.lizenz_klasse IS 'Lizenzklasse (LizenzKlasseE): LIZENZFREI, AMATEUR, PROFI, etc.'; -COMMENT -ON COLUMN reiter.lizenziert_fuer_sparten IS 'JSON-Array der lizenzierten Sparten (SparteE)'; -COMMENT -ON COLUMN reiter.ist_gastreiter IS 'Gastreiter ohne OEPS-Mitgliedschaft (z.B. ausländische Starter)'; -COMMENT -ON COLUMN reiter.daten_quelle IS 'Datenherkunft: MANUELL, IMPORT_ZNS, IMPORT_FEI'; diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/PingServiceApplication.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/PingServiceApplication.kt index 43a33b9d..2296d043 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/PingServiceApplication.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/PingServiceApplication.kt @@ -12,5 +12,5 @@ import org.springframework.context.annotation.EnableAspectJAutoProxy class PingServiceApplication fun main(args: Array) { - runApplication(*args) + runApplication(*args) } diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/application/PingService.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/application/PingService.kt index c246a1e2..93fa3486 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/application/PingService.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/application/PingService.kt @@ -19,35 +19,35 @@ import java.time.Instant @Profile("!test") // Nicht im Test-Profil laden, damit wir Mocks nutzen können @OptIn(ExperimentalUuidApi::class) class PingService( - private val repository: PingRepository + private val repository: PingRepository ) : PingUseCase { - private val logger = LoggerFactory.getLogger(PingService::class.java) + private val logger = LoggerFactory.getLogger(PingService::class.java) - @Transactional - override fun executePing(message: String): Ping { - logger.info("Executing ping with message: {}", message) + @Transactional + override fun executePing(message: String): Ping { + logger.info("Executing ping with message: {}", message) - // Domain Logic: Erstelle neue Entity (generiert UUID v7 automatisch) - val ping = Ping(message = message) + // Domain Logic: Erstelle neue Entity (generiert UUID v7 automatisch) + val ping = Ping(message = message) - // Persistence - return repository.save(ping) - } + // Persistence + return repository.save(ping) + } - @Transactional(readOnly = true) - override fun getPingHistory(): List { - return repository.findAll() - } + @Transactional(readOnly = true) + override fun getPingHistory(): List { + return repository.findAll() + } - @Transactional(readOnly = true) - override fun getPing(id: Uuid): Ping? { - return repository.findById(id) - } + @Transactional(readOnly = true) + override fun getPing(id: Uuid): Ping? { + return repository.findById(id) + } - @Transactional(readOnly = true) - override fun getPingsSince(timestamp: Long): List { - val instant = Instant.ofEpochMilli(timestamp) - return repository.findByTimestampAfter(instant) - } + @Transactional(readOnly = true) + override fun getPingsSince(timestamp: Long): List { + val instant = Instant.ofEpochMilli(timestamp) + return repository.findByTimestampAfter(instant) + } } diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/application/PingUseCase.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/application/PingUseCase.kt index 487b64d3..f907e7f0 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/application/PingUseCase.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/application/PingUseCase.kt @@ -10,8 +10,8 @@ import kotlin.uuid.Uuid */ @OptIn(ExperimentalUuidApi::class) interface PingUseCase { - fun executePing(message: String): Ping - fun getPingHistory(): List - fun getPing(id: Uuid): Ping? - fun getPingsSince(timestamp: Long): List + fun executePing(message: String): Ping + fun getPingHistory(): List + fun getPing(id: Uuid): Ping? + fun getPingsSince(timestamp: Long): List } diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/domain/Ping.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/domain/Ping.kt index 70062dd9..9d3bc907 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/domain/Ping.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/domain/Ping.kt @@ -10,7 +10,7 @@ import java.time.Instant */ @OptIn(ExperimentalUuidApi::class) data class Ping( - val id: Uuid = Uuid.generateV7(), // Kotlin 2.3.0 UUID v7 - val message: String, - val timestamp: Instant = Instant.now() + val id: Uuid = Uuid.generateV7(), // Kotlin 2.3.0 UUID v7 + val message: String, + val timestamp: Instant = Instant.now() ) diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/domain/PingRepository.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/domain/PingRepository.kt index fc067941..9c6c57a1 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/domain/PingRepository.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/domain/PingRepository.kt @@ -10,8 +10,8 @@ import java.time.Instant */ @OptIn(ExperimentalUuidApi::class) interface PingRepository { - fun save(ping: Ping): Ping - fun findAll(): List - fun findById(id: Uuid): Ping? - fun findByTimestampAfter(timestamp: Instant): List + fun save(ping: Ping): Ping + fun findAll(): List + fun findById(id: Uuid): Ping? + fun findByTimestampAfter(timestamp: Instant): List } diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingJpaEntity.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingJpaEntity.kt index f214114f..9e17f21b 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingJpaEntity.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingJpaEntity.kt @@ -9,15 +9,9 @@ import java.util.UUID @Entity @Table(name = "ping") class PingJpaEntity( - @Id - val id: UUID, - val message: String, - val createdAt: Instant + @Id + var id: UUID, + var message: String, + var createdAt: Instant ) { - // The default constructor for JPA - // Protected is fine for Hibernate, but the Kotlin compiler might complain about visibility. - // We can make it private or internal if needed, but protected is standard. - // To suppress the warning "effectively private", we can just leave it as is or make it public/internal. - // Let's try making it internal to satisfy Kotlin while keeping it hidden from public API. - internal constructor() : this(UUID.randomUUID(), "", Instant.now()) } diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingRepositoryAdapter.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingRepositoryAdapter.kt index 555f7c77..bb63148d 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingRepositoryAdapter.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/PingRepositoryAdapter.kt @@ -14,36 +14,36 @@ import java.time.Instant // @Profile("!test") entfernt, damit Integrationstests den echten Adapter nutzen können. // In Unit-Tests wird er durch Mocks (@MockBean oder @TestConfiguration) ersetzt. class PingRepositoryAdapter( - private val jpaRepository: SpringDataPingRepository + private val jpaRepository: SpringDataPingRepository ) : PingRepository { - override fun save(ping: Ping): Ping { - val entity = ping.toEntity() - val savedEntity = jpaRepository.save(entity) - return savedEntity.toDomain() - } + override fun save(ping: Ping): Ping { + val entity = ping.toEntity() + val savedEntity = jpaRepository.save(entity) + return savedEntity.toDomain() + } - override fun findAll(): List { - return jpaRepository.findAll().map { it.toDomain() } - } + override fun findAll(): List { + return jpaRepository.findAll().map { it.toDomain() } + } - override fun findById(id: Uuid): Ping? { - return jpaRepository.findById(id.toJavaUuid()).map { it.toDomain() }.orElse(null) - } + override fun findById(id: Uuid): Ping? { + return jpaRepository.findById(id.toJavaUuid()).map { it.toDomain() }.orElse(null) + } - override fun findByTimestampAfter(timestamp: Instant): List { - return jpaRepository.findByCreatedAtAfter(timestamp).map { it.toDomain() } - } + override fun findByTimestampAfter(timestamp: Instant): List { + return jpaRepository.findByCreatedAtAfter(timestamp).map { it.toDomain() } + } - private fun Ping.toEntity() = PingJpaEntity( - id = this.id.toJavaUuid(), - message = this.message, - createdAt = this.timestamp - ) + private fun Ping.toEntity() = PingJpaEntity( + id = this.id.toJavaUuid(), + message = this.message, + createdAt = this.timestamp + ) - private fun PingJpaEntity.toDomain() = Ping( - id = this.id.toKotlinUuid(), - message = this.message, - timestamp = this.createdAt - ) + private fun PingJpaEntity.toDomain() = Ping( + id = this.id.toKotlinUuid(), + message = this.message, + timestamp = this.createdAt + ) } diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/SpringDataPingRepository.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/SpringDataPingRepository.kt index f415301a..7b4a7840 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/SpringDataPingRepository.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/persistence/SpringDataPingRepository.kt @@ -5,5 +5,5 @@ import java.util.UUID import java.time.Instant interface SpringDataPingRepository : JpaRepository { - fun findByCreatedAtAfter(createdAt: Instant): List + fun findByCreatedAtAfter(createdAt: Instant): List } diff --git a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/web/PingController.kt b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/web/PingController.kt index 371d5912..e30c0f47 100644 --- a/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/web/PingController.kt +++ b/backend/services/ping/ping-service/src/main/kotlin/at/mocode/ping/infrastructure/web/PingController.kt @@ -21,102 +21,102 @@ import kotlin.uuid.ExperimentalUuidApi @RestController @OptIn(ExperimentalUuidApi::class) class PingController( - private val pingUseCase: PingUseCase, - private val properties: PingProperties + private val pingUseCase: PingUseCase, + private val properties: PingProperties ) : PingApi { - private val logger = LoggerFactory.getLogger(PingController::class.java) - private val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME + private val logger = LoggerFactory.getLogger(PingController::class.java) + private val formatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME - companion object { - const val PING_CIRCUIT_BREAKER = "pingCircuitBreaker" + companion object { + const val PING_CIRCUIT_BREAKER = "pingCircuitBreaker" + } + + @GetMapping("/ping/simple") + override suspend fun simplePing(): PingResponse { + val domainPing = pingUseCase.executePing("Simple Ping") + return createResponse(domainPing, "pong") + } + + @GetMapping("/ping/enhanced") + @CircuitBreaker(name = PING_CIRCUIT_BREAKER, fallbackMethod = "fallbackPing") + override suspend fun enhancedPing( + @RequestParam(required = false, defaultValue = "false") simulate: Boolean + ): EnhancedPingResponse { + val start = System.nanoTime() + + if (simulate && Random.nextDouble() < 0.6) { + throw RuntimeException("Simulated service failure") } - @GetMapping("/ping/simple") - override suspend fun simplePing(): PingResponse { - val domainPing = pingUseCase.executePing("Simple Ping") - return createResponse(domainPing, "pong") - } + val domainPing = pingUseCase.executePing("Enhanced Ping") + val elapsedMs = (System.nanoTime() - start) / 1_000_000 - @GetMapping("/ping/enhanced") - @CircuitBreaker(name = PING_CIRCUIT_BREAKER, fallbackMethod = "fallbackPing") - override suspend fun enhancedPing( - @RequestParam(required = false, defaultValue = "false") simulate: Boolean - ): EnhancedPingResponse { - val start = System.nanoTime() - - if (simulate && Random.nextDouble() < 0.6) { - throw RuntimeException("Simulated service failure") - } - - val domainPing = pingUseCase.executePing("Enhanced Ping") - val elapsedMs = (System.nanoTime() - start) / 1_000_000 - - return EnhancedPingResponse( - status = "pong", - timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter), - service = properties.serviceName, - circuitBreakerState = "CLOSED", - responseTime = elapsedMs - ) - } - - // Neue Endpunkte - - @GetMapping("/ping/public") - override suspend fun publicPing(): PingResponse { - val domainPing = pingUseCase.executePing("Public Ping") - return createResponse(domainPing, "public-pong") - } - - @GetMapping("/ping/secure") - @PreAuthorize("hasRole('MELD_USER') or hasRole('MELD_ADMIN')") // Beispiel-Rollen - override suspend fun securePing(): PingResponse { - val domainPing = pingUseCase.executePing("Secure Ping") - return createResponse(domainPing, "secure-pong") - } - - @GetMapping("/ping/sync") - override suspend fun syncPings( - // Changed the parameter name to 'since' to match SyncManager convention - @RequestParam(required = false, defaultValue = "0") since: Long - ): List { - return pingUseCase.getPingsSince(since).map { - PingEvent( - id = it.id.toString(), - message = it.message, - lastModified = it.timestamp.toEpochMilli() - ) - } - } - - // Helper - private fun createResponse(domainPing: at.mocode.ping.domain.Ping, status: String) = PingResponse( - status = status, - timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter), - service = properties.serviceName + return EnhancedPingResponse( + status = "pong", + timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter), + service = properties.serviceName, + circuitBreakerState = "CLOSED", + responseTime = elapsedMs ) + } - // Fallback - @Suppress("unused", "UNUSED_PARAMETER") - fun fallbackPing(simulate: Boolean, ex: Exception): EnhancedPingResponse { - logger.warn("Circuit breaker fallback triggered: {}", ex.message) - return EnhancedPingResponse( - status = "fallback", - timestamp = java.time.OffsetDateTime.now().format(formatter), - service = properties.serviceNameFallback, - circuitBreakerState = "OPEN", - responseTime = 0 - ) - } + // Neue Endpunkte - @GetMapping("/ping/health") - override suspend fun healthCheck(): HealthResponse { - return HealthResponse( - status = "up", - timestamp = java.time.OffsetDateTime.now().format(formatter), - service = properties.serviceName, - healthy = true - ) + @GetMapping("/ping/public") + override suspend fun publicPing(): PingResponse { + val domainPing = pingUseCase.executePing("Public Ping") + return createResponse(domainPing, "public-pong") + } + + @GetMapping("/ping/secure") + @PreAuthorize("hasRole('MELD_USER') or hasRole('MELD_ADMIN')") // Beispiel-Rollen + override suspend fun securePing(): PingResponse { + val domainPing = pingUseCase.executePing("Secure Ping") + return createResponse(domainPing, "secure-pong") + } + + @GetMapping("/ping/sync") + override suspend fun syncPings( + // Changed the parameter name to 'since' to match SyncManager convention + @RequestParam(required = false, defaultValue = "0") since: Long + ): List { + return pingUseCase.getPingsSince(since).map { + PingEvent( + id = it.id.toString(), + message = it.message, + lastModified = it.timestamp.toEpochMilli() + ) } + } + + // Helper + private fun createResponse(domainPing: at.mocode.ping.domain.Ping, status: String) = PingResponse( + status = status, + timestamp = domainPing.timestamp.atOffset(ZoneOffset.UTC).format(formatter), + service = properties.serviceName + ) + + // Fallback + @Suppress("unused", "UNUSED_PARAMETER") + fun fallbackPing(simulate: Boolean, ex: Exception): EnhancedPingResponse { + logger.warn("Circuit breaker fallback triggered: {}", ex.message) + return EnhancedPingResponse( + status = "fallback", + timestamp = java.time.OffsetDateTime.now().format(formatter), + service = properties.serviceNameFallback, + circuitBreakerState = "OPEN", + responseTime = 0 + ) + } + + @GetMapping("/ping/health") + override suspend fun healthCheck(): HealthResponse { + return HealthResponse( + status = "up", + timestamp = java.time.OffsetDateTime.now().format(formatter), + service = properties.serviceName, + healthy = true + ) + } } diff --git a/backend/services/ping/ping-service/src/main/resources/logback-spring.xml b/backend/services/ping/ping-service/src/main/resources/logback-spring.xml index 9a8764a2..ca766ae8 100644 --- a/backend/services/ping/ping-service/src/main/resources/logback-spring.xml +++ b/backend/services/ping/ping-service/src/main/resources/logback-spring.xml @@ -1,19 +1,19 @@ - + - - - ${LOG_PATTERN} - - + + + ${LOG_PATTERN} + + - - - - + + + + - - - + + + diff --git a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/application/PingServiceTest.kt b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/application/PingServiceTest.kt index ac70f442..40118f8a 100644 --- a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/application/PingServiceTest.kt +++ b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/application/PingServiceTest.kt @@ -19,123 +19,123 @@ import kotlin.uuid.Uuid @OptIn(ExperimentalUuidApi::class) class PingServiceTest { - private val repository: PingRepository = mockk() - private lateinit var service: PingService + private val repository: PingRepository = mockk() + private lateinit var service: PingService - @BeforeEach - fun setUp() { - service = PingService(repository) - } + @BeforeEach + fun setUp() { + service = PingService(repository) + } - @Test - fun `executePing should persist and return ping with given message`() { - // Given - every { repository.save(any()) } answers { firstArg() } + @Test + fun `executePing should persist and return ping with given message`() { + // Given + every { repository.save(any()) } answers { firstArg() } - // When - val result = service.executePing("Hello") + // When + val result = service.executePing("Hello") - // Then - assertThat(result.message).isEqualTo("Hello") - verify { repository.save(any()) } - } + // Then + assertThat(result.message).isEqualTo("Hello") + verify { repository.save(any()) } + } - @Test - fun `executePing should generate a new UUID for each ping`() { - // Given - every { repository.save(any()) } answers { firstArg() } + @Test + fun `executePing should generate a new UUID for each ping`() { + // Given + every { repository.save(any()) } answers { firstArg() } - // When - val result1 = service.executePing("Ping 1") - val result2 = service.executePing("Ping 2") + // When + val result1 = service.executePing("Ping 1") + val result2 = service.executePing("Ping 2") - // Then - assertThat(result1.id).isNotEqualTo(result2.id) - } + // Then + assertThat(result1.id).isNotEqualTo(result2.id) + } - @Test - fun `getPingHistory should delegate to repository and return all pings`() { - // Given - val pings = listOf( - Ping(message = "Ping A"), - Ping(message = "Ping B") - ) - every { repository.findAll() } returns pings + @Test + fun `getPingHistory should delegate to repository and return all pings`() { + // Given + val pings = listOf( + Ping(message = "Ping A"), + Ping(message = "Ping B") + ) + every { repository.findAll() } returns pings - // When - val result = service.getPingHistory() + // When + val result = service.getPingHistory() - // Then - assertThat(result).hasSize(2) - assertThat(result.map { it.message }).containsExactly("Ping A", "Ping B") - verify { repository.findAll() } - } + // Then + assertThat(result).hasSize(2) + assertThat(result.map { it.message }).containsExactly("Ping A", "Ping B") + verify { repository.findAll() } + } - @Test - fun `getPingHistory should return empty list when no pings exist`() { - // Given - every { repository.findAll() } returns emptyList() + @Test + fun `getPingHistory should return empty list when no pings exist`() { + // Given + every { repository.findAll() } returns emptyList() - // When - val result = service.getPingHistory() + // When + val result = service.getPingHistory() - // Then - assertThat(result).isEmpty() - } + // Then + assertThat(result).isEmpty() + } - @Test - fun `getPing should return ping by id`() { - // Given - val id = Uuid.generateV7() - val ping = Ping(id = id, message = "Find me") - every { repository.findById(id) } returns ping + @Test + fun `getPing should return ping by id`() { + // Given + val id = Uuid.generateV7() + val ping = Ping(id = id, message = "Find me") + every { repository.findById(id) } returns ping - // When - val result = service.getPing(id) + // When + val result = service.getPing(id) - // Then - assertThat(result).isEqualTo(ping) - verify { repository.findById(id) } - } + // Then + assertThat(result).isEqualTo(ping) + verify { repository.findById(id) } + } - @Test - fun `getPing should return null when ping not found`() { - // Given - val id = Uuid.generateV7() - every { repository.findById(id) } returns null + @Test + fun `getPing should return null when ping not found`() { + // Given + val id = Uuid.generateV7() + every { repository.findById(id) } returns null - // When - val result = service.getPing(id) + // When + val result = service.getPing(id) - // Then - assertThat(result).isNull() - } + // Then + assertThat(result).isNull() + } - @Test - fun `getPingsSince should return pings after given timestamp`() { - // Given - val timestamp = Instant.parse("2024-01-01T00:00:00Z").toEpochMilli() - val ping = Ping(message = "Recent Ping", timestamp = Instant.parse("2024-06-01T00:00:00Z")) - every { repository.findByTimestampAfter(any()) } returns listOf(ping) + @Test + fun `getPingsSince should return pings after given timestamp`() { + // Given + val timestamp = Instant.parse("2024-01-01T00:00:00Z").toEpochMilli() + val ping = Ping(message = "Recent Ping", timestamp = Instant.parse("2024-06-01T00:00:00Z")) + every { repository.findByTimestampAfter(any()) } returns listOf(ping) - // When - val result = service.getPingsSince(timestamp) + // When + val result = service.getPingsSince(timestamp) - // Then - assertThat(result).hasSize(1) - assertThat(result[0].message).isEqualTo("Recent Ping") - verify { repository.findByTimestampAfter(Instant.ofEpochMilli(timestamp)) } - } + // Then + assertThat(result).hasSize(1) + assertThat(result[0].message).isEqualTo("Recent Ping") + verify { repository.findByTimestampAfter(Instant.ofEpochMilli(timestamp)) } + } - @Test - fun `getPingsSince should return empty list when no pings after timestamp`() { - // Given - every { repository.findByTimestampAfter(any()) } returns emptyList() + @Test + fun `getPingsSince should return empty list when no pings after timestamp`() { + // Given + every { repository.findByTimestampAfter(any()) } returns emptyList() - // When - val result = service.getPingsSince(System.currentTimeMillis()) + // When + val result = service.getPingsSince(System.currentTimeMillis()) - // Then - assertThat(result).isEmpty() - } + // Then + assertThat(result).isEmpty() + } } diff --git a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/infrastructure/persistence/PingRepositoryTest.kt b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/infrastructure/persistence/PingRepositoryTest.kt index 83ee935d..0d08013a 100644 --- a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/infrastructure/persistence/PingRepositoryTest.kt +++ b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/infrastructure/persistence/PingRepositoryTest.kt @@ -35,54 +35,54 @@ import kotlin.uuid.toJavaUuid @OptIn(ExperimentalUuidApi::class) class PingRepositoryTest { - @Autowired - private lateinit var repositoryAdapter: PingRepositoryAdapter + @Autowired + private lateinit var repositoryAdapter: PingRepositoryAdapter - // Wir nutzen das Repository direkt, um zu prüfen, ob JPA funktioniert - @Autowired - private lateinit var springDataRepository: SpringDataPingRepository + // Wir nutzen das Repository direkt, um zu prüfen, ob JPA funktioniert + @Autowired + private lateinit var springDataRepository: SpringDataPingRepository - companion object { - @Container - val postgres = PostgreSQLContainer("postgres:16-alpine") - .withDatabaseName("testdb") - .withUsername("test") - .withPassword("test") + companion object { + @Container + val postgres = PostgreSQLContainer("postgres:16-alpine") + .withDatabaseName("testdb") + .withUsername("test") + .withPassword("test") - @JvmStatic - @DynamicPropertySource - fun registerPgProperties(registry: DynamicPropertyRegistry) { - registry.add("spring.datasource.url", postgres::getJdbcUrl) - registry.add("spring.datasource.username", postgres::getUsername) - registry.add("spring.datasource.password", postgres::getPassword) - // Wichtig: Flyway muss laufen, um Tabellen zu erstellen - registry.add("spring.flyway.enabled") { "true" } - } + @JvmStatic + @DynamicPropertySource + fun registerPgProperties(registry: DynamicPropertyRegistry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl) + registry.add("spring.datasource.username", postgres::getUsername) + registry.add("spring.datasource.password", postgres::getPassword) + // Wichtig: Flyway muss laufen, um Tabellen zu erstellen + registry.add("spring.flyway.enabled") { "true" } } + } - @Test - fun `should save and load ping entity`() { - // Given - val pingId = Uuid.generateV7() - val ping = Ping( - id = pingId, - message = "Integration Test Ping", - timestamp = Instant.now() - ) + @Test + fun `should save and load ping entity`() { + // Given + val pingId = Uuid.generateV7() + val ping = Ping( + id = pingId, + message = "Integration Test Ping", + timestamp = Instant.now() + ) - // When - repositoryAdapter.save(ping) + // When + repositoryAdapter.save(ping) - // Then (via Adapter) - val loadedPing = repositoryAdapter.findById(pingId) - assertThat(loadedPing).isNotNull - assertThat(loadedPing?.message).isEqualTo("Integration Test Ping") - assertThat(loadedPing?.id).isEqualTo(pingId) + // Then (via Adapter) + val loadedPing = repositoryAdapter.findById(pingId) + assertThat(loadedPing).isNotNull + assertThat(loadedPing?.message).isEqualTo("Integration Test Ping") + assertThat(loadedPing?.id).isEqualTo(pingId) - // Then (Direct DB Check via Spring Data) - // Fix: Use toJavaUuid() instead of UUID.fromString(pingId.toString()) - val entity = springDataRepository.findById(pingId.toJavaUuid()) - assertThat(entity).isPresent - assertThat(entity.get().message).isEqualTo("Integration Test Ping") - } + // Then (Direct DB Check via Spring Data) + // Fix: Use toJavaUuid() instead of UUID.fromString(pingId.toString()) + val entity = springDataRepository.findById(pingId.toJavaUuid()) + assertThat(entity).isPresent + assertThat(entity.get().message).isEqualTo("Integration Test Ping") + } } diff --git a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/infrastructure/web/PingControllerTest.kt b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/infrastructure/web/PingControllerTest.kt index b2b656c2..517162db 100644 --- a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/infrastructure/web/PingControllerTest.kt +++ b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/infrastructure/web/PingControllerTest.kt @@ -37,8 +37,8 @@ import kotlin.uuid.ExperimentalUuidApi * Nutzt @WebMvcTest für einen isolierten MVC-Slice ohne echte Services oder DB. */ @WebMvcTest( - controllers = [PingController::class], - properties = ["spring.aop.proxy-target-class=true"] + controllers = [PingController::class], + properties = ["spring.aop.proxy-target-class=true"] ) @ContextConfiguration(classes = [TestPingServiceApplication::class]) @ActiveProfiles("test") @@ -47,153 +47,153 @@ import kotlin.uuid.ExperimentalUuidApi @OptIn(ExperimentalUuidApi::class) class PingControllerTest { - @Autowired - private lateinit var mockMvc: MockMvc + @Autowired + private lateinit var mockMvc: MockMvc - @Autowired - @Qualifier("pingUseCaseMock") - private lateinit var pingUseCase: PingUseCase + @Autowired + @Qualifier("pingUseCaseMock") + private lateinit var pingUseCase: PingUseCase - @Autowired - private lateinit var properties: PingProperties + @Autowired + private lateinit var properties: PingProperties - @Autowired - private lateinit var objectMapper: ObjectMapper + @Autowired + private lateinit var objectMapper: ObjectMapper - @TestConfiguration - class PingControllerTestConfig { - @Bean("pingUseCaseMock") - @Primary - fun pingUseCase(): PingUseCase = mockk(relaxed = true) + @TestConfiguration + class PingControllerTestConfig { + @Bean("pingUseCaseMock") + @Primary + fun pingUseCase(): PingUseCase = mockk(relaxed = true) - @Bean - @Primary - fun pingRepositoryAdapter(): PingRepositoryAdapter = mockk(relaxed = true) + @Bean + @Primary + fun pingRepositoryAdapter(): PingRepositoryAdapter = mockk(relaxed = true) - @Bean - @Primary - fun pingProperties(): PingProperties = mockk(relaxed = true) - } + @Bean + @Primary + fun pingProperties(): PingProperties = mockk(relaxed = true) + } - @BeforeEach - fun setUp() { - clearMocks(pingUseCase, properties) - every { properties.serviceName } returns "ping-service" - every { properties.serviceNameFallback } returns "ping-service-fallback" - } + @BeforeEach + fun setUp() { + clearMocks(pingUseCase, properties) + every { properties.serviceName } returns "ping-service" + every { properties.serviceNameFallback } returns "ping-service-fallback" + } - @Test - fun `should return simple ping response`() { - // Given - every { pingUseCase.executePing(any()) } returns Ping( - message = "Simple Ping", - timestamp = Instant.parse("2023-10-01T10:00:00Z") - ) + @Test + fun `should return simple ping response`() { + // Given + every { pingUseCase.executePing(any()) } returns Ping( + message = "Simple Ping", + timestamp = Instant.parse("2023-10-01T10:00:00Z") + ) - // When - val mvcResult: MvcResult = mockMvc.perform(get("/ping/simple")) - .andExpect(request().asyncStarted()) - .andReturn() + // When + val mvcResult: MvcResult = mockMvc.perform(get("/ping/simple")) + .andExpect(request().asyncStarted()) + .andReturn() - val result = mockMvc.perform(asyncDispatch(mvcResult)) - .andExpect(status().isOk) - .andReturn() + val result = mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk) + .andReturn() - // Then - val json = objectMapper.readTree(result.response.contentAsString) - assertThat(json["status"].asText()).isEqualTo("pong") - assertThat(json["service"].asText()).isEqualTo(properties.serviceName) - verify { pingUseCase.executePing("Simple Ping") } - } + // Then + val json = objectMapper.readTree(result.response.contentAsString) + assertThat(json["status"].asText()).isEqualTo("pong") + assertThat(json["service"].asText()).isEqualTo(properties.serviceName) + verify { pingUseCase.executePing("Simple Ping") } + } - @Test - fun `should return enhanced ping response`() { - // Given - every { pingUseCase.executePing(any()) } returns Ping( - message = "Enhanced Ping", - timestamp = Instant.parse("2023-10-01T10:00:00Z") - ) + @Test + fun `should return enhanced ping response`() { + // Given + every { pingUseCase.executePing(any()) } returns Ping( + message = "Enhanced Ping", + timestamp = Instant.parse("2023-10-01T10:00:00Z") + ) - // When - val mvcResult: MvcResult = mockMvc.perform(get("/ping/enhanced")) - .andExpect(request().asyncStarted()) - .andReturn() + // When + val mvcResult: MvcResult = mockMvc.perform(get("/ping/enhanced")) + .andExpect(request().asyncStarted()) + .andReturn() - val result = mockMvc.perform(asyncDispatch(mvcResult)) - .andExpect(status().isOk) - .andReturn() + val result = mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk) + .andReturn() - // Then - val json = objectMapper.readTree(result.response.contentAsString) - assertThat(json["status"].asText()).isEqualTo("pong") - assertThat(json["service"].asText()).isEqualTo(properties.serviceName) - verify { pingUseCase.executePing("Enhanced Ping") } - } + // Then + val json = objectMapper.readTree(result.response.contentAsString) + assertThat(json["status"].asText()).isEqualTo("pong") + assertThat(json["service"].asText()).isEqualTo(properties.serviceName) + verify { pingUseCase.executePing("Enhanced Ping") } + } - @Test - fun `should return health check response with status up`() { - // When - val mvcResult: MvcResult = mockMvc.perform(get("/ping/health")) - .andExpect(request().asyncStarted()) - .andReturn() + @Test + fun `should return health check response with status up`() { + // When + val mvcResult: MvcResult = mockMvc.perform(get("/ping/health")) + .andExpect(request().asyncStarted()) + .andReturn() - val result = mockMvc.perform(asyncDispatch(mvcResult)) - .andExpect(status().isOk) - .andReturn() + val result = mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk) + .andReturn() - // Then - val json = objectMapper.readTree(result.response.contentAsString) - assertThat(json["status"].asText()).isEqualTo("up") - assertThat(json["service"].asText()).isEqualTo(properties.serviceName) - } + // Then + val json = objectMapper.readTree(result.response.contentAsString) + assertThat(json["status"].asText()).isEqualTo("up") + assertThat(json["service"].asText()).isEqualTo(properties.serviceName) + } - @Test - fun `should return sync pings as list`() { - // Given - val timestamp = 1696154400000L - every { pingUseCase.getPingsSince(timestamp) } returns listOf( - Ping( - message = "Sync Ping", - timestamp = Instant.ofEpochMilli(timestamp + 1000) - ) - ) + @Test + fun `should return sync pings as list`() { + // Given + val timestamp = 1696154400000L + every { pingUseCase.getPingsSince(timestamp) } returns listOf( + Ping( + message = "Sync Ping", + timestamp = Instant.ofEpochMilli(timestamp + 1000) + ) + ) - // When - val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("since", timestamp.toString())) - .andExpect(request().asyncStarted()) - .andReturn() + // When + val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("since", timestamp.toString())) + .andExpect(request().asyncStarted()) + .andReturn() - val result = mockMvc.perform(asyncDispatch(mvcResult)) - .andExpect(status().isOk) - .andReturn() + val result = mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk) + .andReturn() - // Then - val json = objectMapper.readTree(result.response.contentAsString) - assertThat(json.isArray).isTrue - assertThat(json.size()).isEqualTo(1) - assertThat(json[0]["message"].asText()).isEqualTo("Sync Ping") - assertThat(json[0]["lastModified"].asLong()).isEqualTo(timestamp + 1000) - verify { pingUseCase.getPingsSince(timestamp) } - } + // Then + val json = objectMapper.readTree(result.response.contentAsString) + assertThat(json.isArray).isTrue + assertThat(json.size()).isEqualTo(1) + assertThat(json[0]["message"].asText()).isEqualTo("Sync Ping") + assertThat(json[0]["lastModified"].asLong()).isEqualTo(timestamp + 1000) + verify { pingUseCase.getPingsSince(timestamp) } + } - @Test - fun `should return empty list when no pings since timestamp`() { - // Given - val timestamp = System.currentTimeMillis() - every { pingUseCase.getPingsSince(timestamp) } returns emptyList() + @Test + fun `should return empty list when no pings since timestamp`() { + // Given + val timestamp = System.currentTimeMillis() + every { pingUseCase.getPingsSince(timestamp) } returns emptyList() - // When - val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("since", timestamp.toString())) - .andExpect(request().asyncStarted()) - .andReturn() + // When + val mvcResult: MvcResult = mockMvc.perform(get("/ping/sync").param("since", timestamp.toString())) + .andExpect(request().asyncStarted()) + .andReturn() - val result = mockMvc.perform(asyncDispatch(mvcResult)) - .andExpect(status().isOk) - .andReturn() + val result = mockMvc.perform(asyncDispatch(mvcResult)) + .andExpect(status().isOk) + .andReturn() - // Then - val json = objectMapper.readTree(result.response.contentAsString) - assertThat(json.isArray).isTrue - assertThat(json.size()).isEqualTo(0) - } + // Then + val json = objectMapper.readTree(result.response.contentAsString) + assertThat(json.isArray).isTrue + assertThat(json.size()).isEqualTo(0) + } } diff --git a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/test/TestPingServiceApplication.kt b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/test/TestPingServiceApplication.kt index 4873e7af..94e0727b 100644 --- a/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/test/TestPingServiceApplication.kt +++ b/backend/services/ping/ping-service/src/test/kotlin/at/mocode/ping/test/TestPingServiceApplication.kt @@ -20,7 +20,7 @@ import org.springframework.context.annotation.Import */ @SpringBootApplication @ComponentScan( - basePackages = ["at.mocode.infrastructure.security"] + basePackages = ["at.mocode.infrastructure.security"] ) @Import(PingController::class, PingProperties::class) @EnableAspectJAutoProxy(proxyTargetClass = true) // Erzwingt CGLIB Proxies für Controller diff --git a/backend/services/zns-import/zns-import-service/build.gradle.kts b/backend/services/zns-import/zns-import-service/build.gradle.kts index 070688ca..016efaf0 100644 --- a/backend/services/zns-import/zns-import-service/build.gradle.kts +++ b/backend/services/zns-import/zns-import-service/build.gradle.kts @@ -14,14 +14,8 @@ dependencies { implementation(projects.core.coreDomain) implementation(projects.core.coreUtils) implementation(projects.backend.infrastructure.znsImporter) - implementation(projects.backend.services.clubs.clubsDomain) - implementation(projects.backend.services.clubs.clubsInfrastructure) - implementation(projects.backend.services.persons.personsDomain) - implementation(projects.backend.services.persons.personsInfrastructure) - implementation(projects.backend.services.horses.horsesDomain) - implementation(projects.backend.services.horses.horsesInfrastructure) - implementation(projects.backend.services.officials.officialsDomain) - implementation(projects.backend.services.officials.officialsInfrastructure) + implementation(projects.backend.services.masterdata.masterdataDomain) + implementation(projects.backend.services.masterdata.masterdataInfrastructure) implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.validation) diff --git a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/ZnsImportServiceApplication.kt b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/ZnsImportServiceApplication.kt index 707e1a48..32e448d0 100644 --- a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/ZnsImportServiceApplication.kt +++ b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/ZnsImportServiceApplication.kt @@ -8,10 +8,7 @@ import org.springframework.context.annotation.ComponentScan @ComponentScan( basePackages = [ "at.mocode.zns.import.service", - "at.mocode.clubs.infrastructure", - "at.mocode.persons.infrastructure", - "at.mocode.horses.infrastructure", - "at.mocode.officials.infrastructure" + "at.mocode.masterdata.infrastructure" ] ) class ZnsImportServiceApplication diff --git a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/api/ZnsImportController.kt b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/api/ZnsImportController.kt index 026d8a99..5f6ab34a 100644 --- a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/api/ZnsImportController.kt +++ b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/api/ZnsImportController.kt @@ -19,7 +19,7 @@ class ZnsImportController( /** * POST /api/v1/import/zns - * Nimmt eine .zip oder .dat Datei entgegen und startet den asynchronen Import. + * nimmt eine .zip oder .dat Datei entgegen und startet den asynchronen Import. * Rückgabe: 202 Accepted mit JobId. */ @PostMapping(consumes = ["multipart/form-data"]) @@ -31,7 +31,7 @@ class ZnsImportController( /** * GET /api/v1/import/zns/{jobId}/status - * Gibt den aktuellen Fortschritt und Statusmeldungen zurück. + * gibt den aktuellen Fortschritt und Statusmeldungen zurück. */ @GetMapping("/{jobId}/status") fun holeStatus(@PathVariable jobId: String): ResponseEntity { diff --git a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/RepositoryConfiguration.kt b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/RepositoryConfiguration.kt index 726057ae..a1711f3b 100644 --- a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/RepositoryConfiguration.kt +++ b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/RepositoryConfiguration.kt @@ -1,13 +1,7 @@ package at.mocode.zns.import.service.config -import at.mocode.clubs.domain.repository.VereinRepository -import at.mocode.clubs.infrastructure.persistence.ExposedVereinRepository -import at.mocode.horses.domain.repository.HorseRepository -import at.mocode.horses.infrastructure.persistence.HorseRepositoryImpl -import at.mocode.officials.domain.repository.FunktionaerRepository -import at.mocode.officials.infrastructure.persistence.ExposedFunktionaerRepository -import at.mocode.persons.domain.repository.ReiterRepository -import at.mocode.persons.infrastructure.persistence.ExposedReiterRepository +import at.mocode.masterdata.domain.repository.* +import at.mocode.masterdata.infrastructure.persistence.* import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration diff --git a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/ZnsImportDatabaseConfiguration.kt b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/ZnsImportDatabaseConfiguration.kt index 5a072e9c..a20c6650 100644 --- a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/ZnsImportDatabaseConfiguration.kt +++ b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/config/ZnsImportDatabaseConfiguration.kt @@ -1,9 +1,9 @@ package at.mocode.zns.import.service.config -import at.mocode.clubs.infrastructure.persistence.VereinTable -import at.mocode.horses.infrastructure.persistence.HorseTable -import at.mocode.officials.infrastructure.persistence.FunktionaerTable -import at.mocode.persons.infrastructure.persistence.ReiterTable +import at.mocode.masterdata.infrastructure.persistence.FunktionaerTable +import at.mocode.masterdata.infrastructure.persistence.HorseTable +import at.mocode.masterdata.infrastructure.persistence.ReiterTable +import at.mocode.masterdata.infrastructure.persistence.VereinTable import jakarta.annotation.PostConstruct import org.jetbrains.exposed.v1.jdbc.Database import org.jetbrains.exposed.v1.jdbc.transactions.transaction diff --git a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/job/ZnsImportOrchestrator.kt b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/job/ZnsImportOrchestrator.kt index 1f3b4da2..a342c286 100644 --- a/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/job/ZnsImportOrchestrator.kt +++ b/backend/services/zns-import/zns-import-service/src/main/kotlin/at/mocode/zns/import/service/job/ZnsImportOrchestrator.kt @@ -1,9 +1,9 @@ package at.mocode.zns.import.service.job -import at.mocode.clubs.domain.repository.VereinRepository -import at.mocode.horses.domain.repository.HorseRepository -import at.mocode.officials.domain.repository.FunktionaerRepository -import at.mocode.persons.domain.repository.ReiterRepository +import at.mocode.masterdata.domain.repository.VereinRepository +import at.mocode.masterdata.domain.repository.HorseRepository +import at.mocode.masterdata.domain.repository.FunktionaerRepository +import at.mocode.masterdata.domain.repository.ReiterRepository import at.mocode.zns.importer.ZnsImportService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/event/DomainEvent.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/event/DomainEvent.kt index c30d04cd..5be0ece8 100644 --- a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/event/DomainEvent.kt +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/event/DomainEvent.kt @@ -3,7 +3,7 @@ package at.mocode.core.domain.event import at.mocode.core.domain.model.* -import at.mocode.core.domain.serialization.KotlinxInstantSerializer +import at.mocode.core.domain.serialization.InstantSerializer import kotlinx.serialization.Serializable import kotlin.time.Clock as KtClock import kotlin.time.Instant @@ -33,7 +33,7 @@ abstract class BaseDomainEvent( override val version: EventVersion, override val eventId: EventId = EventId(Uuid.random()), - @Serializable(with = KotlinxInstantSerializer::class) + @Serializable(with = InstantSerializer::class) override val timestamp: Instant, override val correlationId: CorrelationId? = null, override val causationId: CausationId? = null diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/BaseDto.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/BaseDto.kt index 3a95c7ef..8989c994 100644 --- a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/BaseDto.kt +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/BaseDto.kt @@ -1,10 +1,9 @@ package at.mocode.core.domain.model -import at.mocode.core.domain.serialization.KotlinxInstantSerializer -import kotlinx.serialization.Serializable +import at.mocode.core.domain.serialization.InstantSerializer import kotlin.time.Clock -import kotlin.time.ExperimentalTime import kotlin.time.Instant +import kotlinx.serialization.Serializable /** * Marker-Interface für alle Data-Transfer-Objekte (DTO). @@ -18,10 +17,10 @@ interface BaseDto abstract class EntityDto : BaseDto { abstract val id: EntityId - @Serializable(with = KotlinxInstantSerializer::class) + @Serializable(with = InstantSerializer::class) abstract val createdAt: Instant - @Serializable(with = KotlinxInstantSerializer::class) + @Serializable(with = InstantSerializer::class) abstract val updatedAt: Instant } @@ -43,19 +42,17 @@ data class ApiResponse( val data: T?, val success: Boolean, val errors: List = emptyList(), - @Serializable(with = KotlinxInstantSerializer::class) + @Serializable(with = InstantSerializer::class) val timestamp: Instant ) { companion object { - @OptIn(ExperimentalTime::class) fun success(data: T): ApiResponse = ApiResponse( data = data, success = true, - timestamp = Instant.parse(Clock.System.now().toString()) + timestamp = Clock.System.now() ) - @OptIn(ExperimentalTime::class) fun error( code: ErrorCode, message: String, @@ -65,7 +62,7 @@ data class ApiResponse( data = null, success = false, errors = listOf(ErrorDto(code = code, message = message, field = field)), - timestamp = Instant.parse(Clock.System.now().toString()) + timestamp = Clock.System.now() ) } @@ -77,13 +74,12 @@ data class ApiResponse( return error(ErrorCode(code), message, field) } - @OptIn(ExperimentalTime::class) fun error(errors: List): ApiResponse { return ApiResponse( data = null, success = false, errors = errors, - timestamp = Instant.parse(Clock.System.now().toString()) + timestamp = Clock.System.now() ) } } diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt index f0306a1d..1e4222bd 100644 --- a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/Enums.kt @@ -438,3 +438,21 @@ enum class AusschreibungsStatusE { /** Ausschreibung veröffentlicht (für Reiter sichtbar) */ VEROEFFENTLICHT } + +/** + * Definiert den Typ eines Platzes auf einer Turnieranlage. + */ +@Serializable +enum class PlatzTypE { + /** Hauptplatz für Wettbewerbe */ + AUSTRAGUNG, + + /** Vorbereitungsplatz / Abreiteplatz */ + VORBEREITUNG, + + /** Longierplatz */ + LONGIEREN, + + /** Sonstige Fläche */ + SONSTIGE +} diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/ValidationResult.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/ValidationResult.kt new file mode 100644 index 00000000..ea672b1d --- /dev/null +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/model/ValidationResult.kt @@ -0,0 +1,30 @@ +package at.mocode.core.domain.model + +import kotlinx.serialization.Serializable + +/** + * Representiert das Ergebnis einer Validierung. + */ +@Serializable +sealed class ValidationResult { + /** + * Gibt an, ob die Validierung erfolgreich war. + */ + abstract fun isValid(): Boolean + + /** + * Repräsentiert eine erfolgreiche Validierung. + */ + @Serializable + object Valid : ValidationResult() { + override fun isValid(): Boolean = true + } + + /** + * Repräsentiert eine fehlgeschlagene Validierung mit einer Liste von Fehlern. + */ + @Serializable + data class Invalid(val errors: List) : ValidationResult() { + override fun isValid(): Boolean = false + } +} diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/KotlinLocalDateSerializer.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/KotlinLocalDateSerializer.kt deleted file mode 100644 index d39c915d..00000000 --- a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/KotlinLocalDateSerializer.kt +++ /dev/null @@ -1,32 +0,0 @@ -package at.mocode.core.domain.serialization - -import kotlinx.datetime.LocalDate -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -/** - * Kotlinx Serialization serializer for kotlinx.datetime.LocalDate. - * Serializes as ISO-8601 date string (yyyy-MM-dd). - */ -object KotlinLocalDateSerializer : KSerializer { - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("KotlinLocalDate", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: LocalDate) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): LocalDate { - val text = decoder.decodeString() - return try { - LocalDate.parse(text) - } catch (e: Exception) { - throw SerializationException("Invalid LocalDate format: '$text'", e) - } - } -} diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/KotlinxInstantSerializer.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/KotlinxInstantSerializer.kt deleted file mode 100644 index 3b5f9eeb..00000000 --- a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/KotlinxInstantSerializer.kt +++ /dev/null @@ -1,25 +0,0 @@ -package at.mocode.core.domain.serialization - -import kotlin.time.Instant -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -/** - * Serializer for kotlin.time.Instant. - * Uses ISO-8601 string representation. - */ -object KotlinxInstantSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("KotlinxInstant", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: Instant) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): Instant { - return Instant.parse(decoder.decodeString()) - } -} diff --git a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/Serializers.kt b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/Serializers.kt index 01a096c7..346f0458 100644 --- a/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/Serializers.kt +++ b/core/core-domain/src/commonMain/kotlin/at/mocode/core/domain/serialization/Serializers.kt @@ -1,99 +1,59 @@ +@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) package at.mocode.core.domain.serialization import kotlinx.datetime.LocalDate import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalTime +import kotlin.time.Instant import kotlinx.serialization.KSerializer import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -import kotlin.time.ExperimentalTime -import kotlin.time.Instant -import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid /** - * Serializer für kotlin.time. Instant Objekte. - * Konvertiert Instant zu/von ISO-8601 String-Repräsentation. + * Serializer für kotlin.time.Instant Objekte. */ -@OptIn(ExperimentalTime::class) -object KotlinInstantSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: Instant) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): Instant { - return Instant.parse(decoder.decodeString()) - } +object InstantSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: Instant) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): Instant = Instant.parse(decoder.decodeString()) } -// Note: Serializer for kotlinx.datetime.Instant is defined in a separate file - /** - * Serializer für UUID Objekte. - * Konvertiert UUID zu/von String-Repräsentation. + * Serializer für kotlin.uuid.Uuid Objekte. */ -@OptIn(ExperimentalUuidApi::class) object UuidSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: Uuid) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): Uuid { - return Uuid.parse(decoder.decodeString()) - } + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Uuid", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: Uuid) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): Uuid = Uuid.parse(decoder.decodeString()) } /** * Serializer für kotlinx.datetime.LocalDate Objekte. - * Konvertiert LocalDate zu/von ISO-8601 String-Repräsentation. */ object LocalDateSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: LocalDate) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): LocalDate { - return LocalDate.parse(decoder.decodeString()) - } + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDate", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: LocalDate) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): LocalDate = LocalDate.parse(decoder.decodeString()) } /** - * Serializer für kotlinx.datetime. LocalDateTime Objekte. - * Konvertiert LocalDateTime zu/von ISO-8601 String-Repräsentation. + * Serializer für kotlinx.datetime.LocalDateTime Objekte. */ object LocalDateTimeSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: LocalDateTime) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): LocalDateTime { - return LocalDateTime.parse(decoder.decodeString()) - } + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: LocalDateTime) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): LocalDateTime = LocalDateTime.parse(decoder.decodeString()) } /** * Serializer für kotlinx.datetime.LocalTime Objekte. - * Konvertiert LocalTime zu/von ISO-8601 String-Repräsentation. */ object LocalTimeSerializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalTime", PrimitiveKind.STRING) - - override fun serialize(encoder: Encoder, value: LocalTime) { - encoder.encodeString(value.toString()) - } - - override fun deserialize(decoder: Decoder): LocalTime { - return LocalTime.parse(decoder.decodeString()) - } + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalTime", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: LocalTime) = encoder.encodeString(value.toString()) + override fun deserialize(decoder: Decoder): LocalTime = LocalTime.parse(decoder.decodeString()) } diff --git a/core/core-domain/src/commonTest/kotlin/at/mocode/core/domain/SerializersTest.kt b/core/core-domain/src/commonTest/kotlin/at/mocode/core/domain/SerializersTest.kt index 72639986..79deab76 100644 --- a/core/core-domain/src/commonTest/kotlin/at/mocode/core/domain/SerializersTest.kt +++ b/core/core-domain/src/commonTest/kotlin/at/mocode/core/domain/SerializersTest.kt @@ -15,8 +15,8 @@ class SerializersTest { @Test fun `Instant roundtrip`() { val instant = kotlin.time.Instant.parse("2024-01-01T00:00:00Z") - val json = Json.encodeToString(KotlinInstantSerializer, instant) - val decoded = Json.decodeFromString(KotlinInstantSerializer, json) + val json = Json.encodeToString(InstantSerializer, instant) + val decoded = Json.decodeFromString(InstantSerializer, json) assertEquals(instant, decoded) } diff --git a/core/core-utils/build.gradle.kts b/core/core-utils/build.gradle.kts index f88ceef9..087bbcf7 100644 --- a/core/core-utils/build.gradle.kts +++ b/core/core-utils/build.gradle.kts @@ -34,9 +34,9 @@ kotlin { } jvmMain { dependencies { - // Removed Exposed dependencies to make this module KMP compatible - // implementation(libs.exposed.core) - // implementation(libs.exposed.jdbc) + implementation(libs.exposed.core) + implementation(libs.exposed.jdbc) + implementation(libs.exposed.kotlin.datetime) } } } diff --git a/core/core-utils/src/jvmMain/kotlin/at/mocode/core/utils/database/DatabaseFactory.kt b/core/core-utils/src/jvmMain/kotlin/at/mocode/core/utils/database/DatabaseFactory.kt new file mode 100644 index 00000000..5029d8a6 --- /dev/null +++ b/core/core-utils/src/jvmMain/kotlin/at/mocode/core/utils/database/DatabaseFactory.kt @@ -0,0 +1,31 @@ +package at.mocode.core.utils.database + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jetbrains.exposed.v1.core.Transaction +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import org.jetbrains.exposed.v1.jdbc.transactions.experimental.newSuspendedTransaction + +/** + * Utility for database operations using Exposed. + */ +object DatabaseFactory { + + /** + * Executes a database query in a suspended transaction. + */ + suspend fun dbQuery(block: suspend Transaction.() -> T): T = + withContext(Dispatchers.IO) { + newSuspendedTransaction { + block() + } + } + + /** + * Executes a database query in a blocking transaction. + */ + fun transaction(block: Transaction.() -> T): T = + org.jetbrains.exposed.v1.jdbc.transactions.transaction { + block() + } +} diff --git a/core/zns-parser/build.gradle.kts b/core/zns-parser/build.gradle.kts index 929d69b1..cdfb2c11 100644 --- a/core/zns-parser/build.gradle.kts +++ b/core/zns-parser/build.gradle.kts @@ -5,17 +5,17 @@ plugins { kotlin { jvm() + js(IR) { + browser() + } sourceSets { commonMain { dependencies { implementation(projects.core.coreDomain) implementation(projects.core.coreUtils) - // Domänen-Modelle für das Parsing - implementation(projects.backend.services.clubs.clubsDomain) - implementation(projects.backend.services.persons.personsDomain) - implementation(projects.backend.services.horses.horsesDomain) - implementation(projects.backend.services.officials.officialsDomain) + // Domänen-Modelle für das Parsing aus dem Master-Data-Context + implementation(projects.backend.services.masterdata.masterdataDomain) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.datetime) diff --git a/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsLegacyParsers.kt b/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsLegacyParsers.kt index 89755e1e..2ecb86b1 100644 --- a/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsLegacyParsers.kt +++ b/core/zns-parser/src/commonMain/kotlin/at/mocode/zns/parser/ZnsLegacyParsers.kt @@ -1,13 +1,13 @@ package at.mocode.zns.parser -import at.mocode.clubs.domain.model.DomVerein +import at.mocode.masterdata.domain.model.DomVerein +import at.mocode.masterdata.domain.model.DomReiter +import at.mocode.masterdata.domain.model.DomPferd +import at.mocode.masterdata.domain.model.DomFunktionaer import at.mocode.core.domain.model.DatenQuelleE import at.mocode.core.domain.model.LizenzKlasseE import at.mocode.core.domain.model.PferdeGeschlechtE import at.mocode.core.utils.parser.FixedWidthLineReader -import at.mocode.horses.domain.model.DomPferd -import at.mocode.officials.domain.model.DomFunktionaer -import at.mocode.persons.domain.model.DomReiter import kotlinx.datetime.LocalDate import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid diff --git a/docs/01_Architecture/Fahrplan_Konsolidierung_2026-03-28.md b/docs/01_Architecture/Fahrplan_Konsolidierung_2026-03-28.md new file mode 100644 index 00000000..40343dd7 --- /dev/null +++ b/docs/01_Architecture/Fahrplan_Konsolidierung_2026-03-28.md @@ -0,0 +1,95 @@ +# Roadmap: System-Konsolidierung & Strategie + +🏗️ **[Lead Architect]** & 🧹 **[Curator]** | 28. März 2026 + +## 1. Zusammenfassung + +Dieser Fahrplan beschreibt die Schritte zur Konsolidierung der technischen Basis und die Strategie zur Ausrichtung der +Feature-Implementierung an der verfeinerten DDD-Struktur (ADR-0014) sowie der Design-Baseline Vision_03. + +--- + +## 2. Abgeschlossene Meilensteine (Letzte Sessions) + +### 🟢 Technische Stabilisierung + +* **Kotlin 2.3.20:** Alle Module wurden auf Kotlin 2.3.20 migriert. Deprecation-Warnungen für `Clock` und `Instant` + wurden durch Standardisierung auf `kotlin.time.*` behoben. +* **Zentralisierte Serialisierung:** Erstellung der `Serializers.kt` im `core-domain` Modul für `Uuid`, `Instant`, + `LocalDate`, `LocalDateTime` und `LocalTime`. +* **Exposed Framework:** Fixierung der Exposed-Version auf `1.1.1` für alle Module, um eine stabile Persistenzschicht zu + gewährleisten. +* **Infrastruktur-Refactoring:** Umzug der `DatabaseFactory` nach `core-utils` (jvmMain) als wiederverwendbare + Komponente. + +### 🔵 DDD-Konsolidierung: `master-data-context` + +* **Context-Merge:** Die separaten Services (`clubs`, `persons`, `horses`, `officials`) wurden aufgelöst und im + zentralen `master-data-context` vereint. +* **ZNS-Importer Verifizierung:** Erfolgreicher Testlauf mit der offiziellen `ZNS.zip`. Ca. 70.000 Datensätze wurden + korrekt in die neue Struktur importiert. +* **Library of Truth:** Etablierung des `master-data-context` als schreibgeschützte (für Enduser) "Single Source of + Truth" für Verbandsdaten. + +### 🟡 Identity Integration + +* **ZNS-Identity Link:** Technische Grundlage im `identity`-Service geschaffen, um System-User (Keycloak) mit + offiziellen ZNS-Satznummern zu verknüpfen. +* **Profil-Erweiterungen:** Implementierung von `DomProfil` für angereicherte Daten (Logos, Bios), ohne die + ZNS-Integrität zu gefährden. + +--- + +## 3. Detaillierter Fahrplan (Aktuelle & Nächste Schritte) + +### Phase A: Fundament finalisieren (Status: In Arbeit) + +* [ ] **Repository-Vervollständigung:** Finalisierung der Persistenzmethoden in `masterdata-infrastructure` unter + Nutzung der neuen Tabellen. +* [ ] **API-Refinement:** Abschluss der REST-Endpunkte für den konsolidierten Master-Data-Context (Länder, + Bundesländer, Altersklassen, Plätze). +* [ ] **Validierungs-Logik:** Implementierung der Matrizen für Startberechtigungen (Altersklassen/Lizenz-Prüfungen) im + Master-Data-Kern. + +### Phase B: Identity & Profil-Erfahrung + +* [ ] **ZNS Link UI:** Erstellung des Frontend-Screens in `meldestelle-desktop`, auf dem User ihre offizielle + Satznummer suchen und verknüpfen können. +* [ ] **Profil-Verwaltung:** Implementierung der UI-Features zur Pflege der erweiterten Profildaten (Logo-Upload, + Kontaktinfo). + +### Phase C: Competition-Context Refinement (§ 39 ÖTO) + +* **Atomarität:** Ausrichtung der Logik auf die **"Abteilung"** als kleinste operative Einheit. +* **Automatische Trennung:** Implementierung von Warnungen bei Überschreitung der Starter-Schwellenwerte (z.B. 80 + Starter Fallback). +* **Listen-Generierung:** Umstellung der Tabs 7-8 im Frontend auf Abteilungs-basierte Selektion für Start- und + Ergebnislisten. + +### Phase D: Vision_03 Evolution + +* **Integration:** Ersetzen der alten administrativen Screens durch die neuen `v2`-Screens (`VeranstalterAuswahlV2`, + `TurnierWizardV2`). +* **Billing-Sync:** Portierung der Gebühren-Logik (Nenngebühren, Tierwohl-Euro, Sportförderung) vom Figma React-Prototyp + in das KMP `billing-feature`. + +--- + +## 4. Bounded Context Map (Konsolidiert) + +| Bounded Context | Verantwortung | Source of Truth | +|:----------------|:---------------------------------------------------|:------------------------| +| `master-data` | SSOT für Personen, Pferde, Vereine, Regelwerk | ZNS Import / Admin | +| `identity` | Auth, Profile, ZNS-Links | Keycloak / Link-Tabelle | +| `registration` | Nennungs-Management, Validierung gegen Master-Data | System-Nennungen | +| `competition` | Live-Scoring, Abteilungen, Start-/Ergebnislisten | Live-Eingabe | +| `billing` | Konten, Gebühren, Kassa | Finanz-Transaktionen | +| `event-mgmt` | Turnierstruktur, Konfiguration, Zeitplan | User-Konfiguration | + +--- + +## 5. Sofort-Maßnahmen + +1. **Backend:** Letzte Kompilierfehler in `masterdata-infrastructure` beheben. +2. **Backend:** ZNS-Linking Endpunkte über die Identity-API bereitstellen. +3. **Frontend:** `NennungsMaske` auf die neuen, konsolidierten Masterdata-Endpunkte umstellen. diff --git a/docs/01_Architecture/adr/0014-bounded-context-mapping-de.md b/docs/01_Architecture/adr/0014-bounded-context-mapping-de.md index 31fe4afa..2767418d 100644 --- a/docs/01_Architecture/adr/0014-bounded-context-mapping-de.md +++ b/docs/01_Architecture/adr/0014-bounded-context-mapping-de.md @@ -3,7 +3,7 @@ type: ADR id: ADR-0014 status: ACTIVE owner: Lead Architect -last_update: 2026-03-24 +last_update: 2026-03-28 --- # ADR-0014: Bounded Context Mapping (SCS-Architektur) diff --git a/docs/03_Domain/01_Glossary/Ubiquitous_Language.md b/docs/03_Domain/01_Glossary/Ubiquitous_Language.md index c5698eb4..5de13794 100644 --- a/docs/03_Domain/01_Glossary/Ubiquitous_Language.md +++ b/docs/03_Domain/01_Glossary/Ubiquitous_Language.md @@ -2,7 +2,7 @@ type: Reference status: ACTIVE owner: Lead Architect & ÖTO/FEI Rulebook Expert -last_update: 2026-03-24 +last_update: 2026-03-28 sources: - ÖTO 2026, Abschnitt A I, § 2 & § 3 & § 4 - Domain Workshop 2026-03-17 diff --git a/docs/99_Journal/2026-03-28_Session_Log_Masterdata_Build_Fix.md b/docs/99_Journal/2026-03-28_Session_Log_Masterdata_Build_Fix.md new file mode 100644 index 00000000..21f9ccf7 --- /dev/null +++ b/docs/99_Journal/2026-03-28_Session_Log_Masterdata_Build_Fix.md @@ -0,0 +1,47 @@ +--- +type: Journal +status: COMPLETED +owner: DevOps Engineer +last_update: 2026-03-28 +--- + +# Session Log: Korrektur der Spring Boot Konfiguration im Masterdata-Modul + +🐧 **[DevOps Engineer]** | 28. März 2026 + +## Kontext + +Der Build schlug im Modul `:backend:services:masterdata:masterdata-infrastructure` beim Task `bootJar` fehl, da keine +`mainClass` konfiguriert war. Da dieses Modul nur Infrastruktur-Code (Exposed Repositories etc.) bereitstellt und keine +eigenständige Spring Boot Application ist, sollte kein `bootJar` (ausführbares JAR) erstellt werden. + +## Erledigte Aufgaben + +### 1. ✅ build.gradle.kts Anpassung + +- Das `spring-boot` Plugin in `masterdata-infrastructure/build.gradle.kts` auf `apply false` gesetzt. +- Dadurch wird der `bootJar` Task (der eine Main-Class zwingend erfordert) für dieses Modul nicht mehr registriert. +- Der Standard `jar` Task bleibt aktiv und stellt die Library für andere Module zur Verfügung. + +### 2. ✅ Build-Verifizierung + +- Lokaler Build der betroffenen Tasks erfolgreich durchgeführt: + - `./gradlew :backend:services:masterdata:masterdata-infrastructure:jar` (Erfolgreich) + - `./gradlew :backend:services:masterdata:masterdata-service:bootJar` (Erfolgreich, nutzt die Infrastruktur-Library) +- Status: **GRÜN** + +## Technische Änderungen + +### `backend/services/masterdata/masterdata-infrastructure/build.gradle.kts` + +- Geändert: `alias(libs.plugins.spring.boot) apply false` + +## Nächste Schritte + +- Prüfung anderer Infrastruktur-Module auf ähnliche Fehlkonfigurationen (redundante `bootJar` Tasks). + +--- + +## Referenzen + +- `MASTER_ROADMAP.md` (Phase 4: MVP-Implementierung) diff --git a/docs/99_Journal/2026-03-28_Session_Log_Metaspace_Fix.md b/docs/99_Journal/2026-03-28_Session_Log_Metaspace_Fix.md new file mode 100644 index 00000000..9574c7ca --- /dev/null +++ b/docs/99_Journal/2026-03-28_Session_Log_Metaspace_Fix.md @@ -0,0 +1,54 @@ +--- +type: Journal +status: COMPLETED +owner: DevOps Engineer +last_update: 2026-03-28 +--- + +# Session Log: Metaspace-Optimierung & Build-Fix + +🐧 **[DevOps Engineer]** | 28. März 2026 + +## Kontext + +Der Build schlug in mehreren Modulen mit `java.lang.OutOfMemoryError: Metaspace` fehl. +Betroffen waren insbesondere die Kotlin/JS und WASM Kompilationen: + +- `:contracts:ping-api:compileKotlinWasmJs` +- `:core:core-domain:compileTestKotlinJs` +- `:core:core-utils:compileKotlinJs` + +Die bisherigen Limits (1GB Metaspace) reichten für die komplexe Multiplatform-Struktur nicht mehr aus. + +## Erledigte Aufgaben + +### 1. ✅ Metaspace & Heap Erhöhung + +- Metaspace-Limit für den Kotlin Daemon und den Gradle Daemon von **1GB auf 2GB** erhöht. +- Heap-Speicher für den Kotlin Daemon von **4GB auf 6GB** erhöht. +- Redundante/widersprüchliche JVM-Argumente in `gradle.properties` harmonisiert. + +### 2. ✅ Build-Verifizierung + +- Lokaler Build der betroffenen Tasks erfolgreich durchgeführt: + `./gradlew :contracts:ping-api:compileKotlinWasmJs :core:core-domain:compileTestKotlinJs :core:core-utils:compileKotlinJs --no-daemon` +- Status: **GRÜN** + +## Technische Änderungen + +### `gradle.properties` + +- `kotlin.daemon.jvmargs`: `-Xmx6g -XX:MaxMetaspaceSize=2g` +- `org.gradle.jvmargs`: `-Xmx6g -Dkotlin.daemon.jvm.options="-Xmx4g" -XX:MaxMetaspaceSize=2g` + +## Nächste Schritte + +- Überwachung der CI/CD Pipeline auf ähnliche Ressourcen-Engpässe. +- Bei weiteren Problemen: Prüfung, ob `--parallel` in Kombination mit vielen JS-Targets zu hohen Lastspitzen führt. + +--- + +## Referenzen + +- `gradle.properties` +- `MASTER_ROADMAP.md` (Phase 4: MVP-Implementierung) diff --git a/docs/99_Journal/2026-03-28_Session_Log_Ping_Migration_Fix.md b/docs/99_Journal/2026-03-28_Session_Log_Ping_Migration_Fix.md new file mode 100644 index 00000000..1f888bd4 --- /dev/null +++ b/docs/99_Journal/2026-03-28_Session_Log_Ping_Migration_Fix.md @@ -0,0 +1,50 @@ +--- +type: Journal +status: COMPLETED +owner: QA Specialist +last_update: 2026-03-28 +--- + +# Session Log: Behebung Flyway Migrations-Fehler (Ping-Service) + +🧐 **[QA Specialist]** | 28. März 2026 + +## Kontext + +Der Test-Task `:backend:services:ping:ping-service:test` schlug fehl. Die Ursache war ein `FlywayMigrateException` mit +der Meldung `ERROR: relation "ping" already exists`. +Dies passierte, weil zwei separate Migrations-Dateien versuchten, die gleiche Tabelle `ping` zu erstellen. + +## Erledigte Aufgaben + +### 1. ✅ Identifizierung des Konflikts + +- `V1__init_ping.sql` enthielt bereits die `CREATE TABLE ping` Anweisung. +- `V3__.sql` (vermutlich ein automatisches Relikt oder Fehl-Generat) versuchte die gleiche Tabelle erneut anzulegen. + +### 2. ✅ Bereinigung + +- Die redundante Datei `backend/services/ping/ping-service/src/main/resources/db/migration/V3__.sql` wurde gelöscht. +- `V1__init_ping.sql` (Schema) und `V2__seed_data.sql` (Testdaten) bleiben als Basis bestehen. + +### 3. ✅ Test-Verifizierung + +- Ausführung von `./gradlew :backend:services:ping:ping-service:test` +- Ergebnis: **BUILD SUCCESSFUL** +- Alle Tests (Controller, Service, Repository mit Testcontainers) sind grün. + +## Technische Details + +- Die Warnung bezüglich `sun.misc.Unsafe` (ByteBuddy) in Java 25 wurde zur Kenntnis genommen, blockiert den Build aber + nicht und ist ein bekanntes Upstream-Thema bei Spring Boot / Hibernate auf neuesten JDKs. + +## Nächste Schritte + +- Überwachung der Schema-Generierung in anderen Services, um ähnliche Duplikate zu vermeiden. + +--- + +## Referenzen + +- `MASTER_ROADMAP.md` (Phase 4: MVP-Implementierung) +- `backend/services/ping/ping-service/src/main/resources/db/migration/` diff --git a/docs/99_Journal/2026-03-28_Session_Log_TabRow_Migration.md b/docs/99_Journal/2026-03-28_Session_Log_TabRow_Migration.md new file mode 100644 index 00000000..64c17384 --- /dev/null +++ b/docs/99_Journal/2026-03-28_Session_Log_TabRow_Migration.md @@ -0,0 +1,59 @@ +--- +type: Journal +status: COMPLETED +owner: Frontend Expert +last_update: 2026-03-28 +--- + +# Session Log: Modernisierung der Tab-Komponenten (Material 3) + +🎨 **[Frontend Expert]** | 28. März 2026 + +## Kontext + +Die generische `TabRow`-Komponente aus Material 3 wurde als `@Deprecated` markiert. Gemäß den aktuellen Guidelines muss +sie durch `PrimaryTabRow` oder `SecondaryTabRow` ersetzt werden, um eine bessere semantische Trennung und konsistente +Visualisierung (Indikatoren, Divider) zu gewährleisten. + +## Erledigte Aufgaben + +### 1. ✅ Ersetzung in `turnier-feature` + +- In `TurnierAbrechnungTab.kt` wurde die `TabRow` für die Sidebar durch `SecondaryTabRow` ersetzt. +- Eine fehlerhafte/veraltete `SecondaryTabRow` im Hauptbereich wurde korrigiert und vereinfacht (Entfernung von + manuellem `tabIndicatorOffset`). +- Redundante und fehlerhafte Hilfsmethoden für `tabIndicatorOffset` wurden entfernt. + +### 2. ✅ Ersetzung in `veranstaltung-feature` + +- In `VeranstaltungUebersichtScreen.kt` wurde die Header-`TabRow` durch `PrimaryTabRow` ersetzt. + +### 3. ✅ Build & Verifizierung + +- Test-Kompilation der betroffenen Module erfolgreich: + - `:frontend:features:turnier-feature:compileKotlinJvm` + - `:frontend:features:veranstaltung-feature:compileKotlinJvm` +- Alle unpräfixierten `TabRow`-Aufrufe im Projekt wurden identifiziert und (wo nötig) migriert. + +## Technische Änderungen + +### `TurnierAbrechnungTab.kt` + +- Wechsel zu `SecondaryTabRow` für Sidebar und Hauptbereich. +- Cleanup der Imports und Entfernung von `androidx.compose.material` Relikten. + +### `VeranstaltungUebersichtScreen.kt` + +- Wechsel zu `PrimaryTabRow` für den Haupt-Header. + +## Nächste Schritte + +- Prüfung weiterer Screens auf ähnliche Deprecations bei zukünftigen Material 3 Updates. +- Visueller Abgleich mit Figma Vision_03 nach der Migration der restlichen UI-Komponenten. + +--- + +## Referenzen + +- Material 3 Design Guidelines (Tabs) +- `MASTER_ROADMAP.md` (Phase 4: MVP-Implementierung) diff --git a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierAbrechnungTab.kt b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierAbrechnungTab.kt index 833c5ece..0c5c8a56 100644 --- a/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierAbrechnungTab.kt +++ b/frontend/features/turnier-feature/src/jvmMain/kotlin/at/mocode/turnier/feature/presentation/TurnierAbrechnungTab.kt @@ -4,10 +4,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Print import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import at.mocode.frontend.core.designsystem.models.PlaceholderContent +import at.mocode.frontend.core.navigation.AppScreen private val PrimaryBlue = Color(0xFF1E3A8A) private val AccentBlue = Color(0xFF3B82F6) @@ -50,7 +51,7 @@ fun AbrechnungTabContent() { // ── Hauptbereich ───────────────────────────────────────────────────── Column(modifier = Modifier.weight(1f).fillMaxHeight()) { // Sub-Tabs - TabRow( + SecondaryTabRow( selectedTabIndex = subTab, containerColor = MaterialTheme.colorScheme.surface, contentColor = Color(0xFF1E3A8A), @@ -81,7 +82,7 @@ fun AbrechnungTabContent() { // ── Rechte Sidebar ─────────────────────────────────────────────────── Column(modifier = Modifier.width(320.dp).fillMaxHeight()) { - TabRow( + SecondaryTabRow( selectedTabIndex = sidebarTab, containerColor = MaterialTheme.colorScheme.surface, contentColor = Color(0xFF1E3A8A), diff --git a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungUebersichtScreen.kt b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungUebersichtScreen.kt index dcdac376..cd288fb4 100644 --- a/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungUebersichtScreen.kt +++ b/frontend/features/veranstaltung-feature/src/jvmMain/kotlin/at/mocode/veranstaltung/feature/presentation/VeranstaltungUebersichtScreen.kt @@ -105,7 +105,7 @@ fun VeranstaltungUebersichtScreen( Column(modifier = Modifier.fillMaxSize()) { // Tab-Header gemäß Figma - TabRow( + PrimaryTabRow( selectedTabIndex = 0, containerColor = MaterialTheme.colorScheme.surface, contentColor = Color(0xFF1E3A8A), diff --git a/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/zns/feature/presentation/StammdatenImportScreen.kt b/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/zns/feature/presentation/StammdatenImportScreen.kt index a8f35cca..174f5e7f 100644 --- a/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/zns/feature/presentation/StammdatenImportScreen.kt +++ b/frontend/features/zns-import-feature/src/jvmMain/kotlin/at/mocode/zns/feature/presentation/StammdatenImportScreen.kt @@ -109,7 +109,7 @@ fun StammdatenImportScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Icon(Icons.Default.Error, contentDescription = null, tint = MaterialTheme.colorScheme.error) - Text(state.errorMessage!!, color = MaterialTheme.colorScheme.onErrorContainer) + Text(state.errorMessage, color = MaterialTheme.colorScheme.onErrorContainer) } } } diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt index 53c854b6..f23bfd16 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/Screens.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Visibility @@ -68,7 +69,10 @@ fun VeranstalterAuswahlV2( DesktopThemeV2 { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() }) + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Zurück", + modifier = Modifier.clickable { onBack() }) Text("Veranstalter auswählen", style = MaterialTheme.typography.titleLarge) Spacer(Modifier.weight(1f)) OutlinedButton(onClick = onNeu) { Text("+ Neuer Veranstalter") } @@ -113,7 +117,10 @@ fun VeranstalterDetailV2( DesktopThemeV2 { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() }) + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Zurück", + modifier = Modifier.clickable { onBack() }) val verein = StoreV2.vereine.firstOrNull { it.id == veranstalterId } Text(verein?.name ?: "Veranstalter", style = MaterialTheme.typography.titleLarge) Spacer(Modifier.weight(1f)) diff --git a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt index a5ac433c..57608c51 100644 --- a/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt +++ b/frontend/shells/meldestelle-desktop/src/jvmMain/kotlin/at/mocode/desktop/v2/VeranstaltungScreens.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.* @@ -24,7 +25,10 @@ fun VeranstaltungKonfigV2( DesktopThemeV2 { Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() }) + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Zurück", + modifier = Modifier.clickable { onBack() }) Text("Neue Veranstaltung", style = MaterialTheme.typography.titleLarge) } @@ -79,7 +83,10 @@ fun VeranstaltungUebersichtV2( val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId } Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() }) + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Zurück", + modifier = Modifier.clickable { onBack() }) Text(veranstaltung?.titel ?: "Veranstaltung", style = MaterialTheme.typography.titleLarge) Spacer(Modifier.weight(1f)) Button(onClick = onTurnierNeu) { Text("+ Neues Turnier") } @@ -134,7 +141,10 @@ fun TurnierWizardV2( val veranstaltung = StoreV2.eventsFor(veranstalterId).firstOrNull { it.id == veranstaltungId } Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", modifier = Modifier.clickable { onBack() }) + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Zurück", + modifier = Modifier.clickable { onBack() }) Text("Neues Turnier", style = MaterialTheme.typography.titleLarge) } diff --git a/gradle.properties b/gradle.properties index 0246fe9e..577de07d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ android.nonTransitiveRClass=true # Kotlin Configuration kotlin.code.style=official # Increased Kotlin Daemon Heap for JS Compilation -kotlin.daemon.jvmargs=-Xmx4g -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g +kotlin.daemon.jvmargs=-Xmx6g -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g kotlin.js.compiler.sourcemaps=false # Kotlin Compiler Optimizations (Phase 5) @@ -20,7 +20,7 @@ kotlin.stdlib.default.dependency=true # Gradle Configuration # Increased Gradle Daemon Heap -org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx3g" -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Xshare:off -Djava.awt.headless=true +org.gradle.jvmargs=-Xmx6g -Dfile.encoding=UTF-8 -Dkotlin.daemon.jvm.options\="-Xmx4g" -XX:+UseParallelGC -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -Xshare:off -Djava.awt.headless=true org.gradle.workers.max=8 org.gradle.vfs.watch=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f42e44db..79cef206 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -51,7 +51,7 @@ springdoc = "3.0.0" # Server Persistence # Final release 1.0.0 (UUID API refinements vs. rc-4) -exposed = "1.0.0" +exposed = "1.1.1" postgresql = "42.7.8" hikari = "7.0.2" h2 = "2.4.240" @@ -100,6 +100,7 @@ benManesVersions = "0.51.0" detekt = "1.23.6" ktlint = "12.1.1" dokka = "2.1.0" +firebaseDatabaseKtx = "22.0.1" [libraries] # ============================================================================== @@ -280,6 +281,7 @@ archunit-junit5-engine = { module = "com.tngtech.archunit:archunit-junit5-engine kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } kotlinx-coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-reactor = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactor", version.ref = "kotlinx-coroutines" } +firebase-database-ktx = { group = "com.google.firebase", name = "firebase-database-ktx", version.ref = "firebaseDatabaseKtx" } [bundles] # === FRONTEND BUNDLES === diff --git a/settings.gradle.kts b/settings.gradle.kts index 0d1c7c03..8ec4173b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -75,29 +75,17 @@ include(":backend:services:entries:entries-api") include(":backend:services:entries:entries-domain") include(":backend:services:entries:entries-service") -// --- CLUBS (Vereine) --- -include(":backend:services:clubs:clubs-domain") -include(":backend:services:clubs:clubs-infrastructure") -include(":backend:services:clubs:clubs-service") +// --- IDENTITY (Benutzerprofile & ZNS-Link) --- +include(":backend:services:identity:identity-domain") +include(":backend:services:identity:identity-infrastructure") +include(":backend:services:identity:identity-service") -// --- HORSES (Pferde-Verwaltung) --- -include(":backend:services:horses:horses-domain") -// horses-common: ON HOLD – veraltete API-Referenzen -// include(":backend:services:horses:horses-common") -include(":backend:services:horses:horses-infrastructure") -// horses-api: ON HOLD – Ktor-basiert, wird separat aktiviert -// include(":backend:services:horses:horses-api") -include(":backend:services:horses:horses-service") - -// --- OFFICIALS (Richter) --- -include(":backend:services:officials:officials-domain") -include(":backend:services:officials:officials-infrastructure") -include(":backend:services:officials:officials-service") - -// --- PERSONS (Personen/Reiter) --- -include(":backend:services:persons:persons-domain") -include(":backend:services:persons:persons-infrastructure") -include(":backend:services:persons:persons-service") +// --- MASTERDATA (Zentrale Stammdaten: Länder, Orte, Reiter, Pferde, Vereine, Richter) --- +include(":backend:services:masterdata:masterdata-api") +include(":backend:services:masterdata:masterdata-common") +include(":backend:services:masterdata:masterdata-domain") +include(":backend:services:masterdata:masterdata-infrastructure") +include(":backend:services:masterdata:masterdata-service") // --- PING (Ping Service) --- include(":backend:services:ping:ping-service")