chore: remove deprecated horses, clubs, officials, and persons services

- Deleted obsolete modules related to horses, clubs, officials, and persons services, including their configurations, build files, and database provisioning scripts.
- Cleaned up associated references in the project structure (e.g., `settings.gradle.kts`).
- Removed unused database tables and Spring beans related to these domains.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-28 16:50:49 +01:00
parent 2cb3f0b125
commit c806660685
181 changed files with 4121 additions and 8694 deletions
@@ -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)
}
}
}
}
@@ -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()
)
@@ -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<DomVerein> = transaction {
VereinTable.selectAll().where { VereinTable.name like "%$searchTerm%" }
.limit(limit).map { rowToVerein(it) }
}
override suspend fun findByBundesland(bundesland: String, activeOnly: Boolean): List<DomVerein> = 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<DomVerein> = 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<DomVerein> = 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<DomVerein> = 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
}
}
@@ -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)
}
}
@@ -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")
}
@@ -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()
}
@@ -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<String>) {
runApplication<ClubsServiceApplication>(*args)
}
@@ -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")
}
}
}
@@ -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)
}
}
@@ -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';
@@ -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()
) {
/**
@@ -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()
) {
/**
@@ -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()
) {
/**
@@ -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()
) {
/**
@@ -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<StartlistenEintrag> = 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()
) {
/**
@@ -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()
) {
/**
@@ -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()
) {
/**
@@ -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()
) {
/**
@@ -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()
) {
/**
-167
View File
@@ -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
# ===================================================================
@@ -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)
}
@@ -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<Any>(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<Any>("Invalid ownerId format")
)
}
val geschlecht = call.request.queryParameters["geschlecht"]?.let {
try {
PferdeGeschlechtE.valueOf(it)
} catch (_: IllegalArgumentException) {
return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>("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<Any>("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<Any>("Horse not found"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("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<Any>("Horse with life number '$lebensnummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("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<Any>("Horse with chip number '$chipNummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("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<Any>("Horse with passport number '$passNummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("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<Any>("Horse with OEPS number '$oepsNummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("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<Any>("Horse with FEI number '$feiNummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("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<Any>("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<Any>("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<Any>("Failed to retrieve statistics: ${e.message}"))
}
}
// POST /api/horses - Create new horse
post {
try {
val createRequest = call.receive<CreateHorseUseCase.CreateHorseRequest>()
// 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<Any>(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<Any>("Validation failed"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("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<UpdateHorseRequest>()
// 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<Any>(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<Any>("Update failed: ${response.errors.joinToString(", ")}"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("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<Any>("Delete failed: ${response.errors.joinToString(", ")}"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("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<Any>("Soft delete failed: ${response.errors.joinToString(", ")}"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to soft delete horse: ${e.message}"))
}
}
// POST /api/horses/batch-delete - Batch delete multiple horses
post("/batch-delete") {
try {
val batchRequest = call.receive<BatchDeleteRequest>()
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<Any>("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
)
}
@@ -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)
}
@@ -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<DomPferd> {
// 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<ValidationError>()
// 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<ValidationError>()
// 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)
}
}
}
@@ -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<String> = emptyList(),
val warnings: List<String> = 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<String> {
val errors = mutableListOf<String>()
// 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<String> {
val warnings = mutableListOf<String>()
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<Uuid>, forceDelete: Boolean = false): BatchDeleteResponse {
val results = mutableListOf<DeleteResult>()
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<String> = emptyList(),
val warnings: List<String> = emptyList()
)
/**
* Response for batch delete operation.
*/
data class BatchDeleteResponse(
val results: List<DeleteResult>,
val successCount: Int,
val errorCount: Int,
val totalCount: Int
) {
val overallSuccess: Boolean = errorCount == 0
}
}
@@ -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<DomPferd> {
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<DomPferd> {
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<DomPferd> {
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<DomPferd> {
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<DomPferd> {
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<DomPferd> {
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<DomPferd> {
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<DomPferd> {
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<DomPferd> {
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<DomPferd> {
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)
}
}
@@ -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<DomPferd> {
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<ValidationError>()
// 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<ValidationError>()
// 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)
}
}
}
@@ -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<String> = 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<String> {
val errors = mutableListOf<String>()
// 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<String> {
val errors = mutableListOf<String>()
// 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
}
}
@@ -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)
}
}
}
}
@@ -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<String> {
val errors = mutableListOf<String>()
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())
}
}
@@ -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<DomPferd>
/**
* 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<DomPferd>
/**
* 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<DomPferd>
/**
* 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<DomPferd>
/**
* 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<DomPferd>
/**
* 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<DomPferd>
/**
* 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<DomPferd>
/**
* Finds all active horses.
*
* @param limit Maximum number of results to return
* @return List of active horses
*/
suspend fun findAllActive(limit: Int = 1000): List<DomPferd>
/**
* 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<DomPferd>
/**
* 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<DomPferd>
/**
* 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
}
@@ -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)
}
@@ -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<DomPferd> = 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<DomPferd> = 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<DomPferd> =
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<DomPferd> = 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<DomPferd> = 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<DomPferd> = 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<DomPferd> =
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<DomPferd> = transaction {
HorseTable.selectAll().where { HorseTable.istAktiv eq true }
.limit(limit)
.map { rowToDomPferd(it) }
}
override suspend fun findOepsRegistered(activeOnly: Boolean): List<DomPferd> = 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<DomPferd> = 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
}
}
@@ -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<DomPferd> = 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<DomPferd> = 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<DomPferd> = 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<DomPferd> = 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<DomPferd> = 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<DomPferd> = 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<DomPferd> = 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<DomPferd> = 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<DomPferd> = 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<DomPferd> = 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
}
}
@@ -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<PferdeGeschlechtE>("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<DatenQuelleE>("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)
}
}
@@ -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")
}
@@ -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")
}
@@ -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()
}
@@ -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<String>) {
runApplication<HorsesServiceApplication>(*args)
}
@@ -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)
}
}
@@ -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")
}
}
}
@@ -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)
}
}
}
@@ -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
}
}
}
@@ -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)
}
@@ -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()
)
@@ -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<DomProfil>
suspend fun save(profil: DomProfil): DomProfil
suspend fun delete(id: Uuid): Boolean
}
@@ -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)
}
}
@@ -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<ProfileRepository>()
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 }) }
}
}
@@ -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)
}
@@ -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<DomProfil> = 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
}
}
@@ -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)
}
}
@@ -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)
}
@@ -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<String>) {
runApplication<IdentityServiceApplication>(*args)
}
@@ -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)
}
@@ -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
)
@@ -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)
@@ -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<IllegalArgumentException> { call, cause ->
log.warn("Bad Request: ${cause.message}")
val errorResponse = ApiResponse<Unit>(
message = cause.message ?: "Invalid input provided.",
errors = listOf("BAD_REQUEST")
logger.warn("Bad Request: ${cause.message}")
val errorResponse = ApiResponse.error<Unit>(
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<NotFoundException> { call, cause ->
log.info("Resource not found: ${cause.message}")
val errorResponse = ApiResponse<Unit>(
message = cause.message ?: "The requested resource was not found.",
errors = listOf("NOT_FOUND")
logger.info("Resource not found: ${cause.message}")
val errorResponse = ApiResponse.error<Unit>(
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<Throwable> { call, cause ->
log.error("Internal Server Error", cause) // Logge den kompletten Stacktrace
val errorResponse = ApiResponse<Unit>(
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<Unit>(
code = ErrorCode("INTERNAL_SERVER_ERROR"),
message = "An unexpected internal server error occurred."
)
call.respond(HttpStatusCode.InternalServerError, errorResponse)
}
@@ -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<List<AltersklasseDto>>("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<List<AltersklasseDto>>("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<List<AltersklasseDto>>("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<AltersklasseDto>("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<AltersklasseDto>("Age class not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<AltersklasseDto>("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<AltersklasseDto>("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<AltersklasseDto>("Age class not found"))
}
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<AltersklasseDto>(e.message ?: "Invalid age class code"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<AltersklasseDto>("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<List<AltersklasseDto>>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@get
}
val searchTerm = call.request.queryParameters["q"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<AltersklasseDto>>("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<List<AltersklasseDto>>(e.message ?: "Invalid search parameters"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<AltersklasseDto>>("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<List<AltersklasseDto>>("Invalid age parameter"))
if (age < 0) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<AltersklasseDto>>("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<List<AltersklasseDto>>("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<List<AltersklasseDto>>("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<List<AltersklasseDto>>("Sport type is required"))
val sparte = try {
SparteE.valueOf(sparteParam.uppercase())
} catch (_: Exception) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<AltersklasseDto>>("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<List<AltersklasseDto>>("Failed to retrieve age classes: ${e.message}"))
}
}
// POST /api/masterdata/altersklassen - Create new age class
post {
try {
val createDto = call.receive<CreateAltersklasseDto>()
// Basic validation
if (createDto.altersklasseCode.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("Age class code is required")
)
return@post
}
if (createDto.bezeichnung.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("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<AltersklasseDto>("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<AltersklasseDto>("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<AltersklasseDto>("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<AltersklasseDto>("Validation failed: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<AltersklasseDto>("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<AltersklasseDto>("Invalid age class ID"))
val updateDto = call.receive<UpdateAltersklasseDto>()
// Basic validation
if (updateDto.altersklasseCode.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("Age class code is required")
)
return@put
}
if (updateDto.bezeichnung.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("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<AltersklasseDto>("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<AltersklasseDto>("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<AltersklasseDto>("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<AltersklasseDto>("Validation failed: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<AltersklasseDto>("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<Unit>("Invalid age class ID"))
val result = createAltersklasseUseCase.deleteAltersklasse(altersklasseId)
if (result.success) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>("Age class not found: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Unit>("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<Boolean>("Invalid age class ID"))
val ageParam = call.request.queryParameters["age"]?.toIntOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Boolean>("Age parameter is required"))
val geschlechtParam = call.request.queryParameters["geschlecht"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Boolean>("Gender parameter is required"))
if (geschlechtParam.length != 1 || (geschlechtParam != "M" && geschlechtParam != "W")) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Boolean>("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<Boolean>("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<Unit>("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<Unit>("NOT_FOUND", "Age class not found"))
}
post {
val dto = call.receive<CreateAltersklasseDto>()
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<Unit>("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<Unit>("INVALID_ID", "Missing or invalid ID")
)
val dto = call.receive<UpdateAltersklasseDto>()
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<Unit>("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<Unit>("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<Unit>("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()
)
}
@@ -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<List<BundeslandDto>>("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<List<BundeslandDto>>("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<BundeslandDto>("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<BundeslandDto>("Federal state not found"))
}
Uuid.parse(it)
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<BundeslandDto>("Failed to retrieve federal state: ${e.message}"))
null
}
}
?: return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("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<Unit>("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<BundeslandDto>("OEPS code is required"))
val landIdParam = call.request.queryParameters["landId"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<BundeslandDto>("Country ID (landId) is required"))
val landId = try {
Uuid.parse(landIdParam)
} catch (_: Exception) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<BundeslandDto>("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<BundeslandDto>("Federal state not found"))
}
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<BundeslandDto>(e.message ?: "Invalid OEPS code"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<BundeslandDto>("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<BundeslandDto>("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<BundeslandDto>("Federal state not found"))
}
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<BundeslandDto>(e.message ?: "Invalid ISO code"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<BundeslandDto>("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<List<BundeslandDto>>("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<List<BundeslandDto>>("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<List<BundeslandDto>>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@get
}
val searchTerm = call.request.queryParameters["q"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<BundeslandDto>>("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<List<BundeslandDto>>(e.message ?: "Invalid search parameters"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<BundeslandDto>>("Failed to search federal states: ${e.message}"))
}
}
// POST /api/masterdata/bundeslaender - Create new federal state
post {
try {
val createDto = call.receive<CreateBundeslandDto>()
val dto = call.receive<CreateBundeslandDto>()
val landId = try {
Uuid.parse(dto.landId)
} catch (e: Exception) {
return@post call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("INVALID_LAND_ID", "Invalid landId format")
)
}
// Basic validation
if (createDto.name.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<BundeslandDto>("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<BundeslandDto>("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<BundeslandDto>("Validation failed: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<BundeslandDto>("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<Unit>("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<BundeslandDto>("Invalid federal state ID"))
val updateDto = call.receive<UpdateBundeslandDto>()
// Basic validation
if (updateDto.name.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<BundeslandDto>("Name is required")
)
return@put
}
try {
uuidFrom(updateDto.landId)
} catch (_: Exception) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<BundeslandDto>("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<BundeslandDto>("Validation failed: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<BundeslandDto>("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<Unit>("Invalid federal state ID"))
val result = createBundeslandUseCase.deleteBundesland(bundeslandId)
if (result.success) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>("Federal state not found: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Unit>("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<Long>("Invalid country ID"))
val count = getBundeslandUseCase.countActiveByCountry(landId)
call.respond(HttpStatusCode.OK, ApiResponse.success(count))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Long>("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()
)
}
@@ -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<List<CountryDto>>("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<List<CountryDto>>("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<CountryDto>("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<CountryDto>("Country not found"))
}
Uuid.parse(it)
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<CountryDto>("Failed to retrieve country: ${e.message}"))
null
}
}
?: return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("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<Unit>("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<CountryDto>("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<CountryDto>("Country not found"))
}
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<CountryDto>(e.message ?: "Invalid ISO code"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<CountryDto>("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<CountryDto>("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<CountryDto>("Country not found"))
}
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<CountryDto>(e.message ?: "Invalid ISO code"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<CountryDto>("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<List<CountryDto>>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@get
}
val searchTerm = call.request.queryParameters["q"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<CountryDto>>("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<List<CountryDto>>(e.message ?: "Invalid search parameters"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<CountryDto>>("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<List<CountryDto>>("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<List<CountryDto>>("Failed to retrieve EWR countries: ${e.message}"))
}
}
// POST /api/masterdata/countries - Create new country
post {
try {
val createDto = call.receive<CreateCountryDto>()
val dto = call.receive<CreateCountryDto>()
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<CountryDto>(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<CountryDto>("Validation failed: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<CountryDto>("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<CountryDto>("Invalid country ID"))
val updateDto = call.receive<UpdateCountryDto>()
// 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<CountryDto>(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<CountryDto>("Validation failed: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<CountryDto>("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<Unit>("Invalid country ID"))
val result = createCountryUseCase.deleteCountry(countryId)
if (result.success) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>("Country not found: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Unit>("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<Unit>("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()
)
}
@@ -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<PlatzDto>("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<PlatzDto>("Venue not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<PlatzDto>("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<List<PlatzDto>>("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<List<PlatzDto>>("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<List<PlatzDto>>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@get
}
val searchTerm = call.request.queryParameters["q"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>("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<List<PlatzDto>>(e.message ?: "Invalid search parameters"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<PlatzDto>>("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<List<PlatzDto>>("Venue type is required"))
val typ = try {
PlatzTypE.valueOf(typParam.uppercase())
} catch (_: Exception) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>("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<List<PlatzDto>>("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<List<PlatzDto>>("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<List<PlatzDto>>(e.message ?: "Invalid ground type"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<PlatzDto>>("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<List<PlatzDto>>("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<List<PlatzDto>>(e.message ?: "Invalid dimension"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<PlatzDto>>("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<List<PlatzDto>>("Required venue type parameter is missing"))
val requiredType = try {
PlatzTypE.valueOf(typParam.uppercase())
} catch (_: Exception) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>("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<List<PlatzDto>>("Failed to retrieve suitable venues: ${e.message}"))
}
}
// POST /api/masterdata/plaetze - Create new venue
post {
try {
val createDto = call.receive<CreatePlatzDto>()
// Basic validation
if (createDto.name.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<PlatzDto>("Name is required")
)
return@post
}
val turnierId = try {
uuidFrom(createDto.turnierId)
} catch (_: Exception) {
return@post call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<PlatzDto>("Invalid tournament ID format")
)
}
val typ = try {
PlatzTypE.valueOf(createDto.typ.uppercase())
} catch (_: Exception) {
return@post call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<PlatzDto>("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<PlatzDto>("Validation failed: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<PlatzDto>("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<PlatzDto>("Invalid venue ID"))
val updateDto = call.receive<UpdatePlatzDto>()
// Basic validation
if (updateDto.name.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<PlatzDto>("Name is required")
)
return@put
}
val turnierId = try {
uuidFrom(updateDto.turnierId)
} catch (_: Exception) {
return@put call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<PlatzDto>("Invalid tournament ID format")
)
}
val typ = try {
PlatzTypE.valueOf(updateDto.typ.uppercase())
} catch (_: Exception) {
return@put call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<PlatzDto>("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<PlatzDto>("Validation failed: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<PlatzDto>("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<Unit>("Invalid venue ID"))
val result = createPlatzUseCase.deletePlatz(platzId)
if (result.success) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>("Venue not found: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Unit>("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<Long>("Invalid tournament ID"))
val count = getPlatzUseCase.countActiveByTournament(turnierId)
call.respond(HttpStatusCode.OK, ApiResponse.success(count))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Long>("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<Long>("Venue type is required"))
val typ = try {
PlatzTypE.valueOf(typParam.uppercase())
} catch (_: Exception) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Long>("Invalid venue type: $typParam"))
}
val turnierId = call.parameters["turnierId"]?.let { uuidFrom(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Long>("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<Long>("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<Map<String, List<PlatzDto>>>("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<Map<String, List<PlatzDto>>>("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<Map<String, Any>>("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<Map<String, Any>>("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<Map<String, Any>>("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<Unit>("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<Unit>("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<Unit>("NOT_FOUND", "Venue not found"))
}
post {
val dto = call.receive<CreatePlatzDto>()
val turnierId = try {
Uuid.parse(dto.turnierId)
} catch (e: Exception) {
return@post call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Unit>("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<Unit>("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()
)
}
@@ -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)
}
@@ -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<ValidationError>()
@@ -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.
@@ -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.
@@ -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.
@@ -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<AltersklasseDefinition> {
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<AltersklasseDefinition>)
/**
* Finds age classes applicable for a specific age.
*
@@ -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<BundeslandDefinition> {
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<BundeslandDefinition> {
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<BundeslandDefinition> {
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<BundeslandDefinition>)
/**
* Checks if a federal state with the given OEPS code exists for a country.
*
@@ -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<LandDefinition> {
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<LandDefinition>)
/**
* Retrieves all EU member countries.
*
@@ -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<Platz> {
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<Platz>)
/**
* Searches for venues by name (partial match).
*
@@ -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)
}
}
@@ -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
)
@@ -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
)
@@ -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()
) {
/**
@@ -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<String> {
val errors = mutableListOf<String>()
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())
}
}
@@ -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()
) {
/**
@@ -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()
) {
/**
@@ -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
)
@@ -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
)
@@ -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<AltersklasseDefinition>
/**
* 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<AltersklasseDefinition>
/**
* 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<AltersklasseDefinition>
/**
* 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<AltersklasseDefinition>
/**
* 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<AltersklasseDefinition>
/**
* 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<AltersklasseDefinition>
/**
* 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<AltersklasseDefinition>
/**
* 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
}
@@ -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
/**
@@ -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<DomPferd>
/**
* 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<DomPferd>
/**
* 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<DomPferd>
/**
* 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<DomPferd>
/**
* 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<DomPferd>
/**
* 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<DomPferd>
/**
* 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<DomPferd>
/**
* Finds all active horses.
*
* @param limit Maximum number of results to return
* @return List of active horses
*/
suspend fun findAllActive(limit: Int = 1000): List<DomPferd>
/**
* 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<DomPferd>
/**
* 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<DomPferd>
/**
* 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
}
@@ -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
/**
@@ -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 {
@@ -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<AltersklasseDefinition>
/**
* 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<AltersklasseDefinition>
/**
* 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<AltersklasseDefinition>
/**
* 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<AltersklasseDefinition>
/**
* 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<AltersklasseDefinition>
/**
* 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<AltersklasseDefinition>
/**
* 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<AltersklasseDefinition>
/**
* 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
}
@@ -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)
@@ -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<AltersklasseDefinition> =
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<AltersklasseDefinition> =
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<AltersklasseDefinition> = 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<AltersklasseDefinition> = 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<AltersklasseDefinition> = 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<AltersklasseDefinition> = 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<AltersklasseDefinition> = 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<AltersklasseDefinition> = 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<AltersklasseDefinition> = 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<AltersklasseDefinition> = 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<AltersklasseDefinition> =
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<AltersklasseDefinition> =
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<AltersklasseDefinition> =
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<AltersklasseDefinition> =
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
}
}
@@ -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))
}
}
@@ -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<BundeslandDefinition> = 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<BundeslandDefinition> =
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<BundeslandDefinition> = 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<BundeslandDefinition> = 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<BundeslandDefinition> = 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<BundeslandDefinition> = 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()
}
}
@@ -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)
}
}
@@ -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<DomFunktionaer> = 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<DomFunktionaer> =
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<DomFunktionaer> = DatabaseFactory.dbQuery {
// Qualifikationen werden aktuell nicht in FunktionaerTable gespeichert.
emptyList()
}
override suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean): List<DomFunktionaer> =
DatabaseFactory.dbQuery {
emptyList()
}
override suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean): List<DomFunktionaer> =
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<DomFunktionaer> = 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<DomFunktionaer> = 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()
}
}
@@ -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<DomReiter> = 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<DomReiter> =
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<DomReiter> =
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<DomReiter> = 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<DomReiter> = 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<DomReiter> = 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<DomReiter> = 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()
}
}
@@ -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<DomVerein> = 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<DomVerein> =
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<DomVerein> = 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<DomVerein> = 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<DomVerein> = 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()
}
}
@@ -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)
}
@@ -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<DomPferd> = 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<DomPferd> = 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<DomPferd> =
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<DomPferd> = 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<DomPferd> =
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<DomPferd> = 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<DomPferd> =
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<DomPferd> = DatabaseFactory.dbQuery {
HorseTable.selectAll().where { HorseTable.istAktiv eq true }
.limit(limit)
.map(::rowToDomPferd)
}
override suspend fun findOepsRegistered(activeOnly: Boolean): List<DomPferd> = 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<DomPferd> = 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()
}
}
@@ -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)
}
}
@@ -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<LandDefinition> = 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<LandDefinition> = 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<LandDefinition> = 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<LandDefinition> = 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<LandDefinition> = 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 {

Some files were not shown because too many files have changed in this diff Show More