Umbau zu SCS

This commit is contained in:
stefan
2025-07-17 15:17:31 +02:00
parent 67c52f7381
commit 029b0c86bc
255 changed files with 6458 additions and 26663 deletions
+47
View File
@@ -0,0 +1,47 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvm()
js(IR) {
browser()
nodejs()
}
sourceSets {
commonMain.dependencies {
implementation(project(":shared-kernel"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
implementation(libs.uuid)
implementation(libs.bignum)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
jvmMain.dependencies {
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.contentNegotiation)
implementation(libs.ktor.server.serializationKotlinxJson)
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.postgresql.driver)
}
jsMain.dependencies {
// Kotlin React dependencies with explicit versions
implementation("org.jetbrains.kotlin-wrappers:kotlin-react:${libs.versions.kotlinWrappers.get()}")
implementation("org.jetbrains.kotlin-wrappers:kotlin-emotion:${libs.versions.kotlinWrappers.get()}")
// NPM dependencies
implementation(npm("react", "18.2.0"))
implementation(npm("react-dom", "18.2.0"))
implementation(npm("react-to-web-component", "2.0.2"))
}
}
}
@@ -0,0 +1,276 @@
package at.mocode.masterdata.application.usecase
import at.mocode.masterdata.domain.model.LandDefinition
import at.mocode.masterdata.domain.repository.LandRepository
import at.mocode.validation.ValidationResult
import com.benasher44.uuid.Uuid
import kotlinx.datetime.Clock
/**
* Use case for creating and updating country information.
*
* This use case encapsulates the business logic for country management
* including validation, duplicate checking, and persistence.
*/
class CreateCountryUseCase(
private val landRepository: LandRepository
) {
/**
* Request data for creating a new country.
*/
data class CreateCountryRequest(
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
)
/**
* Request data for updating an existing country.
*/
data class UpdateCountryRequest(
val landId: Uuid,
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
)
/**
* Creates a new country after validation.
*
* @param request The country creation request
* @return ValidationResult containing the created country or validation errors
*/
suspend fun createCountry(request: CreateCountryRequest): ValidationResult<LandDefinition> {
// Validate the request
val validationResult = validateCreateRequest(request)
if (!validationResult.isValid) {
return ValidationResult.failure(validationResult.errors)
}
// Check for duplicates
val duplicateCheck = checkForDuplicates(request.isoAlpha2Code, request.isoAlpha3Code)
if (!duplicateCheck.isValid) {
return ValidationResult.failure(duplicateCheck.errors)
}
// Create the domain object
val now = Clock.System.now()
val country = LandDefinition(
isoAlpha2Code = request.isoAlpha2Code.uppercase(),
isoAlpha3Code = request.isoAlpha3Code.uppercase(),
isoNumerischerCode = request.isoNumerischerCode,
nameDeutsch = request.nameDeutsch.trim(),
nameEnglisch = request.nameEnglisch?.trim(),
wappenUrl = request.wappenUrl?.trim(),
istEuMitglied = request.istEuMitglied,
istEwrMitglied = request.istEwrMitglied,
istAktiv = request.istAktiv,
sortierReihenfolge = request.sortierReihenfolge,
createdAt = now,
updatedAt = now
)
// Save to repository
val savedCountry = landRepository.save(country)
return ValidationResult.success(savedCountry)
}
/**
* Updates an existing country after validation.
*
* @param request The country update request
* @return ValidationResult containing the updated country or validation errors
*/
suspend fun updateCountry(request: UpdateCountryRequest): ValidationResult<LandDefinition> {
// Check if country exists
val existingCountry = landRepository.findById(request.landId)
?: return ValidationResult.failure(listOf("Country with ID ${request.landId} not found"))
// Validate the request
val validationResult = validateUpdateRequest(request)
if (!validationResult.isValid) {
return ValidationResult.failure(validationResult.errors)
}
// Check for duplicates (excluding current country)
val duplicateCheck = checkForDuplicatesExcluding(
request.isoAlpha2Code,
request.isoAlpha3Code,
request.landId
)
if (!duplicateCheck.isValid) {
return ValidationResult.failure(duplicateCheck.errors)
}
// Update the domain object
val updatedCountry = existingCountry.copy(
isoAlpha2Code = request.isoAlpha2Code.uppercase(),
isoAlpha3Code = request.isoAlpha3Code.uppercase(),
isoNumerischerCode = request.isoNumerischerCode,
nameDeutsch = request.nameDeutsch.trim(),
nameEnglisch = request.nameEnglisch?.trim(),
wappenUrl = request.wappenUrl?.trim(),
istEuMitglied = request.istEuMitglied,
istEwrMitglied = request.istEwrMitglied,
istAktiv = request.istAktiv,
sortierReihenfolge = request.sortierReihenfolge,
updatedAt = Clock.System.now()
)
// Save to repository
val savedCountry = landRepository.save(updatedCountry)
return ValidationResult.success(savedCountry)
}
/**
* Deletes a country by ID.
*
* @param countryId The unique identifier of the country to delete
* @return ValidationResult indicating success or failure
*/
suspend fun deleteCountry(countryId: Uuid): ValidationResult<Unit> {
val deleted = landRepository.delete(countryId)
return if (deleted) {
ValidationResult.success(Unit)
} else {
ValidationResult.failure(listOf("Country with ID $countryId not found or could not be deleted"))
}
}
/**
* Validates a create country request.
*/
private fun validateCreateRequest(request: CreateCountryRequest): ValidationResult<Unit> {
val errors = mutableListOf<String>()
// ISO Alpha-2 Code validation
if (request.isoAlpha2Code.isBlank()) {
errors.add("ISO Alpha-2 code is required")
} else if (request.isoAlpha2Code.length != 2) {
errors.add("ISO Alpha-2 code must be exactly 2 characters")
} else if (!request.isoAlpha2Code.all { it.isLetter() }) {
errors.add("ISO Alpha-2 code must contain only letters")
}
// ISO Alpha-3 Code validation
if (request.isoAlpha3Code.isBlank()) {
errors.add("ISO Alpha-3 code is required")
} else if (request.isoAlpha3Code.length != 3) {
errors.add("ISO Alpha-3 code must be exactly 3 characters")
} else if (!request.isoAlpha3Code.all { it.isLetter() }) {
errors.add("ISO Alpha-3 code must contain only letters")
}
// German name validation
if (request.nameDeutsch.isBlank()) {
errors.add("German name is required")
} else if (request.nameDeutsch.length > 100) {
errors.add("German name must not exceed 100 characters")
}
// English name validation
request.nameEnglisch?.let { name ->
if (name.length > 100) {
errors.add("English name must not exceed 100 characters")
}
}
// Sorting order validation
request.sortierReihenfolge?.let { order ->
if (order < 0) {
errors.add("Sorting order must be non-negative")
}
}
return if (errors.isEmpty()) {
ValidationResult.success(Unit)
} else {
ValidationResult.failure(errors)
}
}
/**
* Validates an update country request.
*/
private fun validateUpdateRequest(request: UpdateCountryRequest): ValidationResult<Unit> {
// Use the same validation logic as create request
val createRequest = CreateCountryRequest(
isoAlpha2Code = request.isoAlpha2Code,
isoAlpha3Code = request.isoAlpha3Code,
isoNumerischerCode = request.isoNumerischerCode,
nameDeutsch = request.nameDeutsch,
nameEnglisch = request.nameEnglisch,
wappenUrl = request.wappenUrl,
istEuMitglied = request.istEuMitglied,
istEwrMitglied = request.istEwrMitglied,
istAktiv = request.istAktiv,
sortierReihenfolge = request.sortierReihenfolge
)
return validateCreateRequest(createRequest)
}
/**
* Checks for duplicate ISO codes.
*/
private suspend fun checkForDuplicates(isoAlpha2Code: String, isoAlpha3Code: String): ValidationResult<Unit> {
val errors = mutableListOf<String>()
if (landRepository.existsByIsoAlpha2Code(isoAlpha2Code.uppercase())) {
errors.add("Country with ISO Alpha-2 code '${isoAlpha2Code.uppercase()}' already exists")
}
if (landRepository.existsByIsoAlpha3Code(isoAlpha3Code.uppercase())) {
errors.add("Country with ISO Alpha-3 code '${isoAlpha3Code.uppercase()}' already exists")
}
return if (errors.isEmpty()) {
ValidationResult.success(Unit)
} else {
ValidationResult.failure(errors)
}
}
/**
* Checks for duplicate ISO codes excluding a specific country ID.
*/
private suspend fun checkForDuplicatesExcluding(
isoAlpha2Code: String,
isoAlpha3Code: String,
excludeId: Uuid
): ValidationResult<Unit> {
val errors = mutableListOf<String>()
// Check Alpha-2 code
val existingAlpha2 = landRepository.findByIsoAlpha2Code(isoAlpha2Code.uppercase())
if (existingAlpha2 != null && existingAlpha2.landId != excludeId) {
errors.add("Country with ISO Alpha-2 code '${isoAlpha2Code.uppercase()}' already exists")
}
// Check Alpha-3 code
val existingAlpha3 = landRepository.findByIsoAlpha3Code(isoAlpha3Code.uppercase())
if (existingAlpha3 != null && existingAlpha3.landId != excludeId) {
errors.add("Country with ISO Alpha-3 code '${isoAlpha3Code.uppercase()}' already exists")
}
return if (errors.isEmpty()) {
ValidationResult.success(Unit)
} else {
ValidationResult.failure(errors)
}
}
}
@@ -0,0 +1,120 @@
package at.mocode.masterdata.application.usecase
import at.mocode.masterdata.domain.model.LandDefinition
import at.mocode.masterdata.domain.repository.LandRepository
import com.benasher44.uuid.Uuid
/**
* Use case for retrieving country information.
*
* This use case encapsulates the business logic for fetching country data
* and provides a clean interface for the application layer.
*/
class GetCountryUseCase(
private val landRepository: LandRepository
) {
/**
* Retrieves a country by its unique ID.
*
* @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)
}
/**
* Retrieves a country by its ISO Alpha-2 code.
*
* @param isoCode The 2-letter ISO code (e.g., "AT", "DE")
* @return The country if found, null otherwise
*/
suspend fun getByIsoAlpha2Code(isoCode: String): LandDefinition? {
require(isoCode.length == 2) { "ISO Alpha-2 code must be exactly 2 characters" }
return landRepository.findByIsoAlpha2Code(isoCode.uppercase())
}
/**
* Retrieves a country by its ISO Alpha-3 code.
*
* @param isoCode The 3-letter ISO code (e.g., "AUT", "DEU")
* @return The country if found, null otherwise
*/
suspend fun getByIsoAlpha3Code(isoCode: String): LandDefinition? {
require(isoCode.length == 3) { "ISO Alpha-3 code must be exactly 3 characters" }
return landRepository.findByIsoAlpha3Code(isoCode.uppercase())
}
/**
* Searches for countries by name (partial match).
*
* @param searchTerm The search term to match against country names
* @param limit Maximum number of results to return (default: 50)
* @return List of matching countries
*/
suspend fun searchByName(searchTerm: String, limit: Int = 50): List<LandDefinition> {
require(searchTerm.isNotBlank()) { "Search term cannot be blank" }
require(limit > 0) { "Limit must be positive" }
return landRepository.findByName(searchTerm.trim(), limit)
}
/**
* Retrieves all active countries.
*
* @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)
}
/**
* Retrieves all EU member countries.
*
* @return List of EU member countries
*/
suspend fun getEuMembers(): List<LandDefinition> {
return landRepository.findEuMembers()
}
/**
* Retrieves all EWR (European Economic Area) member countries.
*
* @return List of EWR member countries
*/
suspend fun getEwrMembers(): List<LandDefinition> {
return landRepository.findEwrMembers()
}
/**
* Checks if a country with the given ISO Alpha-2 code exists.
*
* @param isoCode The ISO Alpha-2 code to check
* @return true if a country with this code exists, false otherwise
*/
suspend fun existsByIsoAlpha2Code(isoCode: String): Boolean {
require(isoCode.length == 2) { "ISO Alpha-2 code must be exactly 2 characters" }
return landRepository.existsByIsoAlpha2Code(isoCode.uppercase())
}
/**
* Checks if a country with the given ISO Alpha-3 code exists.
*
* @param isoCode The ISO Alpha-3 code to check
* @return true if a country with this code exists, false otherwise
*/
suspend fun existsByIsoAlpha3Code(isoCode: String): Boolean {
require(isoCode.length == 3) { "ISO Alpha-3 code must be exactly 3 characters" }
return landRepository.existsByIsoAlpha3Code(isoCode.uppercase())
}
/**
* Counts the total number of active countries.
*
* @return The total count of active countries
*/
suspend fun countActive(): Long {
return landRepository.countActive()
}
}
@@ -0,0 +1,59 @@
package at.mocode.masterdata.domain.model
import at.mocode.enums.SparteE // Optional, falls Altersklassen stark spartenspezifisch sind
import at.mocode.serializers.KotlinInstantSerializer
import at.mocode.serializers.UuidSerializer
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
/**
* Definiert eine spezifische Altersklasse für Teilnehmer (Reiter, Fahrer, Voltigierer)
* oder ggf. auch für Pferde, basierend auf den Vorgaben der ÖTO oder anderer Regelwerke.
*
* Beispiele: "Jugend U16", "Junioren U18", "Junge Reiter U21", "Allgemeine Klasse",
* "Pony Jugend U14", "Senioren Ü40".
* Diese Definitionen dienen zur Überprüfung von Teilnahmeberechtigungen in Bewerben und Abteilungen.
*
* @property altersklasseId Eindeutiger interner Identifikator für diese Altersklassendefinition (UUID).
* @property altersklasseCode Ein eindeutiges Kürzel oder Code für die Altersklasse
* (z.B. "JGD_U16", "JUN_U18", "YR_U21", "AK", "PONY_U14"). Dient als fachlicher Schlüssel.
* @property bezeichnung Die offizielle oder allgemein verständliche Bezeichnung der Altersklasse.
* @property minAlter Das Mindestalter (Jahre, inklusive) für diese Altersklasse. `null`, wenn es keine Untergrenze gibt.
* @property maxAlter Das Höchstalter (Jahre, inklusive) für diese Altersklasse. `null`, wenn es keine Obergrenze gibt.
* @property stichtagRegelText Eine Beschreibung der Regel für den Stichtag zur Altersberechnung
* (z.B. "31.12. des laufenden Kalenderjahres", "Geburtstag im laufenden Jahr").
* @property sparteFilter Optionale Angabe, ob diese Altersklassendefinition nur für eine spezifische Sparte gilt.
* @property geschlechtFilter Optionaler Filter für das Geschlecht ('M', 'W'), falls die Altersklasse geschlechtsspezifisch ist.
* `null` bedeutet für alle Geschlechter gültig.
* @property oetoRegelReferenzId Optionale Verknüpfung zu einer spezifischen Regel in der `OETORegelReferenz`-Tabelle,
* die diese Altersklasse definiert.
* @property istAktiv Gibt an, ob diese Altersklassendefinition aktuell im System verwendet werden kann.
* @property createdAt Zeitstempel der Erstellung dieses Datensatzes.
* @property updatedAt Zeitstempel der letzten Aktualisierung dieses Datensatzes.
*/
@Serializable
data class AltersklasseDefinition(
@Serializable(with = UuidSerializer::class)
val altersklasseId: Uuid = uuid4(), // Interner Primärschlüssel
var altersklasseCode: String, // Fachlicher PK, z.B. "JGD_U16"
var bezeichnung: String,
var minAlter: Int? = null,
var maxAlter: Int? = null,
var stichtagRegelText: String? = "31.12. des laufenden Kalenderjahres", // Typischer Default
var sparteFilter: SparteE? = null, // Ist diese Definition spartenspezifisch?
var geschlechtFilter: Char? = null, // 'M', 'W', oder null für beide
@Serializable(with = UuidSerializer::class)
var oetoRegelReferenzId: Uuid? = null, // FK zu OETORegelReferenz.oetoRegelReferenzId
var istAktiv: Boolean = true,
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
)
@@ -0,0 +1,51 @@
package at.mocode.masterdata.domain.model
import at.mocode.serializers.KotlinInstantSerializer
import at.mocode.serializers.UuidSerializer
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
/**
* Definiert ein Bundesland oder eine vergleichbare subnationale Verwaltungseinheit.
*
* Diese Entität ist primär für die österreichischen Bundesländer mit ihren OEPS-spezifischen
* Codes gedacht, kann aber auch für Bundesländer/Regionen anderer Nationen erweitert werden.
*
* @property bundeslandId Eindeutiger interner Identifikator für dieses Bundesland (UUID).
* @property landId Fremdschlüssel zur `LandDefinition`, dem dieses Bundesland angehört.
* @property oepsCode Der 2-stellige numerische OEPS-Code für österreichische Bundesländer
* (z.B. "01" für Wien, "02" für Niederösterreich). Sollte eindeutig sein für Land "Österreich".
* @property iso3166_2_Code Optionaler offizieller ISO 3166-2 Code für das Bundesland
* (z.B. "AT-1" für Burgenland, "DE-BY" für Bayern).
* @property name Der offizielle Name des Bundeslandes.
* @property kuerzel Ein gängiges Kürzel für das Bundesland (z.B. "NÖ", "W", "STMK").
* @property wappenUrl Optionaler URL-Pfad zu einer Bilddatei des Bundeslandwappens.
* @property istAktiv Gibt an, ob dieses Bundesland aktuell im System ausgewählt/verwendet werden kann.
* @property sortierReihenfolge Optionale Zahl zur Steuerung der Sortierreihenfolge in Auswahllisten.
* @property createdAt Zeitstempel der Erstellung dieses Datensatzes.
* @property updatedAt Zeitstempel der letzten Aktualisierung dieses Datensatzes.
*/
@Serializable
data class BundeslandDefinition(
@Serializable(with = UuidSerializer::class)
val bundeslandId: Uuid = uuid4(),
@Serializable(with = UuidSerializer::class)
var landId: Uuid, // FK zu LandDefinition.landId
var oepsCode: String?, // z.B. "01", "02", ... für Österreich; eindeutig pro landId = Österreich
var iso3166_2_Code: String?, // z.B. "AT-1", "DE-BY"; Eindeutig global oder pro Land?
var name: String, // z.B. "Niederösterreich", "Bayern"
var kuerzel: String? = null, // z.B. "NÖ", "BY"
var wappenUrl: String? = null,
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()
)
@@ -0,0 +1,51 @@
package at.mocode.masterdata.domain.model
import at.mocode.serializers.KotlinInstantSerializer
import at.mocode.serializers.UuidSerializer
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
/**
* Definiert ein Land/eine Nation mit seinen offiziellen Codes und Bezeichnungen.
*
* Diese Entität dient als zentrale Referenz für Länder, die im System für
* Nationalitäten von Personen, Vereinen oder für internationale Turniere relevant sind.
*
* @property landId Eindeutiger interner Identifikator für dieses Land (UUID).
* @property isoAlpha2Code Der 2-stellige ISO 3166-1 Alpha-2 Code des Landes (z.B. "AT", "DE"). Sollte eindeutig sein.
* @property isoAlpha3Code Der 3-stellige ISO 3166-1 Alpha-3 Code des Landes (z.B. "AUT", "DEU"). Sollte eindeutig sein.
* @property isoNumerischerCode Optionaler 3-stelliger numerischer ISO 3166-1 Code des Landes (z.B. "040" für Österreich).
* @property nameDeutsch Der offizielle deutsche Name des Landes.
* @property nameEnglisch Der offizielle englische Name des Landes.
* @property wappenUrl Optionaler URL-Pfad zu einer Bilddatei des Länderwappens oder der Flagge.
* @property istEuMitglied Gibt an, ob das Land Mitglied der Europäischen Union ist.
* @property istEwrMitglied Gibt an, ob das Land Mitglied des Europäischen Wirtschaftsraums ist.
* @property istAktiv Gibt an, ob dieses Land aktuell im System ausgewählt/verwendet werden kann.
* @property sortierReihenfolge Optionale Zahl zur Steuerung der Sortierreihenfolge in Auswahllisten.
* @property createdAt Zeitstempel der Erstellung dieses Datensatzes.
* @property updatedAt Zeitstempel der letzten Aktualisierung dieses Datensatzes.
*/
@Serializable
data class LandDefinition(
@Serializable(with = UuidSerializer::class)
val landId: Uuid = uuid4(),
var isoAlpha2Code: String, // z.B. "AT" → Fachlicher PK oder Unique Constraint
var isoAlpha3Code: String, // z.B. "AUT" -> Unique Constraint
var isoNumerischerCode: String? = null, // z.B. "040"
var nameDeutsch: String, // z.B. "Österreich"
var nameEnglisch: String? = null, // z.B. "Austria"
var wappenUrl: String? = null,
var istEuMitglied: Boolean? = null,
var istEwrMitglied: Boolean? = null, // Europäischer Wirtschaftsraum
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()
)
@@ -0,0 +1,19 @@
package at.mocode.masterdata.domain.model
import at.mocode.enums.PlatzTypE
import at.mocode.serializers.UuidSerializer
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4
import kotlinx.serialization.Serializable
@Serializable
data class Platz(
@Serializable(with = UuidSerializer::class)
val id: Uuid = uuid4(),
@Serializable(with = UuidSerializer::class)
var turnierId: Uuid,
var name: String,
var dimension: String?,
var boden: String?,
var typ: PlatzTypE
)
@@ -0,0 +1,109 @@
package at.mocode.masterdata.domain.repository
import at.mocode.masterdata.domain.model.LandDefinition
import com.benasher44.uuid.Uuid
/**
* Repository interface for LandDefinition (Country) domain operations.
*
* This interface defines the contract for country 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 LandRepository {
/**
* Finds a country by its unique ID.
*
* @param id The unique identifier of the country
* @return The country if found, null otherwise
*/
suspend fun findById(id: Uuid): LandDefinition?
/**
* Finds a country by its ISO Alpha-2 code.
*
* @param isoAlpha2Code The 2-letter ISO code (e.g., "AT", "DE")
* @return The country if found, null otherwise
*/
suspend fun findByIsoAlpha2Code(isoAlpha2Code: String): LandDefinition?
/**
* Finds a country by its ISO Alpha-3 code.
*
* @param isoAlpha3Code The 3-letter ISO code (e.g., "AUT", "DEU")
* @return The country if found, null otherwise
*/
suspend fun findByIsoAlpha3Code(isoAlpha3Code: String): LandDefinition?
/**
* Finds countries by name (partial match on German or English name).
*
* @param searchTerm The search term to match against country names
* @param limit Maximum number of results to return
* @return List of matching countries
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<LandDefinition>
/**
* Finds all active countries.
*
* @param orderBySortierung Whether to order by sortierReihenfolge field
* @return List of active countries
*/
suspend fun findAllActive(orderBySortierung: Boolean = true): List<LandDefinition>
/**
* Finds all EU member countries.
*
* @return List of EU member countries
*/
suspend fun findEuMembers(): List<LandDefinition>
/**
* Finds all EWR (European Economic Area) member countries.
*
* @return List of EWR member countries
*/
suspend fun findEwrMembers(): List<LandDefinition>
/**
* Saves a country (create or update).
*
* @param land The country to save
* @return The saved country with updated timestamps
*/
suspend fun save(land: LandDefinition): LandDefinition
/**
* Deletes a country by ID.
*
* @param id The unique identifier of the country to delete
* @return true if the country was deleted, false if not found
*/
suspend fun delete(id: Uuid): Boolean
/**
* Checks if a country with the given ISO Alpha-2 code exists.
*
* @param isoAlpha2Code The ISO Alpha-2 code to check
* @return true if a country with this code exists, false otherwise
*/
suspend fun existsByIsoAlpha2Code(isoAlpha2Code: String): Boolean
/**
* Checks if a country with the given ISO Alpha-3 code exists.
*
* @param isoAlpha3Code The ISO Alpha-3 code to check
* @return true if a country with this code exists, false otherwise
*/
suspend fun existsByIsoAlpha3Code(isoAlpha3Code: String): Boolean
/**
* Counts the total number of active countries.
*
* @return The total count of active countries
*/
suspend fun countActive(): Long
}
@@ -0,0 +1,293 @@
package at.mocode.masterdata.infrastructure.api
import at.mocode.dto.base.BaseDto
import at.mocode.masterdata.application.usecase.CreateCountryUseCase
import at.mocode.masterdata.application.usecase.GetCountryUseCase
import at.mocode.masterdata.domain.model.LandDefinition
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuidFrom
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
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.
*/
class CountryController(
private val getCountryUseCase: GetCountryUseCase,
private val createCountryUseCase: CreateCountryUseCase
) {
/**
* DTO for country API responses.
*/
@Serializable
data class CountryDto(
val landId: String,
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,
val createdAt: String,
val updatedAt: String
)
/**
* DTO for creating a new country.
*/
@Serializable
data class CreateCountryDto(
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
)
/**
* 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
get {
try {
val orderBySortierung = call.request.queryParameters["orderBySortierung"]?.toBoolean() ?: true
val countries = getCountryUseCase.getAllActive(orderBySortierung)
val countryDtos = countries.map { it.toDto() }
call.respond(HttpStatusCode.OK, BaseDto.success(countryDtos))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, BaseDto.error<List<CountryDto>>("Failed to retrieve countries: ${e.message}"))
}
}
// GET /api/masterdata/countries/{id} - Get country by ID
get("/{id}") {
try {
val countryId = call.parameters["id"]?.let { uuidFrom(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, BaseDto.error<CountryDto>("Invalid country ID"))
val country = getCountryUseCase.getById(countryId)
if (country != null) {
call.respond(HttpStatusCode.OK, BaseDto.success(country.toDto()))
} else {
call.respond(HttpStatusCode.NotFound, BaseDto.error<CountryDto>("Country not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, BaseDto.error<CountryDto>("Failed to retrieve country: ${e.message}"))
}
}
// 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, BaseDto.error<CountryDto>("ISO code is required"))
val country = getCountryUseCase.getByIsoAlpha2Code(isoCode)
if (country != null) {
call.respond(HttpStatusCode.OK, BaseDto.success(country.toDto()))
} else {
call.respond(HttpStatusCode.NotFound, BaseDto.error<CountryDto>("Country not found"))
}
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, BaseDto.error<CountryDto>(e.message ?: "Invalid ISO code"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, BaseDto.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, BaseDto.error<CountryDto>("ISO code is required"))
val country = getCountryUseCase.getByIsoAlpha3Code(isoCode)
if (country != null) {
call.respond(HttpStatusCode.OK, BaseDto.success(country.toDto()))
} else {
call.respond(HttpStatusCode.NotFound, BaseDto.error<CountryDto>("Country not found"))
}
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, BaseDto.error<CountryDto>(e.message ?: "Invalid ISO code"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, BaseDto.error<CountryDto>("Failed to retrieve country: ${e.message}"))
}
}
// GET /api/masterdata/countries/search - Search countries by name
get("/search") {
try {
val searchTerm = call.request.queryParameters["q"]
?: return@get call.respond(HttpStatusCode.BadRequest, BaseDto.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, BaseDto.success(countryDtos))
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, BaseDto.error<List<CountryDto>>(e.message ?: "Invalid search parameters"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, BaseDto.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, BaseDto.success(countryDtos))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, BaseDto.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, BaseDto.success(countryDtos))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, BaseDto.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 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.isValid) {
call.respond(HttpStatusCode.Created, BaseDto.success(result.data!!.toDto()))
} else {
call.respond(HttpStatusCode.BadRequest, BaseDto.error<CountryDto>("Validation failed", result.errors))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, BaseDto.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, BaseDto.error<CountryDto>("Invalid country ID"))
val updateDto = call.receive<UpdateCountryDto>()
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.isValid) {
call.respond(HttpStatusCode.OK, BaseDto.success(result.data!!.toDto()))
} else {
call.respond(HttpStatusCode.BadRequest, BaseDto.error<CountryDto>("Validation failed", result.errors))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, BaseDto.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, BaseDto.error<Unit>("Invalid country ID"))
val result = createCountryUseCase.deleteCountry(countryId)
if (result.isValid) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respond(HttpStatusCode.NotFound, BaseDto.error<Unit>("Country not found", result.errors))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, BaseDto.error<Unit>("Failed to delete country: ${e.message}"))
}
}
}
}
/**
* 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()
)
}
}
@@ -0,0 +1,161 @@
package at.mocode.masterdata.infrastructure.repository
import at.mocode.masterdata.domain.model.LandDefinition
import at.mocode.masterdata.domain.repository.LandRepository
import com.benasher44.uuid.Uuid
import kotlinx.datetime.Clock
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.like
/**
* PostgreSQL implementation of LandRepository using Exposed ORM.
*
* This implementation provides data access operations for country data,
* mapping between the domain model (LandDefinition) and the database table (LandTable).
*/
class LandRepositoryImpl : LandRepository {
override suspend fun findById(id: Uuid): LandDefinition? {
return LandTable.select { LandTable.id eq id }
.singleOrNull()
?.toLandDefinition()
}
override suspend fun findByIsoAlpha2Code(isoAlpha2Code: String): LandDefinition? {
return LandTable.select { LandTable.isoAlpha2Code eq isoAlpha2Code }
.singleOrNull()
?.toLandDefinition()
}
override suspend fun findByIsoAlpha3Code(isoAlpha3Code: String): LandDefinition? {
return LandTable.select { LandTable.isoAlpha3Code eq isoAlpha3Code }
.singleOrNull()
?.toLandDefinition()
}
override suspend fun findByName(searchTerm: String, limit: Int): List<LandDefinition> {
val searchPattern = "%$searchTerm%"
return LandTable.select {
(LandTable.nameGerman like searchPattern) or
(LandTable.nameEnglish like searchPattern) or
(LandTable.nameLocal like searchPattern)
}
.orderBy(LandTable.sortierReihenfolge)
.limit(limit)
.map { it.toLandDefinition() }
}
override suspend fun findAllActive(orderBySortierung: Boolean): List<LandDefinition> {
val query = LandTable.select { LandTable.isActive eq true }
return if (orderBySortierung) {
query.orderBy(LandTable.sortierReihenfolge, LandTable.nameGerman)
} else {
query.orderBy(LandTable.nameGerman)
}.map { it.toLandDefinition() }
}
override suspend fun findEuMembers(): List<LandDefinition> {
return LandTable.select {
(LandTable.isActive eq true) and (LandTable.isEuMember eq true)
}
.orderBy(LandTable.sortierReihenfolge, LandTable.nameGerman)
.map { it.toLandDefinition() }
}
override suspend fun findEwrMembers(): List<LandDefinition> {
return LandTable.select {
(LandTable.isActive eq true) and (LandTable.isEwrMember eq true)
}
.orderBy(LandTable.sortierReihenfolge, LandTable.nameGerman)
.map { it.toLandDefinition() }
}
override suspend fun save(land: LandDefinition): LandDefinition {
val now = Clock.System.now()
// Check if record exists
val existingRecord = LandTable.select { LandTable.id eq land.landId }.singleOrNull()
return if (existingRecord != null) {
// Update existing record
LandTable.update({ LandTable.id eq land.landId }) {
it[isoAlpha2Code] = land.isoAlpha2Code
it[isoAlpha3Code] = land.isoAlpha3Code
it[isoNumericCode] = land.isoNumerischerCode
it[nameGerman] = land.nameDeutsch
it[nameEnglish] = land.nameEnglisch
it[nameLocal] = land.nameEnglisch // Using English as local fallback
it[isActive] = land.istAktiv
it[isEuMember] = land.istEuMitglied ?: false
it[isEwrMember] = land.istEwrMitglied ?: false
it[sortierReihenfolge] = land.sortierReihenfolge ?: 999
it[flagIcon] = land.wappenUrl
it[updatedAt] = now
it[notes] = null // Could be extended later
}
land.copy(updatedAt = now)
} else {
// Insert new record
LandTable.insert {
it[id] = land.landId
it[isoAlpha2Code] = land.isoAlpha2Code
it[isoAlpha3Code] = land.isoAlpha3Code
it[isoNumericCode] = land.isoNumerischerCode
it[nameGerman] = land.nameDeutsch
it[nameEnglish] = land.nameEnglisch
it[nameLocal] = land.nameEnglisch // Using English as local fallback
it[isActive] = land.istAktiv
it[isEuMember] = land.istEuMitglied ?: false
it[isEwrMember] = land.istEwrMitglied ?: false
it[sortierReihenfolge] = land.sortierReihenfolge ?: 999
it[flagIcon] = land.wappenUrl
it[createdAt] = land.createdAt
it[updatedAt] = now
it[notes] = null
}
land.copy(updatedAt = now)
}
}
override suspend fun delete(id: Uuid): Boolean {
val deletedRows = LandTable.deleteWhere { LandTable.id eq id }
return deletedRows > 0
}
override suspend fun existsByIsoAlpha2Code(isoAlpha2Code: String): Boolean {
return LandTable.select { LandTable.isoAlpha2Code eq isoAlpha2Code }
.count() > 0
}
override suspend fun existsByIsoAlpha3Code(isoAlpha3Code: String): Boolean {
return LandTable.select { LandTable.isoAlpha3Code eq isoAlpha3Code }
.count() > 0
}
override suspend fun countActive(): Long {
return LandTable.select { LandTable.isActive eq true }.count()
}
/**
* Extension function to convert a database ResultRow to a LandDefinition domain object.
*/
private fun ResultRow.toLandDefinition(): LandDefinition {
return LandDefinition(
landId = this[LandTable.id].value,
isoAlpha2Code = this[LandTable.isoAlpha2Code],
isoAlpha3Code = this[LandTable.isoAlpha3Code],
isoNumerischerCode = this[LandTable.isoNumericCode],
nameDeutsch = this[LandTable.nameGerman],
nameEnglisch = this[LandTable.nameEnglish],
wappenUrl = this[LandTable.flagIcon],
istEuMitglied = this[LandTable.isEuMember],
istEwrMitglied = this[LandTable.isEwrMember],
istAktiv = this[LandTable.isActive],
sortierReihenfolge = this[LandTable.sortierReihenfolge],
createdAt = this[LandTable.createdAt],
updatedAt = this[LandTable.updatedAt]
)
}
}
@@ -0,0 +1,41 @@
package at.mocode.masterdata.infrastructure.repository
import org.jetbrains.exposed.dao.id.UUIDTable
import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
/**
* Database table definition for LandDefinition (Country) entities.
*
* This table stores country reference data including ISO codes,
* names in multiple languages, and EU/EWR membership information.
*/
object LandTable : UUIDTable("land_definition") {
// ISO Codes
val isoAlpha2Code = varchar("iso_alpha2_code", 2).uniqueIndex()
val isoAlpha3Code = varchar("iso_alpha3_code", 3).uniqueIndex()
val isoNumericCode = varchar("iso_numeric_code", 3).nullable()
// Names
val nameGerman = varchar("name_german", 100)
val nameEnglish = varchar("name_english", 100)
val nameLocal = varchar("name_local", 100).nullable()
// Status and Membership
val isActive = bool("is_active").default(true)
val isEuMember = bool("is_eu_member").default(false)
val isEwrMember = bool("is_ewr_member").default(false)
// Sorting and Display
val sortierReihenfolge = integer("sortier_reihenfolge").default(999)
val flagIcon = varchar("flag_icon", 10).nullable()
// Audit fields
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
val createdBy = varchar("created_by", 50).nullable()
val updatedBy = varchar("updated_by", 50).nullable()
// Additional metadata
val notes = text("notes").nullable()
}