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,126 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.officials.domain.model
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.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.UuidSerializer
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Instant
import kotlin.uuid.Uuid
/**
* Domain-Modell für einen Funktionär im actor-context.
*
* Repräsentiert eine Person mit einer definierten Rolle bei Turnieren (Richter, TBA,
* Parcoursbauer, etc.). Die Qualifikation wird gegen `RICHT01.DAT` aus dem ZNS geprüft.
*
* Aggregate Root des `officials`-Bounded Context.
*
* @property funktionaerId Eindeutige interne ID (UUID).
* @property richterNummer ÖPS-Funktionärsnummer aus ZNS (RICHT01.dat), 6-stellig.
* @property vorname Vorname der Person.
* @property nachname Nachname der Person.
* @property geburtsdatum Geburtsdatum (optional, für Altersklassen-Prüfung).
* @property rollen Menge der Rollen, die diese Person ausüben darf (TBA, Richter, ...).
* @property richterQualifikation Qualifikationsstufe als Richter (GA, G1G3, International).
* @property qualifiziertFuerSparten Sparten, für die eine Richter-Qualifikation vorliegt.
* @property email E-Mail-Adresse für Kommunikation.
* @property telefon Telefonnummer.
* @property vereinsNummer Vereinsnummer des Heimvereins (Referenz auf DomVerein).
* @property istAktiv Ob der Funktionär aktuell aktiv/einsatzbereit ist.
* @property bemerkungen Interne Notizen.
* @property datenQuelle Herkunft des Datensatzes (ZNS-Import oder manuell).
* @property createdAt Erstellungszeitpunkt.
* @property updatedAt Letzter Änderungszeitpunkt.
*/
@Serializable
data class DomFunktionaer(
@Serializable(with = UuidSerializer::class)
val funktionaerId: Uuid = Uuid.random(),
// Identifikation
val richterNummer: String? = null,
// Persönliche Daten
var vorname: String,
var nachname: String,
var geburtsdatum: LocalDate? = null,
// Qualifikation & Rollen
var rollen: Set<FunktionaerRolleE> = emptySet(),
var richterQualifikation: RichterQualifikationE? = null,
var qualifiziertFuerSparten: Set<SparteE> = emptySet(),
// Kontakt
var email: String? = null,
var telefon: String? = null,
// Vereinszugehörigkeit
var vereinsNummer: String? = null,
// Status & Verwaltung
var istAktiv: Boolean = true,
var bemerkungen: String? = null,
var datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS,
// Audit
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Gibt den vollständigen Anzeigenamen zurück.
*/
fun getDisplayName(): String = "$vorname $nachname"
/**
* Gibt den Anzeigenamen mit Funktionärsnummer zurück (falls vorhanden).
*/
fun getDisplayNameWithNummer(): String =
richterNummer?.let { "${getDisplayName()} ($it)" } ?: getDisplayName()
/**
* Prüft, ob der Funktionär als Richter für eine bestimmte Sparte qualifiziert ist.
*/
fun istRichterFuerSparte(sparte: SparteE): Boolean =
rollen.contains(FunktionaerRolleE.RICHTER) && qualifiziertFuerSparten.contains(sparte)
/**
* Prüft, ob der Funktionär die Rolle TBA ausüben darf.
*/
fun istTba(): Boolean = rollen.contains(FunktionaerRolleE.TBA)
/**
* Validiert die Pflichtfelder für den Turniereinsatz.
* Gibt eine Liste von Warnungen zurück (kein harter Fehler Override-Event möglich).
*/
fun validateFuerTurniereinsatz(rolle: FunktionaerRolleE, sparte: SparteE? = null): List<String> {
val warnings = mutableListOf<String>()
if (!istAktiv) {
warnings.add("Funktionär ${getDisplayName()} ist nicht aktiv.")
}
if (!rollen.contains(rolle)) {
warnings.add("Funktionär ${getDisplayName()} hat keine Qualifikation für Rolle $rolle.")
}
if (rolle == FunktionaerRolleE.RICHTER && sparte != null && !istRichterFuerSparte(sparte)) {
warnings.add("Funktionär ${getDisplayName()} ist nicht als Richter für Sparte $sparte qualifiziert.")
}
return warnings
}
/**
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
*/
fun withUpdatedTimestamp(): DomFunktionaer = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,93 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.officials.domain.repository
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 kotlin.uuid.Uuid
/**
* Repository-Interface für DomFunktionaer (Funktionär) Domain-Operationen.
*
* Definiert den Vertrag für Datenzugriffs-Operationen ohne Abhängigkeit
* von konkreten Implementierungsdetails (Datenbank, etc.).
*/
interface FunktionaerRepository {
/**
* Sucht einen Funktionär anhand seiner eindeutigen ID.
*/
suspend fun findById(id: Uuid): DomFunktionaer?
/**
* Sucht einen Funktionär anhand seiner Richternummer.
*/
suspend fun findByRichterNummer(richterNummer: String): DomFunktionaer?
/**
* Sucht Funktionäre anhand von Vor- und/oder Nachname (Teilübereinstimmung).
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<DomFunktionaer>
/**
* Sucht alle Funktionäre mit einer bestimmten Rolle.
*/
suspend fun findByRolle(rolle: FunktionaerRolleE, activeOnly: Boolean = true): List<DomFunktionaer>
/**
* Sucht alle Richter mit einer bestimmten Qualifikation.
*/
suspend fun findByRichterQualifikation(
qualifikation: RichterQualifikationE,
activeOnly: Boolean = true
): List<DomFunktionaer>
/**
* Sucht alle Funktionäre, die für eine bestimmte Sparte qualifiziert sind.
*/
suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean = true): List<DomFunktionaer>
/**
* Sucht alle Funktionäre eines bestimmten Vereins.
*/
suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean = true): List<DomFunktionaer>
/**
* Gibt alle aktiven Funktionäre zurück (paginiert).
*/
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<DomFunktionaer>
/**
* Gibt alle Funktionäre zurück (paginiert).
*/
suspend fun findAll(limit: Int = 100, offset: Int = 0): List<DomFunktionaer>
/**
* Speichert einen Funktionär (Insert oder Update).
*/
suspend fun save(funktionaer: DomFunktionaer): DomFunktionaer
/**
* Löscht einen Funktionär anhand seiner ID.
*
* @return true wenn gelöscht, false wenn nicht gefunden
*/
suspend fun delete(id: Uuid): Boolean
/**
* Zählt alle aktiven Funktionäre.
*/
suspend fun countActive(): Long
/**
* Zählt alle Richter (Rolle = RICHTER) mit einer bestimmten Qualifikation.
*/
suspend fun countByRichterQualifikation(qualifikation: RichterQualifikationE, activeOnly: Boolean = true): Long
/**
* Prüft ob ein Funktionär mit der gegebenen Richternummer bereits existiert.
*/
suspend fun existsByRichterNummer(richterNummer: String): Boolean
}
@@ -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)
}
}
@@ -0,0 +1,82 @@
-- Migration V001: Create Funktionaere (Officials) table
-- Speichert alle Funktionärs-Daten inkl. Rollen (JSON), Richterqualifikation
-- und Sparten-Qualifikationen (JSON) gemäß ÖTO-Anforderungen.
CREATE TABLE IF NOT EXISTS funktionaere
(
id
UUID
PRIMARY
KEY
DEFAULT
gen_random_uuid
(
),
richter_nummer VARCHAR
(
50
),
vorname VARCHAR
(
100
) NOT NULL,
nachname VARCHAR
(
100
) NOT NULL,
geburtsdatum DATE,
rollen TEXT NOT NULL DEFAULT '[]',
richter_qualifikation VARCHAR
(
50
),
qualifiziert_fuer_sparten TEXT NOT NULL DEFAULT '[]',
email VARCHAR
(
255
),
telefon VARCHAR
(
50
),
vereins_nummer VARCHAR
(
20
),
ist_aktiv BOOLEAN NOT NULL DEFAULT true,
bemerkungen TEXT,
daten_quelle VARCHAR
(
50
) NOT NULL DEFAULT 'MANUELL',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Unique index für Richternummer (wenn gesetzt)
CREATE UNIQUE INDEX IF NOT EXISTS uk_funktionaere_richter_nummer
ON funktionaere(richter_nummer)
WHERE richter_nummer IS NOT NULL;
-- Performance-Indizes
CREATE INDEX IF NOT EXISTS idx_funktionaere_nachname ON funktionaere(nachname);
CREATE INDEX IF NOT EXISTS idx_funktionaere_vorname ON funktionaere(vorname);
CREATE INDEX IF NOT EXISTS idx_funktionaere_vereins_nummer ON funktionaere(vereins_nummer);
CREATE INDEX IF NOT EXISTS idx_funktionaere_ist_aktiv ON funktionaere(ist_aktiv);
CREATE INDEX IF NOT EXISTS idx_funktionaere_richter_qual ON funktionaere(richter_qualifikation);
-- Dokumentation
COMMENT
ON TABLE funktionaere IS 'Funktionäre (Richter, Parcoursbauer, TBA, etc.) gemäß ÖTO-Regelwerk';
COMMENT
ON COLUMN funktionaere.id IS 'Eindeutige interne ID (UUID)';
COMMENT
ON COLUMN funktionaere.richter_nummer IS 'Offizielle OEPS-Richternummer (eindeutig, optional)';
COMMENT
ON COLUMN funktionaere.rollen IS 'JSON-Array der Funktionärs-Rollen (FunktionaerRolleE)';
COMMENT
ON COLUMN funktionaere.richter_qualifikation IS 'Richter-Qualifikationsstufe (RichterQualifikationE): GA, G1, G2, G3, INTERNATIONAL';
COMMENT
ON COLUMN funktionaere.qualifiziert_fuer_sparten IS 'JSON-Array der Sparten-Qualifikationen (SparteE)';
COMMENT
ON COLUMN funktionaere.daten_quelle IS 'Datenherkunft: MANUELL, IMPORT_ZNS, IMPORT_FEI';