From c483f4925da64de6f16b27da7a0b65be690fa681 Mon Sep 17 00:00:00 2001 From: StefanMoCoAt Date: Fri, 3 Apr 2026 00:06:16 +0200 Subject: [PATCH] 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. --- .../abteilungen/AbteilungRepository.kt | 21 +++++ .../abteilungen/AbteilungRepositoryImpl.kt | 64 +++++++++++++ .../abteilungen/AbteilungenController.kt | 48 ++++++++++ .../service/abteilungen/AbteilungenService.kt | 49 ++++++++++ .../service/bewerbe/BewerbRepository.kt | 22 +++++ .../service/bewerbe/BewerbRepositoryImpl.kt | 73 +++++++++++++++ .../entries/service/bewerbe/BewerbService.kt | 54 +++++++++++ .../service/bewerbe/BewerbeController.kt | 52 +++++++++++ .../config/EntriesBeansConfiguration.kt | 36 ++++++++ .../service/config/EntriesExceptionHandler.kt | 14 +++ .../service/errors/ServiceExceptions.kt | 4 + .../service/persistence/AbteilungTable.kt | 22 +++++ .../service/persistence/BewerbTable.kt | 22 +++++ .../service/persistence/TurnierTable.kt | 29 ++++++ .../service/persistence/VeranstaltungTable.kt | 15 ++++ .../service/turniere/TurnierRepository.kt | 22 +++++ .../service/turniere/TurnierRepositoryImpl.kt | 89 +++++++++++++++++++ .../service/turniere/TurnierService.kt | 61 +++++++++++++ .../service/turniere/TurniereController.kt | 70 +++++++++++++++ .../veranstaltung/VeranstaltungController.kt | 43 +++++++++ .../db/tenant/V3__turniere_status.sql | 30 +++++++ docs/04_Agents/Roadmaps/Backend_Roadmap.md | 78 +++++++++++++--- 22 files changed, 908 insertions(+), 10 deletions(-) create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepository.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepositoryImpl.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungenController.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungenService.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepository.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepositoryImpl.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbService.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbeController.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/errors/ServiceExceptions.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/AbteilungTable.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/BewerbTable.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/TurnierTable.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/VeranstaltungTable.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurnierRepository.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurnierRepositoryImpl.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurnierService.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurniereController.kt create mode 100644 backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/veranstaltung/VeranstaltungController.kt create mode 100644 backend/services/entries/entries-service/src/main/resources/db/tenant/V3__turniere_status.sql diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepository.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepository.kt new file mode 100644 index 00000000..3d138f14 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepository.kt @@ -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 + suspend fun update(a: Abteilung): Abteilung + suspend fun delete(id: Uuid): Boolean +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepositoryImpl.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepositoryImpl.kt new file mode 100644 index 00000000..e535c2ff --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungRepositoryImpl.kt @@ -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 = 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 + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungenController.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungenController.kt new file mode 100644 index 00000000..161d2c3c --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungenController.kt @@ -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 = 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)) + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungenService.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungenService.kt new file mode 100644 index 00000000..93f76639 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/abteilungen/AbteilungenService.kt @@ -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 = 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) + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepository.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepository.kt new file mode 100644 index 00000000..9da3d61d --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepository.kt @@ -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 + suspend fun update(b: Bewerb): Bewerb + suspend fun delete(id: Uuid): Boolean + suspend fun countAbteilungen(id: Uuid): Long +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepositoryImpl.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepositoryImpl.kt new file mode 100644 index 00000000..509040bd --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbRepositoryImpl.kt @@ -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 = 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() + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbService.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbService.kt new file mode 100644 index 00000000..85195217 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbService.kt @@ -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 = 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) + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbeController.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbeController.kt new file mode 100644 index 00000000..ae4d8c76 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/bewerbe/BewerbeController.kt @@ -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 = 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)) + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesBeansConfiguration.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesBeansConfiguration.kt index f9da0fba..2f11e28b 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesBeansConfiguration.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesBeansConfiguration.kt @@ -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) } diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesExceptionHandler.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesExceptionHandler.kt index 6f0b41eb..29e7645c 100644 --- a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesExceptionHandler.kt +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/config/EntriesExceptionHandler.kt @@ -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") + } } diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/errors/ServiceExceptions.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/errors/ServiceExceptions.kt new file mode 100644 index 00000000..ce085ba5 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/errors/ServiceExceptions.kt @@ -0,0 +1,4 @@ +package at.mocode.entries.service.errors + +class ValidationException(message: String) : RuntimeException(message) +class LockedException(message: String) : RuntimeException(message) diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/AbteilungTable.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/AbteilungTable.kt new file mode 100644 index 00000000..e30184b1 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/AbteilungTable.kt @@ -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) + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/BewerbTable.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/BewerbTable.kt new file mode 100644 index 00000000..e9e0a57d --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/BewerbTable.kt @@ -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) + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/TurnierTable.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/TurnierTable.kt new file mode 100644 index 00000000..e2b6c2d3 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/TurnierTable.kt @@ -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) + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/VeranstaltungTable.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/VeranstaltungTable.kt new file mode 100644 index 00000000..027df691 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/persistence/VeranstaltungTable.kt @@ -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) +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurnierRepository.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurnierRepository.kt new file mode 100644 index 00000000..39e21dda --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurnierRepository.kt @@ -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 + suspend fun update(t: Turnier): Turnier + suspend fun delete(id: Uuid): Boolean + suspend fun updateStatus(id: Uuid, newStatus: String): Turnier +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurnierRepositoryImpl.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurnierRepositoryImpl.kt new file mode 100644 index 00000000..35026224 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurnierRepositoryImpl.kt @@ -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 = 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() + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurnierService.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurnierService.kt new file mode 100644 index 00000000..a48579ce --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurnierService.kt @@ -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 = 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") + } + } +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurniereController.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurniereController.kt new file mode 100644 index 00000000..5f3f122d --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/turniere/TurniereController.kt @@ -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 = 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() +} diff --git a/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/veranstaltung/VeranstaltungController.kt b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/veranstaltung/VeranstaltungController.kt new file mode 100644 index 00000000..9c0d9650 --- /dev/null +++ b/backend/services/entries/entries-service/src/main/kotlin/at/mocode/entries/service/veranstaltung/VeranstaltungController.kt @@ -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()) + } +} diff --git a/backend/services/entries/entries-service/src/main/resources/db/tenant/V3__turniere_status.sql b/backend/services/entries/entries-service/src/main/resources/db/tenant/V3__turniere_status.sql new file mode 100644 index 00000000..0b00799f --- /dev/null +++ b/backend/services/entries/entries-service/src/main/resources/db/tenant/V3__turniere_status.sql @@ -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. diff --git a/docs/04_Agents/Roadmaps/Backend_Roadmap.md b/docs/04_Agents/Roadmaps/Backend_Roadmap.md index a3da6f96..c59365f1 100644 --- a/docs/04_Agents/Roadmaps/Backend_Roadmap.md +++ b/docs/04_Agents/Roadmaps/Backend_Roadmap.md @@ -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