refactor: Migrate from monolithic to modular architecture

- Restructure project into domain-specific modules (core, masterdata, members, horses, events, infrastructure)
- Create shared client components in common-ui module
- Implement CI/CD workflows with GitHub Actions
- Consolidate documentation in docs directory
- Remove deprecated modules and documentation files
- Add cleanup and migration scripts for transition
- Update README with new project structure and setup instructions
This commit is contained in:
stefan
2025-07-22 18:44:18 +02:00
parent 8229e8e571
commit a256622f37
314 changed files with 5930 additions and 19817 deletions
+36
View File
@@ -0,0 +1,36 @@
plugins {
kotlin("jvm")
kotlin("plugin.spring")
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ktor)
application
}
application {
mainClass.set("at.mocode.events.api.ApplicationKt")
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.events.eventsDomain)
implementation(projects.events.eventsApplication)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
// Spring dependencies
implementation("org.springframework:spring-web")
implementation("org.springdoc:springdoc-openapi-starter-common")
// Ktor Server
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.contentNegotiation)
implementation(libs.ktor.server.serializationKotlinxJson)
implementation(libs.ktor.server.statusPages)
implementation(libs.ktor.server.auth)
implementation(libs.ktor.server.authJwt)
testImplementation(projects.platform.platformTesting)
testImplementation(libs.ktor.server.tests)
}
@@ -0,0 +1,333 @@
package at.mocode.events.api.rest
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.SparteE
import at.mocode.events.application.usecase.CreateVeranstaltungUseCase
import at.mocode.events.application.usecase.DeleteVeranstaltungUseCase
import at.mocode.events.application.usecase.GetVeranstaltungUseCase
import at.mocode.events.application.usecase.UpdateVeranstaltungUseCase
import at.mocode.events.domain.repository.VeranstaltungRepository
import at.mocode.core.domain.serialization.UuidSerializer
import at.mocode.core.utils.validation.ApiValidationUtils
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuidFrom
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
/**
* REST API controller for event management operations.
*
* This controller provides HTTP endpoints for all event-related operations
* following REST conventions and proper HTTP status codes.
*/
class VeranstaltungController(
private val veranstaltungRepository: VeranstaltungRepository
) {
private val createVeranstaltungUseCase = CreateVeranstaltungUseCase(veranstaltungRepository)
private val getVeranstaltungUseCase = GetVeranstaltungUseCase(veranstaltungRepository)
private val updateVeranstaltungUseCase = UpdateVeranstaltungUseCase(veranstaltungRepository)
private val deleteVeranstaltungUseCase = DeleteVeranstaltungUseCase(veranstaltungRepository)
/**
* Configures the event-related routes.
*/
fun configureRoutes(routing: Routing) {
routing.route("/api/events") {
// GET /api/events - Get all events with optional filtering
get {
try {
// Validate query parameters
val validationErrors = ApiValidationUtils.validateQueryParameters(
limit = call.request.queryParameters["limit"],
offset = call.request.queryParameters["offset"],
startDate = call.request.queryParameters["startDate"],
endDate = call.request.queryParameters["endDate"],
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 offset = call.request.queryParameters["offset"]?.toInt() ?: 0
val organizerId = call.request.queryParameters["organizerId"]?.let {
ApiValidationUtils.validateUuidString(it) ?: return@get call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>("Invalid organizerId format")
)
}
val searchTerm = call.request.queryParameters["search"]
val publicOnly = call.request.queryParameters["publicOnly"]?.toBoolean() ?: false
val startDate = call.request.queryParameters["startDate"]?.let { LocalDate.parse(it) }
val endDate = call.request.queryParameters["endDate"]?.let { LocalDate.parse(it) }
val events = when {
searchTerm != null -> veranstaltungRepository.findByName(searchTerm, limit)
organizerId != null -> veranstaltungRepository.findByVeranstalterVereinId(organizerId, activeOnly)
publicOnly -> veranstaltungRepository.findPublicEvents(activeOnly)
startDate != null && endDate != null -> veranstaltungRepository.findByDateRange(startDate, endDate, activeOnly)
startDate != null -> veranstaltungRepository.findByStartDate(startDate, activeOnly)
else -> veranstaltungRepository.findAllActive(limit, offset)
}
call.respond(HttpStatusCode.OK, ApiResponse.success(events))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve events: ${e.message}"))
}
}
// GET /api/events/{id} - Get event by ID
get("/{id}") {
try {
val eventId = uuidFrom(call.parameters["id"]!!)
val request = GetVeranstaltungUseCase.GetVeranstaltungRequest(eventId)
val response = getVeranstaltungUseCase.execute(request)
if (response.success && response.data != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success((response.data as GetVeranstaltungUseCase.GetVeranstaltungResponse).veranstaltung))
} else {
call.respond(HttpStatusCode.NotFound, ApiResponse.error<Any>("Event not found"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid event ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve event: ${e.message}"))
}
}
// GET /api/events/stats - Get event statistics
get("/stats") {
try {
val activeCount = veranstaltungRepository.countActive()
val publicCount = veranstaltungRepository.findPublicEvents(true).size
val stats = EventStats(
totalActive = activeCount,
totalPublic = publicCount.toLong()
)
call.respond(HttpStatusCode.OK, ApiResponse.success(stats))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to retrieve event statistics: ${e.message}"))
}
}
// POST /api/events - Create new event
post {
try {
val createRequest = call.receive<CreateEventRequest>()
// Validate input using shared validation utilities
val validationErrors = ApiValidationUtils.validateEventRequest(
name = createRequest.name,
ort = createRequest.ort,
startDatum = createRequest.startDatum,
endDatum = createRequest.endDatum,
maxTeilnehmer = createRequest.maxTeilnehmer
)
if (!ApiValidationUtils.isValid(validationErrors)) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@post
}
val useCaseRequest = CreateVeranstaltungUseCase.CreateVeranstaltungRequest(
name = createRequest.name,
beschreibung = createRequest.beschreibung,
startDatum = createRequest.startDatum,
endDatum = createRequest.endDatum,
ort = createRequest.ort,
veranstalterVereinId = createRequest.veranstalterVereinId,
sparten = createRequest.sparten,
istAktiv = createRequest.istAktiv,
istOeffentlich = createRequest.istOeffentlich,
maxTeilnehmer = createRequest.maxTeilnehmer,
anmeldeschluss = createRequest.anmeldeschluss
)
val response = createVeranstaltungUseCase.execute(useCaseRequest)
if (response.success && response.data != null) {
call.respond(HttpStatusCode.Created, ApiResponse.success((response.data as CreateVeranstaltungUseCase.CreateVeranstaltungResponse).veranstaltung))
} else {
val statusCode = when (response.error?.code) {
"VALIDATION_ERROR" -> HttpStatusCode.BadRequest
"DOMAIN_VALIDATION_ERROR" -> HttpStatusCode.BadRequest
else -> HttpStatusCode.InternalServerError
}
call.respond(statusCode, ApiResponse.error<Any>(response.error?.message ?: "Failed to create event"))
}
} catch (e: Exception) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid request data: ${e.message}"))
}
}
// PUT /api/events/{id} - Update event
put("/{id}") {
try {
val eventId = uuidFrom(call.parameters["id"]!!)
val updateRequest = call.receive<UpdateEventRequest>()
// Validate input using shared validation utilities
val validationErrors = ApiValidationUtils.validateEventRequest(
name = updateRequest.name,
ort = updateRequest.ort,
startDatum = updateRequest.startDatum,
endDatum = updateRequest.endDatum,
maxTeilnehmer = updateRequest.maxTeilnehmer
)
if (!ApiValidationUtils.isValid(validationErrors)) {
call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>(ApiValidationUtils.createErrorMessage(validationErrors))
)
return@put
}
val useCaseRequest = UpdateVeranstaltungUseCase.UpdateVeranstaltungRequest(
veranstaltungId = eventId,
name = updateRequest.name,
beschreibung = updateRequest.beschreibung,
startDatum = updateRequest.startDatum,
endDatum = updateRequest.endDatum,
ort = updateRequest.ort,
veranstalterVereinId = updateRequest.veranstalterVereinId,
sparten = updateRequest.sparten,
istAktiv = updateRequest.istAktiv,
istOeffentlich = updateRequest.istOeffentlich,
maxTeilnehmer = updateRequest.maxTeilnehmer,
anmeldeschluss = updateRequest.anmeldeschluss
)
val response = updateVeranstaltungUseCase.execute(useCaseRequest)
if (response.success && response.data != null) {
call.respond(HttpStatusCode.OK, ApiResponse.success((response.data as UpdateVeranstaltungUseCase.UpdateVeranstaltungResponse).veranstaltung))
} else {
val statusCode = when (response.error?.code) {
"NOT_FOUND" -> HttpStatusCode.NotFound
"VALIDATION_ERROR" -> HttpStatusCode.BadRequest
"DOMAIN_VALIDATION_ERROR" -> HttpStatusCode.BadRequest
else -> HttpStatusCode.InternalServerError
}
call.respond(statusCode, ApiResponse.error<Any>(response.error?.message ?: "Failed to update event"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid event ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid request data: ${e.message}"))
}
}
// DELETE /api/events/{id} - Delete event
delete("/{id}") {
try {
val eventId = ApiValidationUtils.validateUuidString(call.parameters["id"])
?: return@delete call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>("Invalid event ID format")
)
// Validate force parameter if provided
val forceParam = call.request.queryParameters["force"]
val forceDelete = if (forceParam != null) {
try {
forceParam.toBoolean()
} catch (_: Exception) {
return@delete call.respond(
HttpStatusCode.BadRequest,
ApiResponse.error<Any>("Invalid force parameter. Must be true or false")
)
}
} else {
false
}
val useCaseRequest = DeleteVeranstaltungUseCase.DeleteVeranstaltungRequest(
veranstaltungId = eventId,
forceDelete = forceDelete
)
val response = deleteVeranstaltungUseCase.execute(useCaseRequest)
if (response.success) {
call.respond(HttpStatusCode.OK, ApiResponse.success(response.data))
} else {
val statusCode = when (response.error?.code) {
"NOT_FOUND" -> HttpStatusCode.NotFound
"CANNOT_DELETE_ACTIVE_EVENT" -> HttpStatusCode.Conflict
else -> HttpStatusCode.InternalServerError
}
call.respond(statusCode, ApiResponse.error<Any>(response.error?.message ?: "Failed to delete event"))
}
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, ApiResponse.error<Any>("Invalid event ID format"))
} catch (e: Exception) {
call.respond(HttpStatusCode.InternalServerError, ApiResponse.error<Any>("Failed to delete event: ${e.message}"))
}
}
}
}
/**
* Request DTO for creating events.
*/
@Serializable
data class CreateEventRequest(
val name: String,
val beschreibung: String? = null,
val startDatum: LocalDate,
val endDatum: LocalDate,
val ort: String,
@Serializable(with = UuidSerializer::class)
val veranstalterVereinId: Uuid,
val sparten: List<SparteE> = emptyList(),
val istAktiv: Boolean = true,
val istOeffentlich: Boolean = true,
val maxTeilnehmer: Int? = null,
val anmeldeschluss: LocalDate? = null
)
/**
* Request DTO for updating events.
*/
@Serializable
data class UpdateEventRequest(
val name: String,
val beschreibung: String? = null,
val startDatum: LocalDate,
val endDatum: LocalDate,
val ort: String,
@Serializable(with = UuidSerializer::class)
val veranstalterVereinId: Uuid,
val sparten: List<SparteE> = emptyList(),
val istAktiv: Boolean = true,
val istOeffentlich: Boolean = true,
val maxTeilnehmer: Int? = null,
val anmeldeschluss: LocalDate? = null
)
/**
* Response DTO for event statistics.
*/
@Serializable
data class EventStats(
val totalActive: Long,
val totalPublic: Long
)
}
@@ -0,0 +1,10 @@
plugins {
kotlin("jvm")
}
dependencies {
implementation(projects.events.eventsDomain)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,173 @@
package at.mocode.events.application.usecase
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import at.mocode.events.domain.model.Veranstaltung
import at.mocode.events.domain.repository.VeranstaltungRepository
import at.mocode.core.domain.model.SparteE
import at.mocode.core.utils.validation.ValidationResult
import at.mocode.core.utils.validation.ValidationError
import com.benasher44.uuid.Uuid
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate
/**
* Use case for creating new events (Veranstaltung).
*
* This use case handles the business logic for creating events,
* including validation and persistence.
*/
class CreateVeranstaltungUseCase(
private val veranstaltungRepository: VeranstaltungRepository
) {
/**
* Request data for creating a new event.
*/
data class CreateVeranstaltungRequest(
val name: String,
val beschreibung: String? = null,
val startDatum: LocalDate,
val endDatum: LocalDate,
val ort: String,
val veranstalterVereinId: Uuid,
val sparten: List<SparteE> = emptyList(),
val istAktiv: Boolean = true,
val istOeffentlich: Boolean = true,
val maxTeilnehmer: Int? = null,
val anmeldeschluss: LocalDate? = null
)
/**
* Response data containing the created event.
*/
data class CreateVeranstaltungResponse(
val veranstaltung: Veranstaltung
)
/**
* Executes the create event use case.
*
* @param request The request containing event data
* @return ApiResponse with the created event or error information
*/
suspend fun execute(request: CreateVeranstaltungRequest): ApiResponse<CreateVeranstaltungResponse> {
return try {
// Validate the request
val validationResult = validateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors
return ApiResponse(
success = false,
error = ErrorDto(
code = "VALIDATION_ERROR",
message = "Invalid input data",
details = errors.associate { it.field to it.message }
)
)
}
// Create the domain object
val veranstaltung = Veranstaltung(
name = request.name.trim(),
beschreibung = request.beschreibung?.trim(),
startDatum = request.startDatum,
endDatum = request.endDatum,
ort = request.ort.trim(),
veranstalterVereinId = request.veranstalterVereinId,
sparten = request.sparten,
istAktiv = request.istAktiv,
istOeffentlich = request.istOeffentlich,
maxTeilnehmer = request.maxTeilnehmer,
anmeldeschluss = request.anmeldeschluss,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
// Validate the domain object
val domainValidationErrors = veranstaltung.validate()
if (domainValidationErrors.isNotEmpty()) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DOMAIN_VALIDATION_ERROR",
message = "Domain validation failed",
details = domainValidationErrors.mapIndexed { index, error ->
"error_$index" to error
}.toMap()
)
)
}
// Save the event
val savedVeranstaltung = veranstaltungRepository.save(veranstaltung)
ApiResponse(
success = true,
data = CreateVeranstaltungResponse(savedVeranstaltung)
)
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to create event: ${e.message}"
)
)
}
}
/**
* Validates the create event request.
*/
private fun validateRequest(request: CreateVeranstaltungRequest): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Validate name
if (request.name.isBlank()) {
errors.add(ValidationError("name", "Event name is required"))
} else if (request.name.length > 255) {
errors.add(ValidationError("name", "Event name must not exceed 255 characters"))
}
// Validate location
if (request.ort.isBlank()) {
errors.add(ValidationError("ort", "Event location is required"))
} else if (request.ort.length > 255) {
errors.add(ValidationError("ort", "Event location must not exceed 255 characters"))
}
// Validate dates
if (request.endDatum < request.startDatum) {
errors.add(ValidationError("endDatum", "End date cannot be before start date"))
}
// Validate registration deadline
request.anmeldeschluss?.let { deadline ->
if (deadline > request.startDatum) {
errors.add(ValidationError("anmeldeschluss", "Registration deadline cannot be after event start date"))
}
}
// Validate max participants
request.maxTeilnehmer?.let { max ->
if (max <= 0) {
errors.add(ValidationError("maxTeilnehmer", "Maximum participants must be positive"))
}
}
// Validate description length
request.beschreibung?.let { desc ->
if (desc.length > 5000) {
errors.add(ValidationError("beschreibung", "Description must not exceed 5000 characters"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
}
@@ -0,0 +1,108 @@
package at.mocode.events.application.usecase
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import at.mocode.events.domain.repository.VeranstaltungRepository
import com.benasher44.uuid.Uuid
/**
* Use case for deleting events (Veranstaltung).
*
* This use case handles the business logic for deleting events,
* including validation and cleanup.
*/
class DeleteVeranstaltungUseCase(
private val veranstaltungRepository: VeranstaltungRepository
) {
/**
* Request data for deleting an event.
*/
data class DeleteVeranstaltungRequest(
val veranstaltungId: Uuid,
val forceDelete: Boolean = false
)
/**
* Response data for successful deletion.
*/
data class DeleteVeranstaltungResponse(
val deleted: Boolean,
val message: String
)
/**
* Executes the delete event use case.
*
* @param request The request containing the event ID to delete
* @return ApiResponse with deletion result or error information
*/
suspend fun execute(request: DeleteVeranstaltungRequest): ApiResponse<DeleteVeranstaltungResponse> {
return try {
// Check if event exists
val existingVeranstaltung = veranstaltungRepository.findById(request.veranstaltungId)
if (existingVeranstaltung == null) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "NOT_FOUND",
message = "Event not found"
)
)
}
// Check if event can be safely deleted
if (!request.forceDelete) {
// In a real implementation, you might check for:
// - Active registrations
// - Related competitions
// - Financial transactions
// For now, we'll allow deletion if the event is not active or is in the future
if (existingVeranstaltung.istAktiv) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "CANNOT_DELETE_ACTIVE_EVENT",
message = "Cannot delete active event. Use forceDelete=true to override.",
details = mapOf(
"eventId" to request.veranstaltungId.toString(),
"eventName" to existingVeranstaltung.name
)
)
)
}
}
// Perform the deletion
val deleted = veranstaltungRepository.delete(request.veranstaltungId)
if (deleted) {
ApiResponse(
success = true,
data = DeleteVeranstaltungResponse(
deleted = true,
message = "Event '${existingVeranstaltung.name}' has been successfully deleted"
)
)
} else {
ApiResponse(
success = false,
error = ErrorDto(
code = "DELETE_FAILED",
message = "Failed to delete event from database"
)
)
}
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to delete event: ${e.message}"
)
)
}
}
}
@@ -0,0 +1,68 @@
package at.mocode.events.application.usecase
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import at.mocode.events.domain.model.Veranstaltung
import at.mocode.events.domain.repository.VeranstaltungRepository
import com.benasher44.uuid.Uuid
/**
* Use case for retrieving events (Veranstaltung) by ID.
*
* This use case handles the business logic for fetching events
* from the repository.
*/
class GetVeranstaltungUseCase(
private val veranstaltungRepository: VeranstaltungRepository
) {
/**
* Request data for retrieving an event.
*/
data class GetVeranstaltungRequest(
val veranstaltungId: Uuid
)
/**
* Response data containing the retrieved event.
*/
data class GetVeranstaltungResponse(
val veranstaltung: Veranstaltung
)
/**
* Executes the get event use case.
*
* @param request The request containing the event ID
* @return ApiResponse with the event or error information
*/
suspend fun execute(request: GetVeranstaltungRequest): ApiResponse<GetVeranstaltungResponse> {
return try {
val veranstaltung = veranstaltungRepository.findById(request.veranstaltungId)
if (veranstaltung != null) {
ApiResponse(
success = true,
data = GetVeranstaltungResponse(veranstaltung)
)
} else {
ApiResponse(
success = false,
error = ErrorDto(
code = "NOT_FOUND",
message = "Event not found"
)
)
}
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to retrieve event: ${e.message}"
)
)
}
}
}
@@ -0,0 +1,185 @@
package at.mocode.events.application.usecase
import at.mocode.core.domain.model.ApiResponse
import at.mocode.core.domain.model.ErrorDto
import at.mocode.events.domain.model.Veranstaltung
import at.mocode.events.domain.repository.VeranstaltungRepository
import at.mocode.core.domain.model.SparteE
import at.mocode.core.utils.validation.ValidationResult
import at.mocode.core.utils.validation.ValidationError
import com.benasher44.uuid.Uuid
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate
/**
* Use case for updating existing events (Veranstaltung).
*
* This use case handles the business logic for updating events,
* including validation and persistence.
*/
class UpdateVeranstaltungUseCase(
private val veranstaltungRepository: VeranstaltungRepository
) {
/**
* Request data for updating an event.
*/
data class UpdateVeranstaltungRequest(
val veranstaltungId: Uuid,
val name: String,
val beschreibung: String? = null,
val startDatum: LocalDate,
val endDatum: LocalDate,
val ort: String,
val veranstalterVereinId: Uuid,
val sparten: List<SparteE> = emptyList(),
val istAktiv: Boolean = true,
val istOeffentlich: Boolean = true,
val maxTeilnehmer: Int? = null,
val anmeldeschluss: LocalDate? = null
)
/**
* Response data containing the updated event.
*/
data class UpdateVeranstaltungResponse(
val veranstaltung: Veranstaltung
)
/**
* Executes the update event use case.
*
* @param request The request containing updated event data
* @return ApiResponse with the updated event or error information
*/
suspend fun execute(request: UpdateVeranstaltungRequest): ApiResponse<UpdateVeranstaltungResponse> {
return try {
// Check if event exists
val existingVeranstaltung = veranstaltungRepository.findById(request.veranstaltungId)
if (existingVeranstaltung == null) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "NOT_FOUND",
message = "Event not found"
)
)
}
// Validate the request
val validationResult = validateRequest(request)
if (!validationResult.isValid()) {
val errors = (validationResult as ValidationResult.Invalid).errors
return ApiResponse(
success = false,
error = ErrorDto(
code = "VALIDATION_ERROR",
message = "Invalid input data",
details = errors.associate { it.field to it.message }
)
)
}
// Create updated domain object
val updatedVeranstaltung = existingVeranstaltung.copy(
name = request.name.trim(),
beschreibung = request.beschreibung?.trim(),
startDatum = request.startDatum,
endDatum = request.endDatum,
ort = request.ort.trim(),
veranstalterVereinId = request.veranstalterVereinId,
sparten = request.sparten,
istAktiv = request.istAktiv,
istOeffentlich = request.istOeffentlich,
maxTeilnehmer = request.maxTeilnehmer,
anmeldeschluss = request.anmeldeschluss,
updatedAt = Clock.System.now()
)
// Validate the domain object
val domainValidationErrors = updatedVeranstaltung.validate()
if (domainValidationErrors.isNotEmpty()) {
return ApiResponse(
success = false,
error = ErrorDto(
code = "DOMAIN_VALIDATION_ERROR",
message = "Domain validation failed",
details = domainValidationErrors.mapIndexed { index, error ->
"error_$index" to error
}.toMap()
)
)
}
// Save the updated event
val savedVeranstaltung = veranstaltungRepository.save(updatedVeranstaltung)
ApiResponse(
success = true,
data = UpdateVeranstaltungResponse(savedVeranstaltung)
)
} catch (e: Exception) {
ApiResponse(
success = false,
error = ErrorDto(
code = "INTERNAL_ERROR",
message = "Failed to update event: ${e.message}"
)
)
}
}
/**
* Validates the update event request.
*/
private fun validateRequest(request: UpdateVeranstaltungRequest): ValidationResult {
val errors = mutableListOf<ValidationError>()
// Validate name
if (request.name.isBlank()) {
errors.add(ValidationError("name", "Event name is required"))
} else if (request.name.length > 255) {
errors.add(ValidationError("name", "Event name must not exceed 255 characters"))
}
// Validate location
if (request.ort.isBlank()) {
errors.add(ValidationError("ort", "Event location is required"))
} else if (request.ort.length > 255) {
errors.add(ValidationError("ort", "Event location must not exceed 255 characters"))
}
// Validate dates
if (request.endDatum < request.startDatum) {
errors.add(ValidationError("endDatum", "End date cannot be before start date"))
}
// Validate registration deadline
request.anmeldeschluss?.let { deadline ->
if (deadline > request.startDatum) {
errors.add(ValidationError("anmeldeschluss", "Registration deadline cannot be after event start date"))
}
}
// Validate max participants
request.maxTeilnehmer?.let { max ->
if (max <= 0) {
errors.add(ValidationError("maxTeilnehmer", "Maximum participants must be positive"))
}
}
// Validate description length
request.beschreibung?.let { desc ->
if (desc.length > 5000) {
errors.add(ValidationError("beschreibung", "Description must not exceed 5000 characters"))
}
}
return if (errors.isEmpty()) {
ValidationResult.Valid
} else {
ValidationResult.Invalid(errors)
}
}
}
+9
View File
@@ -0,0 +1,9 @@
plugins {
kotlin("jvm")
}
dependencies {
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,15 @@
package at.mocode.events
/**
* Simple Event Management class for testing KMP configuration
*/
class EventManagement {
fun createEvent(name: String): String {
return "Event created: $name"
}
}
fun main() {
val eventManager = EventManagement()
println(eventManager.createEvent("Test Event"))
}
@@ -0,0 +1,132 @@
package at.mocode.events.domain.model
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.KotlinLocalDateSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import com.benasher44.uuid.Uuid
import com.benasher44.uuid.uuid4
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.serialization.Serializable
/**
* Domain model representing an event/competition in the event management system.
*
* This entity represents a sporting event that can contain multiple tournaments
* and competitions. It serves as the main aggregate root for event planning.
*
* @property veranstaltungId Unique internal identifier for this event (UUID).
* @property name Name of the event.
* @property beschreibung Description of the event.
* @property startDatum Start date of the event.
* @property endDatum End date of the event.
* @property ort Location where the event takes place.
* @property veranstalterVereinId ID of the organizing club/association.
* @property sparten List of sport disciplines included in this event.
* @property istAktiv Whether the event is currently active.
* @property istOeffentlich Whether the event is public.
* @property maxTeilnehmer Maximum number of participants (optional).
* @property anmeldeschluss Registration deadline.
* @property createdAt Timestamp when this record was created.
* @property updatedAt Timestamp when this record was last updated.
*/
@Serializable
data class Veranstaltung(
@Serializable(with = UuidSerializer::class)
val veranstaltungId: Uuid = uuid4(),
// Basic Information
var name: String,
var beschreibung: String? = null,
// Dates
@Serializable(with = KotlinLocalDateSerializer::class)
var startDatum: LocalDate,
@Serializable(with = KotlinLocalDateSerializer::class)
var endDatum: LocalDate,
// Location and Organization
var ort: String,
@Serializable(with = UuidSerializer::class)
var veranstalterVereinId: Uuid,
// Event Details
var sparten: List<SparteE> = emptyList(),
var istAktiv: Boolean = true,
var istOeffentlich: Boolean = true,
var maxTeilnehmer: Int? = null,
@Serializable(with = KotlinLocalDateSerializer::class)
var anmeldeschluss: LocalDate? = null,
// Audit Fields
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Checks if the event is currently accepting registrations.
*/
fun isRegistrationOpen(): Boolean {
// Simplified implementation - can be enhanced with proper date comparison
return istAktiv && anmeldeschluss != null
}
/**
* Returns the duration of the event in days.
*/
fun getDurationInDays(): Int {
return (endDatum.toEpochDays() - startDatum.toEpochDays()).toInt() + 1
}
/**
* Checks if the event spans multiple days.
*/
fun isMultiDay(): Boolean {
return startDatum != endDatum
}
/**
* Validates that the event data is consistent.
*/
fun validate(): List<String> {
val errors = mutableListOf<String>()
if (name.isBlank()) {
errors.add("Event name is required")
}
if (ort.isBlank()) {
errors.add("Event location is required")
}
if (endDatum < startDatum) {
errors.add("End date cannot be before start date")
}
anmeldeschluss?.let { deadline ->
if (deadline > startDatum) {
errors.add("Registration deadline cannot be after event start date")
}
}
maxTeilnehmer?.let { max ->
if (max <= 0) {
errors.add("Maximum participants must be positive")
}
}
return errors
}
/**
* Creates a copy of this event with updated timestamp.
*/
fun withUpdatedTimestamp(): Veranstaltung {
return this.copy(updatedAt = Clock.System.now())
}
}
@@ -0,0 +1,108 @@
package at.mocode.events.domain.repository
import at.mocode.events.domain.model.Veranstaltung
import com.benasher44.uuid.Uuid
import kotlinx.datetime.LocalDate
/**
* Repository interface for Veranstaltung (Event) entities.
*
* This interface defines the contract for data access operations
* related to events in the event management bounded context.
*/
interface VeranstaltungRepository {
/**
* Finds an event by its unique identifier.
*
* @param id The unique identifier of the event
* @return The event if found, null otherwise
*/
suspend fun findById(id: Uuid): Veranstaltung?
/**
* Finds events by name (partial match).
*
* @param searchTerm The search term to match against event names
* @param limit Maximum number of results to return
* @return List of matching events
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<Veranstaltung>
/**
* Finds events organized by a specific club/association.
*
* @param vereinId The ID of the organizing club
* @param activeOnly Whether to return only active events
* @return List of events organized by the specified club
*/
suspend fun findByVeranstalterVereinId(vereinId: Uuid, activeOnly: Boolean = true): List<Veranstaltung>
/**
* Finds events within a date range.
*
* @param startDate The earliest start date to include
* @param endDate The latest end date to include
* @param activeOnly Whether to return only active events
* @return List of events within the specified date range
*/
suspend fun findByDateRange(startDate: LocalDate, endDate: LocalDate, activeOnly: Boolean = true): List<Veranstaltung>
/**
* Finds events starting on a specific date.
*
* @param date The date to search for
* @param activeOnly Whether to return only active events
* @return List of events starting on the specified date
*/
suspend fun findByStartDate(date: LocalDate, activeOnly: Boolean = true): List<Veranstaltung>
/**
* Finds all active events.
*
* @param limit Maximum number of results to return
* @param offset Number of results to skip
* @return List of active events
*/
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<Veranstaltung>
/**
* Finds public events (events that are open to public registration).
*
* @param activeOnly Whether to return only active events
* @return List of public events
*/
suspend fun findPublicEvents(activeOnly: Boolean = true): List<Veranstaltung>
/**
* Saves an event (insert or update).
*
* @param veranstaltung The event to save
* @return The saved event
*/
suspend fun save(veranstaltung: Veranstaltung): Veranstaltung
/**
* Deletes an event by its ID.
*
* @param id The unique identifier of the event to delete
* @return True if the event was deleted, false if not found
*/
suspend fun delete(id: Uuid): Boolean
/**
* Counts the number of active events.
*
* @return The number of active events
*/
suspend fun countActive(): Long
/**
* Counts events organized by a specific club.
*
* @param vereinId The ID of the organizing club
* @param activeOnly Whether to count only active events
* @return The number of events organized by the specified club
*/
suspend fun countByVeranstalterVereinId(vereinId: Uuid, activeOnly: Boolean = true): Long
}
@@ -0,0 +1,22 @@
plugins {
kotlin("jvm")
kotlin("plugin.spring")
kotlin("plugin.jpa") version "2.1.20"
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.events.eventsDomain)
implementation(projects.events.eventsApplication)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
implementation(projects.infrastructure.cache.cacheApi)
implementation(projects.infrastructure.eventStore.eventStoreApi)
implementation(projects.infrastructure.messaging.messagingClient)
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.postgresql:postgresql")
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,188 @@
package at.mocode.events.infrastructure.persistence
import at.mocode.core.domain.model.SparteE
import at.mocode.events.domain.model.Veranstaltung
import at.mocode.events.domain.repository.VeranstaltungRepository
import at.mocode.core.utils.database.DatabaseFactory
import com.benasher44.uuid.Uuid
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.statements.UpdateBuilder
/**
* Exposed-based implementation of VeranstaltungRepository.
*
* This implementation provides data persistence for Veranstaltung entities
* using the Exposed SQL framework and PostgreSQL database.
*/
class VeranstaltungRepositoryImpl : VeranstaltungRepository {
override suspend fun findById(id: Uuid): Veranstaltung? = DatabaseFactory.dbQuery {
VeranstaltungTable.selectAll().where { VeranstaltungTable.id eq id }
.map { rowToVeranstaltung(it) }
.singleOrNull()
}
override suspend fun findByName(searchTerm: String, limit: Int): List<Veranstaltung> = DatabaseFactory.dbQuery {
val searchPattern = "%$searchTerm%"
VeranstaltungTable.selectAll().where { VeranstaltungTable.name like searchPattern }
.orderBy(VeranstaltungTable.startDatum, SortOrder.DESC)
.limit(limit)
.map { rowToVeranstaltung(it) }
}
override suspend fun findByVeranstalterVereinId(vereinId: Uuid, activeOnly: Boolean): List<Veranstaltung> = DatabaseFactory.dbQuery {
val query = VeranstaltungTable.selectAll().where { VeranstaltungTable.veranstalterVereinId eq vereinId }
if (activeOnly) {
query.andWhere { VeranstaltungTable.istAktiv eq true }
} else {
query
}.orderBy(VeranstaltungTable.startDatum, SortOrder.DESC)
.map { rowToVeranstaltung(it) }
}
override suspend fun findByDateRange(startDate: LocalDate, endDate: LocalDate, activeOnly: Boolean): List<Veranstaltung> = DatabaseFactory.dbQuery {
val query = VeranstaltungTable.selectAll().where {
(VeranstaltungTable.startDatum greaterEq startDate) and
(VeranstaltungTable.endDatum lessEq endDate)
}
if (activeOnly) {
query.andWhere { VeranstaltungTable.istAktiv eq true }
} else {
query
}.orderBy(VeranstaltungTable.startDatum)
.map { rowToVeranstaltung(it) }
}
override suspend fun findByStartDate(date: LocalDate, activeOnly: Boolean): List<Veranstaltung> = DatabaseFactory.dbQuery {
val query = VeranstaltungTable.selectAll().where { VeranstaltungTable.startDatum eq date }
if (activeOnly) {
query.andWhere { VeranstaltungTable.istAktiv eq true }
} else {
query
}.orderBy(VeranstaltungTable.name)
.map { rowToVeranstaltung(it) }
}
override suspend fun findAllActive(limit: Int, offset: Int): List<Veranstaltung> = DatabaseFactory.dbQuery {
VeranstaltungTable.selectAll().where { VeranstaltungTable.istAktiv eq true }
.orderBy(VeranstaltungTable.startDatum, SortOrder.DESC)
.limit(limit, offset.toLong())
.map { rowToVeranstaltung(it) }
}
override suspend fun findPublicEvents(activeOnly: Boolean): List<Veranstaltung> = DatabaseFactory.dbQuery {
val query = VeranstaltungTable.selectAll().where { VeranstaltungTable.istOeffentlich eq true }
if (activeOnly) {
query.andWhere { VeranstaltungTable.istAktiv eq true }
} else {
query
}.orderBy(VeranstaltungTable.startDatum, SortOrder.DESC)
.map { rowToVeranstaltung(it) }
}
override suspend fun save(veranstaltung: Veranstaltung): Veranstaltung = DatabaseFactory.dbQuery {
val now = Clock.System.now()
val updatedVeranstaltung = veranstaltung.copy(updatedAt = now)
// Check if a record exists
val existingRecord = VeranstaltungTable.selectAll()
.where { VeranstaltungTable.id eq veranstaltung.veranstaltungId }
.singleOrNull()
if (existingRecord != null) {
// Update existing record
VeranstaltungTable.update({ VeranstaltungTable.id eq veranstaltung.veranstaltungId }) {
veranstaltungToStatement(it, updatedVeranstaltung)
}
updatedVeranstaltung
} else {
// Insert a new record
VeranstaltungTable.insert {
it[id] = veranstaltung.veranstaltungId
veranstaltungToStatement(it, updatedVeranstaltung)
}
updatedVeranstaltung
}
}
override suspend fun delete(id: Uuid): Boolean = DatabaseFactory.dbQuery {
val deletedRows = VeranstaltungTable.deleteWhere { VeranstaltungTable.id eq id }
deletedRows > 0
}
override suspend fun countActive(): Long = DatabaseFactory.dbQuery {
VeranstaltungTable.selectAll().where { VeranstaltungTable.istAktiv eq true }
.count()
}
override suspend fun countByVeranstalterVereinId(vereinId: Uuid, activeOnly: Boolean): Long = DatabaseFactory.dbQuery {
val query = VeranstaltungTable.selectAll().where { VeranstaltungTable.veranstalterVereinId eq vereinId }
if (activeOnly) {
query.andWhere { VeranstaltungTable.istAktiv eq true }
} else {
query
}.count()
}
/**
* Converts a database row to a Veranstaltung domain object.
*/
private fun rowToVeranstaltung(row: ResultRow): Veranstaltung {
// Parse sparten from JSON string
val spartenJson = row[VeranstaltungTable.sparten]
val sparten = if (spartenJson.isNotBlank()) {
try {
Json.decodeFromString<List<SparteE>>(spartenJson)
} catch (_: Exception) {
emptyList()
}
} else {
emptyList()
}
return Veranstaltung(
veranstaltungId = row[VeranstaltungTable.id].value,
name = row[VeranstaltungTable.name],
beschreibung = row[VeranstaltungTable.beschreibung],
startDatum = row[VeranstaltungTable.startDatum],
endDatum = row[VeranstaltungTable.endDatum],
ort = row[VeranstaltungTable.ort],
veranstalterVereinId = row[VeranstaltungTable.veranstalterVereinId],
sparten = sparten,
istAktiv = row[VeranstaltungTable.istAktiv],
istOeffentlich = row[VeranstaltungTable.istOeffentlich],
maxTeilnehmer = row[VeranstaltungTable.maxTeilnehmer],
anmeldeschluss = row[VeranstaltungTable.anmeldeschluss],
createdAt = row[VeranstaltungTable.createdAt],
updatedAt = row[VeranstaltungTable.updatedAt]
)
}
/**
* Maps a Veranstaltung domain object to database statement values.
*/
private fun veranstaltungToStatement(statement: UpdateBuilder<*>, veranstaltung: Veranstaltung) {
statement[VeranstaltungTable.name] = veranstaltung.name
statement[VeranstaltungTable.beschreibung] = veranstaltung.beschreibung
statement[VeranstaltungTable.startDatum] = veranstaltung.startDatum
statement[VeranstaltungTable.endDatum] = veranstaltung.endDatum
statement[VeranstaltungTable.ort] = veranstaltung.ort
statement[VeranstaltungTable.veranstalterVereinId] = veranstaltung.veranstalterVereinId
statement[VeranstaltungTable.sparten] = Json.encodeToString(veranstaltung.sparten)
statement[VeranstaltungTable.istAktiv] = veranstaltung.istAktiv
statement[VeranstaltungTable.istOeffentlich] = veranstaltung.istOeffentlich
statement[VeranstaltungTable.maxTeilnehmer] = veranstaltung.maxTeilnehmer
statement[VeranstaltungTable.anmeldeschluss] = veranstaltung.anmeldeschluss
statement[VeranstaltungTable.createdAt] = veranstaltung.createdAt
statement[VeranstaltungTable.updatedAt] = veranstaltung.updatedAt
}
}
@@ -0,0 +1,48 @@
package at.mocode.events.infrastructure.persistence
import at.mocode.core.domain.model.SparteE
import org.jetbrains.exposed.dao.id.UUIDTable
import org.jetbrains.exposed.sql.kotlin.datetime.date
import org.jetbrains.exposed.sql.kotlin.datetime.timestamp
/**
* Database table definition for events (Veranstaltung) in the event-management context.
*
* This table stores all event information including dates, location,
* organization details, and administrative information.
*/
object VeranstaltungTable : UUIDTable("veranstaltungen") {
// Basic Information
val name = varchar("name", 255)
val beschreibung = text("beschreibung").nullable()
// Dates
val startDatum = date("start_datum")
val endDatum = date("end_datum")
val anmeldeschluss = date("anmeldeschluss").nullable()
// Location and Organization
val ort = varchar("ort", 255)
val veranstalterVereinId = uuid("veranstalter_verein_id")
// Event Details
val sparten = text("sparten") // JSON array of SparteE values
val istAktiv = bool("ist_aktiv").default(true)
val istOeffentlich = bool("ist_oeffentlich").default(true)
val maxTeilnehmer = integer("max_teilnehmer").nullable()
// Audit Fields
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
init {
// Indexes for performance
index(false, name)
index(false, startDatum)
index(false, endDatum)
index(false, veranstalterVereinId)
index(false, istAktiv)
index(false, istOeffentlich)
}
}
+32
View File
@@ -0,0 +1,32 @@
plugins {
kotlin("jvm")
kotlin("plugin.spring")
id("org.springframework.boot")
}
springBoot {
mainClass.set("at.mocode.events.service.EventsServiceApplicationKt")
}
dependencies {
implementation(projects.platform.platformDependencies)
implementation(projects.events.eventsDomain)
implementation(projects.events.eventsApplication)
implementation(projects.events.eventsInfrastructure)
implementation(projects.events.eventsApi)
implementation(projects.infrastructure.auth.authClient)
implementation(projects.infrastructure.cache.redisCache)
implementation(projects.infrastructure.messaging.messagingClient)
implementation(projects.infrastructure.monitoring.monitoringClient)
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui")
runtimeOnly("org.postgresql:postgresql")
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,19 @@
package at.mocode.events.service
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
/**
* Main application class for the Events Service.
*
* This service provides APIs for managing events and competitions.
*/
@SpringBootApplication
class EventsServiceApplication
/**
* Main entry point for the Events Service application.
*/
fun main(args: Array<String>) {
runApplication<EventsServiceApplication>(*args)
}