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
+167
View File
@@ -0,0 +1,167 @@
# syntax=docker/dockerfile:1.7
# ===================================================================
# Dockerfile for Events Service
# Based on Spring Boot Service Template with Events-specific configuration
# ===================================================================
# === CENTRALIZED BUILD ARGUMENTS ===
# Values sourced from docker/versions.toml and docker/build-args/
# Global arguments (docker/build-args/global.env)
ARG GRADLE_VERSION
ARG JAVA_VERSION
ARG BUILD_DATE
ARG VERSION
# Service-specific arguments (docker/build-args/services.env)
# Note: Keine Runtime-Profile/Ports als Build-ARGs
ARG SERVICE_PATH=events/events-service
ARG SERVICE_NAME=events-service
# ===================================================================
# Build Stage
# ===================================================================
FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION}-alpine AS builder
# Re-declare build arguments for this stage (nur Build-Zeit)
ARG SERVICE_PATH=events/events-service
ARG SERVICE_NAME=events-service
LABEL stage=builder
LABEL maintainer="Meldestelle Development Team"
WORKDIR /workspace
# Gradle optimizations
ENV GRADLE_OPTS="-Dorg.gradle.caching=true \
-Dorg.gradle.daemon=false \
-Dorg.gradle.parallel=true \
-Dorg.gradle.configureondemand=true \
-Xmx2g"
# Copy build files in optimal order for caching
COPY gradlew gradlew.bat gradle.properties settings.gradle.kts ./
COPY gradle/ gradle/
# Make gradlew executable (required on Linux/Unix systems)
RUN chmod +x gradlew
COPY platform/ platform/
COPY core/ core/
COPY build.gradle.kts ./
# Copy events service modules in dependency order
COPY events/events-domain/ events/events-domain/
COPY events/events-api/ events/events-api/
COPY events/events-application/ events/events-application/
COPY events/events-infrastructure/ events/events-infrastructure/
COPY events/events-service/ events/events-service/
# Build events service (ohne Runtime-Profile bei Build)
RUN echo "Building Events Service..." && \
./gradlew :events:events-service:dependencies --no-daemon --info && \
./gradlew :events:events-service:bootJar --no-daemon --info
# Extract JAR layers for optimized Docker layer caching
WORKDIR /builder
RUN cp /workspace/events/events-service/build/libs/*.jar app.jar && \
java -Djarmode=layertools -jar app.jar extract
# ===================================================================
# Runtime Stage
# ===================================================================
FROM eclipse-temurin:${JAVA_VERSION}-jre-alpine AS runtime
# Metadata
LABEL service="events-service" \
version="1.0.0" \
description="Events Management Service for Austrian Equestrian Federation" \
maintainer="Meldestelle Development Team" \
java.version="${JAVA_VERSION}"
# Build arguments
ARG APP_USER=eventsuser
ARG APP_GROUP=eventsgroup
ARG APP_UID=1006
ARG APP_GID=1006
WORKDIR /app
# System setup
RUN apk update && \
apk upgrade && \
apk add --no-cache curl jq tzdata && \
rm -rf /var/cache/apk/*
# Non-root user creation
RUN addgroup -g ${APP_GID} -S ${APP_GROUP} && \
adduser -u ${APP_UID} -S ${APP_USER} -G ${APP_GROUP} -h /app -s /bin/sh
# Directory setup
RUN mkdir -p /app/logs /app/tmp && \
chown -R ${APP_USER}:${APP_GROUP} /app
# Re-declare build arguments for runtime stage
ARG SERVICE_PATH=events/events-service
ARG SERVICE_NAME=events-service
# Copy Spring Boot layers in optimal order for Docker layer caching
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/dependencies/ ./
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/spring-boot-loader/ ./
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/snapshot-dependencies/ ./
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /builder/application/ ./
USER ${APP_USER}
# Expose application port and debug port
EXPOSE 8085 5006
# Health check
HEALTHCHECK --interval=15s --timeout=3s --start-period=40s --retries=3 \
CMD curl -fsS --max-time 2 http://localhost:8085/actuator/health/readiness || exit 1
# JVM configuration optimized for events service
ENV JAVA_OPTS="-XX:MaxRAMPercentage=80.0 \
-XX:+UseG1GC \
-XX:+UseStringDeduplication \
-XX:+UseContainerSupport \
-XX:G1HeapRegionSize=16m \
-XX:+OptimizeStringConcat \
-XX:+UseCompressedOops \
-Djava.security.egd=file:/dev/./urandom \
-Djava.awt.headless=true \
-Dfile.encoding=UTF-8 \
-Duser.timezone=Europe/Vienna \
-Dmanagement.endpoints.web.exposure.include=health,info,metrics,prometheus"
# Spring Boot configuration (Profile nur zur Laufzeit via Compose/Env)
ENV SPRING_OUTPUT_ANSI_ENABLED=ALWAYS \
SERVER_PORT=8085 \
LOGGING_LEVEL_ROOT=INFO \
LOGGING_LEVEL_AT_MOCODE_EVENTS=DEBUG
# Startup command with debug support
ENTRYPOINT ["sh", "-c", "\
echo 'Starting Events Service on port 8085...'; \
if [ \"${DEBUG:-false}\" = \"true\" ]; then \
echo 'Debug mode enabled on port 5006'; \
exec java $JAVA_OPTS -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5006 org.springframework.boot.loader.launch.JarLauncher; \
else \
exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher; \
fi"]
# ===================================================================
# Documentation
# ===================================================================
# Build commands:
# docker build -t meldestelle/events-service:latest -f dockerfiles/services/events-service/Dockerfile .
# docker run -p 8086:8086 --name events-service meldestelle/events-service:latest
#
# Key features:
# - Multi-stage build with JAR layer extraction for optimal caching
# - Non-root user execution for security (UID/GID 1006)
# - Optimized JVM settings for containers
# - Comprehensive health checks with events-specific endpoint
# - Debug support on port 5006
# - Vienna timezone configuration for Austrian operations
# ===================================================================
@@ -0,0 +1,38 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ktor)
application
// KORREKTUR 1: Dieses Plugin hinzufügen, um die Spring-BOM zu aktivieren.
alias(libs.plugins.spring.dependencyManagement)
}
application {
mainClass.set("at.mocode.events.api.ApplicationKt")
}
dependencies {
// KORREKTUR 2: Die Spring-Boot-BOM hier explizit als Plattform deklarieren.
api(platform(libs.spring.boot.dependencies))
// Bestehende Abhängigkeiten
implementation(projects.platform.platformDependencies)
implementation(projects.events.eventsDomain)
implementation(projects.events.eventsApplication)
implementation(projects.core.coreDomain)
implementation(projects.core.coreUtils)
// Spring dependencies (jetzt mit korrekter Version aus der BOM)
implementation(libs.spring.web)
implementation(libs.springdoc.openapi.starter.common)
// Ktor Server
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.netty)
implementation(libs.ktor.server.contentNegotiation)
implementation(libs.ktor.server.serialization.kotlinx.json)
implementation(libs.ktor.server.statusPages)
implementation(libs.ktor.server.auth)
implementation(libs.ktor.server.authJwt)
testImplementation(projects.platform.platformTesting)
testImplementation(libs.ktor.server.tests)
}
@@ -0,0 +1,333 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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 kotlin.uuid.Uuid
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 = Uuid.parse(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 = Uuid.parse(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,32 @@
plugins {
// KORREKTUR: Von 'kotlin("jvm")' zu Multiplattform wechseln.
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
jvm()
js(IR) {
browser()
}
sourceSets {
val commonMain by getting {
dependencies {
// Hier die jeweiligen Modul-Abhängigkeiten eintragen
// z.B. für events-domain:
implementation(projects.core.coreDomain)
// z.B. für events-application:
// implementation(projects.events.eventsDomain)
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation(projects.platform.platformTesting)
}
}
}
}
@@ -0,0 +1,174 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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.domain.model.ValidationResult
import at.mocode.core.domain.model.ValidationError
import kotlin.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,109 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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 kotlin.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,69 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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 kotlin.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,186 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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.domain.model.ValidationResult
import at.mocode.core.domain.model.ValidationError
import kotlin.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)
}
}
}
@@ -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 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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 kotlin.uuid.Uuid
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 = Uuid.random(),
// 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,109 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.events.domain.repository
import at.mocode.events.domain.model.Veranstaltung
import kotlin.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,24 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ktor)
application
}
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(libs.spring.boot.starter.data.jpa)
implementation(libs.postgresql.driver)
testImplementation(projects.platform.platformTesting)
}
@@ -0,0 +1,189 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
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 kotlin.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)
}
}
@@ -0,0 +1,50 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.spring)
// KORREKTUR: Dieses Plugin ist entscheidend. Es schaltet den `springBoot`-Block
// und alle Spring-Boot-spezifischen Gradle-Tasks frei.
alias(libs.plugins.spring.boot)
// Dependency Management für konsistente Spring-Versionen
alias(libs.plugins.spring.dependencyManagement)
}
// Dieser Block funktioniert jetzt, weil das `springBoot`-Plugin oben aktiviert ist.
springBoot {
mainClass.set("at.mocode.events.service.EventsServiceApplicationKt")
}
dependencies {
// Interne Module
implementation(projects.platform.platformDependencies)
implementation(projects.core.coreUtils)
implementation(projects.events.eventsDomain)
implementation(projects.events.eventsApplication)
implementation(projects.events.eventsInfrastructure)
implementation(projects.events.eventsApi)
// Infrastruktur-Clients
implementation(projects.infrastructure.cache.redisCache)
implementation(projects.infrastructure.messaging.messagingClient)
implementation(projects.infrastructure.monitoring.monitoringClient)
// KORREKTUR: Alle externen Abhängigkeiten werden jetzt über den Version Catalog bezogen.
// Spring Boot Starters
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.actuator)
// Datenbank-Treiber
runtimeOnly(libs.postgresql.driver)
// Testing
testImplementation(projects.platform.platformTesting)
testImplementation(libs.spring.boot.starter.test)
testImplementation(libs.logback.classic) // SLF4J provider for tests
}
tasks.test {
useJUnitPlatform()
}
@@ -0,0 +1,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)
}
@@ -0,0 +1,104 @@
package at.mocode.events.service.config
import at.mocode.core.utils.database.DatabaseConfig
import at.mocode.core.utils.database.DatabaseFactory
import at.mocode.events.infrastructure.persistence.VeranstaltungTable
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Profile
import jakarta.annotation.PostConstruct
import jakarta.annotation.PreDestroy
import org.slf4j.LoggerFactory
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction
/**
* Database configuration for the Events Service.
*
* This configuration ensures that Database.connect() is called properly
* before any Exposed operations are performed.
*/
@Configuration
@Profile("!test")
class EventsDatabaseConfiguration {
private val log = LoggerFactory.getLogger(EventsDatabaseConfiguration::class.java)
@PostConstruct
fun initializeDatabase() {
log.info("Initializing database schema for Events Service...")
try {
// Database connection is already initialized by the gateway
// Only initialize the schema for this service
transaction {
SchemaUtils.createMissingTablesAndColumns(VeranstaltungTable)
log.info("Events database schema initialized successfully")
}
} catch (e: Exception) {
log.error("Failed to initialize database schema", e)
throw e
}
}
@PreDestroy
fun closeDatabase() {
log.info("Closing database connection for Events Service...")
try {
DatabaseFactory.close()
log.info("Database connection closed successfully")
} catch (e: Exception) {
log.error("Error closing database connection", e)
}
}
}
/**
* Test-specific database configuration.
*/
@Configuration
@Profile("test")
class EventsTestDatabaseConfiguration {
private val log = LoggerFactory.getLogger(EventsTestDatabaseConfiguration::class.java)
@PostConstruct
fun initializeTestDatabase() {
log.info("Initializing test database connection for Events Service...")
try {
// Use H2 in-memory database for tests
val testConfig = DatabaseConfig(
jdbcUrl = "jdbc:h2:mem:events_test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE",
username = "sa",
password = "",
driverClassName = "org.h2.Driver",
maxPoolSize = 5,
minPoolSize = 1,
autoMigrate = true
)
DatabaseFactory.init(testConfig)
log.info("Test database connection initialized successfully")
// Initialize database schema for tests
transaction {
SchemaUtils.createMissingTablesAndColumns(VeranstaltungTable)
log.info("Test events database schema initialized successfully")
}
} catch (e: Exception) {
log.error("Failed to initialize test database connection", e)
throw e
}
}
@PreDestroy
fun closeTestDatabase() {
log.info("Closing test database connection for Events Service...")
try {
DatabaseFactory.close()
log.info("Test database connection closed successfully")
} catch (e: Exception) {
log.error("Error closing test database connection", e)
}
}
}
@@ -0,0 +1,10 @@
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>