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.domain.repository.NennungsTransferRepository
|
||||||
import at.mocode.entries.service.persistence.NennungRepositoryImpl
|
import at.mocode.entries.service.persistence.NennungRepositoryImpl
|
||||||
import at.mocode.entries.service.persistence.NennungsTransferRepositoryImpl
|
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.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
|
|
||||||
@@ -21,4 +30,31 @@ class EntriesBeansConfiguration {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun nennungsTransferRepository(): NennungsTransferRepository = NennungsTransferRepositoryImpl()
|
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.http.ProblemDetail
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
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.
|
* Globaler Exception-Handler für den Entries Service.
|
||||||
@@ -33,4 +35,16 @@ class EntriesExceptionHandler {
|
|||||||
log.warn("Konflikt: {}", ex.message)
|
log.warn("Konflikt: {}", ex.message)
|
||||||
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.message ?: "Konflikt")
|
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
|
# 👷 [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
|
> **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 `teilnehmer_konten` anlegen (FK → `veranstaltung_id`, aggregiert Salden über Turniere)
|
||||||
- [x] Tabelle `turnier_kassa` anlegen (FK → `turnier_id`, separate Kassa pro Turnier)
|
- [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] 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
|
- [ ] **A-3** | Validierungs-Grundlage: Turnierkategorie-Limits
|
||||||
- [x] Grundlage implementiert: Entkoppelte Policy-Schnittstelle + Bewerb-Descriptor
|
- [x] Grundlage implementiert: Entkoppelte Policy-Schnittstelle + Bewerb-Descriptor
|
||||||
@@ -57,15 +63,67 @@
|
|||||||
|
|
||||||
## 🟠 Sprint B — Kurzfristig (nächste Woche)
|
## 🟠 Sprint B — Kurzfristig (nächste Woche)
|
||||||
|
|
||||||
- [ ] **B-1** | CRUD-Endpunkte für alle Stammdaten-Entitäten
|
- [ ] **B-1** | CRUD-Endpunkte für alle Stammdaten-Entitäten (überarbeitet)
|
||||||
- [ ] `POST/GET/PUT/DELETE /veranstaltungen`
|
- 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).
|
||||||
- [ ] `POST/GET/PUT/DELETE /turniere` (inkl. Status-Feld: `DRAFT | PUBLISHED`)
|
- Konventionen:
|
||||||
- [ ] `POST/GET/PUT/DELETE /bewerbe`
|
- POST → 201 + `Location`-Header; GET (Liste) ist paginiert (`page`, `size`) + einfache Filter (`q`, spezifische Felder).
|
||||||
- [ ] `POST/GET/PUT/DELETE /abteilungen`
|
- PUT = Voll-Update; PATCH = Teil-Update für Status/kleine Änderungen, wo sinnvoll.
|
||||||
- [ ] `POST/GET/PUT/DELETE /reiter`
|
- Lösch-Strategie: Hard-Delete nur für Stammdaten ohne Referenzen; sonst 409 bei FK-Verletzung.
|
||||||
- [ ] `POST/GET/PUT/DELETE /pferde`
|
- Standard-HTTP-Codes: `GET` 200, `POST` 201, `PUT` 200, `PATCH` 200, `DELETE` 204; Fehler gemäß obiger Liste.
|
||||||
- [ ] `POST/GET/PUT/DELETE /vereine`
|
|
||||||
- [ ] `POST/GET/PUT/DELETE /funktionaere`
|
- 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
|
- [ ] **B-2** | Kassa-Service implementieren
|
||||||
- [ ] `TeilnehmerKonto`-Service: Saldo aus mehreren Turnieren aggregieren
|
- [ ] `TeilnehmerKonto`-Service: Saldo aus mehreren Turnieren aggregieren
|
||||||
|
|||||||
Reference in New Issue
Block a user