chore(ci): Align GH Workflows with Docker SSoT, new paths; minimal SSoT guard; staticAnalysis (#23)

* chore(MP-21): snapshot pre-refactor state (Epic 1)

* chore(MP-22): scaffold new repo structure, relocate Docker Compose, move frontend/backend modules, update Makefile; add docs mapping and env template

* MP-22 Epic 2: Erfolgreich umgesetzt und verifiziert

* MP-23 Epic 3: Gradle/Build Governance zentralisieren

* MP-23 Epic 3: Gradle/Build Governance zentralisieren

* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt

- ENV Single Source of Truth
  - docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
  - config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])

- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
  - Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
    und Start mit -c config_file=/etc/postgresql/postgresql.conf
  - Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
    und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
  - Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
  - Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT

- Frontend/DI/Network (MP-23 Grundlage)
  - :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
  - Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
  - Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)

- Build/Gradle & Module-Refs
  - settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
  - Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
  - Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht

- Dockerfiles angepasst
  - backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
  - backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service

- Static Analysis / Guards
  - config/detekt/detekt.yml hinzugefügt
  - Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet

- Doku
  - docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
  - docs/adr/README.md angelegt

BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)

Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`

Refs: MP-22 (Epic 2), MP-23 (Epic 3)

* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt

- ENV Single Source of Truth
  - docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
  - config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])

- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
  - Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
    und Start mit -c config_file=/etc/postgresql/postgresql.conf
  - Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
    und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
  - Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
  - Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT

- Frontend/DI/Network (MP-23 Grundlage)
  - :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
  - Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
  - Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)

- Build/Gradle & Module-Refs
  - settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
  - Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
  - Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht

- Dockerfiles angepasst
  - backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
  - backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service

- Static Analysis / Guards
  - config/detekt/detekt.yml hinzugefügt
  - Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet

- Doku
  - docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
  - docs/adr/README.md angelegt

BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)

Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`

Refs: MP-22 (Epic 2), MP-23 (Epic 3)

* chore(devops)!: Docker-SSoT (.env) konsolidiert, Compose-Mounts ergänzt, Makefile entfernt

- ENV Single Source of Truth
  - docker/.env.example neu (inkl. REDIS_PASSWORD, Ports, Build-Overrides)
  - config/.env(.example) als DEPRECATED markiert (Verweis auf docker/.env[.example])

- Docker Compose vereinheitlicht (docker/docker-compose.yaml)
  - Postgres: zentralen postgresql.conf mounten (../config/postgres/postgresql.conf)
    und Start mit -c config_file=/etc/postgresql/postgresql.conf
  - Redis: zentralen redis.conf mounten (../config/redis/redis.conf)
    und Start via "redis-server … ${REDIS_PASSWORD:+--requirepass $REDIS_PASSWORD}"
  - Web-Nginx: ../config/nginx/nginx.prod.conf → /etc/nginx/nginx.conf (ro)
  - Monitoring: Prometheus/Grafana nutzen ../config/monitoring/* als SSoT

- Frontend/DI/Network (MP-23 Grundlage)
  - :frontend:core:network Modul mit Koin `apiClient` (Ktor + JSON/Retry/Timeout/Logging)
  - Plattform-Basis-URL-Auflösung (JVM: ENV API_BASE_URL; JS: globalThis.API_BASE_URL / Same-Origin)
  - Web index.html setzt API_BASE_URL (Query `?apiBaseUrl=…` > Same-Origin > Fallback)

- Build/Gradle & Module-Refs
  - settings.gradle.kts: neue Frontend-/Backend-Pfade bereits inkludiert
  - Features/Shell: Abhängigkeiten auf :frontend:shared / :frontend:core:* angepasst
  - Ping-API-Refs auf :backend:services:ping:ping-api vereinheitlicht

- Dockerfiles angepasst
  - backend/infrastructure/gateway/Dockerfile → Tasks/Pfade auf :backend:gateway
  - backend/services/ping/Dockerfile → Tasks/Pfade auf :backend:services:ping:ping-service

- Static Analysis / Guards
  - config/detekt/detekt.yml hinzugefügt
  - Leichter Arch-Guard (Frontend) gegen manuelle Authorization-Header vorbereitet

- Doku
  - docs/ARCHITECTURE.md (Struktur, Mapping, Next Steps) ergänzt
  - docs/adr/README.md angelegt

BREAKING CHANGES:
- Makefile komplett entfernt (bitte direkt `docker compose` verwenden)
- ENV-Quelle ist jetzt docker/.env (statt config/.env oder Root)
- Compose-Datei unter docker/docker-compose.yaml (nicht mehr compose.yaml im Repo-Root)

Verifikation (lokal):
- ENV anlegen: `cp docker/.env.example docker/.env` (Werte anpassen)
- Compose prüfen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml config`
- Infrastruktur: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle up -d postgres redis keycloak web-app`
- Services bauen: `docker compose --env-file docker/.env -f docker/docker-compose.yaml -p meldestelle build api-gateway ping-service --no-cache --progress=plain`

Refs: MP-22 (Epic 2), MP-23 (Epic 3)

* chore(ci): Workflows an Docker-SSoT & neue Struktur angepasst, minimaler SSoT-Guard

- ssot-guard.yml: Option B (minimal) → `docker compose -f docker/docker-compose.yaml config` als Lint
- integration-tests.yml: `./gradlew staticAnalysis` vor Integrationstests
- docs-kdoc-sync.yml: Dokka-Task Fallback (dokkaGfmAll || dokkaGfm), YouTrack-Sync nur wenn Script vorhanden
- deploy-proxmox.yml: Compose-Pfade auf docker/docker-compose.yaml + `--env-file docker/.env`; Build/Test Schritte vereinheitlicht
- ci-main.yml: SSoT-Skripte per `if: hashFiles(...)` guarded, Compose-Lint Fallback; OpenAPI‑Pfad → backend/gateway; ADR‑Pfade → docs/adr/**; `staticAnalysis` in Build integriert
- youtrack-sync.yml: unverändert (funktional)

Refs: MP-22, MP-23

* chore(ci): Workflows an Docker-SSoT & neue Struktur angepasst, minimaler SSoT-Guard

- ssot-guard.yml: Option B (minimal) → `docker compose -f docker/docker-compose.yaml config` als Lint
- integration-tests.yml: `./gradlew staticAnalysis` vor Integrationstests
- docs-kdoc-sync.yml: Dokka-Task Fallback (dokkaGfmAll || dokkaGfm), YouTrack-Sync nur wenn Script vorhanden
- deploy-proxmox.yml: Compose-Pfade auf docker/docker-compose.yaml + `--env-file docker/.env`; Build/Test Schritte vereinheitlicht
- ci-main.yml: SSoT-Skripte per `if: hashFiles(...)` guarded, Compose-Lint Fallback; OpenAPI‑Pfad → backend/gateway; ADR‑Pfade → docs/adr/**; `staticAnalysis` in Build integriert
- youtrack-sync.yml: unverändert (funktional)

Refs: MP-22, MP-23

* fix(ci): create .env from example before validating compose config

* fix(ci): update ssot-guard filename (.yaml) and sync workflow state

* fixing

* fix(webpack): correct sql.js fallback configuration for webpack 5
This commit is contained in:
StefanMo
2025-12-03 12:03:40 +01:00
committed by GitHub
parent 034892e890
commit 95fe3e0573
365 changed files with 2283 additions and 15142 deletions
@@ -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,464 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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 kotlin.uuid.Uuid
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.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 { Uuid.parse(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<AltersklasseDto>("Invalid age class ID"))
val altersklasse = getAltersklasseUseCase.getById(altersklasseId)
if (altersklasse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(altersklasse.toDto()))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<AltersklasseDto>("Age class not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<AltersklasseDto>("Failed to retrieve age class: ${e.message}"))
}
}
// GET /api/masterdata/altersklassen/code/{code} - Get age class by code
get("/code/{code}") {
try {
val altersklasseCode = call.parameters["code"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<AltersklasseDto>("Age class code is required"))
val altersklasse = getAltersklasseUseCase.getByCode(altersklasseCode)
if (altersklasse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(altersklasse.toDto()))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<AltersklasseDto>("Age class not found"))
}
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<AltersklasseDto>(e.message ?: "Invalid age class code"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<AltersklasseDto>("Failed to retrieve age class: ${e.message}"))
}
}
// GET /api/masterdata/altersklassen/search - Search age classes by name
get("/search") {
try {
val validationErrors = ApiValidationUtils.validateQueryParameters(
limit = call.request.queryParameters["limit"],
q = call.request.queryParameters["q"]
)
if (!ApiValidationUtils.isValid(validationErrors)) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<List<AltersklasseDto>>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@get
}
val searchTerm = call.request.queryParameters["q"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<AltersklasseDto>>("Search term 'q' is required"))
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
val altersklassen = getAltersklasseUseCase.searchByName(searchTerm, limit)
val altersklasseDtos = altersklassen.map { it.toDto() }
call.respond(HttpStatusCode.OK, ApiResponse.success(altersklasseDtos))
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<AltersklasseDto>>(e.message ?: "Invalid search parameters"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<AltersklasseDto>>("Failed to search age classes: ${e.message}"))
}
}
// GET /api/masterdata/altersklassen/age/{age} - Get age classes applicable for specific age
get("/age/{age}") {
try {
val age = call.parameters["age"]?.toIntOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<AltersklasseDto>>("Invalid age parameter"))
if (age < 0) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<AltersklasseDto>>("Age must be non-negative"))
}
val sparteFilterParam = call.request.queryParameters["sparte"]
val sparteFilter = sparteFilterParam?.let { SparteE.valueOf(it.uppercase()) }
val geschlechtFilterParam = call.request.queryParameters["geschlecht"]
val geschlechtFilter = geschlechtFilterParam?.let { gender ->
if (gender.length == 1 && (gender == "M" || gender == "W")) {
gender[0]
} else {
return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<List<AltersklasseDto>>("Invalid geschlecht parameter. Must be 'M' or 'W'")
)
}
}
val altersklassen = getAltersklasseUseCase.getApplicableForAge(age, sparteFilter, geschlechtFilter)
val altersklasseDtos = altersklassen.map { it.toDto() }
call.respond(HttpStatusCode.OK, ApiResponse.success(altersklasseDtos))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<AltersklasseDto>>("Failed to retrieve age classes: ${e.message}"))
}
}
// GET /api/masterdata/altersklassen/sparte/{sparte} - Get age classes by sport type
get("/sparte/{sparte}") {
try {
val sparteParam = call.parameters["sparte"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<AltersklasseDto>>("Sport type is required"))
val sparte = try {
SparteE.valueOf(sparteParam.uppercase())
} catch (_: Exception) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<AltersklasseDto>>("Invalid sport type: $sparteParam"))
}
val activeOnlyParam = call.request.queryParameters["activeOnly"]
val activeOnly = activeOnlyParam?.toBoolean() ?: true
val altersklassen = getAltersklasseUseCase.getBySparte(sparte, activeOnly)
val altersklasseDtos = altersklassen.map { it.toDto() }
call.respond(HttpStatusCode.OK, ApiResponse.success(altersklasseDtos))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<AltersklasseDto>>("Failed to retrieve age classes: ${e.message}"))
}
}
// POST /api/masterdata/altersklassen - Create new age class
post {
try {
val createDto = call.receive<CreateAltersklasseDto>()
// Basic validation
if (createDto.altersklasseCode.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("Age class code is required")
)
return@post
}
if (createDto.bezeichnung.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("Bezeichnung is required")
)
return@post
}
val sparteFilter = createDto.sparteFilter?.let {
try {
SparteE.valueOf(it.uppercase())
} catch (_: Exception) {
return@post call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("Invalid sparte filter: $it")
)
}
}
val geschlechtFilter = createDto.geschlechtFilter?.let { gender ->
if (gender.length == 1 && (gender == "M" || gender == "W")) {
gender[0]
} else {
return@post call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("Invalid geschlecht filter. Must be 'M' or 'W'")
)
}
}
val oetoRegelReferenzId = createDto.oetoRegelReferenzId?.let {
try {
Uuid.parse(it)
} catch (_: Exception) {
return@post call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("Invalid OETO regel referenz ID format")
)
}
}
val request = CreateAltersklasseUseCase.CreateAltersklasseRequest(
altersklasseCode = createDto.altersklasseCode,
bezeichnung = createDto.bezeichnung,
minAlter = createDto.minAlter,
maxAlter = createDto.maxAlter,
stichtagRegelText = createDto.stichtagRegelText,
sparteFilter = sparteFilter,
geschlechtFilter = geschlechtFilter,
oetoRegelReferenzId = oetoRegelReferenzId,
istAktiv = createDto.istAktiv
)
val result = createAltersklasseUseCase.createAltersklasse(request)
if (result.success) {
call.respond(HttpStatusCode.Created, ApiResponse.success(result.altersklasse!!.toDto()))
} else {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<AltersklasseDto>("Validation failed: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<AltersklasseDto>("Failed to create age class: ${e.message}"))
}
}
// PUT /api/masterdata/altersklassen/{id} - Update existing age class
put("/{id}") {
try {
val altersklasseId = call.parameters["id"]?.let { Uuid.parse(it) }
?: return@put call.respond(HttpStatusCode.BadRequest, ApiResponse.error<AltersklasseDto>("Invalid age class ID"))
val updateDto = call.receive<UpdateAltersklasseDto>()
// Basic validation
if (updateDto.altersklasseCode.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("Age class code is required")
)
return@put
}
if (updateDto.bezeichnung.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("Bezeichnung is required")
)
return@put
}
val sparteFilter = updateDto.sparteFilter?.let {
try {
SparteE.valueOf(it.uppercase())
} catch (_: Exception) {
return@put call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("Invalid sparte filter: $it")
)
}
}
val geschlechtFilter = updateDto.geschlechtFilter?.let { gender ->
if (gender.length == 1 && (gender == "M" || gender == "W")) {
gender[0]
} else {
return@put call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("Invalid geschlecht filter. Must be 'M' or 'W'")
)
}
}
val oetoRegelReferenzId = updateDto.oetoRegelReferenzId?.let {
try {
Uuid.parse(it)
} catch (_: Exception) {
return@put call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<AltersklasseDto>("Invalid OETO regel referenz ID format")
)
}
}
val request = CreateAltersklasseUseCase.UpdateAltersklasseRequest(
altersklasseId = altersklasseId,
altersklasseCode = updateDto.altersklasseCode,
bezeichnung = updateDto.bezeichnung,
minAlter = updateDto.minAlter,
maxAlter = updateDto.maxAlter,
stichtagRegelText = updateDto.stichtagRegelText,
sparteFilter = sparteFilter,
geschlechtFilter = geschlechtFilter,
oetoRegelReferenzId = oetoRegelReferenzId,
istAktiv = updateDto.istAktiv
)
val result = createAltersklasseUseCase.updateAltersklasse(request)
if (result.success) {
call.respond(HttpStatusCode.OK, ApiResponse.success(result.altersklasse!!.toDto()))
} else {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<AltersklasseDto>("Validation failed: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<AltersklasseDto>("Failed to update age class: ${e.message}"))
}
}
// DELETE /api/masterdata/altersklassen/{id} - Delete age class
delete("/{id}") {
try {
val altersklasseId = call.parameters["id"]?.let { Uuid.parse(it) }
?: return@delete call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Unit>("Invalid age class ID"))
val result = createAltersklasseUseCase.deleteAltersklasse(altersklasseId)
if (result.success) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>("Age class not found: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Unit>("Failed to delete age class: ${e.message}"))
}
}
// GET /api/masterdata/altersklassen/eligible/{id} - Check eligibility for age class
get("/eligible/{id}") {
try {
val altersklasseId = call.parameters["id"]?.let { Uuid.parse(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Boolean>("Invalid age class ID"))
val ageParam = call.request.queryParameters["age"]?.toIntOrNull()
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Boolean>("Age parameter is required"))
val geschlechtParam = call.request.queryParameters["geschlecht"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Boolean>("Gender parameter is required"))
if (geschlechtParam.length != 1 || (geschlechtParam != "M" && geschlechtParam != "W")) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Boolean>("Gender must be 'M' or 'W'"))
}
val isEligible = getAltersklasseUseCase.isEligible(altersklasseId, ageParam, geschlechtParam[0])
call.respond(HttpStatusCode.OK, ApiResponse.success(isEligible))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Boolean>("Failed to check eligibility: ${e.message}"))
}
}
}
}
/**
* 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,369 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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 kotlin.uuid.Uuid
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.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 { Uuid.parse(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<BundeslandDto>("Invalid federal state ID"))
val bundesland = getBundeslandUseCase.getById(bundeslandId)
if (bundesland != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(bundesland.toDto()))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<BundeslandDto>("Federal state not found"))
}
} 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 {
Uuid.parse(landIdParam)
} catch (_: Exception) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<BundeslandDto>("Invalid country ID format"))
}
val bundesland = getBundeslandUseCase.getByOepsCode(oepsCode, landId)
if (bundesland != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(bundesland.toDto()))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<BundeslandDto>("Federal state not found"))
}
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<BundeslandDto>(e.message ?: "Invalid OEPS code"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<BundeslandDto>("Failed to retrieve federal state: ${e.message}"))
}
}
// GET /api/masterdata/bundeslaender/iso/{code} - Get federal state by ISO 3166-2 code
get("/iso/{code}") {
try {
val isoCode = call.parameters["code"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<BundeslandDto>("ISO 3166-2 code is required"))
val bundesland = getBundeslandUseCase.getByIso3166_2_Code(isoCode)
if (bundesland != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(bundesland.toDto()))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<BundeslandDto>("Federal state not found"))
}
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<BundeslandDto>(e.message ?: "Invalid ISO code"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<BundeslandDto>("Failed to retrieve federal state: ${e.message}"))
}
}
// GET /api/masterdata/bundeslaender/country/{countryId} - Get federal states by country
get("/country/{countryId}") {
try {
val landId = call.parameters["countryId"]?.let { Uuid.parse(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<BundeslandDto>>("Invalid country ID"))
val activeOnlyParam = call.request.queryParameters["activeOnly"]
val activeOnly = activeOnlyParam?.toBoolean() ?: true
val orderBySortierungParam = call.request.queryParameters["orderBySortierung"]
val orderBySortierung = orderBySortierungParam?.toBoolean() ?: true
val bundeslaender = getBundeslandUseCase.getByCountry(landId, activeOnly, orderBySortierung)
val bundeslandDtos = bundeslaender.map { it.toDto() }
call.respond(HttpStatusCode.OK, ApiResponse.success(bundeslandDtos))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<BundeslandDto>>("Failed to retrieve federal states: ${e.message}"))
}
}
// GET /api/masterdata/bundeslaender/search - Search federal states by name
get("/search") {
try {
val validationErrors = ApiValidationUtils.validateQueryParameters(
limit = call.request.queryParameters["limit"],
q = call.request.queryParameters["q"]
)
if (!ApiValidationUtils.isValid(validationErrors)) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<List<BundeslandDto>>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@get
}
val searchTerm = call.request.queryParameters["q"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<BundeslandDto>>("Search term 'q' is required"))
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
val landIdParam = call.request.queryParameters["landId"]
val landId = landIdParam?.let { Uuid.parse(it) }
val bundeslaender = getBundeslandUseCase.searchByName(searchTerm, landId, limit)
val bundeslandDtos = bundeslaender.map { it.toDto() }
call.respond(HttpStatusCode.OK, ApiResponse.success(bundeslandDtos))
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<BundeslandDto>>(e.message ?: "Invalid search parameters"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<BundeslandDto>>("Failed to search federal states: ${e.message}"))
}
}
// POST /api/masterdata/bundeslaender - Create new federal state
post {
try {
val createDto = call.receive<CreateBundeslandDto>()
// 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,354 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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 kotlin.uuid.Uuid
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.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 { Uuid.parse(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<CountryDto>("Invalid country ID"))
val country = getCountryUseCase.getById(countryId)
if (country != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(country.toDto()))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<CountryDto>("Country not found"))
}
} 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,475 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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 kotlin.uuid.Uuid
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.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 { Uuid.parse(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<PlatzDto>("Invalid venue ID"))
val platz = getPlatzUseCase.getById(platzId)
if (platz != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(platz.toDto()))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<PlatzDto>("Venue not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<PlatzDto>("Failed to retrieve venue: ${e.message}"))
}
}
// GET /api/masterdata/plaetze/tournament/{turnierId} - Get venues by tournament
get("/tournament/{turnierId}") {
try {
val turnierId = call.parameters["turnierId"]?.let { Uuid.parse(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>("Invalid tournament ID"))
val activeOnlyParam = call.request.queryParameters["activeOnly"]
val activeOnly = activeOnlyParam?.toBoolean() ?: true
val orderBySortierungParam = call.request.queryParameters["orderBySortierung"]
val orderBySortierung = orderBySortierungParam?.toBoolean() ?: true
val plaetze = getPlatzUseCase.getByTournament(turnierId, activeOnly, orderBySortierung)
val platzDtos = plaetze.map { it.toDto() }
call.respond(HttpStatusCode.OK, ApiResponse.success(platzDtos))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<PlatzDto>>("Failed to retrieve venues: ${e.message}"))
}
}
// GET /api/masterdata/plaetze/search - Search venues by name
get("/search") {
try {
val validationErrors = ApiValidationUtils.validateQueryParameters(
limit = call.request.queryParameters["limit"],
q = call.request.queryParameters["q"]
)
if (!ApiValidationUtils.isValid(validationErrors)) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<List<PlatzDto>>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@get
}
val searchTerm = call.request.queryParameters["q"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>("Search term 'q' is required"))
val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 50
val turnierIdParam = call.request.queryParameters["turnierId"]
val turnierId = turnierIdParam?.let { uuidFrom(it) }
val plaetze = getPlatzUseCase.searchByName(searchTerm, turnierId, limit)
val platzDtos = plaetze.map { it.toDto() }
call.respond(HttpStatusCode.OK, ApiResponse.success(platzDtos))
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>(e.message ?: "Invalid search parameters"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<PlatzDto>>("Failed to search venues: ${e.message}"))
}
}
// GET /api/masterdata/plaetze/type/{typ} - Get venues by type
get("/type/{typ}") {
try {
val typParam = call.parameters["typ"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>("Venue type is required"))
val typ = try {
PlatzTypE.valueOf(typParam.uppercase())
} catch (_: Exception) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>("Invalid venue type: $typParam"))
}
val turnierIdParam = call.request.queryParameters["turnierId"]
val turnierId = turnierIdParam?.let { uuidFrom(it) }
val activeOnlyParam = call.request.queryParameters["activeOnly"]
val activeOnly = activeOnlyParam?.toBoolean() ?: true
val plaetze = getPlatzUseCase.getByType(typ, turnierId, activeOnly)
val platzDtos = plaetze.map { it.toDto() }
call.respond(HttpStatusCode.OK, ApiResponse.success(platzDtos))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<PlatzDto>>("Failed to retrieve venues: ${e.message}"))
}
}
// GET /api/masterdata/plaetze/ground/{boden} - Get venues by ground type
get("/ground/{boden}") {
try {
val boden = call.parameters["boden"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>("Ground type is required"))
val turnierIdParam = call.request.queryParameters["turnierId"]
val turnierId = turnierIdParam?.let { uuidFrom(it) }
val activeOnlyParam = call.request.queryParameters["activeOnly"]
val activeOnly = activeOnlyParam?.toBoolean() ?: true
val plaetze = getPlatzUseCase.getByGroundType(boden, turnierId, activeOnly)
val platzDtos = plaetze.map { it.toDto() }
call.respond(HttpStatusCode.OK, ApiResponse.success(platzDtos))
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>(e.message ?: "Invalid ground type"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<PlatzDto>>("Failed to retrieve venues: ${e.message}"))
}
}
// GET /api/masterdata/plaetze/dimension/{dimension} - Get venues by dimensions
get("/dimension/{dimension}") {
try {
val dimension = call.parameters["dimension"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>("Dimension is required"))
val turnierIdParam = call.request.queryParameters["turnierId"]
val turnierId = turnierIdParam?.let { uuidFrom(it) }
val activeOnlyParam = call.request.queryParameters["activeOnly"]
val activeOnly = activeOnlyParam?.toBoolean() ?: true
val plaetze = getPlatzUseCase.getByDimensions(dimension, turnierId, activeOnly)
val platzDtos = plaetze.map { it.toDto() }
call.respond(HttpStatusCode.OK, ApiResponse.success(platzDtos))
} catch (e: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>(e.message ?: "Invalid dimension"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<PlatzDto>>("Failed to retrieve venues: ${e.message}"))
}
}
// GET /api/masterdata/plaetze/suitable - Get venues suitable for discipline
get("/suitable") {
try {
val typParam = call.request.queryParameters["typ"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>("Required venue type parameter is missing"))
val requiredType = try {
PlatzTypE.valueOf(typParam.uppercase())
} catch (_: Exception) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<List<PlatzDto>>("Invalid venue type: $typParam"))
}
val requiredDimensions = call.request.queryParameters["dimension"]
val turnierIdParam = call.request.queryParameters["turnierId"]
val turnierId = turnierIdParam?.let { uuidFrom(it) }
val plaetze = getPlatzUseCase.getSuitableForDiscipline(requiredType, requiredDimensions, turnierId)
val platzDtos = plaetze.map { it.toDto() }
call.respond(HttpStatusCode.OK, ApiResponse.success(platzDtos))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<List<PlatzDto>>("Failed to retrieve suitable venues: ${e.message}"))
}
}
// POST /api/masterdata/plaetze - Create new venue
post {
try {
val createDto = call.receive<CreatePlatzDto>()
// Basic validation
if (createDto.name.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<PlatzDto>("Name is required")
)
return@post
}
val turnierId = try {
uuidFrom(createDto.turnierId)
} catch (_: Exception) {
return@post call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<PlatzDto>("Invalid tournament ID format")
)
}
val typ = try {
PlatzTypE.valueOf(createDto.typ.uppercase())
} catch (_: Exception) {
return@post call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<PlatzDto>("Invalid venue type: ${createDto.typ}")
)
}
val request = CreatePlatzUseCase.CreatePlatzRequest(
turnierId = turnierId,
name = createDto.name,
dimension = createDto.dimension,
boden = createDto.boden,
typ = typ,
istAktiv = createDto.istAktiv,
sortierReihenfolge = createDto.sortierReihenfolge
)
val result = createPlatzUseCase.createPlatz(request)
if (result.success) {
call.respond(HttpStatusCode.Created, ApiResponse.success(result.platz!!.toDto()))
} else {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<PlatzDto>("Validation failed: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<PlatzDto>("Failed to create venue: ${e.message}"))
}
}
// PUT /api/masterdata/plaetze/{id} - Update existing venue
put("/{id}") {
try {
val platzId = call.parameters["id"]?.let { uuidFrom(it) }
?: return@put call.respond(HttpStatusCode.BadRequest, ApiResponse.error<PlatzDto>("Invalid venue ID"))
val updateDto = call.receive<UpdatePlatzDto>()
// Basic validation
if (updateDto.name.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<PlatzDto>("Name is required")
)
return@put
}
val turnierId = try {
uuidFrom(updateDto.turnierId)
} catch (_: Exception) {
return@put call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<PlatzDto>("Invalid tournament ID format")
)
}
val typ = try {
PlatzTypE.valueOf(updateDto.typ.uppercase())
} catch (_: Exception) {
return@put call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<PlatzDto>("Invalid venue type: ${updateDto.typ}")
)
}
val request = CreatePlatzUseCase.UpdatePlatzRequest(
platzId = platzId,
turnierId = turnierId,
name = updateDto.name,
dimension = updateDto.dimension,
boden = updateDto.boden,
typ = typ,
istAktiv = updateDto.istAktiv,
sortierReihenfolge = updateDto.sortierReihenfolge
)
val result = createPlatzUseCase.updatePlatz(request)
if (result.success) {
call.respond(HttpStatusCode.OK, ApiResponse.success(result.platz!!.toDto()))
} else {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<PlatzDto>("Validation failed: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<PlatzDto>("Failed to update venue: ${e.message}"))
}
}
// DELETE /api/masterdata/plaetze/{id} - Delete venue
delete("/{id}") {
try {
val platzId = call.parameters["id"]?.let { uuidFrom(it) }
?: return@delete call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Unit>("Invalid venue ID"))
val result = createPlatzUseCase.deletePlatz(platzId)
if (result.success) {
call.respond(HttpStatusCode.NoContent)
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Unit>("Venue not found: ${result.errors.joinToString(", ")}"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Unit>("Failed to delete venue: ${e.message}"))
}
}
// GET /api/masterdata/plaetze/count/tournament/{turnierId} - Count venues by tournament
get("/count/tournament/{turnierId}") {
try {
val turnierId = call.parameters["turnierId"]?.let { uuidFrom(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Long>("Invalid tournament ID"))
val count = getPlatzUseCase.countActiveByTournament(turnierId)
call.respond(HttpStatusCode.OK, ApiResponse.success(count))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Long>("Failed to count venues: ${e.message}"))
}
}
// GET /api/masterdata/plaetze/count/type/{typ}/tournament/{turnierId} - Count venues by type and tournament
get("/count/type/{typ}/tournament/{turnierId}") {
try {
val typParam = call.parameters["typ"]
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Long>("Venue type is required"))
val typ = try {
PlatzTypE.valueOf(typParam.uppercase())
} catch (_: Exception) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Long>("Invalid venue type: $typParam"))
}
val turnierId = call.parameters["turnierId"]?.let { uuidFrom(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Long>("Invalid tournament ID"))
val activeOnlyParam = call.request.queryParameters["activeOnly"]
val activeOnly = activeOnlyParam?.toBoolean() ?: true
val count = getPlatzUseCase.countByTypeAndTournament(typ, turnierId, activeOnly)
call.respond(HttpStatusCode.OK, ApiResponse.success(count))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Long>("Failed to count venues: ${e.message}"))
}
}
// GET /api/masterdata/plaetze/grouped/tournament/{turnierId} - Get venues grouped by type
get("/grouped/tournament/{turnierId}") {
try {
val turnierId = call.parameters["turnierId"]?.let { uuidFrom(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Map<String, List<PlatzDto>>>("Invalid tournament ID"))
val activeOnlyParam = call.request.queryParameters["activeOnly"]
val activeOnly = activeOnlyParam?.toBoolean() ?: true
val groupedVenues = getPlatzUseCase.getGroupedByTypeForTournament(turnierId, activeOnly)
val groupedDtos = groupedVenues.mapKeys { it.key.name }.mapValues { entry ->
entry.value.map { it.toDto() }
}
call.respond(HttpStatusCode.OK, ApiResponse.success(groupedDtos))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Map<String, List<PlatzDto>>>("Failed to retrieve grouped venues: ${e.message}"))
}
}
// GET /api/masterdata/plaetze/validate/{id} - Validate venue suitability
get("/validate/{id}") {
try {
val platzId = call.parameters["id"]?.let { uuidFrom(it) }
?: return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Map<String, Any>>("Invalid venue ID"))
val requiredTypeParam = call.request.queryParameters["requiredType"]
val requiredType = requiredTypeParam?.let {
try {
PlatzTypE.valueOf(it.uppercase())
} catch (_: Exception) {
return@get call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Map<String, Any>>("Invalid required type: $it"))
}
}
val requiredDimensions = call.request.queryParameters["requiredDimensions"]
val requiredGroundType = call.request.queryParameters["requiredGroundType"]
val (isValid, reasons) = getPlatzUseCase.validateVenueSuitability(platzId, requiredType, requiredDimensions, requiredGroundType)
val response = mapOf(
"isValid" to isValid,
"reasons" to reasons
)
call.respond(HttpStatusCode.OK, ApiResponse.success(response))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Map<String, Any>>("Failed to validate venue: ${e.message}"))
}
}
}
}
/**
* 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()
)
}
}