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,44 @@
plugins {
// KORREKTUR: Alle Plugins werden jetzt konsistent über den Version Catalog geladen.
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
alias(libs.plugins.kotlin.serialization)
// Das Ktor-Plugin wird hier nicht benötigt, da Ktor als Bibliothek in Spring Boot läuft.
// Das 'application'-Plugin wird vom Spring Boot Plugin bereitgestellt.
alias(libs.plugins.spring.boot)
alias(libs.plugins.spring.dependencyManagement)
}
// Der springBoot-Block konfiguriert die Anwendung, wenn sie als JAR-Datei ausgeführt wird.
springBoot {
mainClass.set("at.mocode.horses.api.ApplicationKt")
}
dependencies {
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.horses.horsesDomain)
implementation(projects.horses.horsesApplication)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
// Spring dependencies
implementation(libs.spring.web)
// Ktor Server (als embedded Server in Spring)
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.contentNegotiation)
implementation(libs.ktor.server.serialization.kotlinx.json)
implementation(libs.ktor.server.statusPages)
implementation(libs.ktor.server.auth)
implementation(libs.ktor.server.authJwt)
// Testing
testImplementation(projects.platform.platformTesting)
testImplementation(libs.ktor.server.tests)
testImplementation(libs.spring.boot.starter.test)
}
@@ -0,0 +1,438 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.horses.api.rest
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.PferdeGeschlechtE
import at.mocode.horses.application.usecase.CreateHorseUseCase
import at.mocode.horses.application.usecase.DeleteHorseUseCase
import at.mocode.horses.application.usecase.GetHorseUseCase
import at.mocode.horses.application.usecase.UpdateHorseUseCase
import at.mocode.horses.domain.repository.HorseRepository
import at.mocode.core.utils.validation.ApiValidationUtils
import kotlin.uuid.Uuid
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
/**
* REST API controller for horse registry operations.
*
* This controller provides HTTP endpoints for all horse-related operations
* following REST conventions and proper HTTP status codes.
*/
class HorseController(
private val horseRepository: HorseRepository
) {
private val getHorseUseCase = GetHorseUseCase(horseRepository)
private val createHorseUseCase = CreateHorseUseCase(horseRepository)
private val updateHorseUseCase = UpdateHorseUseCase(horseRepository)
private val deleteHorseUseCase = DeleteHorseUseCase(horseRepository)
/**
* Configures the horse-related routes.
*/
fun configureRoutes(routing: Routing) {
routing.route("/api/horses") {
// GET /api/horses - Get all horses with optional filtering
get {
try {
// Validate query parameters
val validationErrors = ApiValidationUtils.validateQueryParameters(
limit = call.request.queryParameters["limit"],
search = call.request.queryParameters["search"]
)
if (!ApiValidationUtils.isValid(validationErrors)) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@get
}
val activeOnly = call.request.queryParameters["activeOnly"]?.toBoolean() ?: true
val limit = call.request.queryParameters["limit"]?.toInt() ?: 100
val ownerId = call.request.queryParameters["ownerId"]?.let {
ApiValidationUtils.validateUuidString(it) ?: return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>("Invalid ownerId format")
)
}
val geschlecht = call.request.queryParameters["geschlecht"]?.let {
try {
PferdeGeschlechtE.valueOf(it)
} catch (_: IllegalArgumentException) {
return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>("Invalid geschlecht value. Valid values: ${PferdeGeschlechtE.entries.joinToString(", ")}")
)
}
}
val rasse = call.request.queryParameters["rasse"]
val searchTerm = call.request.queryParameters["search"]
val horses = when {
searchTerm != null -> getHorseUseCase.searchByName(searchTerm, limit)
ownerId != null -> getHorseUseCase.getByOwnerId(ownerId, activeOnly)
geschlecht != null -> getHorseUseCase.getByGeschlecht(geschlecht, activeOnly, limit)
rasse != null -> getHorseUseCase.getByRasse(rasse, activeOnly, limit)
else -> getHorseUseCase.getAllActive(limit)
}
call.respond(HttpStatusCode.OK, ApiResponse.success(horses))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve horses: ${e.message}"))
}
}
// GET /api/horses/{id} - Get horse by ID
get("/{id}") {
try {
val horseId = Uuid.parse(call.parameters["id"]!!)
val horse = getHorseUseCase.getById(horseId)
if (horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse not found"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve horse: ${e.message}"))
}
}
// GET /api/horses/search/lebensnummer/{nummer} - Find by life number
get("/search/lebensnummer/{nummer}") {
try {
val lebensnummer = call.parameters["nummer"]!!
val horse = getHorseUseCase.getByLebensnummer(lebensnummer)
if (horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with life number '$lebensnummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
}
}
// GET /api/horses/search/chip/{nummer} - Find by chip number
get("/search/chip/{nummer}") {
try {
val chipNummer = call.parameters["nummer"]!!
val horse = getHorseUseCase.getByChipNummer(chipNummer)
if (horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with chip number '$chipNummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
}
}
// GET /api/horses/search/passport/{nummer} - Find by passport number
get("/search/passport/{nummer}") {
try {
val passNummer = call.parameters["nummer"]!!
val horse = getHorseUseCase.getByPassNummer(passNummer)
if (horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with passport number '$passNummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
}
}
// GET /api/horses/search/oeps/{nummer} - Find by OEPS number
get("/search/oeps/{nummer}") {
try {
val oepsNummer = call.parameters["nummer"]!!
val horse = getHorseUseCase.getByOepsNummer(oepsNummer)
if (horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with OEPS number '$oepsNummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
}
}
// GET /api/horses/search/fei/{nummer} - Find by FEI number
get("/search/fei/{nummer}") {
try {
val feiNummer = call.parameters["nummer"]!!
val horse = getHorseUseCase.getByFeiNummer(feiNummer)
if (horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(horse))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Horse with FEI number '$feiNummer' not found"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to search horse: ${e.message}"))
}
}
// GET /api/horses/oeps-registered - Get OEPS registered horses
get("/oeps-registered") {
try {
val activeOnly = call.request.queryParameters["activeOnly"]?.toBoolean() ?: true
val horses = getHorseUseCase.getOepsRegistered(activeOnly)
call.respond(HttpStatusCode.OK, ApiResponse.success(horses))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve OEPS horses: ${e.message}"))
}
}
// GET /api/horses/fei-registered - Get FEI registered horses
get("/fei-registered") {
try {
val activeOnly = call.request.queryParameters["activeOnly"]?.toBoolean() ?: true
val horses = getHorseUseCase.getFeiRegistered(activeOnly)
call.respond(HttpStatusCode.OK, ApiResponse.success(horses))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve FEI horses: ${e.message}"))
}
}
// GET /api/horses/stats - Get horse statistics
get("/stats") {
try {
val activeCount = getHorseUseCase.countActive()
val oepsCount = getHorseUseCase.countOepsRegistered(true)
val feiCount = getHorseUseCase.countFeiRegistered(true)
val stats = HorseStats(
totalActive = activeCount,
oepsRegistered = oepsCount,
feiRegistered = feiCount
)
call.respond(HttpStatusCode.OK, ApiResponse.success(stats))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve statistics: ${e.message}"))
}
}
// POST /api/horses - Create new horse
post {
try {
val createRequest = call.receive<CreateHorseUseCase.CreateHorseRequest>()
// Validate input using shared validation utilities
val validationErrors = ApiValidationUtils.validateHorseRequest(
pferdeName = createRequest.pferdeName,
lebensnummer = createRequest.lebensnummer,
chipNummer = createRequest.chipNummer,
oepsNummer = createRequest.oepsNummer,
feiNummer = createRequest.feiNummer
)
if (!ApiValidationUtils.isValid(validationErrors)) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@post
}
val response = createHorseUseCase.execute(createRequest)
if (response.success) {
call.respond(HttpStatusCode.Created, ApiResponse.success(response.data!!))
} else {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Validation failed"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to create horse: ${e.message}"))
}
}
// PUT /api/horses/{id} - Update horse
put("/{id}") {
try {
val horseId = Uuid.parse(call.parameters["id"]!!)
val updateData = call.receive<UpdateHorseRequest>()
// Validate input using shared validation utilities
val validationErrors = ApiValidationUtils.validateHorseRequest(
pferdeName = updateData.pferdeName,
lebensnummer = updateData.lebensnummer,
chipNummer = updateData.chipNummer,
oepsNummer = updateData.oepsNummer,
feiNummer = updateData.feiNummer
)
if (!ApiValidationUtils.isValid(validationErrors)) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@put
}
val updateRequest = UpdateHorseUseCase.UpdateHorseRequest(
pferdId = horseId,
pferdeName = updateData.pferdeName,
geschlecht = updateData.geschlecht,
geburtsdatum = updateData.geburtsdatum,
rasse = updateData.rasse,
farbe = updateData.farbe,
besitzerId = updateData.besitzerId,
verantwortlichePersonId = updateData.verantwortlichePersonId,
zuechterName = updateData.zuechterName,
zuchtbuchNummer = updateData.zuchtbuchNummer,
lebensnummer = updateData.lebensnummer,
chipNummer = updateData.chipNummer,
passNummer = updateData.passNummer,
oepsNummer = updateData.oepsNummer,
feiNummer = updateData.feiNummer,
vaterName = updateData.vaterName,
mutterName = updateData.mutterName,
mutterVaterName = updateData.mutterVaterName,
stockmass = updateData.stockmass,
istAktiv = updateData.istAktiv,
bemerkungen = updateData.bemerkungen,
datenQuelle = updateData.datenQuelle
)
val response = updateHorseUseCase.execute(updateRequest)
if (response.success && response.horse != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success(response.horse))
} else {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Update failed: ${response.errors.joinToString(", ")}"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to update horse: ${e.message}"))
}
}
// DELETE /api/horses/{id} - Delete horse
delete("/{id}") {
try {
val horseId = Uuid.parse(call.parameters["id"]!!)
val forceDelete = call.request.queryParameters["force"]?.toBoolean() ?: false
val deleteRequest = DeleteHorseUseCase.DeleteHorseRequest(horseId, forceDelete)
val response = deleteHorseUseCase.execute(deleteRequest)
if (response.success) {
val message = if (response.warnings.isNotEmpty()) {
"Horse deleted successfully. Warnings: ${response.warnings.joinToString(", ")}"
} else {
"Horse deleted successfully"
}
call.respond(HttpStatusCode.OK, ApiResponse.success(message))
} else {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Delete failed: ${response.errors.joinToString(", ")}"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to delete horse: ${e.message}"))
}
}
// POST /api/horses/{id}/soft-delete - Soft delete horse (mark as inactive)
post("/{id}/soft-delete") {
try {
val horseId = Uuid.parse(call.parameters["id"]!!)
val response = deleteHorseUseCase.softDelete(horseId)
if (response.success) {
val message = if (response.warnings.isNotEmpty()) {
"Horse marked as inactive. Warnings: ${response.warnings.joinToString(", ")}"
} else {
"Horse marked as inactive"
}
call.respond(HttpStatusCode.OK, ApiResponse.success(message))
} else {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Soft delete failed: ${response.errors.joinToString(", ")}"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid horse ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to soft delete horse: ${e.message}"))
}
}
// POST /api/horses/batch-delete - Batch delete multiple horses
post("/batch-delete") {
try {
val batchRequest = call.receive<BatchDeleteRequest>()
val response = deleteHorseUseCase.batchDelete(batchRequest.horseIds, batchRequest.forceDelete)
val statusCode = if (response.overallSuccess) HttpStatusCode.OK else HttpStatusCode.PartialContent
call.respond(statusCode, ApiResponse.success(response))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to batch delete horses: ${e.message}"))
}
}
}
}
/**
* DTO for updating horse data via API.
*/
@Serializable
data class UpdateHorseRequest(
val pferdeName: String,
val geschlecht: PferdeGeschlechtE,
val geburtsdatum: kotlinx.datetime.LocalDate? = null,
val rasse: String? = null,
val farbe: String? = null,
@Contextual val besitzerId: Uuid? = null,
@Contextual val verantwortlichePersonId: Uuid? = null,
val zuechterName: String? = null,
val zuchtbuchNummer: String? = null,
val lebensnummer: String? = null,
val chipNummer: String? = null,
val passNummer: String? = null,
val oepsNummer: String? = null,
val feiNummer: String? = null,
val vaterName: String? = null,
val mutterName: String? = null,
val mutterVaterName: String? = null,
val stockmass: Int? = null,
val istAktiv: Boolean = true,
val bemerkungen: String? = null,
val datenQuelle: at.mocode.core.domain.model.DatenQuelleE = at.mocode.core.domain.model.DatenQuelleE.MANUELL
)
/**
* DTO for batch delete request.
*/
@Serializable
data class BatchDeleteRequest(
val horseIds: List<@Contextual Uuid>,
val forceDelete: Boolean = false
)
/**
* DTO for horse statistics.
*/
@Serializable
data class HorseStats(
val totalActive: Long,
val oepsRegistered: Long,
val feiRegistered: Long
)
}