feat: integrate new desktop shell and extend backend & ADRs

- Added `meldestelle-desktop` module using JVM/Compose Desktop, registered in `settings.gradle.kts`.
- Integrated new screens and desktop navigation into core: `Veranstaltungen`, `TurnierDetail`, etc.
- Expanded backend with `ExposedFunktionaerRepository` in `officials-infrastructure`.
- Completed ADRs for bounded context mapping (`ADR-0014`) and context map (`ADR-0015`).
- Updated and extended project documentation with session logs and architecture decisions.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-03-24 18:22:15 +01:00
parent c624df8744
commit 354bd49de6
75 changed files with 7616 additions and 48 deletions
@@ -0,0 +1,186 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.officials.infrastructure.persistence
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.FunktionaerRolleE
import at.mocode.core.domain.model.RichterQualifikationE
import at.mocode.core.domain.model.SparteE
import at.mocode.officials.domain.model.DomFunktionaer
import at.mocode.officials.domain.repository.FunktionaerRepository
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.core.statements.UpdateBuilder
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.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import java.util.*
import kotlin.time.Clock
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid
import kotlin.uuid.toKotlinUuid
/**
* Exposed-basierte Implementierung des FunktionaerRepository.
*/
class ExposedFunktionaerRepository : FunktionaerRepository {
override suspend fun findById(id: Uuid): DomFunktionaer? = transaction {
FunktionaerTable.selectAll().where { FunktionaerTable.id eq id.toJavaUuid() }
.map { rowToFunktionaer(it) }
.singleOrNull()
}
override suspend fun findByRichterNummer(richterNummer: String): DomFunktionaer? = transaction {
FunktionaerTable.selectAll().where { FunktionaerTable.richterNummer eq richterNummer }
.map { rowToFunktionaer(it) }
.singleOrNull()
}
override suspend fun findByName(searchTerm: String, limit: Int): List<DomFunktionaer> = transaction {
val pattern = "%$searchTerm%"
FunktionaerTable.selectAll().where {
(FunktionaerTable.nachname like pattern) or (FunktionaerTable.vorname like pattern)
}.limit(limit).map { rowToFunktionaer(it) }
}
override suspend fun findByRolle(rolle: FunktionaerRolleE, activeOnly: Boolean): List<DomFunktionaer> = transaction {
FunktionaerTable.selectAll().where {
(FunktionaerTable.rollen like "%${rolle.name}%").let {
if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it
}
}.map { rowToFunktionaer(it) }
}
override suspend fun findByRichterQualifikation(
qualifikation: RichterQualifikationE,
activeOnly: Boolean
): List<DomFunktionaer> = transaction {
FunktionaerTable.selectAll().where {
(FunktionaerTable.richterQualifikation eq qualifikation.name).let {
if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it
}
}.map { rowToFunktionaer(it) }
}
override suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean): List<DomFunktionaer> = transaction {
FunktionaerTable.selectAll().where {
(FunktionaerTable.qualifiziertFuerSparten like "%${sparte.name}%").let {
if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it
}
}.map { rowToFunktionaer(it) }
}
override suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean): List<DomFunktionaer> =
transaction {
FunktionaerTable.selectAll().where {
(FunktionaerTable.vereinsNummer eq vereinsNummer).let {
if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it
}
}.map { rowToFunktionaer(it) }
}
override suspend fun findAllActive(limit: Int, offset: Int): List<DomFunktionaer> = transaction {
FunktionaerTable.selectAll().where { FunktionaerTable.istAktiv eq true }
.limit(limit).offset(offset.toLong())
.map { rowToFunktionaer(it) }
}
override suspend fun findAll(limit: Int, offset: Int): List<DomFunktionaer> = transaction {
FunktionaerTable.selectAll()
.limit(limit).offset(offset.toLong())
.map { rowToFunktionaer(it) }
}
override suspend fun save(funktionaer: DomFunktionaer): DomFunktionaer = transaction {
val now = Clock.System.now()
val updated = funktionaer.copy(updatedAt = now)
val javaId = funktionaer.funktionaerId.toJavaUuid()
val existing = FunktionaerTable.selectAll().where { FunktionaerTable.id eq javaId }.singleOrNull()
if (existing != null) {
FunktionaerTable.update({ FunktionaerTable.id eq javaId }) { funktionaerToStatement(it, updated) }
} else {
FunktionaerTable.insert {
it[id] = javaId
funktionaerToStatement(it, updated)
}
}
updated
}
override suspend fun delete(id: Uuid): Boolean = transaction {
FunktionaerTable.deleteWhere { FunktionaerTable.id eq id.toJavaUuid() } > 0
}
override suspend fun countActive(): Long = transaction {
FunktionaerTable.selectAll().where { FunktionaerTable.istAktiv eq true }.count()
}
override suspend fun countByRichterQualifikation(qualifikation: RichterQualifikationE, activeOnly: Boolean): Long =
transaction {
FunktionaerTable.selectAll().where {
(FunktionaerTable.richterQualifikation eq qualifikation.name).let {
if (activeOnly) it and (FunktionaerTable.istAktiv eq true) else it
}
}.count()
}
override suspend fun existsByRichterNummer(richterNummer: String): Boolean = transaction {
FunktionaerTable.selectAll().where { FunktionaerTable.richterNummer eq richterNummer }.count() > 0
}
private fun rowToFunktionaer(row: ResultRow): DomFunktionaer {
val rollen = try {
Json.decodeFromString<List<FunktionaerRolleE>>(row[FunktionaerTable.rollen]).toSet()
} catch (_: Exception) {
emptySet()
}
val sparten = try {
Json.decodeFromString<List<SparteE>>(row[FunktionaerTable.qualifiziertFuerSparten]).toSet()
} catch (_: Exception) {
emptySet()
}
return DomFunktionaer(
funktionaerId = (row[FunktionaerTable.id] as UUID).toKotlinUuid(),
richterNummer = row[FunktionaerTable.richterNummer],
vorname = row[FunktionaerTable.vorname],
nachname = row[FunktionaerTable.nachname],
geburtsdatum = row[FunktionaerTable.geburtsdatum],
rollen = rollen,
richterQualifikation = row[FunktionaerTable.richterQualifikation]?.let {
runCatching { RichterQualifikationE.valueOf(it) }.getOrNull()
},
qualifiziertFuerSparten = sparten,
email = row[FunktionaerTable.email],
telefon = row[FunktionaerTable.telefon],
vereinsNummer = row[FunktionaerTable.vereinsNummer],
istAktiv = row[FunktionaerTable.istAktiv],
bemerkungen = row[FunktionaerTable.bemerkungen],
datenQuelle = runCatching { DatenQuelleE.valueOf(row[FunktionaerTable.datenQuelle]) }.getOrDefault(DatenQuelleE.IMPORT_ZNS),
createdAt = row[FunktionaerTable.createdAt],
updatedAt = row[FunktionaerTable.updatedAt]
)
}
private fun funktionaerToStatement(stmt: UpdateBuilder<*>, f: DomFunktionaer) {
stmt[FunktionaerTable.richterNummer] = f.richterNummer
stmt[FunktionaerTable.vorname] = f.vorname
stmt[FunktionaerTable.nachname] = f.nachname
stmt[FunktionaerTable.geburtsdatum] = f.geburtsdatum
stmt[FunktionaerTable.rollen] = Json.encodeToString(f.rollen.toList())
stmt[FunktionaerTable.richterQualifikation] = f.richterQualifikation?.name
stmt[FunktionaerTable.qualifiziertFuerSparten] = Json.encodeToString(f.qualifiziertFuerSparten.toList())
stmt[FunktionaerTable.email] = f.email
stmt[FunktionaerTable.telefon] = f.telefon
stmt[FunktionaerTable.vereinsNummer] = f.vereinsNummer
stmt[FunktionaerTable.istAktiv] = f.istAktiv
stmt[FunktionaerTable.bemerkungen] = f.bemerkungen
stmt[FunktionaerTable.datenQuelle] = f.datenQuelle.name
stmt[FunktionaerTable.createdAt] = f.createdAt
stmt[FunktionaerTable.updatedAt] = f.updatedAt
}
}
@@ -0,0 +1,53 @@
package at.mocode.officials.infrastructure.persistence
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.core.java.javaUUID
import org.jetbrains.exposed.v1.datetime.date
import org.jetbrains.exposed.v1.datetime.timestamp
/**
* Exposed-Tabellendefinition für Funktionäre (DomFunktionaer).
*
* Speichert alle Funktionärs-Daten inkl. Rollen (JSON), Richterqualifikation
* und Sparten-Qualifikationen (JSON) gemäß ÖTO-Anforderungen.
*/
object FunktionaerTable : Table("funktionaere") {
val id = javaUUID("id").autoGenerate()
override val primaryKey = PrimaryKey(id)
// Identifikation
val richterNummer = varchar("richter_nummer", 50).nullable()
// Persönliche Daten
val vorname = varchar("vorname", 100)
val nachname = varchar("nachname", 100)
val geburtsdatum = date("geburtsdatum").nullable()
// Rollen & Qualifikationen (als JSON-Arrays gespeichert)
val rollen = text("rollen") // JSON array of FunktionaerRolleE
val richterQualifikation = varchar("richter_qualifikation", 50).nullable()
val qualifiziertFuerSparten = text("qualifiziert_fuer_sparten") // JSON array of SparteE
// Kontaktdaten
val email = varchar("email", 255).nullable()
val telefon = varchar("telefon", 50).nullable()
val vereinsNummer = varchar("vereins_nummer", 20).nullable()
// Status & Verwaltung
val istAktiv = bool("ist_aktiv").default(true)
val bemerkungen = text("bemerkungen").nullable()
val datenQuelle = varchar("daten_quelle", 50)
// Audit-Felder
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
init {
index(false, nachname)
index(false, vorname)
index(false, vereinsNummer)
index(false, istAktiv)
index(false, richterQualifikation)
}
}