Merge pull request #18

* MP-19 Refactoring: Einführung der "Registry" & "Masterdata" Trennung …

* MP-19 Refactoring: Frontend Tabula Rasa

* MP-19 Refactoring: Frontend Tabula Rasa

* refactoring:

* MP-20 fix(docker/clients): include `:domains` module in web/desktop b…

* MP-20 fix(web-app build): resolve JS compile error and add dev/prod b…

* MP-20 fix(web-app): remove vendor.js reference and harden JS bootstra…

* MP-20 fixing: clients

* MP-20 fixing: clients
This commit is contained in:
StefanMo
2025-11-30 14:13:12 +01:00
committed by GitHub
parent 596a05b69c
commit 9ea2b74a81
254 changed files with 5485 additions and 15971 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
)
}