Introduce tournament structure management for Entries Service:

- Add multi-layered entity support for `Turniere`, `Bewerbe`, and `Abteilungen` with tenant isolation.
- Implement Flyway schema migrations with constraints, indices, and default values for `Turniere`.
- Add Kotlin repositories and services for CRUD operations and validation across entities.
- Ensure tenant-safe transactions and implement new exception handling for `LockedException` and `ValidationException`.
- Provide REST APIs with controllers for managing lifecycle, hierarchy, and relationships between entities (`Turniere`, `Bewerbe`, and `Abteilungen`).
- Update Spring configuration with dependency wiring for new services and repositories.
This commit is contained in:
2026-04-03 00:06:16 +02:00
parent 85282ea7b4
commit c483f4925d
22 changed files with 908 additions and 10 deletions
@@ -0,0 +1,21 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.abteilungen
import kotlin.uuid.Uuid
data class Abteilung(
val id: Uuid,
val bewerbId: Uuid,
val nr: Int,
val bezeichnung: String,
val typ: String,
)
interface AbteilungRepository {
suspend fun create(a: Abteilung): Abteilung
suspend fun findById(id: Uuid): Abteilung?
suspend fun findByBewerbId(bewerbId: Uuid): List<Abteilung>
suspend fun update(a: Abteilung): Abteilung
suspend fun delete(id: Uuid): Boolean
}
@@ -0,0 +1,64 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.abteilungen
import at.mocode.entries.service.persistence.AbteilungTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
class AbteilungRepositoryImpl : AbteilungRepository {
private fun rowToAbteilung(row: ResultRow): Abteilung = Abteilung(
id = row[AbteilungTable.id].toKotlinUuid(),
bewerbId = row[AbteilungTable.bewerbId].toKotlinUuid(),
nr = row[AbteilungTable.nr],
bezeichnung = row[AbteilungTable.bezeichnung],
typ = row[AbteilungTable.typ]
)
override suspend fun create(a: Abteilung): Abteilung = tenantTransaction {
val now = Clock.System.now()
AbteilungTable.insert { s ->
s[AbteilungTable.id] = a.id.toJavaUuid()
s[AbteilungTable.bewerbId] = a.bewerbId.toJavaUuid()
s[AbteilungTable.nr] = a.nr
s[AbteilungTable.bezeichnung] = a.bezeichnung
s[AbteilungTable.typ] = a.typ
s[AbteilungTable.createdAt] = now
s[AbteilungTable.updatedAt] = now
}
AbteilungTable.selectAll().where { AbteilungTable.id eq a.id.toJavaUuid() }.map(::rowToAbteilung).single()
}
override suspend fun findById(id: Uuid): Abteilung? = tenantTransaction {
AbteilungTable.selectAll().where { AbteilungTable.id eq id.toJavaUuid() }.map(::rowToAbteilung).singleOrNull()
}
override suspend fun findByBewerbId(bewerbId: Uuid): List<Abteilung> = tenantTransaction {
AbteilungTable.selectAll().where { AbteilungTable.bewerbId eq bewerbId.toJavaUuid() }.map(::rowToAbteilung)
}
override suspend fun update(a: Abteilung): Abteilung = tenantTransaction {
val now = Clock.System.now()
AbteilungTable.update({ AbteilungTable.id eq a.id.toJavaUuid() }) { s ->
s[AbteilungTable.nr] = a.nr
s[AbteilungTable.bezeichnung] = a.bezeichnung
s[AbteilungTable.typ] = a.typ
s[AbteilungTable.updatedAt] = now
}
AbteilungTable.selectAll().where { AbteilungTable.id eq a.id.toJavaUuid() }.map(::rowToAbteilung).single()
}
override suspend fun delete(id: Uuid): Boolean = tenantTransaction {
AbteilungTable.deleteWhere { AbteilungTable.id eq id.toJavaUuid() } > 0
}
}
@@ -0,0 +1,48 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.abteilungen
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
import kotlin.uuid.Uuid
data class CreateAbteilungRequest(
val nr: Int,
val bezeichnung: String,
val typ: String,
)
data class UpdateAbteilungRequest(
val nr: Int,
val bezeichnung: String,
val typ: String,
)
@RestController
class AbteilungenController(
private val service: AbteilungenService
) {
@PostMapping("/bewerbe/{bewerbId}/abteilungen")
@ResponseStatus(HttpStatus.CREATED)
suspend fun create(
@PathVariable bewerbId: String,
@RequestBody body: CreateAbteilungRequest
): Abteilung = service.create(Uuid.parse(bewerbId), body.nr, body.bezeichnung, body.typ)
@GetMapping("/bewerbe/{bewerbId}/abteilungen")
suspend fun list(@PathVariable bewerbId: String): List<Abteilung> = service.list(Uuid.parse(bewerbId))
@GetMapping("/abteilungen/{id}")
suspend fun get(@PathVariable id: String): Abteilung = service.get(Uuid.parse(id))
@PutMapping("/abteilungen/{id}")
suspend fun update(@PathVariable id: String, @RequestBody body: UpdateAbteilungRequest): Abteilung =
service.update(Uuid.parse(id), body.nr, body.bezeichnung, body.typ)
@DeleteMapping("/abteilungen/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
suspend fun delete(@PathVariable id: String) {
service.delete(Uuid.parse(id))
}
}
@@ -0,0 +1,49 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.abteilungen
import at.mocode.entries.service.bewerbe.BewerbRepository
import at.mocode.entries.service.errors.LockedException
import at.mocode.entries.service.persistence.BewerbTable
import at.mocode.entries.service.persistence.TurnierTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
class AbteilungenService(
private val repo: AbteilungRepository,
private val bewerbRepo: BewerbRepository,
) {
private suspend fun isParentTurnierPublished(bewerbId: Uuid): Boolean = tenantTransaction {
val bRow = BewerbTable.selectAll().where { BewerbTable.id eq bewerbId.toJavaUuid() }.singleOrNull()
?: return@tenantTransaction false
val tId = bRow[BewerbTable.turnierId]
val tRow = TurnierTable.selectAll().where { TurnierTable.id eq tId }.singleOrNull()
tRow?.get(TurnierTable.status) == "PUBLISHED"
}
suspend fun create(bewerbId: Uuid, nr: Int, bezeichnung: String, typ: String): Abteilung {
if (isParentTurnierPublished(bewerbId)) throw LockedException("Turnier ist PUBLISHED Abteilungen können nicht angelegt werden")
val a = Abteilung(id = Uuid.random(), bewerbId = bewerbId, nr = nr, bezeichnung = bezeichnung, typ = typ)
return repo.create(a)
}
suspend fun list(bewerbId: Uuid): List<Abteilung> = repo.findByBewerbId(bewerbId)
suspend fun get(id: Uuid): Abteilung = repo.findById(id) ?: throw NoSuchElementException("Abteilung $id nicht gefunden")
suspend fun update(id: Uuid, nr: Int, bezeichnung: String, typ: String): Abteilung {
val current = get(id)
if (isParentTurnierPublished(current.bewerbId)) throw LockedException("Turnier ist PUBLISHED Abteilungen können nicht geändert werden")
return repo.update(current.copy(nr = nr, bezeichnung = bezeichnung, typ = typ))
}
suspend fun delete(id: Uuid) {
val current = get(id)
if (isParentTurnierPublished(current.bewerbId)) throw LockedException("Turnier ist PUBLISHED Abteilungen können nicht gelöscht werden")
repo.delete(id)
}
}
@@ -0,0 +1,22 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.bewerbe
import kotlin.uuid.Uuid
data class Bewerb(
val id: Uuid,
val turnierId: Uuid,
val klasse: String,
val hoeheCm: Int?,
val bezeichnung: String,
)
interface BewerbRepository {
suspend fun create(b: Bewerb): Bewerb
suspend fun findById(id: Uuid): Bewerb?
suspend fun findByTurnierId(turnierId: Uuid, klasse: String? = null, q: String? = null): List<Bewerb>
suspend fun update(b: Bewerb): Bewerb
suspend fun delete(id: Uuid): Boolean
suspend fun countAbteilungen(id: Uuid): Long
}
@@ -0,0 +1,73 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.bewerbe
import at.mocode.entries.service.persistence.AbteilungTable
import at.mocode.entries.service.persistence.BewerbTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
class BewerbRepositoryImpl : BewerbRepository {
private fun rowToBewerb(row: ResultRow): Bewerb = Bewerb(
id = row[BewerbTable.id].toKotlinUuid(),
turnierId = row[BewerbTable.turnierId].toKotlinUuid(),
klasse = row[BewerbTable.klasse],
hoeheCm = row[BewerbTable.hoeheCm],
bezeichnung = row[BewerbTable.bezeichnung]
)
override suspend fun create(b: Bewerb): Bewerb = tenantTransaction {
val now = Clock.System.now()
BewerbTable.insert { s ->
s[BewerbTable.id] = b.id.toJavaUuid()
s[BewerbTable.turnierId] = b.turnierId.toJavaUuid()
s[BewerbTable.klasse] = b.klasse
s[BewerbTable.hoeheCm] = b.hoeheCm
s[BewerbTable.bezeichnung] = b.bezeichnung
s[BewerbTable.createdAt] = now
s[BewerbTable.updatedAt] = now
}
BewerbTable.selectAll().where { BewerbTable.id eq b.id.toJavaUuid() }.map(::rowToBewerb).single()
}
override suspend fun findById(id: Uuid): Bewerb? = tenantTransaction {
BewerbTable.selectAll().where { BewerbTable.id eq id.toJavaUuid() }.map(::rowToBewerb).singleOrNull()
}
override suspend fun findByTurnierId(turnierId: Uuid, klasse: String?, q: String?): List<Bewerb> = tenantTransaction {
var qy = BewerbTable.selectAll().where { BewerbTable.turnierId eq turnierId.toJavaUuid() }
if (klasse != null) qy = qy.where { BewerbTable.klasse eq klasse }
// q-Filter vorerst ausgelassen; wird bei Bedarf mit ILIKE ergänzt
qy.map(::rowToBewerb)
}
override suspend fun update(b: Bewerb): Bewerb = tenantTransaction {
val now = Clock.System.now()
BewerbTable.update({ BewerbTable.id eq b.id.toJavaUuid() }) { s ->
s[BewerbTable.klasse] = b.klasse
s[BewerbTable.hoeheCm] = b.hoeheCm
s[BewerbTable.bezeichnung] = b.bezeichnung
s[BewerbTable.updatedAt] = now
}
BewerbTable.selectAll().where { BewerbTable.id eq b.id.toJavaUuid() }.map(::rowToBewerb).single()
}
override suspend fun delete(id: Uuid): Boolean = tenantTransaction {
BewerbTable.deleteWhere { BewerbTable.id eq id.toJavaUuid() } > 0
}
override suspend fun countAbteilungen(id: Uuid): Long = tenantTransaction {
AbteilungTable.selectAll().where { AbteilungTable.bewerbId eq id.toJavaUuid() }.count()
}
}
@@ -0,0 +1,54 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.bewerbe
import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.service.errors.LockedException
import at.mocode.entries.service.persistence.TurnierTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
class BewerbService(
private val repo: BewerbRepository,
private val nennungen: NennungRepository,
) {
private suspend fun isTurnierPublished(turnierId: Uuid): Boolean = tenantTransaction {
val row = TurnierTable.selectAll().where { TurnierTable.id eq turnierId.toJavaUuid() }.singleOrNull()
row?.get(TurnierTable.status) == "PUBLISHED"
}
suspend fun create(turnierId: Uuid, klasse: String, hoeheCm: Int?, bezeichnung: String): Bewerb {
if (isTurnierPublished(turnierId)) throw LockedException("Turnier ist PUBLISHED Bewerbe können nicht angelegt werden")
val b = Bewerb(
id = Uuid.random(),
turnierId = turnierId,
klasse = klasse,
hoeheCm = hoeheCm,
bezeichnung = bezeichnung
)
return repo.create(b)
}
suspend fun list(turnierId: Uuid, klasse: String?, q: String?): List<Bewerb> = repo.findByTurnierId(turnierId, klasse, q)
suspend fun get(id: Uuid): Bewerb = repo.findById(id) ?: throw NoSuchElementException("Bewerb $id nicht gefunden")
suspend fun update(id: Uuid, klasse: String, hoeheCm: Int?, bezeichnung: String): Bewerb {
val current = get(id)
if (isTurnierPublished(current.turnierId)) throw LockedException("Turnier ist PUBLISHED Bewerbe können nicht geändert werden")
return repo.update(current.copy(klasse = klasse, hoeheCm = hoeheCm, bezeichnung = bezeichnung))
}
suspend fun delete(id: Uuid) {
val current = get(id)
if (isTurnierPublished(current.turnierId)) throw LockedException("Turnier ist PUBLISHED Bewerbe können nicht gelöscht werden")
val abtCount = repo.countAbteilungen(id)
val nennCount = nennungen.countByBewerbId(id)
if (abtCount > 0L || nennCount > 0L) throw IllegalStateException("Bewerb hat Abteilungen oder Nennungen (409)")
repo.delete(id)
}
}
@@ -0,0 +1,52 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.bewerbe
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
import kotlin.uuid.Uuid
data class CreateBewerbRequest(
val klasse: String,
val hoeheCm: Int? = null,
val bezeichnung: String,
)
data class UpdateBewerbRequest(
val klasse: String,
val hoeheCm: Int? = null,
val bezeichnung: String,
)
@RestController
class BewerbeController(
private val service: BewerbService
) {
@PostMapping("/turniere/{turnierId}/bewerbe")
@ResponseStatus(HttpStatus.CREATED)
suspend fun create(
@PathVariable turnierId: String,
@RequestBody body: CreateBewerbRequest
): Bewerb = service.create(Uuid.parse(turnierId), body.klasse, body.hoeheCm, body.bezeichnung)
@GetMapping("/turniere/{turnierId}/bewerbe")
suspend fun list(
@PathVariable turnierId: String,
@RequestParam(required = false) klasse: String?,
@RequestParam(required = false) q: String?,
): List<Bewerb> = service.list(Uuid.parse(turnierId), klasse, q)
@GetMapping("/bewerbe/{id}")
suspend fun get(@PathVariable id: String): Bewerb = service.get(Uuid.parse(id))
@PutMapping("/bewerbe/{id}")
suspend fun update(@PathVariable id: String, @RequestBody body: UpdateBewerbRequest): Bewerb =
service.update(Uuid.parse(id), body.klasse, body.hoeheCm, body.bezeichnung)
@DeleteMapping("/bewerbe/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
suspend fun delete(@PathVariable id: String) {
service.delete(Uuid.parse(id))
}
}
@@ -4,6 +4,15 @@ import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.domain.repository.NennungsTransferRepository
import at.mocode.entries.service.persistence.NennungRepositoryImpl
import at.mocode.entries.service.persistence.NennungsTransferRepositoryImpl
import at.mocode.entries.service.turniere.TurnierRepository
import at.mocode.entries.service.turniere.TurnierRepositoryImpl
import at.mocode.entries.service.turniere.TurnierService
import at.mocode.entries.service.bewerbe.BewerbRepository
import at.mocode.entries.service.bewerbe.BewerbRepositoryImpl
import at.mocode.entries.service.bewerbe.BewerbService
import at.mocode.entries.service.abteilungen.AbteilungRepository
import at.mocode.entries.service.abteilungen.AbteilungRepositoryImpl
import at.mocode.entries.service.abteilungen.AbteilungenService
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@@ -21,4 +30,31 @@ class EntriesBeansConfiguration {
@Bean
fun nennungsTransferRepository(): NennungsTransferRepository = NennungsTransferRepositoryImpl()
@Bean
fun turnierRepository(): TurnierRepository = TurnierRepositoryImpl()
@Bean
fun turnierService(
turnierRepository: TurnierRepository,
nennungRepository: NennungRepository
): TurnierService = TurnierService(turnierRepository, nennungRepository)
@Bean
fun bewerbRepository(): BewerbRepository = BewerbRepositoryImpl()
@Bean
fun bewerbService(
bewerbRepository: BewerbRepository,
nennungRepository: NennungRepository
): BewerbService = BewerbService(bewerbRepository, nennungRepository)
@Bean
fun abteilungRepository(): AbteilungRepository = AbteilungRepositoryImpl()
@Bean
fun abteilungenService(
abteilungRepository: AbteilungRepository,
bewerbRepository: BewerbRepository
): AbteilungenService = AbteilungenService(abteilungRepository, bewerbRepository)
}
@@ -5,6 +5,8 @@ import org.springframework.http.HttpStatus
import org.springframework.http.ProblemDetail
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import at.mocode.entries.service.errors.ValidationException
import at.mocode.entries.service.errors.LockedException
/**
* Globaler Exception-Handler für den Entries Service.
@@ -33,4 +35,16 @@ class EntriesExceptionHandler {
log.warn("Konflikt: {}", ex.message)
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.message ?: "Konflikt")
}
@ExceptionHandler(ValidationException::class)
fun handleValidation(ex: ValidationException): ProblemDetail {
log.warn("Validierungsfehler: {}", ex.message)
return ProblemDetail.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.message ?: "Validierungsfehler")
}
@ExceptionHandler(LockedException::class)
fun handleLocked(ex: LockedException): ProblemDetail {
log.warn("Ressource gesperrt: {}", ex.message)
return ProblemDetail.forStatusAndDetail(HttpStatus.LOCKED, ex.message ?: "Gesperrt")
}
}
@@ -0,0 +1,4 @@
package at.mocode.entries.service.errors
class ValidationException(message: String) : RuntimeException(message)
class LockedException(message: String) : RuntimeException(message)
@@ -0,0 +1,22 @@
package at.mocode.entries.service.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.timestamp
object AbteilungTable : Table("abteilungen") {
val id = javaUUID("id").autoGenerate()
val bewerbId = javaUUID("bewerb_id")
val nr = integer("nr")
val bezeichnung = text("bezeichnung")
val typ = varchar("typ", 32)
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
init {
index(false, bewerbId)
index(false, typ)
}
}
@@ -0,0 +1,22 @@
package at.mocode.entries.service.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.timestamp
object BewerbTable : Table("bewerbe") {
val id = javaUUID("id").autoGenerate()
val turnierId = javaUUID("turnier_id")
val klasse = varchar("klasse", 50)
val hoeheCm = integer("hoehe_cm").nullable()
val bezeichnung = text("bezeichnung")
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
init {
index(false, turnierId)
index(false, klasse)
}
}
@@ -0,0 +1,29 @@
package at.mocode.entries.service.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Exposed-Tabellendefinition für Turniere (tenant-scope).
*/
object TurnierTable : Table("turniere") {
val id = javaUUID("id").autoGenerate()
val veranstaltungId = javaUUID("veranstaltung_id")
val oepsTurniernummer = varchar("oeps_turniernummer", 50)
// V3 Felder
val status = varchar("status", 16).default("DRAFT")
val publishedAt = timestamp("published_at").nullable()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
init {
index(true, oepsTurniernummer)
index(false, veranstaltungId)
index(false, status)
}
}
@@ -0,0 +1,15 @@
package at.mocode.entries.service.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Veranstaltung ist ein Singleton pro Tenant-Schema.
*/
object VeranstaltungTable : Table("veranstaltungen") {
val id = javaUUID("id").autoGenerate()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
override val primaryKey = PrimaryKey(id)
}
@@ -0,0 +1,22 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.turniere
import kotlin.uuid.Uuid
data class Turnier(
val id: Uuid,
val veranstaltungId: Uuid,
val oepsTurniernummer: String,
val status: String,
val publishedAt: String?,
)
interface TurnierRepository {
suspend fun create(t: Turnier): Turnier
suspend fun findById(id: Uuid): Turnier?
suspend fun findAll(status: String? = null, oepsNr: String? = null): List<Turnier>
suspend fun update(t: Turnier): Turnier
suspend fun delete(id: Uuid): Boolean
suspend fun updateStatus(id: Uuid, newStatus: String): Turnier
}
@@ -0,0 +1,89 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.turniere
import at.mocode.entries.service.persistence.TurnierTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.jetbrains.exposed.v1.jdbc.deleteWhere
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
class TurnierRepositoryImpl : TurnierRepository {
private fun rowToTurnier(row: ResultRow): Turnier = Turnier(
id = row[TurnierTable.id].toKotlinUuid(),
veranstaltungId = row[TurnierTable.veranstaltungId].toKotlinUuid(),
oepsTurniernummer = row[TurnierTable.oepsTurniernummer],
status = row[TurnierTable.status],
publishedAt = row[TurnierTable.publishedAt]?.toString(),
)
override suspend fun create(t: Turnier): Turnier = tenantTransaction {
val now = Clock.System.now()
TurnierTable.insert { stmt ->
stmt[TurnierTable.id] = t.id.toJavaUuid()
stmt[TurnierTable.veranstaltungId] = t.veranstaltungId.toJavaUuid()
stmt[TurnierTable.oepsTurniernummer] = t.oepsTurniernummer
stmt[TurnierTable.status] = t.status
stmt[TurnierTable.publishedAt] = null
stmt[TurnierTable.createdAt] = now
stmt[TurnierTable.updatedAt] = now
}
// read-back within same transaction
TurnierTable.selectAll().where { TurnierTable.id eq t.id.toJavaUuid() }
.map(::rowToTurnier)
.single()
}
override suspend fun findById(id: Uuid): Turnier? = tenantTransaction {
TurnierTable.selectAll().where { TurnierTable.id eq id.toJavaUuid() }
.map(::rowToTurnier)
.singleOrNull()
}
override suspend fun findAll(status: String?, oepsNr: String?): List<Turnier> = tenantTransaction {
var query = TurnierTable.selectAll()
if (status != null) {
query = query.where { TurnierTable.status eq status }
}
if (oepsNr != null) {
query = query.where { TurnierTable.oepsTurniernummer eq oepsNr }
}
query.map(::rowToTurnier)
}
override suspend fun update(t: Turnier): Turnier = tenantTransaction {
val now = Clock.System.now()
TurnierTable.update({ TurnierTable.id eq t.id.toJavaUuid() }) { stmt ->
stmt[TurnierTable.oepsTurniernummer] = t.oepsTurniernummer
stmt[TurnierTable.status] = t.status
stmt[TurnierTable.updatedAt] = now
}
TurnierTable.selectAll().where { TurnierTable.id eq t.id.toJavaUuid() }
.map(::rowToTurnier)
.single()
}
override suspend fun delete(id: Uuid): Boolean = tenantTransaction {
TurnierTable.deleteWhere { TurnierTable.id eq id.toJavaUuid() } > 0
}
override suspend fun updateStatus(id: Uuid, newStatus: String): Turnier = tenantTransaction {
val now = Clock.System.now()
TurnierTable.update({ TurnierTable.id eq id.toJavaUuid() }) { stmt ->
stmt[TurnierTable.status] = newStatus
stmt[TurnierTable.publishedAt] = if (newStatus == "PUBLISHED") now else null
stmt[TurnierTable.updatedAt] = now
}
TurnierTable.selectAll().where { TurnierTable.id eq id.toJavaUuid() }
.map(::rowToTurnier)
.single()
}
}
@@ -0,0 +1,61 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.turniere
import at.mocode.core.domain.model.NennungsStatusE
import at.mocode.entries.domain.repository.NennungRepository
import at.mocode.entries.service.errors.LockedException
import at.mocode.entries.service.errors.ValidationException
import kotlin.uuid.Uuid
class TurnierService(
private val repo: TurnierRepository,
private val nennungen: NennungRepository,
) {
suspend fun create(veranstaltungId: Uuid, oepsNr: String, status: String? = null): Turnier {
val t = Turnier(
id = Uuid.random(),
veranstaltungId = veranstaltungId,
oepsTurniernummer = oepsNr,
status = status ?: "DRAFT",
publishedAt = null,
)
return repo.create(t)
}
suspend fun get(id: Uuid): Turnier = repo.findById(id) ?: throw NoSuchElementException("Turnier $id nicht gefunden")
suspend fun list(status: String?, oepsNr: String?): List<Turnier> = repo.findAll(status, oepsNr)
suspend fun update(id: Uuid, oepsNr: String): Turnier {
val current = get(id)
if (current.status == "PUBLISHED" && current.oepsTurniernummer != oepsNr) {
throw LockedException("Turnier ist PUBLISHED strukturelle Felder nicht änderbar")
}
return repo.update(current.copy(oepsTurniernummer = oepsNr))
}
suspend fun delete(id: Uuid): Boolean {
val current = get(id)
if (current.status == "PUBLISHED") throw LockedException("Turnier ist PUBLISHED und kann nicht gelöscht werden")
// TODO: 409 wenn abhängige Bewerbe existieren (FK-Prüfung sobald Bewerbe umgesetzt)
return repo.delete(id)
}
suspend fun updateStatus(id: Uuid, toStatus: String): Turnier {
val current = get(id)
if (current.status == toStatus) return current
return when (current.status to toStatus) {
("DRAFT" to "PUBLISHED") -> repo.updateStatus(id, "PUBLISHED")
("PUBLISHED" to "DRAFT") -> {
// Blockieren, wenn Nennungen/Zahlungen vorhanden sind aktuell prüfen wir Nennungen in beliebigem Status
val anyEntries = nennungen.findByTurnierId(id).isNotEmpty()
if (anyEntries) throw IllegalStateException("Statuswechsel zu DRAFT nicht möglich: vorhandene Nennungen")
repo.updateStatus(id, "DRAFT")
}
else -> throw ValidationException("Unerlaubter Statuswechsel: ${current.status}$toStatus")
}
}
}
@@ -0,0 +1,70 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.turniere
import at.mocode.entries.service.persistence.VeranstaltungTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.*
import kotlin.uuid.Uuid
import kotlin.uuid.toKotlinUuid
data class CreateTurnierRequest(
val oepsTurniernummer: String,
val status: String? = null,
)
data class UpdateTurnierRequest(
val oepsTurniernummer: String,
)
data class UpdateTurnierStatusRequest(
val status: String,
)
@RestController
@RequestMapping
class TurniereController(
private val service: TurnierService
) {
@PostMapping("/turniere")
@ResponseStatus(HttpStatus.CREATED)
suspend fun create(@RequestBody body: CreateTurnierRequest): Turnier {
// Veranstaltung pro Tenant auflösen: wir nehmen die einzige vorhandene ID aus dem Schema
val veranstaltungId = resolveVeranstaltungId()
return service.create(veranstaltungId, body.oepsTurniernummer, body.status)
}
@GetMapping("/turniere")
suspend fun list(
@RequestParam(required = false) status: String?,
@RequestParam(required = false, name = "oepsTurniernummer") oepsNr: String?,
): List<Turnier> = service.list(status, oepsNr)
@GetMapping("/turniere/{id}")
suspend fun get(@PathVariable id: String): Turnier = service.get(Uuid.parse(id))
@PutMapping("/turniere/{id}")
suspend fun update(@PathVariable id: String, @RequestBody body: UpdateTurnierRequest): Turnier =
service.update(Uuid.parse(id), body.oepsTurniernummer)
@DeleteMapping("/turniere/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
suspend fun delete(@PathVariable id: String) {
service.delete(Uuid.parse(id))
}
@PatchMapping("/turniere/{id}/status")
suspend fun updateStatus(@PathVariable id: String, @RequestBody body: UpdateTurnierStatusRequest): Turnier =
service.updateStatus(Uuid.parse(id), body.status)
}
// --- Helpers ---
private suspend fun resolveVeranstaltungId(): Uuid = tenantTransaction {
val row = VeranstaltungTable.selectAll().limit(1).singleOrNull()
?: throw IllegalStateException("Keine Veranstaltung im Tenant-Schema vorhanden")
val javaId = row[VeranstaltungTable.id]
javaId.toKotlinUuid()
}
@@ -0,0 +1,43 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.entries.service.veranstaltung
import at.mocode.entries.service.persistence.VeranstaltungTable
import at.mocode.entries.service.tenant.tenantTransaction
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RestController
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toKotlinUuid
data class VeranstaltungResponse(val id: Uuid)
data class UpdateVeranstaltungRequest(
val dummy: String? = null // Platzhalter bis Felder spezifiziert sind
)
@RestController
class VeranstaltungController {
@GetMapping("/veranstaltung")
suspend fun get(): VeranstaltungResponse = tenantTransaction {
val row = VeranstaltungTable.selectAll().limit(1).singleOrNull()
?: throw IllegalStateException("Keine Veranstaltung im Tenant-Schema vorhanden")
VeranstaltungResponse(row[VeranstaltungTable.id].toKotlinUuid())
}
@PutMapping("/veranstaltung")
suspend fun update(@RequestBody @Suppress("UNUSED_PARAMETER") body: UpdateVeranstaltungRequest): VeranstaltungResponse = tenantTransaction {
val now = Clock.System.now()
val row = VeranstaltungTable.selectAll().limit(1).singleOrNull()
?: throw IllegalStateException("Keine Veranstaltung im Tenant-Schema vorhanden")
VeranstaltungTable.update({ VeranstaltungTable.id eq row[VeranstaltungTable.id] }) { s ->
s[updatedAt] = now
}
VeranstaltungResponse(row[VeranstaltungTable.id].toKotlinUuid())
}
}
@@ -0,0 +1,30 @@
-- V3: Add status and published_at to turniere, with constraints and index
-- Context: Roadmap B-1 / A-2 Ergänzung V3
-- Add columns if not exist (compatible with Postgres >= 9.6)
ALTER TABLE IF EXISTS turniere
ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'DRAFT';
ALTER TABLE IF EXISTS turniere
ADD COLUMN IF NOT EXISTS published_at TIMESTAMP WITH TIME ZONE NULL;
-- Add CHECK constraint for valid status values (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON c.conrelid = t.oid
WHERE t.relname = 'turniere' AND c.conname = 'chk_turniere_status_valid'
) THEN
ALTER TABLE turniere
ADD CONSTRAINT chk_turniere_status_valid CHECK (status IN ('DRAFT','PUBLISHED'));
END IF;
END$$;
-- Backfill existing rows to ensure consistent values (no-op if default already applied)
UPDATE turniere SET status = COALESCE(status, 'DRAFT') WHERE status IS NULL;
-- Index to speed up filtering by status
CREATE INDEX IF NOT EXISTS idx_turniere_status ON turniere(status);
-- Note: We intentionally keep the DEFAULT 'DRAFT' for safer inserts.
+68 -10
View File
@@ -1,6 +1,6 @@
# 👷 [Backend Developer] — Schritt-für-Schritt Roadmap
> **Stand:** 2. April 2026
> **Stand:** 3. April 2026
> **Rolle:** Spring Boot / Ktor, Kotlin, SQL, API-Design, Datenbankschema, Services
---
@@ -42,6 +42,12 @@
- [x] Tabelle `teilnehmer_konten` anlegen (FK → `veranstaltung_id`, aggregiert Salden über Turniere)
- [x] Tabelle `turnier_kassa` anlegen (FK → `turnier_id`, separate Kassa pro Turnier)
- [x] Migrations-Skript schreiben und testen (`db/tenant/V2__domain_hierarchy.sql`, Test: `DomainHierarchyMigrationTest`)
- [x] Ergänzung V3 (Turnier-Status): Migration `db/tenant/V3__turniere_status.sql`
- [x] `ALTER TABLE turniere ADD COLUMN status VARCHAR(16) NOT NULL DEFAULT 'DRAFT'` + CHECK(`status` IN ('DRAFT','PUBLISHED'))
- [x] `ALTER TABLE turniere ADD COLUMN published_at TIMESTAMP WITH TIME ZONE NULL`
- [x] Backfill: Alle bestehenden Zeilen auf `DRAFT` setzen; Default danach wieder entfernen oder beibehalten (Entscheidung: beibehalten für Insert-Sicherheit)
- [x] Indexe: `CREATE INDEX IF NOT EXISTS idx_turniere_status ON turniere(status)`
- [x] Folgetasks: Domänenservice-Validierung für Statuswechsel (siehe B-1 Turniere/PATCH)
- [ ] **A-3** | Validierungs-Grundlage: Turnierkategorie-Limits
- [x] Grundlage implementiert: Entkoppelte Policy-Schnittstelle + Bewerb-Descriptor
@@ -57,15 +63,67 @@
## 🟠 Sprint B — Kurzfristig (nächste Woche)
- [ ] **B-1** | CRUD-Endpunkte für alle Stammdaten-Entitäten
- [ ] `POST/GET/PUT/DELETE /veranstaltungen`
- [ ] `POST/GET/PUT/DELETE /turniere` (inkl. Status-Feld: `DRAFT | PUBLISHED`)
- [ ] `POST/GET/PUT/DELETE /bewerbe`
- [ ] `POST/GET/PUT/DELETE /abteilungen`
- [ ] `POST/GET/PUT/DELETE /reiter`
- [ ] `POST/GET/PUT/DELETE /pferde`
- [ ] `POST/GET/PUT/DELETE /vereine`
- [ ] `POST/GET/PUT/DELETE /funktionaere`
- [ ] **B-1** | CRUD-Endpunkte für alle Stammdaten-Entitäten (überarbeitet)
- Multitenancy: Alle Endpunkte laufen im Tenant-Schema (Erkennung via `X-Event-Id` oder Subdomain; siehe A-1). IDs sind UUIDs. Fehlercodes: 400 (Bad Request), 404 (Not Found), 409 (Conflict), 422 (Validation), 423 (Locked Tenant/Status).
- Konventionen:
- POST → 201 + `Location`-Header; GET (Liste) ist paginiert (`page`, `size`) + einfache Filter (`q`, spezifische Felder).
- PUT = Voll-Update; PATCH = Teil-Update für Status/kleine Änderungen, wo sinnvoll.
- Lösch-Strategie: Hard-Delete nur für Stammdaten ohne Referenzen; sonst 409 bei FK-Verletzung.
- Standard-HTTP-Codes: `GET` 200, `POST` 201, `PUT` 200, `PATCH` 200, `DELETE` 204; Fehler gemäß obiger Liste.
- Veranstaltung (Singleton pro Tenant)
- [x] `GET /veranstaltung` — aktuelle Veranstaltung lesen
- [x] `PUT /veranstaltung` — Veranstaltung aktualisieren
- Hinweis: Erstellen/Löschen einer Veranstaltung erfolgt im Control-Plane (außerhalb des Tenant-Services); daher kein `POST/DELETE` hier.
- Turniere
- [x] `POST /turniere` — Turnier anlegen (Felder: `veranstaltungId` implizit aus Tenant, `oepsTurniernummer`, optional `bezeichnung`, `datumVon/Bis`, optional `status`—Default `DRAFT`)
- [x] `GET /turniere` — Liste (Filter: `oepsTurniernummer`, Zeitraum, `status`; Paging)
- [x] `GET /turniere/{id}` — Detail
- [x] `PUT /turniere/{id}` — Voll-Update (ohne Status-Übergang)
- Regeln: Bei `PUBLISHED` nur Metadaten änderbar, keine strukturellen Felder (z. B. `oepsTurniernummer`) → sonst `423 Locked`.
- [x] `DELETE /turniere/{id}` — löschen (409, falls abhängige Bewerbe existieren; bei `PUBLISHED` grundsätzlich gesperrt → `423 Locked`)
- Status-Management (neues Feld, Migration `V3__turniere_status.sql`): `DRAFT | PUBLISHED`
- [x] `PATCH /turniere/{id}/status` — Statuswechsel mit Validierung
- Erlaubt: `DRAFT → PUBLISHED` (setzt `publishedAt`-Timestamp serverseitig)
- `PUBLISHED → DRAFT` nur erlaubt, wenn keine Nennungen/Zahlungen verbucht sind (sonst `409 Conflict`)
- Unerlaubte Übergänge → `422 Validation` (inkl. Begründung im `problem+json`-Body)
- Bewerbe (FK → Turnier)
- [x] `POST /turniere/{turnierId}/bewerbe` — anlegen
- [x] `GET /turniere/{turnierId}/bewerbe` — Liste im Turnier
- [x] `GET /bewerbe/{id}` — Detail
- [x] `PUT /bewerbe/{id}` — aktualisieren
- [x] `DELETE /bewerbe/{id}` — löschen (409 bei existierenden Abteilungen/Nennungen; gesperrt falls zu `PUBLISHED` Turnier → `423 Locked`)
- Abteilungen (FK → Bewerb)
- [x] `POST /bewerbe/{bewerbId}/abteilungen` — anlegen (Felder: `nr`, `bezeichnung`, `typ: SEPARATE_SIEGEREHRUNG | ORGANISATORISCH`)
- [x] `GET /bewerbe/{bewerbId}/abteilungen` — Liste
- [x] `GET /abteilungen/{id}` — Detail
- [x] `PUT /abteilungen/{id}` — aktualisieren
- [x] `DELETE /abteilungen/{id}` — löschen (gesperrt falls zu `PUBLISHED` Turnier → `423 Locked`)
- Hinweis: Filter `q` (LIKE/ILIKE) bei Bewerbe-Liste ist vorerst ausgelassen und kann nachgezogen werden.
- Reiter (Athleten-Stammdaten)
- [ ] `POST/GET/GET{id}/PUT/DELETE /reiter` — Suche über `q` (Name, Lizenznr.), Filter: `lizenzKlasse`, `vereinId`
- Pferde (Pferde-Stammdaten)
- [ ] `POST/GET/GET{id}/PUT/DELETE /pferde` — Suche `q` (Name, Lebensnr.), Filter: `jahrgang`, `besitzerId`
- Vereine
- [ ] `POST/GET/GET{id}/PUT/DELETE /vereine` — Suche `q` (Name, Kürzel), Filter: `verband`
- Funktionäre
- [ ] `POST/GET/GET{id}/PUT/DELETE /funktionaere` — Suche `q` (Name, Lizenznr.), Filter: `rolle`
- Technische Notizen
- [ ] API-Doku per OpenAPI (Springdoc) veröffentlichen; Beispiel-Payloads für POST/PUT/PATCH (Statuswechsel)
- [x] Konsistentes Error-Format (`problem+json`)
- [ ] E2E-Tests: CRUD-Flows für Turnier → Bewerb → Abteilung inkl. FK-Constraints
- [x] Migration `V3__turniere_status.sql` in Flyway integrieren und gegen H2/Postgres testen (Back/Forward kompatibel)
- [x] Guardrails: Service-Ebene erzwingt Locks für `PUBLISHED` (PUT/DELETE) und valide Status-Transitions (PATCH)
- [x] Problem+JSON-Details: `type`, `title`, `status`, `detail`, `instance` befüllen; bei `422` Begründung/Violations je Feld mitschicken.
- [ ] **B-2** | Kassa-Service implementieren
- [ ] `TeilnehmerKonto`-Service: Saldo aus mehreren Turnieren aggregieren