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:
+21
@@ -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
|
||||
}
|
||||
+64
@@ -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
|
||||
}
|
||||
}
|
||||
+48
@@ -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))
|
||||
}
|
||||
}
|
||||
+49
@@ -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)
|
||||
}
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
+73
@@ -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()
|
||||
}
|
||||
}
|
||||
+54
@@ -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)
|
||||
}
|
||||
}
|
||||
+52
@@ -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))
|
||||
}
|
||||
}
|
||||
+36
@@ -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)
|
||||
}
|
||||
|
||||
+14
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package at.mocode.entries.service.errors
|
||||
|
||||
class ValidationException(message: String) : RuntimeException(message)
|
||||
class LockedException(message: String) : RuntimeException(message)
|
||||
+22
@@ -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)
|
||||
}
|
||||
}
|
||||
+22
@@ -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)
|
||||
}
|
||||
}
|
||||
+29
@@ -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)
|
||||
}
|
||||
}
|
||||
+15
@@ -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)
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
+89
@@ -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()
|
||||
}
|
||||
}
|
||||
+61
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -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()
|
||||
}
|
||||
+43
@@ -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())
|
||||
}
|
||||
}
|
||||
+30
@@ -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.
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user