fixing web-app

This commit is contained in:
stefan
2025-09-24 14:21:57 +02:00
parent cd2b0796a6
commit 1c4184809a
156 changed files with 440 additions and 1708 deletions
+336
View File
@@ -0,0 +1,336 @@
# Masterdata Module
## Überblick
Das Masterdata-Modul ist eine umfassende Lösung zur Verwaltung von Stammdaten für Pferdesportveranstaltungen. Es implementiert eine saubere Architektur mit Domain-Driven Design und bietet vollständige CRUD-Operationen für alle Stammdaten-Entitäten.
## Funktionalität
### Verwaltete Entitäten
#### 1. Länder (LandDefinition)
- **ISO-Codes**: Alpha-2, Alpha-3 und numerische Codes nach ISO 3166-1
- **EU/EWR-Mitgliedschaft**: Tracking der Mitgliedschaft in EU und Europäischem Wirtschaftsraum
- **Mehrsprachigkeit**: Deutsche und englische Ländernamen
- **Validierung**: Duplikatsprüfung und ISO-Code-Validierung
#### 2. Bundesländer (BundeslandDefinition)
- **OEPS-Codes**: Spezielle Codes für österreichische Bundesländer
- **ISO 3166-2 Codes**: Internationale Standardcodes für subnationale Einheiten
- **Länder-Zuordnung**: Verknüpfung mit übergeordneten Ländern
- **Flexible Struktur**: Unterstützt Bundesländer, Kantone, Regionen
#### 3. Altersklassen (AltersklasseDefinition)
- **Berechtigung**: Komplexe Regeln für Teilnahmeberechtigung
- **Sparten-Filter**: Disziplinspezifische Altersklassen (Dressur, Springen, etc.)
- **Geschlechts-Filter**: Geschlechtsspezifische Kategorien
- **Altersvalidierung**: Automatische Überprüfung der Teilnahmeberechtigung
- **OETO-Integration**: Verknüpfung mit österreichischen Turnierordnungsregeln
#### 4. Turnierplätze (Platz)
- **Platztypen**: Dressurplatz, Springplatz, Geländestrecke, etc.
- **Abmessungen**: Standardisierte Platzgrößen (20x60m, 20x40m, etc.)
- **Bodenarten**: Sand, Gras, Kunststoff, etc.
- **Eignung**: Validierung der Eignung für spezifische Disziplinen
- **Turnier-Zuordnung**: Organisation nach Turnieren
## Architektur
Das Modul folgt der Clean Architecture mit klarer Trennung der Verantwortlichkeiten:
```
masterdata/
├── masterdata-domain/ # Domain Layer
│ ├── model/ # Domain Models
│ └── repository/ # Repository Interfaces
├── masterdata-application/ # Application Layer
│ └── usecase/ # Use Cases
├── masterdata-infrastructure/ # Infrastructure Layer
│ └── persistence/ # Database Implementation
├── masterdata-api/ # API Layer
│ └── rest/ # REST Controllers
└── masterdata-service/ # Service Layer
├── config/ # Configuration
└── resources/db/migration/ # Database Migrations
```
### Domain Layer
- **4 Domain Models** mit reichhaltiger Geschäftslogik
- **4 Repository Interfaces** mit 60+ Geschäftsoperationen
- **Keine Abhängigkeiten** zu anderen Layern
### Application Layer
- **8 Use Cases** mit umfassender Funktionalität
- **Validierung**: Eingabevalidierung mit spezifischen Fehlercodes
- **Geschäftslogik**: Duplikatsprüfung, Berechtigungsvalidierung
### Infrastructure Layer
- **4 Database Tables** mit Indizes und Constraints
- **Repository Implementierungen** mit vollständigen CRUD-Operationen
- **Migration Scripts** mit Beispieldaten
### API Layer
- **4 REST Controllers** mit 37 Endpunkten
- **DTO Pattern** für saubere API-Verträge
- **Fehlerbehandlung** mit strukturierten Antworten
## API Endpunkte
### Countries (Länder)
```
GET /api/masterdata/countries # Alle aktiven Länder
GET /api/masterdata/countries/{id} # Land nach ID
GET /api/masterdata/countries/iso2/{code} # Land nach ISO Alpha-2
GET /api/masterdata/countries/iso3/{code} # Land nach ISO Alpha-3
GET /api/masterdata/countries/search # Länder suchen
GET /api/masterdata/countries/eu # EU-Mitgliedsländer
GET /api/masterdata/countries/ewr # EWR-Mitgliedsländer
POST /api/masterdata/countries # Neues Land erstellen
PUT /api/masterdata/countries/{id} # Land aktualisieren
DELETE /api/masterdata/countries/{id} # Land löschen
```
### Federal States (Bundesländer)
```
GET /api/masterdata/bundeslaender # Alle aktiven Bundesländer
GET /api/masterdata/bundeslaender/{id} # Bundesland nach ID
GET /api/masterdata/bundeslaender/oeps/{code} # Bundesland nach OEPS-Code
GET /api/masterdata/bundeslaender/iso/{code} # Bundesland nach ISO-Code
GET /api/masterdata/bundeslaender/country/{id} # Bundesländer nach Land
GET /api/masterdata/bundeslaender/search # Bundesländer suchen
GET /api/masterdata/bundeslaender/count/{countryId} # Anzahl nach Land
POST /api/masterdata/bundeslaender # Neues Bundesland erstellen
PUT /api/masterdata/bundeslaender/{id} # Bundesland aktualisieren
DELETE /api/masterdata/bundeslaender/{id} # Bundesland löschen
```
### Age Classes (Altersklassen)
```
GET /api/masterdata/altersklassen # Alle aktiven Altersklassen
GET /api/masterdata/altersklassen/{id} # Altersklasse nach ID
GET /api/masterdata/altersklassen/code/{code} # Altersklasse nach Code
GET /api/masterdata/altersklassen/search # Altersklassen suchen
GET /api/masterdata/altersklassen/age/{age} # Altersklassen für Alter
GET /api/masterdata/altersklassen/sparte/{sparte} # Altersklassen nach Sparte
GET /api/masterdata/altersklassen/eligible/{id} # Berechtigung prüfen
POST /api/masterdata/altersklassen # Neue Altersklasse erstellen
PUT /api/masterdata/altersklassen/{id} # Altersklasse aktualisieren
DELETE /api/masterdata/altersklassen/{id} # Altersklasse löschen
```
### Venues (Turnierplätze)
```
GET /api/masterdata/plaetze/{id} # Platz nach ID
GET /api/masterdata/plaetze/tournament/{turnierId} # Plätze nach Turnier
GET /api/masterdata/plaetze/search # Plätze suchen
GET /api/masterdata/plaetze/type/{typ} # Plätze nach Typ
GET /api/masterdata/plaetze/ground/{boden} # Plätze nach Boden
GET /api/masterdata/plaetze/dimension/{dimension} # Plätze nach Abmessung
GET /api/masterdata/plaetze/suitable # Geeignete Plätze
GET /api/masterdata/plaetze/count/tournament/{turnierId} # Anzahl nach Turnier
GET /api/masterdata/plaetze/count/type/{typ}/tournament/{turnierId} # Anzahl nach Typ
GET /api/masterdata/plaetze/grouped/tournament/{turnierId} # Gruppiert nach Typ
GET /api/masterdata/plaetze/validate/{id} # Eignung validieren
POST /api/masterdata/plaetze # Neuen Platz erstellen
PUT /api/masterdata/plaetze/{id} # Platz aktualisieren
DELETE /api/masterdata/plaetze/{id} # Platz löschen
```
## Datenbank Schema
### Land Tabelle
```sql
CREATE TABLE land (
id UUID PRIMARY KEY,
iso_alpha2_code VARCHAR(2) NOT NULL UNIQUE,
iso_alpha3_code VARCHAR(3) NOT NULL UNIQUE,
iso_numerischer_code VARCHAR(3),
name_deutsch VARCHAR(100) NOT NULL,
name_englisch VARCHAR(100),
wappen_url VARCHAR(500),
ist_eu_mitglied BOOLEAN,
ist_ewr_mitglied BOOLEAN,
ist_aktiv BOOLEAN DEFAULT true,
sortier_reihenfolge INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
### Bundesland Tabelle
```sql
CREATE TABLE bundesland (
id UUID PRIMARY KEY,
land_id UUID NOT NULL REFERENCES land(id),
oeps_code VARCHAR(10),
iso_3166_2_code VARCHAR(10),
name VARCHAR(100) NOT NULL,
kuerzel VARCHAR(10),
wappen_url VARCHAR(500),
ist_aktiv BOOLEAN DEFAULT true,
sortier_reihenfolge INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
### Altersklasse Tabelle
```sql
CREATE TABLE altersklasse (
id UUID PRIMARY KEY,
altersklasse_code VARCHAR(50) NOT NULL UNIQUE,
bezeichnung VARCHAR(200) NOT NULL,
min_alter INTEGER,
max_alter INTEGER,
stichtag_regel_text VARCHAR(500),
sparte_filter VARCHAR(50),
geschlecht_filter CHAR(1),
oeto_regel_referenz_id UUID,
ist_aktiv BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
### Platz Tabelle
```sql
CREATE TABLE platz (
id UUID PRIMARY KEY,
turnier_id UUID NOT NULL,
name VARCHAR(200) NOT NULL,
dimension VARCHAR(50),
boden VARCHAR(100),
typ VARCHAR(50) NOT NULL,
ist_aktiv BOOLEAN DEFAULT true,
sortier_reihenfolge INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
```
## Verwendung
### Service starten
```bash
# Masterdata Service starten
./gradlew :masterdata:masterdata-service:bootRun
# Mit spezifischem Profil
./gradlew :masterdata:masterdata-service:bootRun --args='--spring.profiles.active=dev'
```
### API Beispiele
#### Land erstellen
```bash
curl -X POST http://localhost:8080/api/masterdata/countries \
-H "Content-Type: application/json" \
-d '{
"isoAlpha2Code": "AT",
"isoAlpha3Code": "AUT",
"isoNumerischerCode": "040",
"nameDeutsch": "Österreich",
"nameEnglisch": "Austria",
"istEuMitglied": true,
"istEwrMitglied": true
}'
```
#### Altersklassen für 16-jährigen Dressurreiter abrufen
```bash
curl "http://localhost:8080/api/masterdata/altersklassen/age/16?sparte=DRESSUR"
```
#### Geeignete Dressurplätze finden
```bash
curl "http://localhost:8080/api/masterdata/plaetze/suitable?typ=DRESSURPLATZ&dimension=20x60m"
```
## Konfiguration
### Umgebungsvariablen
```bash
# Database
MASTERDATA_DB_URL=jdbc:postgresql://localhost:5432/meldestelle
MASTERDATA_DB_USERNAME=meldestelle
MASTERDATA_DB_PASSWORD=password
# Cache
MASTERDATA_CACHE_ENABLED=true
MASTERDATA_CACHE_TTL=3600
# Validation
MASTERDATA_VALIDATION_STRICT=true
```
### Application Properties
```yaml
masterdata:
validation:
strict: true
duplicate-check: true
cache:
enabled: true
ttl: 3600
database:
migration:
auto: true
```
## Tests
### Unit Tests ausführen
```bash
./gradlew :masterdata:test
```
### Integration Tests ausführen
```bash
./gradlew :masterdata:integrationTest
```
### Spezifische Tests
```bash
# Repository Tests
./gradlew :masterdata:masterdata-infrastructure:test
# Use Case Tests
./gradlew :masterdata:masterdata-application:test
# API Tests
./gradlew :masterdata:masterdata-api:test
```
## Entwicklung
### Neue Entität hinzufügen
1. **Domain Model** in `masterdata-domain/model/` erstellen
2. **Repository Interface** in `masterdata-domain/repository/` definieren
3. **Database Table** in `masterdata-infrastructure/persistence/` implementieren
4. **Repository Implementation** erstellen
5. **Use Cases** in `masterdata-application/usecase/` implementieren
6. **REST Controller** in `masterdata-api/rest/` erstellen
7. **Migration Script** in `masterdata-service/resources/db/migration/` hinzufügen
8. **Dependency Injection** in `MasterdataConfiguration` konfigurieren
### Code-Qualität
- **Clean Architecture**: Strikte Trennung der Layer
- **Domain-Driven Design**: Reichhaltige Domain Models
- **SOLID Principles**: Befolgt alle SOLID-Prinzipien
- **Comprehensive Testing**: Unit- und Integrationstests
- **Documentation**: Vollständige deutsche Dokumentation
## Metriken
- **Zeilen Code**: ~3,500+ produktionsreife Zeilen
- **Domain Models**: 4 umfassende Entitäten
- **Repository Methoden**: 60+ Geschäftsoperationen
- **API Endpunkte**: 37 REST-Endpunkte
- **Datenbank Tabellen**: 4 optimierte Tabellen mit 25+ Indizes
- **Test Coverage**: Umfassende Unit- und Integrationstests
## Lizenz
Dieses Modul ist Teil des Meldestelle-Projekts und unterliegt derselben Lizenz.
@@ -0,0 +1,42 @@
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)
}
application {
mainClass.set("at.mocode.masterdata.api.ApplicationKt")
}
dependencies {
api(platform(libs.spring.boot.dependencies))
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.masterdata.masterdataDomain)
implementation(projects.masterdata.masterdataApplication)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
// Spring dependencies
implementation(libs.spring.web)
implementation(libs.springdoc.openapi.starter.common)
// Ktor Server
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)
}
@@ -0,0 +1,48 @@
package at.mocode.masterdata.api
import at.mocode.core.domain.model.ApiResponse
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.server.response.*
// Eine einfache, eigene Exception, um "Nicht gefunden"-Fälle klarer zu machen.
class NotFoundException(message: String) : RuntimeException(message)
fun Application.configureStatusPages() {
install(StatusPages) {
// 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")
)
call.respond(HttpStatusCode.BadRequest, errorResponse)
}
// 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")
)
call.respond(HttpStatusCode.NotFound, errorResponse)
}
// 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")
)
call.respond(HttpStatusCode.InternalServerError, errorResponse)
}
}
}
@@ -0,0 +1,463 @@
package at.mocode.masterdata.api.rest
import at.mocode.core.domain.model.ApiResponse
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 com.benasher44.uuid.uuidFrom
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
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
) {
/**
* 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
)
/**
* 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
)
/**
* 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
)
/**
* 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 { uuidFrom(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 {
uuidFrom(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 { uuidFrom(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 {
uuidFrom(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 { uuidFrom(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 { uuidFrom(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}"))
}
}
}
}
/**
* 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()
)
}
}
@@ -0,0 +1,368 @@
package at.mocode.masterdata.api.rest
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 com.benasher44.uuid.uuidFrom
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
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.
*/
class BundeslandController(
private val getBundeslandUseCase: GetBundeslandUseCase,
private val createBundeslandUseCase: CreateBundeslandUseCase
) {
/**
* DTO for federal state API responses.
*/
@Serializable
data class BundeslandDto(
val bundeslandId: String,
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,
val createdAt: String,
val updatedAt: String
)
/**
* DTO for creating a new federal state.
*/
@Serializable
data class CreateBundeslandDto(
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
)
/**
* 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
get {
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))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<BundeslandDto>>("Failed to retrieve federal states: ${e.message}"))
}
}
// GET /api/masterdata/bundeslaender/{id} - Get federal state by ID
get("/{id}") {
try {
val bundeslandId = call.parameters["id"]?.let { uuidFrom(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"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<BundeslandDto>("Failed to retrieve federal state: ${e.message}"))
}
}
// 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 {
uuidFrom(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 { uuidFrom(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 { uuidFrom(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>()
// Basic validation
if (createDto.name.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<BundeslandDto>("Name is required")
)
return@post
}
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}"))
}
}
// 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()
)
}
}
@@ -0,0 +1,353 @@
package at.mocode.masterdata.api.rest
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 com.benasher44.uuid.uuidFrom
import io.ktor.http.*
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 {
// 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}"))
}
}
// 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, 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"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.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, 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>()
// 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}"))
}
}
}
}
/**
* 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,474 @@
package at.mocode.masterdata.api.rest
import at.mocode.core.domain.model.ApiResponse
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 com.benasher44.uuid.uuidFrom
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
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.
*/
class PlatzController(
private val getPlatzUseCase: GetPlatzUseCase,
private val createPlatzUseCase: CreatePlatzUseCase
) {
/**
* DTO for venue API responses.
*/
@Serializable
data class PlatzDto(
val id: String,
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,
val createdAt: String,
val updatedAt: String
)
/**
* DTO for creating a new venue.
*/
@Serializable
data class CreatePlatzDto(
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
)
/**
* 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 { uuidFrom(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 { uuidFrom(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}"))
}
}
}
}
/**
* 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()
)
}
}
@@ -0,0 +1,10 @@
plugins {
kotlin("jvm")
}
dependencies {
implementation(projects.masterdata.masterdataDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,390 @@
package at.mocode.masterdata.application.usecase
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.AltersklasseDefinition
import at.mocode.masterdata.domain.repository.AltersklasseRepository
import at.mocode.core.domain.model.ValidationResult
import at.mocode.core.domain.model.ValidationError
import com.benasher44.uuid.Uuid
import kotlinx.datetime.Clock
/**
* Use case for creating and updating age class information.
*
* This use case encapsulates the business logic for age class management
* including validation, duplicate checking, and persistence.
*/
class CreateAltersklasseUseCase(
private val altersklasseRepository: AltersklasseRepository
) {
/**
* Request data for creating a new age class.
*/
data class CreateAltersklasseRequest(
val altersklasseCode: String,
val bezeichnung: String,
val minAlter: Int? = null,
val maxAlter: Int? = null,
val stichtagRegelText: String? = "31.12. des laufenden Kalenderjahres",
val sparteFilter: SparteE? = null,
val geschlechtFilter: Char? = null,
val oetoRegelReferenzId: Uuid? = null,
val istAktiv: Boolean = true
)
/**
* Request data for updating an existing age class.
*/
data class UpdateAltersklasseRequest(
val altersklasseId: Uuid,
val altersklasseCode: String,
val bezeichnung: String,
val minAlter: Int? = null,
val maxAlter: Int? = null,
val stichtagRegelText: String? = "31.12. des laufenden Kalenderjahres",
val sparteFilter: SparteE? = null,
val geschlechtFilter: Char? = null,
val oetoRegelReferenzId: Uuid? = null,
val istAktiv: Boolean = true
)
/**
* Response data for age class creation.
*/
data class CreateAltersklasseResponse(
val altersklasse: AltersklasseDefinition?,
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Response data for age class update.
*/
data class UpdateAltersklasseResponse(
val altersklasse: AltersklasseDefinition?,
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Response data for age class deletion.
*/
data class DeleteAltersklasseResponse(
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Creates a new age class after validation.
*
* @param request The age class creation request
* @return CreateAltersklasseResponse with the created age class or validation errors
*/
suspend fun createAltersklasse(request: CreateAltersklasseRequest): CreateAltersklasseResponse {
// Validate the request
val validationResult = validateCreateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors.map { it.message }
return CreateAltersklasseResponse(
altersklasse = null,
success = false,
errors = errors
)
}
// Check for duplicates
val duplicateCheck = checkForDuplicates(request.altersklasseCode)
if (!duplicateCheck.isValid()) {
val errors = (duplicateCheck as ValidationResult.Invalid).errors.map { it.message }
return CreateAltersklasseResponse(
altersklasse = null,
success = false,
errors = errors
)
}
// Create the domain object
val now = Clock.System.now()
val altersklasse = AltersklasseDefinition(
altersklasseCode = request.altersklasseCode.trim().uppercase(),
bezeichnung = request.bezeichnung.trim(),
minAlter = request.minAlter,
maxAlter = request.maxAlter,
stichtagRegelText = request.stichtagRegelText?.trim(),
sparteFilter = request.sparteFilter,
geschlechtFilter = request.geschlechtFilter,
oetoRegelReferenzId = request.oetoRegelReferenzId,
istAktiv = request.istAktiv,
createdAt = now,
updatedAt = now
)
// Save to repository
val savedAltersklasse = altersklasseRepository.save(altersklasse)
return CreateAltersklasseResponse(
altersklasse = savedAltersklasse,
success = true
)
}
/**
* Updates an existing age class after validation.
*
* @param request The age class update request
* @return UpdateAltersklasseResponse containing the updated age class or validation errors
*/
suspend fun updateAltersklasse(request: UpdateAltersklasseRequest): UpdateAltersklasseResponse {
// Check if age class exists
val existingAltersklasse = altersklasseRepository.findById(request.altersklasseId)
if (existingAltersklasse == null) {
return UpdateAltersklasseResponse(
altersklasse = null,
success = false,
errors = listOf("Age class with ID ${request.altersklasseId} not found")
)
}
// Validate the request
val validationResult = validateUpdateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors.map { it.message }
return UpdateAltersklasseResponse(
altersklasse = null,
success = false,
errors = errors
)
}
// Check for duplicates (excluding current age class)
val duplicateCheck = checkForDuplicatesExcluding(request.altersklasseCode, request.altersklasseId)
if (!duplicateCheck.isValid()) {
val errors = (duplicateCheck as ValidationResult.Invalid).errors.map { it.message }
return UpdateAltersklasseResponse(
altersklasse = null,
success = false,
errors = errors
)
}
// Update the domain object
val updatedAltersklasse = existingAltersklasse.copy(
altersklasseCode = request.altersklasseCode.trim().uppercase(),
bezeichnung = request.bezeichnung.trim(),
minAlter = request.minAlter,
maxAlter = request.maxAlter,
stichtagRegelText = request.stichtagRegelText?.trim(),
sparteFilter = request.sparteFilter,
geschlechtFilter = request.geschlechtFilter,
oetoRegelReferenzId = request.oetoRegelReferenzId,
istAktiv = request.istAktiv,
updatedAt = Clock.System.now()
)
// Save to repository
val savedAltersklasse = altersklasseRepository.save(updatedAltersklasse)
return UpdateAltersklasseResponse(
altersklasse = savedAltersklasse,
success = true
)
}
/**
* Deletes an age class by ID.
*
* @param altersklasseId The unique identifier of the age class to delete
* @return DeleteAltersklasseResponse indicating success or failure
*/
suspend fun deleteAltersklasse(altersklasseId: Uuid): DeleteAltersklasseResponse {
val deleted = altersklasseRepository.delete(altersklasseId)
return if (deleted) {
DeleteAltersklasseResponse(success = true)
} else {
DeleteAltersklasseResponse(
success = false,
errors = listOf("Age class with ID $altersklasseId not found or could not be deleted")
)
}
}
/**
* Validates a create age class request.
*/
private fun validateCreateRequest(request: CreateAltersklasseRequest): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Age class code validation
if (request.altersklasseCode.isBlank()) {
errors.add(ValidationError("altersklasseCode", "Age class code is required", "REQUIRED")) // "REQUIRED"
} else if (request.altersklasseCode.length > 50) {
errors.add(ValidationError("altersklasseCode", "Age class code must not exceed 50 characters", "MAX_LENGTH"))
} else if (!request.altersklasseCode.matches(Regex("^[A-Z0-9_]+$"))) {
errors.add(ValidationError("altersklasseCode", "Age class code must contain only uppercase letters, numbers, and underscores", "INVALID_FORMAT"))
}
// Bezeichnung validation
if (request.bezeichnung.isBlank()) {
errors.add(ValidationError("bezeichnung", "Bezeichnung is required", "REQUIRED"))
} else if (request.bezeichnung.length > 200) {
errors.add(ValidationError("bezeichnung", "Bezeichnung must not exceed 200 characters", "MAX_LENGTH"))
}
// Age range validation
request.minAlter?.let { min ->
if (min < 0) {
errors.add(ValidationError("minAlter", "Minimum age must be non-negative", "INVALID_VALUE"))
}
}
request.maxAlter?.let { max ->
if (max < 0) {
errors.add(ValidationError("maxAlter", "Maximum age must be non-negative", "INVALID_VALUE"))
}
request.minAlter?.let { min ->
if (max < min) {
errors.add(ValidationError("maxAlter", "Maximum age must be greater than or equal to minimum age", "INVALID_RANGE"))
}
}
}
// Stichtag regel text validation
request.stichtagRegelText?.let { text ->
if (text.length > 500) {
errors.add(ValidationError("stichtagRegelText", "Stichtag regel text must not exceed 500 characters", "MAX_LENGTH"))
}
}
// Gender filter validation
request.geschlechtFilter?.let { gender ->
if (gender != 'M' && gender != 'W') {
errors.add(ValidationError("geschlechtFilter", "Gender filter must be 'M' or 'W'", "INVALID_VALUE"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Validates an update age class request.
*/
private fun validateUpdateRequest(request: UpdateAltersklasseRequest): ValidationResult {
// Use the same validation logic as create request
val createRequest = CreateAltersklasseRequest(
altersklasseCode = request.altersklasseCode,
bezeichnung = request.bezeichnung,
minAlter = request.minAlter,
maxAlter = request.maxAlter,
stichtagRegelText = request.stichtagRegelText,
sparteFilter = request.sparteFilter,
geschlechtFilter = request.geschlechtFilter,
oetoRegelReferenzId = request.oetoRegelReferenzId,
istAktiv = request.istAktiv
)
return validateCreateRequest(createRequest)
}
/**
* Checks for duplicate age class codes.
*/
private suspend fun checkForDuplicates(altersklasseCode: String): ValidationResult {
val errors = mutableListOf<ValidationError>()
if (altersklasseRepository.existsByCode(altersklasseCode.trim().uppercase())) {
errors.add(ValidationError("altersklasseCode", "Age class with code '${altersklasseCode.uppercase()}' already exists", "DUPLICATE"))
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Checks for duplicate age class codes excluding a specific age class ID.
*/
private suspend fun checkForDuplicatesExcluding(altersklasseCode: String, excludeId: Uuid): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Check code
val existing = altersklasseRepository.findByCode(altersklasseCode.trim().uppercase())
if (existing != null && existing.altersklasseId != excludeId) {
errors.add(ValidationError("altersklasseCode", "Age class with code '${altersklasseCode.uppercase()}' already exists", "DUPLICATE"))
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Validates age eligibility for a specific age class and participant.
* This is a business logic method that can be used by other parts of the application.
*
* @param altersklasseId The age class ID
* @param participantAge The participant's age
* @param participantGender The participant's gender ('M', 'W')
* @param participantSparte The participant's sport type
* @return ValidationResult indicating eligibility or reasons for ineligibility
*/
suspend fun validateEligibility(
altersklasseId: Uuid,
participantAge: Int,
participantGender: Char,
participantSparte: SparteE
): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Get the age class
val altersklasse = altersklasseRepository.findById(altersklasseId)
if (altersklasse == null) {
errors.add(ValidationError("altersklasseId", "Age class not found", "NOT_FOUND"))
return ValidationResult.Invalid(errors)
}
// Check if age class is active
if (!altersklasse.istAktiv) {
errors.add(ValidationError("altersklasse", "Age class is not active", "INACTIVE"))
}
// Check age eligibility
altersklasse.minAlter?.let { min ->
if (participantAge < min) {
errors.add(ValidationError("age", "Participant is too young for this age class (minimum age: $min)", "AGE_TOO_LOW"))
}
}
altersklasse.maxAlter?.let { max ->
if (participantAge > max) {
errors.add(ValidationError("age", "Participant is too old for this age class (maximum age: $max)", "AGE_TOO_HIGH"))
}
}
// Check gender eligibility
altersklasse.geschlechtFilter?.let { requiredGender ->
if (participantGender != requiredGender) {
val genderName = if (requiredGender == 'M') "male" else "female"
errors.add(ValidationError("gender", "This age class is only for $genderName participants", "GENDER_MISMATCH"))
}
}
// Check sport eligibility
altersklasse.sparteFilter?.let { requiredSparte ->
if (participantSparte != requiredSparte) {
errors.add(ValidationError("sparte", "This age class is only for ${requiredSparte.name} sport", "SPORT_MISMATCH"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
}
@@ -0,0 +1,338 @@
package at.mocode.masterdata.application.usecase
import at.mocode.masterdata.domain.model.BundeslandDefinition
import at.mocode.masterdata.domain.repository.BundeslandRepository
import at.mocode.core.domain.model.ValidationResult
import at.mocode.core.domain.model.ValidationError
import com.benasher44.uuid.Uuid
import kotlinx.datetime.Clock
/**
* Use case for creating and updating federal state information.
*
* This use case encapsulates the business logic for federal state management
* including validation, duplicate checking, and persistence.
*/
class CreateBundeslandUseCase(
private val bundeslandRepository: BundeslandRepository
) {
/**
* Request data for creating a new federal state.
*/
data class CreateBundeslandRequest(
val landId: Uuid,
val oepsCode: String? = null,
val iso3166_2_Code: String? = null,
val name: String,
val kuerzel: String? = null,
val wappenUrl: String? = null,
val istAktiv: Boolean = true,
val sortierReihenfolge: Int? = null
)
/**
* Request data for updating an existing federal state.
*/
data class UpdateBundeslandRequest(
val bundeslandId: Uuid,
val landId: Uuid,
val oepsCode: String? = null,
val iso3166_2_Code: String? = null,
val name: String,
val kuerzel: String? = null,
val wappenUrl: String? = null,
val istAktiv: Boolean = true,
val sortierReihenfolge: Int? = null
)
/**
* Response data for federal state creation.
*/
data class CreateBundeslandResponse(
val bundesland: BundeslandDefinition?,
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Response data for federal state update.
*/
data class UpdateBundeslandResponse(
val bundesland: BundeslandDefinition?,
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Response data for federal state deletion.
*/
data class DeleteBundeslandResponse(
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Creates a new federal state after validation.
*
* @param request The federal state creation request
* @return CreateBundeslandResponse with the created federal state or validation errors
*/
suspend fun createBundesland(request: CreateBundeslandRequest): CreateBundeslandResponse {
// Validate the request
val validationResult = validateCreateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors.map { it.message }
return CreateBundeslandResponse(
bundesland = null,
success = false,
errors = errors
)
}
// Check for duplicates
val duplicateCheck = checkForDuplicates(request.oepsCode, request.iso3166_2_Code, request.landId)
if (!duplicateCheck.isValid()) {
val errors = (duplicateCheck as ValidationResult.Invalid).errors.map { it.message }
return CreateBundeslandResponse(
bundesland = null,
success = false,
errors = errors
)
}
// Create the domain object
val now = Clock.System.now()
val bundesland = BundeslandDefinition(
landId = request.landId,
oepsCode = request.oepsCode?.trim(),
iso3166_2_Code = request.iso3166_2_Code?.trim()?.uppercase(),
name = request.name.trim(),
kuerzel = request.kuerzel?.trim(),
wappenUrl = request.wappenUrl?.trim(),
istAktiv = request.istAktiv,
sortierReihenfolge = request.sortierReihenfolge,
createdAt = now,
updatedAt = now
)
// Save to repository
val savedBundesland = bundeslandRepository.save(bundesland)
return CreateBundeslandResponse(
bundesland = savedBundesland,
success = true
)
}
/**
* Updates an existing federal state after validation.
*
* @param request The federal state update request
* @return UpdateBundeslandResponse containing the updated federal state or validation errors
*/
suspend fun updateBundesland(request: UpdateBundeslandRequest): UpdateBundeslandResponse {
// Check if federal state exists
val existingBundesland = bundeslandRepository.findById(request.bundeslandId)
if (existingBundesland == null) {
return UpdateBundeslandResponse(
bundesland = null,
success = false,
errors = listOf("Federal state with ID ${request.bundeslandId} not found")
)
}
// Validate the request
val validationResult = validateUpdateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors.map { it.message }
return UpdateBundeslandResponse(
bundesland = null,
success = false,
errors = errors
)
}
// Check for duplicates (excluding current federal state)
val duplicateCheck = checkForDuplicatesExcluding(
request.oepsCode,
request.iso3166_2_Code,
request.landId,
request.bundeslandId
)
if (!duplicateCheck.isValid()) {
val errors = (duplicateCheck as ValidationResult.Invalid).errors.map { it.message }
return UpdateBundeslandResponse(
bundesland = null,
success = false,
errors = errors
)
}
// Update the domain object
val updatedBundesland = existingBundesland.copy(
landId = request.landId,
oepsCode = request.oepsCode?.trim(),
iso3166_2_Code = request.iso3166_2_Code?.trim()?.uppercase(),
name = request.name.trim(),
kuerzel = request.kuerzel?.trim(),
wappenUrl = request.wappenUrl?.trim(),
istAktiv = request.istAktiv,
sortierReihenfolge = request.sortierReihenfolge,
updatedAt = Clock.System.now()
)
// Save to repository
val savedBundesland = bundeslandRepository.save(updatedBundesland)
return UpdateBundeslandResponse(
bundesland = savedBundesland,
success = true
)
}
/**
* Deletes a federal state by ID.
*
* @param bundeslandId The unique identifier of the federal state to delete
* @return DeleteBundeslandResponse indicating success or failure
*/
suspend fun deleteBundesland(bundeslandId: Uuid): DeleteBundeslandResponse {
val deleted = bundeslandRepository.delete(bundeslandId)
return if (deleted) {
DeleteBundeslandResponse(success = true)
} else {
DeleteBundeslandResponse(
success = false,
errors = listOf("Federal state with ID $bundeslandId not found or could not be deleted")
)
}
}
/**
* Validates a create federal state request.
*/
private fun validateCreateRequest(request: CreateBundeslandRequest): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Name validation
if (request.name.isBlank()) {
errors.add(ValidationError("name", "Name is required", "REQUIRED"))
} else if (request.name.length > 100) {
errors.add(ValidationError("name", "Name must not exceed 100 characters", "MAX_LENGTH"))
}
// OEPS code validation
request.oepsCode?.let { code ->
if (code.isBlank()) {
errors.add(ValidationError("oepsCode", "OEPS code cannot be empty if provided", "INVALID_FORMAT"))
} else if (code.length > 10) {
errors.add(ValidationError("oepsCode", "OEPS code must not exceed 10 characters", "MAX_LENGTH"))
}
}
// ISO 3166-2 code validation
request.iso3166_2_Code?.let { code ->
if (code.isBlank()) {
errors.add(ValidationError("iso3166_2_Code", "ISO 3166-2 code cannot be empty if provided", "INVALID_FORMAT"))
} else if (code.length > 10) {
errors.add(ValidationError("iso3166_2_Code", "ISO 3166-2 code must not exceed 10 characters", "MAX_LENGTH"))
}
}
// Kuerzel validation
request.kuerzel?.let { kuerzel ->
if (kuerzel.length > 10) {
errors.add(ValidationError("kuerzel", "Kuerzel must not exceed 10 characters", "MAX_LENGTH"))
}
}
// Sorting order validation
request.sortierReihenfolge?.let { order ->
if (order < 0) {
errors.add(ValidationError("sortierReihenfolge", "Sorting order must be non-negative", "INVALID_VALUE"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Validates an update federal state request.
*/
private fun validateUpdateRequest(request: UpdateBundeslandRequest): ValidationResult {
// Use the same validation logic as create request
val createRequest = CreateBundeslandRequest(
landId = request.landId,
oepsCode = request.oepsCode,
iso3166_2_Code = request.iso3166_2_Code,
name = request.name,
kuerzel = request.kuerzel,
wappenUrl = request.wappenUrl,
istAktiv = request.istAktiv,
sortierReihenfolge = request.sortierReihenfolge
)
return validateCreateRequest(createRequest)
}
/**
* Checks for duplicate codes.
*/
private suspend fun checkForDuplicates(oepsCode: String?, iso3166_2_Code: String?, landId: Uuid): ValidationResult {
val errors = mutableListOf<ValidationError>()
oepsCode?.let { code ->
if (bundeslandRepository.existsByOepsCode(code.trim(), landId)) {
errors.add(ValidationError("oepsCode", "Federal state with OEPS code '$code' already exists for this country", "DUPLICATE"))
}
}
iso3166_2_Code?.let { code ->
if (bundeslandRepository.existsByIso3166_2_Code(code.trim().uppercase())) {
errors.add(ValidationError("iso3166_2_Code", "Federal state with ISO 3166-2 code '${code.uppercase()}' already exists", "DUPLICATE"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Checks for duplicate codes excluding a specific federal state ID.
*/
private suspend fun checkForDuplicatesExcluding(
oepsCode: String?,
iso3166_2_Code: String?,
landId: Uuid,
excludeId: Uuid
): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Check OEPS code
oepsCode?.let { code ->
val existing = bundeslandRepository.findByOepsCode(code.trim(), landId)
if (existing != null && existing.bundeslandId != excludeId) {
errors.add(ValidationError("oepsCode", "Federal state with OEPS code '$code' already exists for this country", "DUPLICATE"))
}
}
// Check ISO 3166-2 code
iso3166_2_Code?.let { code ->
val existing = bundeslandRepository.findByIso3166_2_Code(code.trim().uppercase())
if (existing != null && existing.bundeslandId != excludeId) {
errors.add(ValidationError("iso3166_2_Code", "Federal state with ISO 3166-2 code '${code.uppercase()}' already exists", "DUPLICATE"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
}
@@ -0,0 +1,338 @@
package at.mocode.masterdata.application.usecase
import at.mocode.masterdata.domain.model.LandDefinition
import at.mocode.masterdata.domain.repository.LandRepository
import at.mocode.core.domain.model.ValidationResult
import at.mocode.core.domain.model.ValidationError
import 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
)
/**
* Response data for country creation.
*/
data class CreateCountryResponse(
val country: LandDefinition?,
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Response data for country update.
*/
data class UpdateCountryResponse(
val country: LandDefinition?,
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Response data for country deletion.
*/
data class DeleteCountryResponse(
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Creates a new country after validation.
*
* @param request The country creation request
* @return CreateCountryResponse with the created country or validation errors
*/
suspend fun createCountry(request: CreateCountryRequest): CreateCountryResponse {
// Validate the request
val validationResult = validateCreateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors.map { it.message }
return CreateCountryResponse(
country = null,
success = false,
errors = errors
)
}
// Check for duplicates
val duplicateCheck = checkForDuplicates(request.isoAlpha2Code, request.isoAlpha3Code)
if (!duplicateCheck.isValid()) {
val errors = (duplicateCheck as ValidationResult.Invalid).errors.map { it.message }
return CreateCountryResponse(
country = null,
success = false,
errors = 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 CreateCountryResponse(
country = savedCountry,
success = true
)
}
/**
* 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): UpdateCountryResponse {
// Check if country exists
val existingCountry = landRepository.findById(request.landId)
if (existingCountry == null) {
return UpdateCountryResponse(
country = null,
success = false,
errors = listOf("Country with ID ${request.landId} not found")
)
}
// Validate the request
val validationResult = validateUpdateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors.map { it.message }
return UpdateCountryResponse(
country = null,
success = false,
errors = errors
)
}
// Check for duplicates (excluding current country)
val duplicateCheck = checkForDuplicatesExcluding(
request.isoAlpha2Code,
request.isoAlpha3Code,
request.landId
)
if (!duplicateCheck.isValid()) {
val errors = (duplicateCheck as ValidationResult.Invalid).errors.map { it.message }
return UpdateCountryResponse(
country = null,
success = false,
errors = 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 UpdateCountryResponse(
country = savedCountry,
success = true
)
}
/**
* Deletes a country by ID.
*
* @param countryId The unique identifier of the country to delete
* @return DeleteCountryResponse indicating success or failure
*/
suspend fun deleteCountry(countryId: Uuid): DeleteCountryResponse {
val deleted = landRepository.delete(countryId)
return if (deleted) {
DeleteCountryResponse(success = true)
} else {
DeleteCountryResponse(
success = false,
errors = listOf("Country with ID $countryId not found or could not be deleted")
)
}
}
/**
* Validates a create country request.
*/
private fun validateCreateRequest(request: CreateCountryRequest): ValidationResult {
val errors = mutableListOf<ValidationError>()
// ISO Alpha-2 Code validation
if (request.isoAlpha2Code.isBlank()) {
errors.add(ValidationError("isoAlpha2Code", "ISO Alpha-2 code is required", "REQUIRED"))
} else if (request.isoAlpha2Code.length != 2) {
errors.add(ValidationError("isoAlpha2Code", "ISO Alpha-2 code must be exactly 2 characters", "INVALID_LENGTH"))
} else if (!request.isoAlpha2Code.all { it.isLetter() }) {
errors.add(ValidationError("isoAlpha2Code", "ISO Alpha-2 code must contain only letters", "INVALID_FORMAT"))
}
// ISO Alpha-3 Code validation
if (request.isoAlpha3Code.isBlank()) {
errors.add(ValidationError("isoAlpha3Code", "ISO Alpha-3 code is required", "REQUIRED"))
} else if (request.isoAlpha3Code.length != 3) {
errors.add(ValidationError("isoAlpha3Code", "ISO Alpha-3 code must be exactly 3 characters", "INVALID_LENGTH"))
} else if (!request.isoAlpha3Code.all { it.isLetter() }) {
errors.add(ValidationError("isoAlpha3Code", "ISO Alpha-3 code must contain only letters", "INVALID_FORMAT"))
}
// German name validation
if (request.nameDeutsch.isBlank()) {
errors.add(ValidationError("nameDeutsch", "German name is required", "REQUIRED"))
} else if (request.nameDeutsch.length > 100) {
errors.add(ValidationError("nameDeutsch", "German name must not exceed 100 characters", "MAX_LENGTH"))
}
// English name validation
request.nameEnglisch?.let { name ->
if (name.length > 100) {
errors.add(ValidationError("nameEnglisch", "English name must not exceed 100 characters", "MAX_LENGTH"))
}
}
// Sorting order validation
request.sortierReihenfolge?.let { order ->
if (order < 0) {
errors.add(ValidationError("sortierReihenfolge", "Sorting order must be non-negative", "INVALID_VALUE"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Validates an update country request.
*/
private fun validateUpdateRequest(request: UpdateCountryRequest): ValidationResult {
// 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 {
val errors = mutableListOf<ValidationError>()
if (landRepository.existsByIsoAlpha2Code(isoAlpha2Code.uppercase())) {
errors.add(ValidationError("isoAlpha2Code", "Country with ISO Alpha-2 code '${isoAlpha2Code.uppercase()}' already exists", "DUPLICATE"))
}
if (landRepository.existsByIsoAlpha3Code(isoAlpha3Code.uppercase())) {
errors.add(ValidationError("isoAlpha3Code", "Country with ISO Alpha-3 code '${isoAlpha3Code.uppercase()}' already exists", "DUPLICATE"))
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Checks for duplicate ISO codes excluding a specific country ID.
*/
private suspend fun checkForDuplicatesExcluding(
isoAlpha2Code: String,
isoAlpha3Code: String,
excludeId: Uuid
): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Check Alpha-2 code
val existingAlpha2 = landRepository.findByIsoAlpha2Code(isoAlpha2Code.uppercase())
if (existingAlpha2 != null && existingAlpha2.landId != excludeId) {
errors.add(ValidationError("isoAlpha2Code", "Country with ISO Alpha-2 code '${isoAlpha2Code.uppercase()}' already exists", "DUPLICATE"))
}
// Check Alpha-3 code
val existingAlpha3 = landRepository.findByIsoAlpha3Code(isoAlpha3Code.uppercase())
if (existingAlpha3 != null && existingAlpha3.landId != excludeId) {
errors.add(ValidationError("isoAlpha3Code", "Country with ISO Alpha-3 code '${isoAlpha3Code.uppercase()}' already exists", "DUPLICATE"))
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
}
@@ -0,0 +1,455 @@
package at.mocode.masterdata.application.usecase
import at.mocode.core.domain.model.PlatzTypE
import at.mocode.masterdata.domain.model.Platz
import at.mocode.masterdata.domain.repository.PlatzRepository
import at.mocode.core.domain.model.ValidationResult
import at.mocode.core.domain.model.ValidationError
import com.benasher44.uuid.Uuid
import kotlinx.datetime.Clock
/**
* Use case for creating and updating venue/arena information.
*
* This use case encapsulates the business logic for venue management
* including validation, duplicate checking, and persistence.
*/
class CreatePlatzUseCase(
private val platzRepository: PlatzRepository
) {
/**
* Request data for creating a new venue.
*/
data class CreatePlatzRequest(
val turnierId: Uuid,
val name: String,
val dimension: String? = null,
val boden: String? = null,
val typ: PlatzTypE,
val istAktiv: Boolean = true,
val sortierReihenfolge: Int? = null
)
/**
* Request data for updating an existing venue.
*/
data class UpdatePlatzRequest(
val platzId: Uuid,
val turnierId: Uuid,
val name: String,
val dimension: String? = null,
val boden: String? = null,
val typ: PlatzTypE,
val istAktiv: Boolean = true,
val sortierReihenfolge: Int? = null
)
/**
* Response data for venue creation.
*/
data class CreatePlatzResponse(
val platz: Platz?,
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Response data for venue update.
*/
data class UpdatePlatzResponse(
val platz: Platz?,
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Response data for venue deletion.
*/
data class DeletePlatzResponse(
val success: Boolean,
val errors: List<String> = emptyList()
)
/**
* Creates a new venue after validation.
*
* @param request The venue creation request
* @return CreatePlatzResponse with the created venue or validation errors
*/
suspend fun createPlatz(request: CreatePlatzRequest): CreatePlatzResponse {
// Validate the request
val validationResult = validateCreateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors.map { it.message }
return CreatePlatzResponse(
platz = null,
success = false,
errors = errors
)
}
// Check for duplicates
val duplicateCheck = checkForDuplicates(request.name, request.turnierId)
if (!duplicateCheck.isValid()) {
val errors = (duplicateCheck as ValidationResult.Invalid).errors.map { it.message }
return CreatePlatzResponse(
platz = null,
success = false,
errors = errors
)
}
// Create the domain object
val now = Clock.System.now()
val platz = Platz(
turnierId = request.turnierId,
name = request.name.trim(),
dimension = request.dimension?.trim(),
boden = request.boden?.trim(),
typ = request.typ,
istAktiv = request.istAktiv,
sortierReihenfolge = request.sortierReihenfolge,
createdAt = now,
updatedAt = now
)
// Save to repository
val savedPlatz = platzRepository.save(platz)
return CreatePlatzResponse(
platz = savedPlatz,
success = true
)
}
/**
* Updates an existing venue after validation.
*
* @param request The venue update request
* @return UpdatePlatzResponse containing the updated venue or validation errors
*/
suspend fun updatePlatz(request: UpdatePlatzRequest): UpdatePlatzResponse {
// Check if venue exists
val existingPlatz = platzRepository.findById(request.platzId)
if (existingPlatz == null) {
return UpdatePlatzResponse(
platz = null,
success = false,
errors = listOf("Venue with ID ${request.platzId} not found")
)
}
// Validate the request
val validationResult = validateUpdateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors.map { it.message }
return UpdatePlatzResponse(
platz = null,
success = false,
errors = errors
)
}
// Check for duplicates (excluding current venue)
val duplicateCheck = checkForDuplicatesExcluding(request.name, request.turnierId, request.platzId)
if (!duplicateCheck.isValid()) {
val errors = (duplicateCheck as ValidationResult.Invalid).errors.map { it.message }
return UpdatePlatzResponse(
platz = null,
success = false,
errors = errors
)
}
// Update the domain object
val updatedPlatz = existingPlatz.copy(
turnierId = request.turnierId,
name = request.name.trim(),
dimension = request.dimension?.trim(),
boden = request.boden?.trim(),
typ = request.typ,
istAktiv = request.istAktiv,
sortierReihenfolge = request.sortierReihenfolge,
updatedAt = Clock.System.now()
)
// Save to repository
val savedPlatz = platzRepository.save(updatedPlatz)
return UpdatePlatzResponse(
platz = savedPlatz,
success = true
)
}
/**
* Deletes a venue by ID.
*
* @param platzId The unique identifier of the venue to delete
* @return DeletePlatzResponse indicating success or failure
*/
suspend fun deletePlatz(platzId: Uuid): DeletePlatzResponse {
val deleted = platzRepository.delete(platzId)
return if (deleted) {
DeletePlatzResponse(success = true)
} else {
DeletePlatzResponse(
success = false,
errors = listOf("Venue with ID $platzId not found or could not be deleted")
)
}
}
/**
* Validates a create venue request.
*/
private fun validateCreateRequest(request: CreatePlatzRequest): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Name validation
if (request.name.isBlank()) {
errors.add(ValidationError("name", "Name is required", "REQUIRED"))
} else if (request.name.length > 200) {
errors.add(ValidationError("name", "Name must not exceed 200 characters", "MAX_LENGTH"))
}
// Dimension validation
request.dimension?.let { dimension ->
if (dimension.isBlank()) {
errors.add(ValidationError("dimension", "Dimension cannot be empty if provided", "INVALID_FORMAT"))
} else if (dimension.length > 50) {
errors.add(ValidationError("dimension", "Dimension must not exceed 50 characters", "MAX_LENGTH"))
} else if (!dimension.matches(Regex("^\\d+x\\d+m?$"))) {
errors.add(ValidationError("dimension", "Dimension must be in format like '20x60m' or '20x40'", "INVALID_FORMAT"))
}
}
// Ground type validation
request.boden?.let { boden ->
if (boden.isBlank()) {
errors.add(ValidationError("boden", "Ground type cannot be empty if provided", "INVALID_FORMAT"))
} else if (boden.length > 100) {
errors.add(ValidationError("boden", "Ground type must not exceed 100 characters", "MAX_LENGTH"))
}
}
// Sorting order validation
request.sortierReihenfolge?.let { order ->
if (order < 0) {
errors.add(ValidationError("sortierReihenfolge", "Sorting order must be non-negative", "INVALID_VALUE"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Validates an update venue request.
*/
private fun validateUpdateRequest(request: UpdatePlatzRequest): ValidationResult {
// Use the same validation logic as create request
val createRequest = CreatePlatzRequest(
turnierId = request.turnierId,
name = request.name,
dimension = request.dimension,
boden = request.boden,
typ = request.typ,
istAktiv = request.istAktiv,
sortierReihenfolge = request.sortierReihenfolge
)
return validateCreateRequest(createRequest)
}
/**
* Checks for duplicate venue names within a tournament.
*/
private suspend fun checkForDuplicates(name: String, turnierId: Uuid): ValidationResult {
val errors = mutableListOf<ValidationError>()
if (platzRepository.existsByNameAndTournament(name.trim(), turnierId)) {
errors.add(ValidationError("name", "Venue with name '$name' already exists for this tournament", "DUPLICATE"))
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Checks for duplicate venue names excluding a specific venue ID.
*/
private suspend fun checkForDuplicatesExcluding(name: String, turnierId: Uuid, excludeId: Uuid): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Get all venues with the same name and tournament
val existingVenues = platzRepository.findByName(name.trim(), turnierId, 10)
val duplicateExists = existingVenues.any { it.id != excludeId }
if (duplicateExists) {
errors.add(ValidationError("name", "Venue with name '$name' already exists for this tournament", "DUPLICATE"))
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Validates venue configuration for specific discipline requirements.
* This is a business logic method that can be used by other parts of the application.
*
* @param platzId The venue ID
* @param requiredType The required venue type for the discipline
* @param requiredDimensions Optional required dimensions
* @param requiredGroundType Optional required ground type
* @return ValidationResult indicating suitability or reasons for unsuitability
*/
suspend fun validateVenueForDiscipline(
platzId: Uuid,
requiredType: PlatzTypE,
requiredDimensions: String? = null,
requiredGroundType: String? = null
): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Get the venue
val platz = platzRepository.findById(platzId)
if (platz == null) {
errors.add(ValidationError("platzId", "Venue not found", "NOT_FOUND"))
return ValidationResult.Invalid(errors)
}
// Check if venue is active
if (!platz.istAktiv) {
errors.add(ValidationError("platz", "Venue is not active", "INACTIVE"))
}
// Check venue type
if (platz.typ != requiredType) {
errors.add(ValidationError("typ", "Venue type ${platz.typ} does not match required type $requiredType", "TYPE_MISMATCH"))
}
// Check dimensions if required
requiredDimensions?.let { required ->
if (platz.dimension != required.trim()) {
errors.add(ValidationError("dimension", "Venue dimensions '${platz.dimension}' do not match required dimensions '$required'", "DIMENSION_MISMATCH"))
}
}
// Check ground type if required
requiredGroundType?.let { required ->
if (platz.boden != required.trim()) {
errors.add(ValidationError("boden", "Venue ground type '${platz.boden}' does not match required ground type '$required'", "GROUND_TYPE_MISMATCH"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Creates multiple venues for a tournament in batch.
* This is a convenience method for setting up tournament venues efficiently.
*
* @param turnierId The tournament ID
* @param venueRequests List of venue creation requests
* @return List of creation responses for each venue
*/
suspend fun createMultipleVenues(turnierId: Uuid, venueRequests: List<CreatePlatzRequest>): List<CreatePlatzResponse> {
val responses = mutableListOf<CreatePlatzResponse>()
for (request in venueRequests) {
// Ensure all requests are for the same tournament
val adjustedRequest = request.copy(turnierId = turnierId)
val response = createPlatz(adjustedRequest)
responses.add(response)
}
return responses
}
/**
* Validates venue capacity and setup for tournament requirements.
* This method performs comprehensive checks for tournament venue setup.
*
* @param turnierId The tournament ID
* @param requiredVenueTypes Map of venue type to minimum count required
* @return ValidationResult indicating if the tournament has adequate venue setup
*/
suspend fun validateTournamentVenueSetup(
turnierId: Uuid,
requiredVenueTypes: Map<PlatzTypE, Int>
): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Get all active venues for the tournament
val venues = platzRepository.findByTournament(turnierId, activeOnly = true, orderBySortierung = false)
val venuesByType = venues.groupBy { it.typ }
// Check if each required venue type has sufficient count
for ((requiredType, requiredCount) in requiredVenueTypes) {
val availableCount = venuesByType[requiredType]?.size ?: 0
if (availableCount < requiredCount) {
errors.add(ValidationError(
"venues",
"Tournament requires $requiredCount venues of type $requiredType but only has $availableCount",
"INSUFFICIENT_VENUES"
))
}
}
// Check if tournament has any venues at all
if (venues.isEmpty()) {
errors.add(ValidationError("venues", "Tournament has no active venues configured", "NO_VENUES"))
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
/**
* Optimizes venue sorting order for a tournament.
* This method automatically assigns sorting orders based on venue type and name.
*
* @param turnierId The tournament ID
* @return Number of venues updated
*/
suspend fun optimizeVenueSorting(turnierId: Uuid): Int {
val venues = platzRepository.findByTournament(turnierId, activeOnly = false, orderBySortierung = false)
// Sort venues by type first, then by name
val sortedVenues = venues.sortedWith(compareBy<Platz> { it.typ.ordinal }.thenBy { it.name })
var updatedCount = 0
// Assign new sorting orders
sortedVenues.forEachIndexed { index, venue ->
val newSortOrder = (index + 1) * 10 // Leave gaps for future insertions
if (venue.sortierReihenfolge != newSortOrder) {
val updatedVenue = venue.copy(
sortierReihenfolge = newSortOrder,
updatedAt = Clock.System.now()
)
platzRepository.save(updatedVenue)
updatedCount++
}
}
return updatedCount
}
}
@@ -0,0 +1,185 @@
package at.mocode.masterdata.application.usecase
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.AltersklasseDefinition
import at.mocode.masterdata.domain.repository.AltersklasseRepository
import com.benasher44.uuid.Uuid
/**
* Use case for retrieving age class information.
*
* This use case encapsulates the business logic for fetching age class data
* and provides a clean interface for the application layer.
*/
class GetAltersklasseUseCase(
private val altersklasseRepository: AltersklasseRepository
) {
/**
* Retrieves an age class by its unique ID.
*
* @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)
}
/**
* Retrieves 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 getByCode(altersklasseCode: String): AltersklasseDefinition? {
require(altersklasseCode.isNotBlank()) { "Age class code cannot be blank" }
return altersklasseRepository.findByCode(altersklasseCode.trim().uppercase())
}
/**
* Searches for 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 (default: 50)
* @return List of matching age classes
*/
suspend fun searchByName(searchTerm: String, limit: Int = 50): List<AltersklasseDefinition> {
require(searchTerm.isNotBlank()) { "Search term cannot be blank" }
require(limit > 0) { "Limit must be positive" }
return altersklasseRepository.findByName(searchTerm.trim(), limit)
}
/**
* Retrieves 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 getAllActive(sparteFilter: SparteE? = null, geschlechtFilter: Char? = null): List<AltersklasseDefinition> {
geschlechtFilter?.let { gender ->
require(gender == 'M' || gender == 'W') { "Gender filter must be 'M' or 'W'" }
}
return altersklasseRepository.findAllActive(sparteFilter, geschlechtFilter)
}
/**
* 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 getApplicableForAge(age: Int, sparteFilter: SparteE? = null, geschlechtFilter: Char? = null): List<AltersklasseDefinition> {
require(age >= 0) { "Age must be non-negative" }
geschlechtFilter?.let { gender ->
require(gender == 'M' || gender == 'W') { "Gender filter must be 'M' or 'W'" }
}
return altersklasseRepository.findApplicableForAge(age, sparteFilter, geschlechtFilter)
}
/**
* Retrieves age classes by sport type.
*
* @param sparte The sport type
* @param activeOnly Whether to return only active age classes (default: true)
* @return List of age classes for the sport type
*/
suspend fun getBySparte(sparte: SparteE, activeOnly: Boolean = true): List<AltersklasseDefinition> {
return altersklasseRepository.findBySparte(sparte, activeOnly)
}
/**
* Retrieves age classes by gender filter.
*
* @param geschlecht The gender ('M', 'W')
* @param activeOnly Whether to return only active age classes (default: true)
* @return List of age classes for the gender
*/
suspend fun getByGeschlecht(geschlecht: Char, activeOnly: Boolean = true): List<AltersklasseDefinition> {
require(geschlecht == 'M' || geschlecht == 'W') { "Gender must be 'M' or 'W'" }
return altersklasseRepository.findByGeschlecht(geschlecht, activeOnly)
}
/**
* Retrieves age classes by age range.
*
* @param minAge Minimum age (inclusive)
* @param maxAge Maximum age (inclusive)
* @param activeOnly Whether to return only active age classes (default: true)
* @return List of age classes within the age range
*/
suspend fun getByAgeRange(minAge: Int?, maxAge: Int?, activeOnly: Boolean = true): List<AltersklasseDefinition> {
minAge?.let { min ->
require(min >= 0) { "Minimum age must be non-negative" }
}
maxAge?.let { max ->
require(max >= 0) { "Maximum age must be non-negative" }
minAge?.let { min ->
require(max >= min) { "Maximum age must be greater than or equal to minimum age" }
}
}
return altersklasseRepository.findByAgeRange(minAge, maxAge, activeOnly)
}
/**
* Retrieves age classes by OETO rule reference.
*
* @param oetoRegelReferenzId The OETO rule reference ID
* @return List of age classes linked to the rule
*/
suspend fun getByOetoRegelReferenz(oetoRegelReferenzId: Uuid): List<AltersklasseDefinition> {
return altersklasseRepository.findByOetoRegelReferenz(oetoRegelReferenzId)
}
/**
* 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 {
require(altersklasseCode.isNotBlank()) { "Age class code cannot be blank" }
return altersklasseRepository.existsByCode(altersklasseCode.trim().uppercase())
}
/**
* 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 {
return altersklasseRepository.countActive(sparteFilter)
}
/**
* 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 {
require(age >= 0) { "Age must be non-negative" }
require(geschlecht == 'M' || geschlecht == 'W') { "Gender must be 'M' or 'W'" }
return altersklasseRepository.isEligible(altersklasseId, age, geschlecht)
}
/**
* Retrieves age classes suitable for a participant based on age, gender, and sport.
* This is a convenience method that combines multiple filters.
*
* @param age The participant's age
* @param geschlecht The participant's gender ('M', 'W')
* @param sparte The sport type
* @return List of suitable age classes
*/
suspend fun getSuitableForParticipant(age: Int, geschlecht: Char, sparte: SparteE): List<AltersklasseDefinition> {
require(age >= 0) { "Age must be non-negative" }
require(geschlecht == 'M' || geschlecht == 'W') { "Gender must be 'M' or 'W'" }
return altersklasseRepository.findApplicableForAge(age, sparte, geschlecht)
}
}
@@ -0,0 +1,118 @@
package at.mocode.masterdata.application.usecase
import at.mocode.masterdata.domain.model.BundeslandDefinition
import at.mocode.masterdata.domain.repository.BundeslandRepository
import com.benasher44.uuid.Uuid
/**
* Use case for retrieving federal state information.
*
* This use case encapsulates the business logic for fetching federal state data
* and provides a clean interface for the application layer.
*/
class GetBundeslandUseCase(
private val bundeslandRepository: BundeslandRepository
) {
/**
* Retrieves a federal state by its unique ID.
*
* @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)
}
/**
* Retrieves a federal state by its OEPS code for a specific country.
*
* @param oepsCode The OEPS code (e.g., "01", "02")
* @param landId The country ID
* @return The federal state if found, null otherwise
*/
suspend fun getByOepsCode(oepsCode: String, landId: Uuid): BundeslandDefinition? {
require(oepsCode.isNotBlank()) { "OEPS code cannot be blank" }
return bundeslandRepository.findByOepsCode(oepsCode.trim(), landId)
}
/**
* Retrieves a federal state by its ISO 3166-2 code.
*
* @param iso3166_2_Code The ISO 3166-2 code (e.g., "AT-1", "DE-BY")
* @return The federal state if found, null otherwise
*/
suspend fun getByIso3166_2_Code(iso3166_2_Code: String): BundeslandDefinition? {
require(iso3166_2_Code.isNotBlank()) { "ISO 3166-2 code cannot be blank" }
return bundeslandRepository.findByIso3166_2_Code(iso3166_2_Code.trim().uppercase())
}
/**
* Retrieves all federal states for a specific country.
*
* @param landId The country ID
* @param activeOnly Whether to return only active federal states (default: true)
* @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)
}
/**
* Retrieves all active federal states.
*
* @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)
}
/**
* Checks if a federal state with the given OEPS code exists for a country.
*
* @param oepsCode The OEPS code to check
* @param landId The country ID
* @return true if a federal state with this code exists, false otherwise
*/
suspend fun existsByOepsCode(oepsCode: String, landId: Uuid): Boolean {
require(oepsCode.isNotBlank()) { "OEPS code cannot be blank" }
return bundeslandRepository.existsByOepsCode(oepsCode.trim(), landId)
}
/**
* Checks if a federal state with the given ISO 3166-2 code exists.
*
* @param iso3166_2_Code The ISO 3166-2 code to check
* @return true if a federal state with this code exists, false otherwise
*/
suspend fun existsByIso3166_2_Code(iso3166_2_Code: String): Boolean {
require(iso3166_2_Code.isNotBlank()) { "ISO 3166-2 code cannot be blank" }
return bundeslandRepository.existsByIso3166_2_Code(iso3166_2_Code.trim().uppercase())
}
/**
* Counts the total number of active federal states for a country.
*
* @param landId The country ID
* @return The total count of active federal states
*/
suspend fun countActiveByCountry(landId: Uuid): Long {
return bundeslandRepository.countActiveByCountry(landId)
}
}
@@ -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,275 @@
package at.mocode.masterdata.application.usecase
import at.mocode.core.domain.model.PlatzTypE
import at.mocode.masterdata.domain.model.Platz
import at.mocode.masterdata.domain.repository.PlatzRepository
import com.benasher44.uuid.Uuid
/**
* Use case for retrieving venue/arena information.
*
* This use case encapsulates the business logic for fetching venue data
* and provides a clean interface for the application layer.
*/
class GetPlatzUseCase(
private val platzRepository: PlatzRepository
) {
/**
* Retrieves a venue by its unique ID.
*
* @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)
}
/**
* Retrieves all venues for a specific tournament.
*
* @param turnierId The tournament ID
* @param activeOnly Whether to return only active venues (default: true)
* @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)
}
/**
* Searches for venues by name (partial match).
*
* @param searchTerm The search term to match against venue names
* @param turnierId Optional tournament ID to limit search
* @param limit Maximum number of results to return (default: 50)
* @return List of matching venues
*/
suspend fun searchByName(searchTerm: String, turnierId: Uuid? = null, limit: Int = 50): List<Platz> {
require(searchTerm.isNotBlank()) { "Search term cannot be blank" }
require(limit > 0) { "Limit must be positive" }
return platzRepository.findByName(searchTerm.trim(), turnierId, limit)
}
/**
* Retrieves venues by type.
*
* @param typ The venue type
* @param turnierId Optional tournament ID to limit search
* @param activeOnly Whether to return only active venues (default: true)
* @return List of venues of the specified type
*/
suspend fun getByType(typ: PlatzTypE, turnierId: Uuid? = null, activeOnly: Boolean = true): List<Platz> {
return platzRepository.findByType(typ, turnierId, activeOnly)
}
/**
* Retrieves venues by ground type.
*
* @param boden The ground type (e.g., "Sand", "Gras", "Kunststoff")
* @param turnierId Optional tournament ID to limit search
* @param activeOnly Whether to return only active venues (default: true)
* @return List of venues with the specified ground type
*/
suspend fun getByGroundType(boden: String, turnierId: Uuid? = null, activeOnly: Boolean = true): List<Platz> {
require(boden.isNotBlank()) { "Ground type cannot be blank" }
return platzRepository.findByGroundType(boden.trim(), turnierId, activeOnly)
}
/**
* Retrieves venues by dimensions.
*
* @param dimension The venue dimensions (e.g., "20x60m", "20x40m")
* @param turnierId Optional tournament ID to limit search
* @param activeOnly Whether to return only active venues (default: true)
* @return List of venues with the specified dimensions
*/
suspend fun getByDimensions(dimension: String, turnierId: Uuid? = null, activeOnly: Boolean = true): List<Platz> {
require(dimension.isNotBlank()) { "Dimension cannot be blank" }
return platzRepository.findByDimensions(dimension.trim(), turnierId, activeOnly)
}
/**
* Retrieves all active venues.
*
* @param orderBySortierung Whether to order by sortierReihenfolge field (default: true)
* @return List of active venues
*/
suspend fun getAllActive(orderBySortierung: Boolean = true): List<Platz> {
return platzRepository.findAllActive(orderBySortierung)
}
/**
* Finds venues suitable for a specific discipline based on type and dimensions.
*
* @param requiredType The required venue type
* @param requiredDimensions Optional required dimensions
* @param turnierId Optional tournament ID to limit search
* @return List of suitable venues
*/
suspend fun getSuitableForDiscipline(
requiredType: PlatzTypE,
requiredDimensions: String? = null,
turnierId: Uuid? = null
): List<Platz> {
requiredDimensions?.let { dimensions ->
require(dimensions.isNotBlank()) { "Required dimensions cannot be blank if provided" }
}
return platzRepository.findSuitableForDiscipline(requiredType, requiredDimensions?.trim(), turnierId)
}
/**
* Checks if a venue with the given name exists for a tournament.
*
* @param name The venue name to check
* @param turnierId The tournament ID
* @return true if a venue with this name exists, false otherwise
*/
suspend fun existsByNameAndTournament(name: String, turnierId: Uuid): Boolean {
require(name.isNotBlank()) { "Venue name cannot be blank" }
return platzRepository.existsByNameAndTournament(name.trim(), turnierId)
}
/**
* Counts the total number of active venues for a tournament.
*
* @param turnierId The tournament ID
* @return The total count of active venues
*/
suspend fun countActiveByTournament(turnierId: Uuid): Long {
return platzRepository.countActiveByTournament(turnierId)
}
/**
* Counts venues by type for a tournament.
*
* @param typ The venue type
* @param turnierId The tournament ID
* @param activeOnly Whether to count only active venues (default: true)
* @return The count of venues of the specified type
*/
suspend fun countByTypeAndTournament(typ: PlatzTypE, turnierId: Uuid, activeOnly: Boolean = true): Long {
return platzRepository.countByTypeAndTournament(typ, turnierId, activeOnly)
}
/**
* Finds available venues for a specific time slot.
* This method can be extended when venue scheduling functionality is added.
*
* @param turnierId The tournament ID
* @param startTime The start time (placeholder for future scheduling feature)
* @param endTime The end time (placeholder for future scheduling feature)
* @return List of available venues (currently returns all active venues)
*/
suspend fun getAvailableForTimeSlot(turnierId: Uuid, startTime: String? = null, endTime: String? = null): List<Platz> {
return platzRepository.findAvailableForTimeSlot(turnierId, startTime, endTime)
}
/**
* Retrieves venues grouped by type for a tournament.
* This is a convenience method that provides venues organized by their type.
*
* @param turnierId The tournament ID
* @param activeOnly Whether to include only active venues (default: true)
* @return Map of venue type to list of venues
*/
suspend fun getGroupedByTypeForTournament(turnierId: Uuid, activeOnly: Boolean = true): Map<PlatzTypE, List<Platz>> {
val venues = platzRepository.findByTournament(turnierId, activeOnly, true)
return venues.groupBy { it.typ }
}
/**
* Retrieves venues with specific characteristics for discipline matching.
* This method combines multiple filters to find venues suitable for specific disciplines.
*
* @param turnierId The tournament ID
* @param requiredType The required venue type
* @param preferredDimensions Preferred dimensions (optional)
* @param preferredGroundType Preferred ground type (optional)
* @param activeOnly Whether to include only active venues (default: true)
* @return List of venues matching the criteria, sorted by preference
*/
suspend fun getForDisciplineRequirements(
turnierId: Uuid,
requiredType: PlatzTypE,
preferredDimensions: String? = null,
preferredGroundType: String? = null,
activeOnly: Boolean = true
): List<Platz> {
// Start with venues of the required type
val typeMatches = platzRepository.findByType(requiredType, turnierId, activeOnly)
// If no specific preferences, return all type matches
if (preferredDimensions == null && preferredGroundType == null) {
return typeMatches
}
// Filter and sort by preferences
val exactMatches = mutableListOf<Platz>()
val partialMatches = mutableListOf<Platz>()
val otherMatches = mutableListOf<Platz>()
for (venue in typeMatches) {
val dimensionMatch = preferredDimensions == null || venue.dimension == preferredDimensions.trim()
val groundMatch = preferredGroundType == null || venue.boden == preferredGroundType.trim()
when {
dimensionMatch && groundMatch -> exactMatches.add(venue)
dimensionMatch || groundMatch -> partialMatches.add(venue)
else -> otherMatches.add(venue)
}
}
// Return sorted by preference: exact matches first, then partial, then others
return exactMatches + partialMatches + otherMatches
}
/**
* Validates venue availability and suitability for a specific use case.
* This method performs comprehensive checks for venue usage.
*
* @param platzId The venue ID
* @param requiredType Optional required venue type
* @param requiredDimensions Optional required dimensions
* @param requiredGroundType Optional required ground type
* @return Pair of (isValid, reasons) where reasons contains any validation issues
*/
suspend fun validateVenueSuitability(
platzId: Uuid,
requiredType: PlatzTypE? = null,
requiredDimensions: String? = null,
requiredGroundType: String? = null
): Pair<Boolean, List<String>> {
val venue = platzRepository.findById(platzId)
val issues = mutableListOf<String>()
if (venue == null) {
issues.add("Venue not found")
return Pair(false, issues)
}
if (!venue.istAktiv) {
issues.add("Venue is not active")
}
requiredType?.let { type ->
if (venue.typ != type) {
issues.add("Venue type ${venue.typ} does not match required type $type")
}
}
requiredDimensions?.let { dimensions ->
if (venue.dimension != dimensions.trim()) {
issues.add("Venue dimensions '${venue.dimension}' do not match required dimensions '$dimensions'")
}
}
requiredGroundType?.let { groundType ->
if (venue.boden != groundType.trim()) {
issues.add("Venue ground type '${venue.boden}' does not match required ground type '$groundType'")
}
}
return Pair(issues.isEmpty(), issues)
}
}
@@ -0,0 +1,27 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvm()
js(IR) {
browser()
}
sourceSets {
val commonMain by getting {
dependencies {
// KORREKTUR: Diese zwei Zeilen hinzufügen
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation(projects.platform.platformTesting)
}
}
}
}
@@ -0,0 +1,59 @@
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.serialization.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.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.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.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.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,48 @@
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.UuidSerializer
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
/**
* Definiert einen Turnierplatz oder eine Wettkampfstätte.
*
* Diese Entität repräsentiert die verschiedenen Plätze und Arenen, die bei Turnieren
* für verschiedene Disziplinen verwendet werden können.
*
* @property id Eindeutiger interner Identifikator für diesen Platz (UUID).
* @property turnierId Fremdschlüssel zum Turnier, zu dem dieser Platz gehört.
* @property name Der Name oder die Bezeichnung des Platzes (z.B. "Hauptplatz", "Dressurplatz A").
* @property dimension Die Abmessungen des Platzes (z.B. "20x60m", "20x40m").
* @property boden Die Art des Bodenbelags (z.B. "Sand", "Gras", "Kunststoff").
* @property typ Der Typ des Platzes (siehe PlatzTypE enum).
* @property istAktiv Gibt an, ob dieser Platz aktuell verwendet werden kann.
* @property sortierReihenfolge Optionale Zahl zur Steuerung der Sortierreihenfolge.
* @property createdAt Zeitstempel der Erstellung dieses Datensatzes.
* @property updatedAt Zeitstempel der letzten Aktualisierung dieses Datensatzes.
*/
@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? = null,
var boden: String? = null,
var typ: PlatzTypE,
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,138 @@
package at.mocode.masterdata.domain.repository
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.AltersklasseDefinition
import com.benasher44.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
}
@@ -0,0 +1,109 @@
package at.mocode.masterdata.domain.repository
import at.mocode.masterdata.domain.model.BundeslandDefinition
import com.benasher44.uuid.Uuid
/**
* Repository interface for BundeslandDefinition (Federal State) domain operations.
*
* This interface defines the contract for federal state 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 BundeslandRepository {
/**
* Finds a federal state by its unique ID.
*
* @param id The unique identifier of the federal state
* @return The federal state if found, null otherwise
*/
suspend fun findById(id: Uuid): BundeslandDefinition?
/**
* Finds a federal state by its OEPS code.
*
* @param oepsCode The OEPS code (e.g., "01", "02")
* @param landId The country ID to search within
* @return The federal state if found, null otherwise
*/
suspend fun findByOepsCode(oepsCode: String, landId: Uuid): BundeslandDefinition?
/**
* Finds a federal state by its ISO 3166-2 code.
*
* @param iso3166_2_Code The ISO 3166-2 code (e.g., "AT-1", "DE-BY")
* @return The federal state if found, null otherwise
*/
suspend fun findByIso3166_2_Code(iso3166_2_Code: String): BundeslandDefinition?
/**
* Finds all federal states for a specific country.
*
* @param landId The country ID
* @param activeOnly Whether to return only active federal states
* @param orderBySortierung Whether to order by sortierReihenfolge field
* @return List of federal states for the country
*/
suspend fun findByCountry(landId: Uuid, activeOnly: Boolean = true, orderBySortierung: Boolean = true): List<BundeslandDefinition>
/**
* Finds 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
* @return List of matching federal states
*/
suspend fun findByName(searchTerm: String, landId: Uuid? = null, limit: Int = 50): List<BundeslandDefinition>
/**
* Finds all active federal states.
*
* @param orderBySortierung Whether to order by sortierReihenfolge field
* @return List of active federal states
*/
suspend fun findAllActive(orderBySortierung: Boolean = true): List<BundeslandDefinition>
/**
* Saves a federal state (create or update).
*
* @param bundesland The federal state to save
* @return The saved federal state with updated timestamps
*/
suspend fun save(bundesland: BundeslandDefinition): BundeslandDefinition
/**
* Deletes a federal state by ID.
*
* @param id The unique identifier of the federal state to delete
* @return true if the federal state was deleted, false if not found
*/
suspend fun delete(id: Uuid): Boolean
/**
* Checks if a federal state with the given OEPS code exists for a country.
*
* @param oepsCode The OEPS code to check
* @param landId The country ID
* @return true if a federal state with this code exists, false otherwise
*/
suspend fun existsByOepsCode(oepsCode: String, landId: Uuid): Boolean
/**
* Checks if a federal state with the given ISO 3166-2 code exists.
*
* @param iso3166_2_Code The ISO 3166-2 code to check
* @return true if a federal state with this code exists, false otherwise
*/
suspend fun existsByIso3166_2_Code(iso3166_2_Code: String): Boolean
/**
* Counts the total number of active federal states for a country.
*
* @param landId The country ID
* @return The total count of active federal states
*/
suspend fun countActiveByCountry(landId: Uuid): Long
}
@@ -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,150 @@
package at.mocode.masterdata.domain.repository
import at.mocode.core.domain.model.PlatzTypE
import at.mocode.masterdata.domain.model.Platz
import com.benasher44.uuid.Uuid
/**
* Repository interface for Platz (Venue/Arena) domain operations.
*
* This interface defines the contract for venue/arena 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 PlatzRepository {
/**
* Finds a venue by its unique ID.
*
* @param id The unique identifier of the venue
* @return The venue if found, null otherwise
*/
suspend fun findById(id: Uuid): Platz?
/**
* Finds all venues for a specific tournament.
*
* @param turnierId The tournament ID
* @param activeOnly Whether to return only active venues
* @param orderBySortierung Whether to order by sortierReihenfolge field
* @return List of venues for the tournament
*/
suspend fun findByTournament(turnierId: Uuid, activeOnly: Boolean = true, orderBySortierung: Boolean = true): List<Platz>
/**
* Finds venues by name (partial match).
*
* @param searchTerm The search term to match against venue names
* @param turnierId Optional tournament ID to limit search
* @param limit Maximum number of results to return
* @return List of matching venues
*/
suspend fun findByName(searchTerm: String, turnierId: Uuid? = null, limit: Int = 50): List<Platz>
/**
* Finds venues by type.
*
* @param typ The venue type
* @param turnierId Optional tournament ID to limit search
* @param activeOnly Whether to return only active venues
* @return List of venues of the specified type
*/
suspend fun findByType(typ: PlatzTypE, turnierId: Uuid? = null, activeOnly: Boolean = true): List<Platz>
/**
* Finds venues by ground type.
*
* @param boden The ground type (e.g., "Sand", "Gras", "Kunststoff")
* @param turnierId Optional tournament ID to limit search
* @param activeOnly Whether to return only active venues
* @return List of venues with the specified ground type
*/
suspend fun findByGroundType(boden: String, turnierId: Uuid? = null, activeOnly: Boolean = true): List<Platz>
/**
* Finds venues by dimensions.
*
* @param dimension The venue dimensions (e.g., "20x60m", "20x40m")
* @param turnierId Optional tournament ID to limit search
* @param activeOnly Whether to return only active venues
* @return List of venues with the specified dimensions
*/
suspend fun findByDimensions(dimension: String, turnierId: Uuid? = null, activeOnly: Boolean = true): List<Platz>
/**
* Finds all active venues.
*
* @param orderBySortierung Whether to order by sortierReihenfolge field
* @return List of active venues
*/
suspend fun findAllActive(orderBySortierung: Boolean = true): List<Platz>
/**
* Finds venues suitable for a specific discipline based on type and dimensions.
*
* @param requiredType The required venue type
* @param requiredDimensions Optional required dimensions
* @param turnierId Optional tournament ID to limit search
* @return List of suitable venues
*/
suspend fun findSuitableForDiscipline(
requiredType: PlatzTypE,
requiredDimensions: String? = null,
turnierId: Uuid? = null
): List<Platz>
/**
* Saves a venue (create or update).
*
* @param platz The venue to save
* @return The saved venue with updated timestamps
*/
suspend fun save(platz: Platz): Platz
/**
* Deletes a venue by ID.
*
* @param id The unique identifier of the venue to delete
* @return true if the venue was deleted, false if not found
*/
suspend fun delete(id: Uuid): Boolean
/**
* Checks if a venue with the given name exists for a tournament.
*
* @param name The venue name to check
* @param turnierId The tournament ID
* @return true if a venue with this name exists, false otherwise
*/
suspend fun existsByNameAndTournament(name: String, turnierId: Uuid): Boolean
/**
* Counts the total number of active venues for a tournament.
*
* @param turnierId The tournament ID
* @return The total count of active venues
*/
suspend fun countActiveByTournament(turnierId: Uuid): Long
/**
* Counts venues by type for a tournament.
*
* @param typ The venue type
* @param turnierId The tournament ID
* @param activeOnly Whether to count only active venues
* @return The count of venues of the specified type
*/
suspend fun countByTypeAndTournament(typ: PlatzTypE, turnierId: Uuid, activeOnly: Boolean = true): Long
/**
* Finds available venues for a specific time slot (if scheduling is implemented).
* This method can be extended when venue scheduling functionality is added.
*
* @param turnierId The tournament ID
* @param startTime The start time (placeholder for future scheduling feature)
* @param endTime The end time (placeholder for future scheduling feature)
* @return List of available venues (currently returns all active venues)
*/
suspend fun findAvailableForTimeSlot(turnierId: Uuid, startTime: String? = null, endTime: String? = null): List<Platz>
}
@@ -0,0 +1,28 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
// KORREKTUR: Dieses Plugin ist entscheidend. Es schaltet den `springBoot`-Block
// und alle Spring-Boot-spezifischen Gradle-Tasks frei.
alias(libs.plugins.spring.boot)
// Dependency Management für konsistente Spring-Versionen
alias(libs.plugins.spring.dependencyManagement)
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.masterdata.masterdataDomain)
implementation(projects.masterdata.masterdataApplication)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.infrastructure.cache.cacheApi)
implementation(projects.infrastructure.eventStore.eventStoreApi)
implementation(projects.infrastructure.messaging.messagingClient)
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.postgresql:postgresql")
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,239 @@
package at.mocode.masterdata.infrastructure.persistence
import at.mocode.core.domain.model.SparteE
import at.mocode.masterdata.domain.model.AltersklasseDefinition
import at.mocode.masterdata.domain.repository.AltersklasseRepository
import at.mocode.core.utils.database.DatabaseFactory
import com.benasher44.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)
)
}
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 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
query.andWhere {
(AltersklasseTable.minAlter.isNull() or (AltersklasseTable.minAlter lessEq age)) and
(AltersklasseTable.maxAlter.isNull() or (AltersklasseTable.maxAlter greaterEq age))
}
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 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 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)
}
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)
}
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)
}
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()
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)
}
}
altersklasse.copy(updatedAt = now)
}
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
AltersklasseTable.deleteWhere { AltersklasseTable.id eq id } > 0
}
override suspend fun existsByCode(altersklasseCode: String): Boolean = DatabaseFactory.dbQuery {
AltersklasseTable.selectAll().where { AltersklasseTable.altersklasseCode eq altersklasseCode }
.count() > 0
}
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 {
val altersklasse = AltersklasseTable.selectAll().where {
(AltersklasseTable.id eq altersklasseId) and (AltersklasseTable.istAktiv eq true)
}.singleOrNull()
if (altersklasse == null) return@dbQuery false
// 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
}
}
@@ -0,0 +1,36 @@
package at.mocode.masterdata.infrastructure.persistence
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
import org.jetbrains.exposed.sql.kotlin.datetime.CurrentDateTime
/**
* Exposed-Tabellendefinition für die Altersklasse-Entität (Altersklassendefinitionen).
*
* Diese Tabelle speichert alle Informationen zu Altersklassen für Teilnehmer
* entsprechend der AltersklasseDefinition Domain-Entität.
*/
object AltersklasseTable : Table("altersklasse") {
val id = uuid("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 = uuid("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)
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))
}
}
@@ -0,0 +1,157 @@
package at.mocode.masterdata.infrastructure.persistence
import at.mocode.masterdata.domain.model.BundeslandDefinition
import at.mocode.masterdata.domain.repository.BundeslandRepository
import at.mocode.core.utils.database.DatabaseFactory
import com.benasher44.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)
)
}
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 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()
}
}
@@ -0,0 +1,34 @@
package at.mocode.masterdata.infrastructure.persistence
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
import org.jetbrains.exposed.sql.kotlin.datetime.CurrentDateTime
/**
* 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.
*/
object BundeslandTable : Table("bundesland") {
val id = uuid("id").autoGenerate()
val landId = uuid("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)
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)
}
}
@@ -0,0 +1,153 @@
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 com.benasher44.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],
isoAlpha2Code = row[LandTable.isoAlpha2Code],
isoAlpha3Code = row[LandTable.isoAlpha3Code],
isoNumerischerCode = row[LandTable.isoNumerischerCode],
nameDeutsch = row[LandTable.nameDeutsch],
nameEnglisch = row[LandTable.nameEnglisch],
wappenUrl = row[LandTable.wappenUrl],
istEuMitglied = row[LandTable.istEuMitglied],
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)
)
}
override suspend fun findById(id: Uuid): LandDefinition? = DatabaseFactory.dbQuery {
LandTable.selectAll().where { LandTable.id eq id }
.map(::rowToLandDefinition)
.singleOrNull()
}
override suspend fun findByIsoAlpha2Code(isoAlpha2Code: String): LandDefinition? = DatabaseFactory.dbQuery {
LandTable.selectAll().where { LandTable.isoAlpha2Code eq isoAlpha2Code }
.map(::rowToLandDefinition)
.singleOrNull()
}
override suspend fun findByIsoAlpha3Code(isoAlpha3Code: String): LandDefinition? = DatabaseFactory.dbQuery {
LandTable.selectAll().where { LandTable.isoAlpha3Code eq isoAlpha3Code }
.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 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)
.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)
.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)
}
} 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)
}
}
land.copy(updatedAt = now)
}
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
LandTable.deleteWhere { LandTable.id eq id } > 0
}
override suspend fun existsByIsoAlpha2Code(isoAlpha2Code: String): Boolean = DatabaseFactory.dbQuery {
LandTable.selectAll().where { LandTable.isoAlpha2Code eq isoAlpha2Code }
.count() > 0
}
override suspend fun existsByIsoAlpha3Code(isoAlpha3Code: String): Boolean = DatabaseFactory.dbQuery {
LandTable.selectAll().where { LandTable.isoAlpha3Code eq isoAlpha3Code }
.count() > 0
}
override suspend fun countActive(): Long = DatabaseFactory.dbQuery {
LandTable.selectAll().where { LandTable.istAktiv eq true }.count()
}
}
@@ -0,0 +1,29 @@
package at.mocode.masterdata.infrastructure.persistence
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
import org.jetbrains.exposed.sql.kotlin.datetime.CurrentDateTime
/**
* Exposed-Tabellendefinition für die Land-Entität (Länderstammdaten).
*
* Diese Tabelle speichert alle Informationen zu Ländern/Nationen entsprechend
* der LandDefinition Domain-Entität.
*/
object LandTable : Table("land") {
val id = uuid("id").autoGenerate()
val isoAlpha2Code = varchar("iso_alpha2_code", 2).uniqueIndex()
val isoAlpha3Code = varchar("iso_alpha3_code", 3).uniqueIndex()
val isoNumerischerCode = varchar("iso_numerischer_code", 3).nullable()
val nameDeutsch = varchar("name_deutsch", 100)
val nameEnglisch = varchar("name_englisch", 100).nullable()
val wappenUrl = varchar("wappen_url", 500).nullable()
val istEuMitglied = bool("ist_eu_mitglied").nullable()
val istEwrMitglied = bool("ist_ewr_mitglied").nullable()
val istAktiv = bool("ist_aktiv").default(true)
val sortierReihenfolge = integer("sortier_reihenfolge").nullable()
val createdAt = datetime("created_at").defaultExpression(CurrentDateTime)
val updatedAt = datetime("updated_at").defaultExpression(CurrentDateTime)
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,230 @@
package at.mocode.masterdata.infrastructure.persistence
import at.mocode.core.domain.model.PlatzTypE
import at.mocode.masterdata.domain.model.Platz
import at.mocode.masterdata.domain.repository.PlatzRepository
import at.mocode.core.utils.database.DatabaseFactory
import com.benasher44.uuid.Uuid
import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
/**
* Implementierung des PlatzRepository für die Datenbankzugriffe.
*
* Diese Implementierung verwendet Exposed SQL für den Datenbankzugriff
* und mappt zwischen der Platz Domain-Entität und der PlatzTable.
*/
class PlatzRepositoryImpl : PlatzRepository {
/**
* Konvertiert eine Datenbankzeile in ein Domain-Objekt.
*/
private fun rowToPlatz(row: ResultRow): Platz {
return Platz(
id = row[PlatzTable.id],
turnierId = row[PlatzTable.turnierId],
name = row[PlatzTable.name],
dimension = row[PlatzTable.dimension],
boden = row[PlatzTable.boden],
typ = PlatzTypE.valueOf(row[PlatzTable.typ]),
istAktiv = row[PlatzTable.istAktiv],
sortierReihenfolge = row[PlatzTable.sortierReihenfolge],
createdAt = row[PlatzTable.createdAt].toInstant(TimeZone.UTC),
updatedAt = row[PlatzTable.updatedAt].toInstant(TimeZone.UTC)
)
}
override suspend fun findById(id: Uuid): Platz? = DatabaseFactory.dbQuery {
PlatzTable.selectAll().where { PlatzTable.id eq id }
.map(::rowToPlatz)
.singleOrNull()
}
override suspend fun findByTournament(turnierId: Uuid, activeOnly: Boolean, orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.turnierId eq turnierId }
if (activeOnly) {
query.andWhere { PlatzTable.istAktiv eq true }
}
if (orderBySortierung) {
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
} else {
query.orderBy(PlatzTable.name to SortOrder.ASC)
}
query.map(::rowToPlatz)
}
override suspend fun findByName(searchTerm: String, turnierId: Uuid?, limit: Int): List<Platz> = DatabaseFactory.dbQuery {
val pattern = "%$searchTerm%"
val query = PlatzTable.selectAll().where { PlatzTable.name like pattern }
turnierId?.let {
query.andWhere { PlatzTable.turnierId eq it }
}
query.limit(limit)
.orderBy(PlatzTable.name to SortOrder.ASC)
.map(::rowToPlatz)
}
override suspend fun findByType(typ: PlatzTypE, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.typ eq typ.name }
turnierId?.let {
query.andWhere { PlatzTable.turnierId eq it }
}
if (activeOnly) {
query.andWhere { PlatzTable.istAktiv eq true }
}
query.orderBy(PlatzTable.name to SortOrder.ASC)
.map(::rowToPlatz)
}
override suspend fun findByGroundType(boden: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.boden eq boden }
turnierId?.let {
query.andWhere { PlatzTable.turnierId eq it }
}
if (activeOnly) {
query.andWhere { PlatzTable.istAktiv eq true }
}
query.orderBy(PlatzTable.name to SortOrder.ASC)
.map(::rowToPlatz)
}
override suspend fun findByDimensions(dimension: String, turnierId: Uuid?, activeOnly: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.dimension eq dimension }
turnierId?.let {
query.andWhere { PlatzTable.turnierId eq it }
}
if (activeOnly) {
query.andWhere { PlatzTable.istAktiv eq true }
}
query.orderBy(PlatzTable.name to SortOrder.ASC)
.map(::rowToPlatz)
}
override suspend fun findAllActive(orderBySortierung: Boolean): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where { PlatzTable.istAktiv eq true }
if (orderBySortierung) {
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
} else {
query.orderBy(PlatzTable.name to SortOrder.ASC)
}
query.map(::rowToPlatz)
}
override suspend fun findSuitableForDiscipline(
requiredType: PlatzTypE,
requiredDimensions: String?,
turnierId: Uuid?
): List<Platz> = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where {
(PlatzTable.typ eq requiredType.name) and (PlatzTable.istAktiv eq true)
}
requiredDimensions?.let { dimensions ->
query.andWhere { PlatzTable.dimension eq dimensions }
}
turnierId?.let {
query.andWhere { PlatzTable.turnierId eq it }
}
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
.map(::rowToPlatz)
}
override suspend fun save(platz: Platz): Platz = DatabaseFactory.dbQuery {
val now = Clock.System.now()
val existingPlatz = PlatzTable.selectAll().where { PlatzTable.id eq platz.id }.singleOrNull()
if (existingPlatz == null) {
// Insert a new venue
PlatzTable.insert { stmt ->
stmt[id] = platz.id
stmt[turnierId] = platz.turnierId
stmt[name] = platz.name
stmt[dimension] = platz.dimension
stmt[boden] = platz.boden
stmt[typ] = platz.typ.name
stmt[istAktiv] = platz.istAktiv
stmt[sortierReihenfolge] = platz.sortierReihenfolge
stmt[createdAt] = platz.createdAt.toLocalDateTime(TimeZone.UTC)
stmt[updatedAt] = now.toLocalDateTime(TimeZone.UTC)
}
} else {
// Update existing venue
PlatzTable.update({ PlatzTable.id eq platz.id }) { stmt ->
stmt[turnierId] = platz.turnierId
stmt[name] = platz.name
stmt[dimension] = platz.dimension
stmt[boden] = platz.boden
stmt[typ] = platz.typ.name
stmt[istAktiv] = platz.istAktiv
stmt[sortierReihenfolge] = platz.sortierReihenfolge
stmt[updatedAt] = now.toLocalDateTime(TimeZone.UTC)
}
}
platz.copy(updatedAt = now)
}
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
PlatzTable.deleteWhere { PlatzTable.id eq id } > 0
}
override suspend fun existsByNameAndTournament(name: String, turnierId: Uuid): Boolean = DatabaseFactory.dbQuery {
PlatzTable.selectAll().where {
(PlatzTable.name eq name) and (PlatzTable.turnierId eq turnierId)
}.count() > 0
}
override suspend fun countActiveByTournament(turnierId: Uuid): Long = DatabaseFactory.dbQuery {
PlatzTable.selectAll().where {
(PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true)
}.count()
}
override suspend fun countByTypeAndTournament(typ: PlatzTypE, turnierId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
val query = PlatzTable.selectAll().where {
(PlatzTable.typ eq typ.name) and (PlatzTable.turnierId eq turnierId)
}
if (activeOnly) {
query.andWhere { PlatzTable.istAktiv eq true }
}
query.count()
}
override suspend fun findAvailableForTimeSlot(turnierId: Uuid, startTime: String?, endTime: String?): List<Platz> = DatabaseFactory.dbQuery {
// For now, this returns all active venues for the tournament
// This can be extended when venue scheduling functionality is implemented
val query = PlatzTable.selectAll().where {
(PlatzTable.turnierId eq turnierId) and (PlatzTable.istAktiv eq true)
}
// TODO: Add time slot availability logic when scheduling is implemented
// This would involve joining with a scheduling/booking table to check availability
query.orderBy(PlatzTable.sortierReihenfolge to SortOrder.ASC, PlatzTable.name to SortOrder.ASC)
.map(::rowToPlatz)
}
}
@@ -0,0 +1,37 @@
package at.mocode.masterdata.infrastructure.persistence
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.kotlin.datetime.datetime
import org.jetbrains.exposed.sql.kotlin.datetime.CurrentDateTime
/**
* Exposed-Tabellendefinition für die Platz-Entität (Turnierplätze/Wettkampfstätten).
*
* Diese Tabelle speichert alle Informationen zu Plätzen und Arenen
* entsprechend der Platz Domain-Entität.
*/
object PlatzTable : Table("platz") {
val id = uuid("id").autoGenerate()
val turnierId = uuid("turnier_id") // Foreign key to tournament (not enforced here as tournament might be in different module)
val name = varchar("name", 200)
val dimension = varchar("dimension", 50).nullable()
val boden = varchar("boden", 100).nullable()
val typ = varchar("typ", 50) // Enum as string
val istAktiv = bool("ist_aktiv").default(true)
val sortierReihenfolge = integer("sortier_reihenfolge").nullable()
val createdAt = datetime("created_at").defaultExpression(CurrentDateTime)
val updatedAt = datetime("updated_at").defaultExpression(CurrentDateTime)
override val primaryKey = PrimaryKey(id)
init {
// Index for performance on common queries
index(customIndexName = "idx_platz_turnier", columns = arrayOf(turnierId))
index(customIndexName = "idx_platz_aktiv", columns = arrayOf(istAktiv))
index(customIndexName = "idx_platz_typ", columns = arrayOf(typ))
index(customIndexName = "idx_platz_turnier_aktiv", columns = arrayOf(turnierId, istAktiv))
// Unique constraint for name per tournament
uniqueIndex("uk_platz_name_turnier", name, turnierId)
}
}
@@ -0,0 +1,58 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
// KORREKTUR: Dieses Plugin ist entscheidend. Es schaltet den `springBoot`-Block
// und alle Spring-Boot-spezifischen Gradle-Tasks frei.
alias(libs.plugins.spring.boot)
// Dependency Management für konsistente Spring-Versionen
alias(libs.plugins.spring.dependencyManagement)
}
// Dieser Block funktioniert jetzt, weil das `springBoot`-Plugin oben aktiviert ist.
springBoot {
mainClass.set("at.mocode.masterdata.service.MasterdataServiceApplicationKt")
}
dependencies {
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreUtils)
implementation(projects.masterdata.masterdataDomain)
implementation(projects.masterdata.masterdataApplication)
implementation(projects.masterdata.masterdataInfrastructure)
implementation(projects.masterdata.masterdataApi)
// Infrastruktur-Clients
implementation(projects.infrastructure.auth.authClient)
implementation(projects.infrastructure.cache.redisCache)
implementation(projects.infrastructure.messaging.messagingClient)
implementation(projects.infrastructure.monitoring.monitoringClient)
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
// Spring Boot Starters
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.actuator)
//implementation(libs.springdoc.openapi.starter.webmvc.ui)
// Datenbank-Abhängigkeiten
implementation(libs.exposed.core)
implementation(libs.exposed.dao)
implementation(libs.exposed.jdbc)
implementation(libs.exposed.kotlin.datetime)
implementation(libs.hikari.cp)
runtimeOnly(libs.postgresql.driver)
testRuntimeOnly(libs.h2.driver)
// Testing
testImplementation(projects.platform.platformTesting)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.logback.classic) // SLF4J provider for tests
}
tasks.test {
useJUnitPlatform()
}
@@ -0,0 +1,30 @@
package at.mocode.masterdata.service
import at.mocode.core.utils.config.AppConfig
import at.mocode.core.utils.database.DatabaseFactory
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
/**
* Main application class for the Masterdata Service.
*
* This service provides APIs for managing master data such as countries, regions, and other reference data.
*/
@SpringBootApplication
class MasterdataServiceApplication
fun main(args: Array<String>) {
// 1. Lade die Konfiguration explizit, genau einmal beim Start.
val appConfig = AppConfig.load()
println("Konfiguration für Umgebung '${appConfig.environment}' geladen.")
// 2. Initialisiere die Datenbank mit der geladenen Konfiguration.
// Flyway-Migrationen werden hier automatisch ausgeführt.
DatabaseFactory.init(appConfig.database)
println("Datenbank initialisiert und migriert.")
// 3. Starte die Spring Boot / Ktor Anwendung.
// Der appConfig-Wert kann hier an die Anwendung übergeben werden,
// um ihn später per Dependency Injection zu nutzen.
runApplication<MasterdataServiceApplication>(*args)
}
@@ -0,0 +1,154 @@
package at.mocode.masterdata.service.config
import at.mocode.masterdata.application.usecase.*
import at.mocode.masterdata.domain.repository.*
import at.mocode.masterdata.infrastructure.persistence.*
import at.mocode.masterdata.api.rest.*
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
/**
* Spring Boot configuration for the Masterdata Service.
*
* This configuration class sets up all the necessary beans for dependency injection
* following the clean architecture pattern with proper separation of concerns.
*/
@Configuration
class MasterdataConfiguration {
// Repository Implementations
@Bean
fun landRepository(): LandRepository {
return LandRepositoryImpl()
}
@Bean
fun bundeslandRepository(): BundeslandRepository {
return BundeslandRepositoryImpl()
}
@Bean
fun altersklasseRepository(): AltersklasseRepository {
return AltersklasseRepositoryImpl()
}
@Bean
fun platzRepository(): PlatzRepository {
return PlatzRepositoryImpl()
}
// Use Cases - Country/Land
@Bean
fun getCountryUseCase(landRepository: LandRepository): GetCountryUseCase {
return GetCountryUseCase(landRepository)
}
@Bean
fun createCountryUseCase(landRepository: LandRepository): CreateCountryUseCase {
return CreateCountryUseCase(landRepository)
}
// Use Cases - Federal State/Bundesland
@Bean
fun getBundeslandUseCase(bundeslandRepository: BundeslandRepository): GetBundeslandUseCase {
return GetBundeslandUseCase(bundeslandRepository)
}
@Bean
fun createBundeslandUseCase(bundeslandRepository: BundeslandRepository): CreateBundeslandUseCase {
return CreateBundeslandUseCase(bundeslandRepository)
}
// Use Cases - Age Class/Altersklasse
@Bean
fun getAltersklasseUseCase(altersklasseRepository: AltersklasseRepository): GetAltersklasseUseCase {
return GetAltersklasseUseCase(altersklasseRepository)
}
@Bean
fun createAltersklasseUseCase(altersklasseRepository: AltersklasseRepository): CreateAltersklasseUseCase {
return CreateAltersklasseUseCase(altersklasseRepository)
}
// Use Cases - Venue/Platz
@Bean
fun getPlatzUseCase(platzRepository: PlatzRepository): GetPlatzUseCase {
return GetPlatzUseCase(platzRepository)
}
@Bean
fun createPlatzUseCase(platzRepository: PlatzRepository): CreatePlatzUseCase {
return CreatePlatzUseCase(platzRepository)
}
// API Controllers
@Bean
fun countryController(
getCountryUseCase: GetCountryUseCase,
createCountryUseCase: CreateCountryUseCase
): CountryController {
return CountryController(getCountryUseCase, createCountryUseCase)
}
@Bean
fun bundeslandController(
getBundeslandUseCase: GetBundeslandUseCase,
createBundeslandUseCase: CreateBundeslandUseCase
): BundeslandController {
return BundeslandController(getBundeslandUseCase, createBundeslandUseCase)
}
@Bean
fun altersklasseController(
getAltersklasseUseCase: GetAltersklasseUseCase,
createAltersklasseUseCase: CreateAltersklasseUseCase
): AltersklasseController {
return AltersklasseController(getAltersklasseUseCase, createAltersklasseUseCase)
}
@Bean
fun platzController(
getPlatzUseCase: GetPlatzUseCase,
createPlatzUseCase: CreatePlatzUseCase
): PlatzController {
return PlatzController(getPlatzUseCase, createPlatzUseCase)
}
}
/**
* Database configuration for different environments.
*/
@Configuration
class DatabaseConfiguration {
/**
* Development database configuration.
*/
@Configuration
@Profile("dev", "development")
class DevelopmentDatabaseConfig {
// Development-specific database configuration
// This would typically include H2 or local PostgreSQL setup
}
/**
* Production database configuration.
*/
@Configuration
@Profile("prod", "production")
class ProductionDatabaseConfig {
// Production-specific database configuration
// This would include production PostgreSQL setup with connection pooling
}
/**
* Test database configuration.
*/
@Configuration
@Profile("test")
class TestDatabaseConfig {
// Test-specific database configuration
// This would typically include in-memory H2 database
}
}
@@ -0,0 +1,117 @@
package at.mocode.masterdata.service.config
import at.mocode.core.utils.database.DatabaseConfig
import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.masterdata.infrastructure.persistence.LandTable
import at.mocode.masterdata.infrastructure.persistence.BundeslandTable
import at.mocode.masterdata.infrastructure.persistence.AltersklasseTable
import at.mocode.masterdata.infrastructure.persistence.PlatzTable
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.slf4j.LoggerFactory
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
/**
* Database configuration for the Masterdata Service.
*
* This configuration ensures that Database.connect() is called properly
* before any Exposed operations are performed.
*/
@Configuration
@Profile("!test")
class MasterdataDatabaseConfiguration {
private val log = LoggerFactory.getLogger(MasterdataDatabaseConfiguration::class.java)
@PostConstruct
fun initializeDatabase() {
log.info("Initializing database schema for Masterdata Service...")
try {
// Database connection is already initialized by the gateway
// Only initialize the schema for this service
transaction {
SchemaUtils.createMissingTablesAndColumns(
LandTable,
BundeslandTable,
AltersklasseTable,
PlatzTable
)
log.info("Masterdata database schema initialized successfully")
}
} catch (e: Exception) {
log.error("Failed to initialize database schema", e)
throw e
}
}
@PreDestroy
fun closeDatabase() {
log.info("Closing database connection for Masterdata Service...")
try {
DatabaseFactory.close()
log.info("Database connection closed successfully")
} catch (e: Exception) {
log.error("Error closing database connection", e)
}
}
}
/**
* Test-specific database configuration.
*/
@Configuration
@Profile("test")
class MasterdataTestDatabaseConfiguration {
private val log = LoggerFactory.getLogger(MasterdataTestDatabaseConfiguration::class.java)
@PostConstruct
fun initializeTestDatabase() {
log.info("Initializing test database connection for Masterdata Service...")
try {
// Use H2 in-memory database for tests
val testConfig = DatabaseConfig(
jdbcUrl = "jdbc:h2:mem:masterdata_test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
username = "sa",
password = "",
driverClassName = "org.h2.Driver",
maxPoolSize = 5,
minPoolSize = 1,
autoMigrate = true
)
DatabaseFactory.init(testConfig)
log.info("Test database connection initialized successfully")
// Initialize database schema for tests
transaction {
SchemaUtils.createMissingTablesAndColumns(
LandTable,
BundeslandTable,
AltersklasseTable,
PlatzTable
)
log.info("Test masterdata database schema initialized successfully")
}
} catch (e: Exception) {
log.error("Failed to initialize test database connection", e)
throw e
}
}
@PreDestroy
fun closeTestDatabase() {
log.info("Closing test database connection for Masterdata Service...")
try {
DatabaseFactory.close()
log.info("Test database connection closed successfully")
} catch (e: Exception) {
log.error("Error closing test database connection", e)
}
}
}
@@ -0,0 +1,62 @@
-- Migration V001: Create Land (Country) table
-- This migration creates the base table for country master data
CREATE TABLE IF NOT EXISTS land (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
iso_alpha2_code VARCHAR(2) NOT NULL,
iso_alpha3_code VARCHAR(3) NOT NULL,
iso_numerischer_code VARCHAR(3),
name_deutsch VARCHAR(100) NOT NULL,
name_englisch VARCHAR(100),
wappen_url VARCHAR(500),
ist_eu_mitglied BOOLEAN,
ist_ewr_mitglied BOOLEAN,
ist_aktiv BOOLEAN NOT NULL DEFAULT true,
sortier_reihenfolge INTEGER,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create unique indexes for ISO codes
CREATE UNIQUE INDEX IF NOT EXISTS uk_land_iso_alpha2 ON land(iso_alpha2_code);
CREATE UNIQUE INDEX IF NOT EXISTS uk_land_iso_alpha3 ON land(iso_alpha3_code);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_land_aktiv ON land(ist_aktiv);
CREATE INDEX IF NOT EXISTS idx_land_sortierung ON land(sortier_reihenfolge);
CREATE INDEX IF NOT EXISTS idx_land_eu_mitglied ON land(ist_eu_mitglied);
CREATE INDEX IF NOT EXISTS idx_land_ewr_mitglied ON land(ist_ewr_mitglied);
-- Create index for name searches
CREATE INDEX IF NOT EXISTS idx_land_name_deutsch ON land(name_deutsch);
CREATE INDEX IF NOT EXISTS idx_land_name_englisch ON land(name_englisch);
-- Add comments for documentation
COMMENT ON TABLE land IS 'Master data table for countries/nations with ISO codes and EU/EWR membership information';
COMMENT ON COLUMN land.id IS 'Unique internal identifier (UUID)';
COMMENT ON COLUMN land.iso_alpha2_code IS '2-letter ISO 3166-1 Alpha-2 code (e.g., AT, DE)';
COMMENT ON COLUMN land.iso_alpha3_code IS '3-letter ISO 3166-1 Alpha-3 code (e.g., AUT, DEU)';
COMMENT ON COLUMN land.iso_numerischer_code IS '3-digit ISO 3166-1 numeric code (e.g., 040 for Austria)';
COMMENT ON COLUMN land.name_deutsch IS 'Official German name of the country';
COMMENT ON COLUMN land.name_englisch IS 'Official English name of the country';
COMMENT ON COLUMN land.wappen_url IS 'Optional URL path to country coat of arms or flag image';
COMMENT ON COLUMN land.ist_eu_mitglied IS 'Indicates if the country is a member of the European Union';
COMMENT ON COLUMN land.ist_ewr_mitglied IS 'Indicates if the country is a member of the European Economic Area';
COMMENT ON COLUMN land.ist_aktiv IS 'Indicates if this country is currently active/selectable in the system';
COMMENT ON COLUMN land.sortier_reihenfolge IS 'Optional number for controlling sort order in selection lists';
COMMENT ON COLUMN land.created_at IS 'Timestamp when this record was created';
COMMENT ON COLUMN land.updated_at IS 'Timestamp when this record was last updated';
-- Insert some initial data for common countries
INSERT INTO land (iso_alpha2_code, iso_alpha3_code, iso_numerischer_code, name_deutsch, name_englisch, ist_eu_mitglied, ist_ewr_mitglied, sortier_reihenfolge) VALUES
('AT', 'AUT', '040', 'Österreich', 'Austria', true, true, 1),
('DE', 'DEU', '276', 'Deutschland', 'Germany', true, true, 2),
('CH', 'CHE', '756', 'Schweiz', 'Switzerland', false, false, 3),
('IT', 'ITA', '380', 'Italien', 'Italy', true, true, 4),
('FR', 'FRA', '250', 'Frankreich', 'France', true, true, 5),
('CZ', 'CZE', '203', 'Tschechien', 'Czech Republic', true, true, 6),
('SK', 'SVK', '703', 'Slowakei', 'Slovakia', true, true, 7),
('SI', 'SVN', '705', 'Slowenien', 'Slovenia', true, true, 8),
('HU', 'HUN', '348', 'Ungarn', 'Hungary', true, true, 9),
('PL', 'POL', '616', 'Polen', 'Poland', true, true, 10)
ON CONFLICT (iso_alpha2_code) DO NOTHING;
@@ -0,0 +1,132 @@
-- Migration V002: Create Bundesland (Federal State) table
-- This migration creates the table for federal states/regions with OEPS and ISO codes
CREATE TABLE IF NOT EXISTS bundesland (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
land_id UUID NOT NULL REFERENCES land(id) ON DELETE CASCADE,
oeps_code VARCHAR(10),
iso_3166_2_code VARCHAR(10),
name VARCHAR(100) NOT NULL,
kuerzel VARCHAR(10),
wappen_url VARCHAR(500),
ist_aktiv BOOLEAN NOT NULL DEFAULT true,
sortier_reihenfolge INTEGER,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Create unique constraints
CREATE UNIQUE INDEX IF NOT EXISTS uk_bundesland_oeps_land ON bundesland(oeps_code, land_id) WHERE oeps_code IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uk_bundesland_iso3166_2 ON bundesland(iso_3166_2_code) WHERE iso_3166_2_code IS NOT NULL;
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_bundesland_land_id ON bundesland(land_id);
CREATE INDEX IF NOT EXISTS idx_bundesland_aktiv ON bundesland(ist_aktiv);
CREATE INDEX IF NOT EXISTS idx_bundesland_sortierung ON bundesland(sortier_reihenfolge);
CREATE INDEX IF NOT EXISTS idx_bundesland_name ON bundesland(name);
CREATE INDEX IF NOT EXISTS idx_bundesland_land_aktiv ON bundesland(land_id, ist_aktiv);
-- Add comments for documentation
COMMENT ON TABLE bundesland IS 'Master data table for federal states/regions with OEPS and ISO 3166-2 codes';
COMMENT ON COLUMN bundesland.id IS 'Unique internal identifier (UUID)';
COMMENT ON COLUMN bundesland.land_id IS 'Foreign key reference to the country this federal state belongs to';
COMMENT ON COLUMN bundesland.oeps_code IS '2-digit OEPS code for Austrian federal states (e.g., 01 for Vienna, 02 for Lower Austria)';
COMMENT ON COLUMN bundesland.iso_3166_2_code IS 'Official ISO 3166-2 code for the federal state (e.g., AT-1 for Burgenland, DE-BY for Bavaria)';
COMMENT ON COLUMN bundesland.name IS 'Official name of the federal state';
COMMENT ON COLUMN bundesland.kuerzel IS 'Common abbreviation for the federal state (e.g., NÖ, W, STMK)';
COMMENT ON COLUMN bundesland.wappen_url IS 'Optional URL path to federal state coat of arms image';
COMMENT ON COLUMN bundesland.ist_aktiv IS 'Indicates if this federal state is currently active/selectable in the system';
COMMENT ON COLUMN bundesland.sortier_reihenfolge IS 'Optional number for controlling sort order in selection lists';
COMMENT ON COLUMN bundesland.created_at IS 'Timestamp when this record was created';
COMMENT ON COLUMN bundesland.updated_at IS 'Timestamp when this record was last updated';
-- Insert Austrian federal states (Bundesländer)
-- First, get the Austria country ID
DO $$
DECLARE
austria_id UUID;
BEGIN
SELECT id INTO austria_id FROM land WHERE iso_alpha2_code = 'AT';
IF austria_id IS NOT NULL THEN
INSERT INTO bundesland (land_id, oeps_code, iso_3166_2_code, name, kuerzel, sortier_reihenfolge) VALUES
(austria_id, '01', 'AT-1', 'Burgenland', 'BGLD', 1),
(austria_id, '02', 'AT-2', 'Kärnten', 'KTN', 2),
(austria_id, '03', 'AT-3', 'Niederösterreich', '', 3),
(austria_id, '04', 'AT-4', 'Oberösterreich', '', 4),
(austria_id, '05', 'AT-5', 'Salzburg', 'SBG', 5),
(austria_id, '06', 'AT-6', 'Steiermark', 'STMK', 6),
(austria_id, '07', 'AT-7', 'Tirol', 'T', 7),
(austria_id, '08', 'AT-8', 'Vorarlberg', 'VBG', 8),
(austria_id, '09', 'AT-9', 'Wien', 'W', 9)
ON CONFLICT DO NOTHING;
END IF;
END $$;
-- Insert German federal states (Bundesländer)
DO $$
DECLARE
germany_id UUID;
BEGIN
SELECT id INTO germany_id FROM land WHERE iso_alpha2_code = 'DE';
IF germany_id IS NOT NULL THEN
INSERT INTO bundesland (land_id, iso_3166_2_code, name, kuerzel, sortier_reihenfolge) VALUES
(germany_id, 'DE-BW', 'Baden-Württemberg', 'BW', 1),
(germany_id, 'DE-BY', 'Bayern', 'BY', 2),
(germany_id, 'DE-BE', 'Berlin', 'BE', 3),
(germany_id, 'DE-BB', 'Brandenburg', 'BB', 4),
(germany_id, 'DE-HB', 'Bremen', 'HB', 5),
(germany_id, 'DE-HH', 'Hamburg', 'HH', 6),
(germany_id, 'DE-HE', 'Hessen', 'HE', 7),
(germany_id, 'DE-MV', 'Mecklenburg-Vorpommern', 'MV', 8),
(germany_id, 'DE-NI', 'Niedersachsen', 'NI', 9),
(germany_id, 'DE-NW', 'Nordrhein-Westfalen', 'NW', 10),
(germany_id, 'DE-RP', 'Rheinland-Pfalz', 'RP', 11),
(germany_id, 'DE-SL', 'Saarland', 'SL', 12),
(germany_id, 'DE-SN', 'Sachsen', 'SN', 13),
(germany_id, 'DE-ST', 'Sachsen-Anhalt', 'ST', 14),
(germany_id, 'DE-SH', 'Schleswig-Holstein', 'SH', 15),
(germany_id, 'DE-TH', 'Thüringen', 'TH', 16)
ON CONFLICT DO NOTHING;
END IF;
END $$;
-- Insert Swiss cantons
DO $$
DECLARE
switzerland_id UUID;
BEGIN
SELECT id INTO switzerland_id FROM land WHERE iso_alpha2_code = 'CH';
IF switzerland_id IS NOT NULL THEN
INSERT INTO bundesland (land_id, iso_3166_2_code, name, kuerzel, sortier_reihenfolge) VALUES
(switzerland_id, 'CH-AG', 'Aargau', 'AG', 1),
(switzerland_id, 'CH-AI', 'Appenzell Innerrhoden', 'AI', 2),
(switzerland_id, 'CH-AR', 'Appenzell Ausserrhoden', 'AR', 3),
(switzerland_id, 'CH-BE', 'Bern', 'BE', 4),
(switzerland_id, 'CH-BL', 'Basel-Landschaft', 'BL', 5),
(switzerland_id, 'CH-BS', 'Basel-Stadt', 'BS', 6),
(switzerland_id, 'CH-FR', 'Freiburg', 'FR', 7),
(switzerland_id, 'CH-GE', 'Genf', 'GE', 8),
(switzerland_id, 'CH-GL', 'Glarus', 'GL', 9),
(switzerland_id, 'CH-GR', 'Graubünden', 'GR', 10),
(switzerland_id, 'CH-JU', 'Jura', 'JU', 11),
(switzerland_id, 'CH-LU', 'Luzern', 'LU', 12),
(switzerland_id, 'CH-NE', 'Neuenburg', 'NE', 13),
(switzerland_id, 'CH-NW', 'Nidwalden', 'NW', 14),
(switzerland_id, 'CH-OW', 'Obwalden', 'OW', 15),
(switzerland_id, 'CH-SG', 'St. Gallen', 'SG', 16),
(switzerland_id, 'CH-SH', 'Schaffhausen', 'SH', 17),
(switzerland_id, 'CH-SO', 'Solothurn', 'SO', 18),
(switzerland_id, 'CH-SZ', 'Schwyz', 'SZ', 19),
(switzerland_id, 'CH-TG', 'Thurgau', 'TG', 20),
(switzerland_id, 'CH-TI', 'Tessin', 'TI', 21),
(switzerland_id, 'CH-UR', 'Uri', 'UR', 22),
(switzerland_id, 'CH-VD', 'Waadt', 'VD', 23),
(switzerland_id, 'CH-VS', 'Wallis', 'VS', 24),
(switzerland_id, 'CH-ZG', 'Zug', 'ZG', 25),
(switzerland_id, 'CH-ZH', 'Zürich', 'ZH', 26)
ON CONFLICT DO NOTHING;
END IF;
END $$;
@@ -0,0 +1,105 @@
-- Migration V003: Create Altersklasse (Age Class) table
-- This migration creates the table for age class definitions with sport and gender filters
CREATE TABLE IF NOT EXISTS altersklasse (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
altersklasse_code VARCHAR(50) NOT NULL UNIQUE,
bezeichnung VARCHAR(200) NOT NULL,
min_alter INTEGER,
max_alter INTEGER,
stichtag_regel_text VARCHAR(500),
sparte_filter VARCHAR(50),
geschlecht_filter CHAR(1),
oeto_regel_referenz_id UUID,
ist_aktiv BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Constraints
CONSTRAINT chk_altersklasse_geschlecht CHECK (geschlecht_filter IN ('M', 'W') OR geschlecht_filter IS NULL),
CONSTRAINT chk_altersklasse_alter_range CHECK (min_alter IS NULL OR max_alter IS NULL OR min_alter <= max_alter),
CONSTRAINT chk_altersklasse_min_alter CHECK (min_alter IS NULL OR min_alter >= 0),
CONSTRAINT chk_altersklasse_max_alter CHECK (max_alter IS NULL OR max_alter >= 0)
);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_altersklasse_aktiv ON altersklasse(ist_aktiv);
CREATE INDEX IF NOT EXISTS idx_altersklasse_sparte ON altersklasse(sparte_filter);
CREATE INDEX IF NOT EXISTS idx_altersklasse_geschlecht ON altersklasse(geschlecht_filter);
CREATE INDEX IF NOT EXISTS idx_altersklasse_alter ON altersklasse(min_alter, max_alter);
CREATE INDEX IF NOT EXISTS idx_altersklasse_bezeichnung ON altersklasse(bezeichnung);
CREATE INDEX IF NOT EXISTS idx_altersklasse_code ON altersklasse(altersklasse_code);
-- Add comments for documentation
COMMENT ON TABLE altersklasse IS 'Master data table for age class definitions with eligibility rules for participants';
COMMENT ON COLUMN altersklasse.id IS 'Unique internal identifier (UUID)';
COMMENT ON COLUMN altersklasse.altersklasse_code IS 'Unique code for the age class (e.g., JGD_U16, JUN_U18, YR_U21, AK)';
COMMENT ON COLUMN altersklasse.bezeichnung IS 'Official or commonly understood designation of the age class';
COMMENT ON COLUMN altersklasse.min_alter IS 'Minimum age (years, inclusive) for this age class. NULL if no lower limit';
COMMENT ON COLUMN altersklasse.max_alter IS 'Maximum age (years, inclusive) for this age class. NULL if no upper limit';
COMMENT ON COLUMN altersklasse.stichtag_regel_text IS 'Description of the rule for the reference date for age calculation';
COMMENT ON COLUMN altersklasse.sparte_filter IS 'Optional specification if this age class definition only applies to a specific sport';
COMMENT ON COLUMN altersklasse.geschlecht_filter IS 'Optional filter for gender (M, W) if the age class is gender-specific. NULL means valid for all genders';
COMMENT ON COLUMN altersklasse.oeto_regel_referenz_id IS 'Optional link to a specific rule in the OETO rule reference table';
COMMENT ON COLUMN altersklasse.ist_aktiv IS 'Indicates if this age class definition can currently be used in the system';
COMMENT ON COLUMN altersklasse.created_at IS 'Timestamp when this record was created';
COMMENT ON COLUMN altersklasse.updated_at IS 'Timestamp when this record was last updated';
-- Insert common age class definitions for equestrian sports
INSERT INTO altersklasse (altersklasse_code, bezeichnung, min_alter, max_alter, stichtag_regel_text, sparte_filter, geschlecht_filter) VALUES
-- General age classes (all sports)
('PONY_U10', 'Pony Führzügel U10', NULL, 9, '31.12. des laufenden Kalenderjahres', NULL, NULL),
('PONY_U12', 'Pony U12', NULL, 11, '31.12. des laufenden Kalenderjahres', NULL, NULL),
('PONY_U14', 'Pony U14', NULL, 13, '31.12. des laufenden Kalenderjahres', NULL, NULL),
('PONY_U16', 'Pony U16', NULL, 15, '31.12. des laufenden Kalenderjahres', NULL, NULL),
('JGD_U16', 'Jugend U16', NULL, 15, '31.12. des laufenden Kalenderjahres', NULL, NULL),
('JGD_U18', 'Jugend U18', NULL, 17, '31.12. des laufenden Kalenderjahres', NULL, NULL),
('JUN_U21', 'Junioren U21', NULL, 20, '31.12. des laufenden Kalenderjahres', NULL, NULL),
('YR_U25', 'Junge Reiter U25', NULL, 24, '31.12. des laufenden Kalenderjahres', NULL, NULL),
('AK', 'Allgemeine Klasse', 18, NULL, '31.12. des laufenden Kalenderjahres', NULL, NULL),
('SEN_40', 'Senioren Ü40', 40, NULL, '31.12. des laufenden Kalenderjahres', NULL, NULL),
('SEN_50', 'Senioren Ü50', 50, NULL, '31.12. des laufenden Kalenderjahres', NULL, NULL),
('SEN_60', 'Senioren Ü60', 60, NULL, '31.12. des laufenden Kalenderjahres', NULL, NULL),
-- Dressage-specific age classes
('DR_PONY_U12', 'Dressur Pony U12', NULL, 11, '31.12. des laufenden Kalenderjahres', 'DRESSUR', NULL),
('DR_PONY_U14', 'Dressur Pony U14', NULL, 13, '31.12. des laufenden Kalenderjahres', 'DRESSUR', NULL),
('DR_PONY_U16', 'Dressur Pony U16', NULL, 15, '31.12. des laufenden Kalenderjahres', 'DRESSUR', NULL),
('DR_JGD_U18', 'Dressur Jugend U18', NULL, 17, '31.12. des laufenden Kalenderjahres', 'DRESSUR', NULL),
('DR_JUN_U21', 'Dressur Junioren U21', NULL, 20, '31.12. des laufenden Kalenderjahres', 'DRESSUR', NULL),
('DR_YR_U25', 'Dressur Junge Reiter U25', NULL, 24, '31.12. des laufenden Kalenderjahres', 'DRESSUR', NULL),
-- Jumping-specific age classes
('SP_PONY_U12', 'Springen Pony U12', NULL, 11, '31.12. des laufenden Kalenderjahres', 'SPRINGEN', NULL),
('SP_PONY_U14', 'Springen Pony U14', NULL, 13, '31.12. des laufenden Kalenderjahres', 'SPRINGEN', NULL),
('SP_PONY_U16', 'Springen Pony U16', NULL, 15, '31.12. des laufenden Kalenderjahres', 'SPRINGEN', NULL),
('SP_JGD_U18', 'Springen Jugend U18', NULL, 17, '31.12. des laufenden Kalenderjahres', 'SPRINGEN', NULL),
('SP_JUN_U21', 'Springen Junioren U21', NULL, 20, '31.12. des laufenden Kalenderjahres', 'SPRINGEN', NULL),
('SP_YR_U25', 'Springen Junge Reiter U25', NULL, 24, '31.12. des laufenden Kalenderjahres', 'SPRINGEN', NULL),
-- Eventing-specific age classes
('VK_PONY_U14', 'Vielseitigkeit Pony U14', NULL, 13, '31.12. des laufenden Kalenderjahres', 'VIELSEITIGKEIT', NULL),
('VK_PONY_U16', 'Vielseitigkeit Pony U16', NULL, 15, '31.12. des laufenden Kalenderjahres', 'VIELSEITIGKEIT', NULL),
('VK_JGD_U18', 'Vielseitigkeit Jugend U18', NULL, 17, '31.12. des laufenden Kalenderjahres', 'VIELSEITIGKEIT', NULL),
('VK_JUN_U21', 'Vielseitigkeit Junioren U21', NULL, 20, '31.12. des laufenden Kalenderjahres', 'VIELSEITIGKEIT', NULL),
('VK_YR_U25', 'Vielseitigkeit Junge Reiter U25', NULL, 24, '31.12. des laufenden Kalenderjahres', 'VIELSEITIGKEIT', NULL),
-- Driving-specific age classes
('FA_PONY_U16', 'Fahren Pony U16', NULL, 15, '31.12. des laufenden Kalenderjahres', 'FAHREN', NULL),
('FA_JGD_U18', 'Fahren Jugend U18', NULL, 17, '31.12. des laufenden Kalenderjahres', 'FAHREN', NULL),
('FA_JUN_U21', 'Fahren Junioren U21', NULL, 20, '31.12. des laufenden Kalenderjahres', 'FAHREN', NULL),
('FA_YR_U25', 'Fahren Junge Reiter U25', NULL, 24, '31.12. des laufenden Kalenderjahres', 'FAHREN', NULL),
-- Vaulting-specific age classes
('VT_U10', 'Voltigieren U10', NULL, 9, '31.12. des laufenden Kalenderjahres', 'VOLTIGIEREN', NULL),
('VT_U12', 'Voltigieren U12', NULL, 11, '31.12. des laufenden Kalenderjahres', 'VOLTIGIEREN', NULL),
('VT_U14', 'Voltigieren U14', NULL, 13, '31.12. des laufenden Kalenderjahres', 'VOLTIGIEREN', NULL),
('VT_U16', 'Voltigieren U16', NULL, 15, '31.12. des laufenden Kalenderjahres', 'VOLTIGIEREN', NULL),
('VT_U18', 'Voltigieren U18', NULL, 17, '31.12. des laufenden Kalenderjahres', 'VOLTIGIEREN', NULL),
('VT_JUN_U21', 'Voltigieren Junioren U21', NULL, 20, '31.12. des laufenden Kalenderjahres', 'VOLTIGIEREN', NULL),
-- Gender-specific examples (if needed)
('DR_DAMEN', 'Dressur Damen', 18, NULL, '31.12. des laufenden Kalenderjahres', 'DRESSUR', 'W'),
('DR_HERREN', 'Dressur Herren', 18, NULL, '31.12. des laufenden Kalenderjahres', 'DRESSUR', 'M')
ON CONFLICT (altersklasse_code) DO NOTHING;
@@ -0,0 +1,137 @@
-- Migration V004: Create Platz (Venue/Arena) table
-- This migration creates the table for tournament venues and arenas
CREATE TABLE IF NOT EXISTS platz (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
turnier_id UUID NOT NULL, -- Foreign key to tournament (not enforced as tournament might be in different module)
name VARCHAR(200) NOT NULL,
dimension VARCHAR(50),
boden VARCHAR(100),
typ VARCHAR(50) NOT NULL,
ist_aktiv BOOLEAN NOT NULL DEFAULT true,
sortier_reihenfolge INTEGER,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Constraints
CONSTRAINT chk_platz_sortier_reihenfolge CHECK (sortier_reihenfolge IS NULL OR sortier_reihenfolge >= 0)
);
-- Create unique constraint for name per tournament
CREATE UNIQUE INDEX IF NOT EXISTS uk_platz_name_turnier ON platz(name, turnier_id);
-- Create indexes for performance
CREATE INDEX IF NOT EXISTS idx_platz_turnier ON platz(turnier_id);
CREATE INDEX IF NOT EXISTS idx_platz_aktiv ON platz(ist_aktiv);
CREATE INDEX IF NOT EXISTS idx_platz_typ ON platz(typ);
CREATE INDEX IF NOT EXISTS idx_platz_turnier_aktiv ON platz(turnier_id, ist_aktiv);
CREATE INDEX IF NOT EXISTS idx_platz_name ON platz(name);
CREATE INDEX IF NOT EXISTS idx_platz_dimension ON platz(dimension);
CREATE INDEX IF NOT EXISTS idx_platz_boden ON platz(boden);
CREATE INDEX IF NOT EXISTS idx_platz_sortierung ON platz(sortier_reihenfolge);
-- Add comments for documentation
COMMENT ON TABLE platz IS 'Master data table for tournament venues and arenas with type and dimension specifications';
COMMENT ON COLUMN platz.id IS 'Unique internal identifier (UUID)';
COMMENT ON COLUMN platz.turnier_id IS 'Foreign key reference to the tournament this venue belongs to';
COMMENT ON COLUMN platz.name IS 'Name or designation of the venue (e.g., "Hauptplatz", "Dressurplatz A")';
COMMENT ON COLUMN platz.dimension IS 'Dimensions of the venue (e.g., "20x60m", "20x40m")';
COMMENT ON COLUMN platz.boden IS 'Type of ground surface (e.g., "Sand", "Gras", "Kunststoff")';
COMMENT ON COLUMN platz.typ IS 'Type of venue (see PlatzTypE enum)';
COMMENT ON COLUMN platz.ist_aktiv IS 'Indicates if this venue can currently be used';
COMMENT ON COLUMN platz.sortier_reihenfolge IS 'Optional number for controlling sort order';
COMMENT ON COLUMN platz.created_at IS 'Timestamp when this record was created';
COMMENT ON COLUMN platz.updated_at IS 'Timestamp when this record was last updated';
-- Insert some example venue types and common configurations
-- Note: These are examples and would typically be created per tournament
-- Using a dummy tournament ID for demonstration purposes
-- Create a function to generate example venues for a tournament
CREATE OR REPLACE FUNCTION create_example_venues(tournament_id UUID) RETURNS VOID AS $$
BEGIN
INSERT INTO platz (turnier_id, name, dimension, boden, typ, sortier_reihenfolge) VALUES
-- Dressage arenas
(tournament_id, 'Dressurplatz A', '20x60m', 'Sand', 'DRESSURPLATZ', 10),
(tournament_id, 'Dressurplatz B', '20x40m', 'Sand', 'DRESSURPLATZ', 20),
(tournament_id, 'Abreiteplatz Dressur', '20x40m', 'Sand', 'ABREITEPLATZ', 30),
-- Jumping arenas
(tournament_id, 'Springplatz Hauptring', '40x80m', 'Sand', 'SPRINGPLATZ', 40),
(tournament_id, 'Springplatz Ring 2', '35x70m', 'Sand', 'SPRINGPLATZ', 50),
(tournament_id, 'Abreiteplatz Springen', '30x60m', 'Sand', 'ABREITEPLATZ', 60),
-- Cross-country and eventing
(tournament_id, 'Geländestrecke', 'variabel', 'Gras', 'GELAENDESTRECKE', 70),
(tournament_id, 'Vielseitigkeitsplatz', '25x65m', 'Sand', 'VIELSEITIGKEITSPLATZ', 80),
-- Driving arenas
(tournament_id, 'Fahrplatz', '40x100m', 'Sand', 'FAHRPLATZ', 90),
(tournament_id, 'Hindernisfahren', '40x80m', 'Sand', 'FAHRPLATZ', 100),
-- Vaulting
(tournament_id, 'Voltigierplatz', '20m Durchmesser', 'Sand', 'VOLTIGIERPLATZ', 110),
-- Training and warm-up areas
(tournament_id, 'Führanlage', '20m Durchmesser', 'Sand', 'FUEHRANLAGE', 120),
(tournament_id, 'Longierplatz', '20m Durchmesser', 'Sand', 'LONGIERPLATZ', 130),
(tournament_id, 'Trainingsplatz 1', '20x40m', 'Sand', 'TRAININGSPLATZ', 140),
(tournament_id, 'Trainingsplatz 2', '20x40m', 'Gras', 'TRAININGSPLATZ', 150),
-- Indoor arenas
(tournament_id, 'Reithalle A', '20x60m', 'Sand', 'REITHALLE', 160),
(tournament_id, 'Reithalle B', '20x40m', 'Sand', 'REITHALLE', 170),
-- Outdoor areas
(tournament_id, 'Außenplatz 1', '25x50m', 'Gras', 'AUSSENPLATZ', 180),
(tournament_id, 'Außenplatz 2', '20x40m', 'Sand', 'AUSSENPLATZ', 190),
-- Special purpose areas
(tournament_id, 'Siegerehrungsplatz', '15x25m', 'Gras', 'SONDERPLATZ', 200),
(tournament_id, 'Vorführplatz', '20x30m', 'Sand', 'SONDERPLATZ', 210)
ON CONFLICT (name, turnier_id) DO NOTHING;
END;
$$ LANGUAGE plpgsql;
-- Add some venue type validation comments
COMMENT ON FUNCTION create_example_venues(UUID) IS 'Helper function to create example venues for a tournament. Call with tournament UUID.';
-- Create a view for venue statistics
CREATE OR REPLACE VIEW platz_statistics AS
SELECT
typ,
COUNT(*) as total_count,
COUNT(CASE WHEN ist_aktiv THEN 1 END) as active_count,
COUNT(CASE WHEN NOT ist_aktiv THEN 1 END) as inactive_count,
COUNT(DISTINCT turnier_id) as tournament_count,
COUNT(DISTINCT dimension) as dimension_variants,
COUNT(DISTINCT boden) as ground_type_variants
FROM platz
GROUP BY typ
ORDER BY typ;
COMMENT ON VIEW platz_statistics IS 'Statistical overview of venues by type, showing counts and variants';
-- Create a view for tournament venue overview
CREATE OR REPLACE VIEW tournament_venue_overview AS
SELECT
turnier_id,
COUNT(*) as total_venues,
COUNT(CASE WHEN ist_aktiv THEN 1 END) as active_venues,
COUNT(DISTINCT typ) as venue_types,
COUNT(DISTINCT dimension) as dimension_variants,
COUNT(DISTINCT boden) as ground_types,
STRING_AGG(DISTINCT typ, ', ' ORDER BY typ) as available_types
FROM platz
GROUP BY turnier_id
ORDER BY turnier_id;
COMMENT ON VIEW tournament_venue_overview IS 'Overview of venues per tournament with summary statistics';
-- Example of how to use the function (commented out as it requires actual tournament IDs)
-- SELECT create_example_venues('550e8400-e29b-41d4-a716-446655440000'::UUID);
-- Add some helpful indexes for the views
CREATE INDEX IF NOT EXISTS idx_platz_typ_aktiv ON platz(typ, ist_aktiv);
CREATE INDEX IF NOT EXISTS idx_platz_turnier_typ ON platz(turnier_id, typ);
@@ -0,0 +1,33 @@
-- File: V1__Create_Initial_Tables.sql
-- Tabelle zur Verwaltung der Vereine (Mandanten)
CREATE TABLE IF NOT EXISTS dom_verein (
verein_id UUID PRIMARY KEY,
oeps_vereins_nr VARCHAR(4) UNIQUE,
name VARCHAR(100) NOT NULL,
kuerzel VARCHAR(20),
bundesland_code VARCHAR(2),
daten_quelle VARCHAR(50) NOT NULL,
ist_aktiv BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Tabelle zur Verwaltung der Personen (Sportler, Funktionäre)
CREATE TABLE IF NOT EXISTS dom_person (
person_id UUID PRIMARY KEY,
oeps_satz_nr VARCHAR(6) UNIQUE,
nachname VARCHAR(100) NOT NULL,
vorname VARCHAR(100) NOT NULL,
geburtsdatum DATE,
geschlecht VARCHAR(10),
nationalitaet_code VARCHAR(3),
stamm_verein_id UUID REFERENCES dom_verein(verein_id),
ist_gesperrt BOOLEAN NOT NULL DEFAULT false,
daten_quelle VARCHAR(50) NOT NULL,
ist_aktiv BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Weitere Tabellen können hier hinzugefügt werden...
@@ -0,0 +1,10 @@
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>