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,136 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.persons.domain.model
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.core.domain.serialization.KotlinInstantSerializer
import at.mocode.core.domain.serialization.KotlinLocalDateSerializer
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 model representing a rider (Reiter) in the actor-context.
*
* A rider is a specialization of a person with additional equestrian-specific
* attributes such as license, start card, and competition eligibility.
* Data is primarily sourced from the OEPS ZNS (LIZENZ01.DAT).
*
* Key rules (ÖTO):
* - A rider requires an active Startkarte (annual fee paid) to compete nationally.
* - LizenzKlasse determines which competition classes the rider may enter.
* - Satznummer (6-digit) is the primary key for ZNS data exchange.
* - Kopfnummer is NOT a unique identifier it can change.
*
* @property reiterId Unique internal identifier (UUID).
* @property personId Reference to the base DomPerson record (UUID).
* @property satznummer 6-digit ZNS primary key for data exchange. Primary key for ZNS.
* @property lizenzNummer OEPS license number (from ZNS LIZENZ01.DAT).
* @property lizenzKlasse License class determining competition eligibility (e.g. R1, RD2).
* @property lizenzSparten Disciplines for which the license is valid.
* @property startkartAktiv Whether the annual start card fee has been paid.
* @property startkartSaison Season year for which the start card is valid (e.g. 2026).
* @property feiId FEI international rider ID (optional).
* @property nation Nation code (e.g. AUT).
* @property geburtsdatum Date of birth (for age class validation).
* @property vereinsNummer Club number (OEPS).
* @property vereinsName Club name.
* @property istGastreiter Whether the rider is a guest rider (foreign nationality, not in Austrian club).
* @property istAktiv Whether the rider is currently active in the system.
* @property datenQuelle Source of the data.
* @property createdAt Timestamp when this record was created.
* @property updatedAt Timestamp when this record was last updated.
*/
@Serializable
data class DomReiter(
@Serializable(with = UuidSerializer::class)
val reiterId: Uuid = Uuid.random(),
// Reference to base person
@Serializable(with = UuidSerializer::class)
val personId: Uuid,
// ZNS Identification
val satznummer: String,
val lizenzNummer: String? = null,
// License & Eligibility
val lizenzKlasse: LizenzKlasseE = LizenzKlasseE.LIZENZFREI,
val lizenzSparten: List<SparteE> = emptyList(),
// Start Card (Startkarte) annual fee proof
val startkartAktiv: Boolean = false,
val startkartSaison: Int? = null,
// International
val feiId: String? = null,
val nation: String? = null,
// Personal Data (denormalized from DomPerson for performance)
val nachname: String,
val vorname: String,
@Serializable(with = KotlinLocalDateSerializer::class)
val geburtsdatum: LocalDate? = null,
// Club Affiliation
val vereinsNummer: String? = null,
val vereinsName: String? = null,
// Status
val istGastreiter: Boolean = false,
val istAktiv: Boolean = true,
val datenQuelle: DatenQuelleE = DatenQuelleE.IMPORT_ZNS,
// Audit Fields
@Serializable(with = KotlinInstantSerializer::class)
val createdAt: Instant = Clock.System.now(),
@Serializable(with = KotlinInstantSerializer::class)
var updatedAt: Instant = Clock.System.now()
) {
/**
* Returns the display name of the rider.
*/
fun getDisplayName(): String = "$vorname $nachname"
/**
* Checks if the rider is eligible to compete nationally.
* Requires an active start card (Startkarte).
*/
fun isStartberechtigt(): Boolean = istAktiv && startkartAktiv
/**
* Checks if the rider holds a license for the given discipline.
*/
fun hasLizenzForSparte(sparte: SparteE): Boolean =
lizenzKlasse == LizenzKlasseE.LIZENZFREI || lizenzSparten.contains(sparte)
/**
* Validates the rider for competition entry.
* Returns a list of warning messages (never hard errors TBA has final say).
*/
fun validateForNennung(sparte: SparteE): List<String> {
val warnings = mutableListOf<String>()
if (!istAktiv) {
warnings.add("Reiter ${getDisplayName()} ist nicht aktiv")
}
if (!startkartAktiv) {
warnings.add("Reiter ${getDisplayName()} hat keine aktive Startkarte für Saison $startkartSaison")
}
if (!hasLizenzForSparte(sparte)) {
warnings.add("Reiter ${getDisplayName()} hat keine Lizenz für Sparte $sparte (Lizenzklasse: $lizenzKlasse)")
}
return warnings
}
/**
* Creates a copy of this rider with an updated timestamp.
*/
fun withUpdatedTimestamp(): DomReiter = this.copy(updatedAt = Clock.System.now())
}
@@ -0,0 +1,89 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.persons.domain.repository
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.persons.domain.model.DomReiter
import kotlin.uuid.Uuid
/**
* Repository-Interface für DomReiter (Reiter) Domain-Operationen.
*
* Definiert den Vertrag für Datenzugriffs-Operationen ohne Abhängigkeit
* von konkreten Implementierungsdetails (Datenbank, etc.).
*/
interface ReiterRepository {
/**
* Sucht einen Reiter anhand seiner eindeutigen ID.
*/
suspend fun findById(id: Uuid): DomReiter?
/**
* Sucht einen Reiter anhand seiner Satznummer (OEPS-Mitgliedsnummer).
*/
suspend fun findBySatznummer(satznummer: String): DomReiter?
/**
* Sucht einen Reiter anhand seiner FEI-ID.
*/
suspend fun findByFeiId(feiId: String): DomReiter?
/**
* Sucht Reiter anhand von Vor- und/oder Nachname (Teilübereinstimmung).
*/
suspend fun findByName(searchTerm: String, limit: Int = 50): List<DomReiter>
/**
* Sucht alle Reiter eines bestimmten Vereins.
*/
suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean = true): List<DomReiter>
/**
* Sucht alle Reiter mit einer bestimmten Lizenzklasse.
*/
suspend fun findByLizenzKlasse(lizenzKlasse: LizenzKlasseE, activeOnly: Boolean = true): List<DomReiter>
/**
* Sucht alle Reiter, die für eine bestimmte Sparte lizenziert sind.
*/
suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean = true): List<DomReiter>
/**
* Sucht alle Gastreiter.
*/
suspend fun findGastreiter(activeOnly: Boolean = true): List<DomReiter>
/**
* Gibt alle aktiven Reiter zurück (paginiert).
*/
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<DomReiter>
/**
* Gibt alle Reiter zurück (paginiert).
*/
suspend fun findAll(limit: Int = 100, offset: Int = 0): List<DomReiter>
/**
* Speichert einen Reiter (Insert oder Update).
*/
suspend fun save(reiter: DomReiter): DomReiter
/**
* Löscht einen Reiter anhand seiner ID.
*
* @return true wenn gelöscht, false wenn nicht gefunden
*/
suspend fun delete(id: Uuid): Boolean
/**
* Zählt alle aktiven Reiter.
*/
suspend fun countActive(): Long
/**
* Prüft ob ein Reiter mit der gegebenen Satznummer bereits existiert.
*/
suspend fun existsBySatznummer(satznummer: String): Boolean
}
@@ -0,0 +1,174 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.persons.infrastructure.persistence
import at.mocode.core.domain.model.DatenQuelleE
import at.mocode.core.domain.model.LizenzKlasseE
import at.mocode.core.domain.model.SparteE
import at.mocode.persons.domain.model.DomReiter
import at.mocode.persons.domain.repository.ReiterRepository
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 ReiterRepository.
*/
class ExposedReiterRepository : ReiterRepository {
override suspend fun findById(id: Uuid): DomReiter? = transaction {
ReiterTable.selectAll().where { ReiterTable.id eq id.toJavaUuid() }
.map { rowToReiter(it) }
.singleOrNull()
}
override suspend fun findBySatznummer(satznummer: String): DomReiter? = transaction {
ReiterTable.selectAll().where { ReiterTable.satznummer eq satznummer }
.map { rowToReiter(it) }
.singleOrNull()
}
override suspend fun findByFeiId(feiId: String): DomReiter? = transaction {
ReiterTable.selectAll().where { ReiterTable.feiId eq feiId }
.map { rowToReiter(it) }
.singleOrNull()
}
override suspend fun findByName(searchTerm: String, limit: Int): List<DomReiter> = transaction {
val pattern = "%$searchTerm%"
ReiterTable.selectAll().where {
(ReiterTable.nachname like pattern) or (ReiterTable.vorname like pattern)
}.limit(limit).map { rowToReiter(it) }
}
override suspend fun findByVereinsNummer(vereinsNummer: String, activeOnly: Boolean): List<DomReiter> = transaction {
ReiterTable.selectAll().where {
(ReiterTable.vereinsNummer eq vereinsNummer).let {
if (activeOnly) it and (ReiterTable.istAktiv eq true) else it
}
}.map { rowToReiter(it) }
}
override suspend fun findByLizenzKlasse(lizenzKlasse: LizenzKlasseE, activeOnly: Boolean): List<DomReiter> =
transaction {
ReiterTable.selectAll().where {
(ReiterTable.lizenzKlasse eq lizenzKlasse.name).let {
if (activeOnly) it and (ReiterTable.istAktiv eq true) else it
}
}.map { rowToReiter(it) }
}
override suspend fun findBySparte(sparte: SparteE, activeOnly: Boolean): List<DomReiter> = transaction {
ReiterTable.selectAll().where {
(ReiterTable.lizenziertFuerSparten like "%${sparte.name}%").let {
if (activeOnly) it and (ReiterTable.istAktiv eq true) else it
}
}.map { rowToReiter(it) }
}
override suspend fun findGastreiter(activeOnly: Boolean): List<DomReiter> = transaction {
ReiterTable.selectAll().where {
(ReiterTable.istGastreiter eq true).let {
if (activeOnly) it and (ReiterTable.istAktiv eq true) else it
}
}.map { rowToReiter(it) }
}
override suspend fun findAllActive(limit: Int, offset: Int): List<DomReiter> = transaction {
ReiterTable.selectAll().where { ReiterTable.istAktiv eq true }
.limit(limit).offset(offset.toLong())
.map { rowToReiter(it) }
}
override suspend fun findAll(limit: Int, offset: Int): List<DomReiter> = transaction {
ReiterTable.selectAll()
.limit(limit).offset(offset.toLong())
.map { rowToReiter(it) }
}
override suspend fun save(reiter: DomReiter): DomReiter = transaction {
val now = Clock.System.now()
val updated = reiter.copy(updatedAt = now)
val javaId = reiter.reiterId.toJavaUuid()
val existing = ReiterTable.selectAll().where { ReiterTable.id eq javaId }.singleOrNull()
if (existing != null) {
ReiterTable.update({ ReiterTable.id eq javaId }) { reiterToStatement(it, updated) }
} else {
ReiterTable.insert {
it[id] = javaId
reiterToStatement(it, updated)
}
}
updated
}
override suspend fun delete(id: Uuid): Boolean = transaction {
ReiterTable.deleteWhere { ReiterTable.id eq id.toJavaUuid() } > 0
}
override suspend fun countActive(): Long = transaction {
ReiterTable.selectAll().where { ReiterTable.istAktiv eq true }.count()
}
override suspend fun existsBySatznummer(satznummer: String): Boolean = transaction {
ReiterTable.selectAll().where { ReiterTable.satznummer eq satznummer }.count() > 0
}
private fun rowToReiter(row: ResultRow): DomReiter {
val sparten = try {
Json.decodeFromString<List<SparteE>>(row[ReiterTable.lizenziertFuerSparten])
} catch (_: Exception) {
emptyList()
}
return DomReiter(
reiterId = (row[ReiterTable.id] as UUID).toKotlinUuid(),
personId = (row[ReiterTable.id] as UUID).toKotlinUuid(), // same as reiterId for now
satznummer = row[ReiterTable.satznummer] ?: "",
feiId = row[ReiterTable.feiId],
nation = row[ReiterTable.nation],
vorname = row[ReiterTable.vorname],
nachname = row[ReiterTable.nachname],
geburtsdatum = row[ReiterTable.geburtsdatum],
vereinsNummer = row[ReiterTable.vereinsNummer],
vereinsName = row[ReiterTable.vereinsName],
lizenzKlasse = runCatching { LizenzKlasseE.valueOf(row[ReiterTable.lizenzKlasse] ?: "") }.getOrDefault(
LizenzKlasseE.LIZENZFREI
),
lizenzSparten = sparten,
istGastreiter = row[ReiterTable.istGastreiter],
istAktiv = row[ReiterTable.istAktiv],
datenQuelle = runCatching { DatenQuelleE.valueOf(row[ReiterTable.datenQuelle]) }.getOrDefault(DatenQuelleE.IMPORT_ZNS),
createdAt = row[ReiterTable.createdAt],
updatedAt = row[ReiterTable.updatedAt]
)
}
private fun reiterToStatement(stmt: UpdateBuilder<*>, r: DomReiter) {
stmt[ReiterTable.satznummer] = r.satznummer
stmt[ReiterTable.feiId] = r.feiId
stmt[ReiterTable.vorname] = r.vorname
stmt[ReiterTable.nachname] = r.nachname
stmt[ReiterTable.geburtsdatum] = r.geburtsdatum
stmt[ReiterTable.nation] = r.nation
stmt[ReiterTable.vereinsNummer] = r.vereinsNummer
stmt[ReiterTable.vereinsName] = r.vereinsName
stmt[ReiterTable.lizenzKlasse] = r.lizenzKlasse.name
stmt[ReiterTable.lizenziertFuerSparten] = Json.encodeToString(r.lizenzSparten)
stmt[ReiterTable.istGastreiter] = r.istGastreiter
stmt[ReiterTable.istAktiv] = r.istAktiv
stmt[ReiterTable.datenQuelle] = r.datenQuelle.name
stmt[ReiterTable.createdAt] = r.createdAt
stmt[ReiterTable.updatedAt] = r.updatedAt
}
}
@@ -0,0 +1,55 @@
package at.mocode.persons.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 Reiter (DomReiter).
*
* Speichert alle Reiter-Daten inkl. Lizenz, Sparten (JSON) und ZNS-Identifikation.
*/
object ReiterTable : Table("reiter") {
val id = javaUUID("id").autoGenerate()
override val primaryKey = PrimaryKey(id)
// Identifikation
val satznummer = varchar("satznummer", 20).nullable()
val feiId = varchar("fei_id", 20).nullable()
// Persönliche Daten
val vorname = varchar("vorname", 100)
val nachname = varchar("nachname", 100)
val geburtsdatum = date("geburtsdatum").nullable()
val nation = varchar("nation", 3).nullable().default("AUT")
// Vereinsdaten
val vereinsNummer = varchar("vereins_nummer", 20).nullable()
val vereinsName = varchar("vereins_name", 200).nullable()
// Lizenz & Qualifikation
val lizenzKlasse = varchar("lizenz_klasse", 50).nullable()
val lizenziertFuerSparten = text("lizenziert_fuer_sparten") // JSON array of SparteE
// Status & Verwaltung
val istAktiv = bool("ist_aktiv").default(true)
val istGastreiter = bool("ist_gastreiter").default(false)
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(true, satznummer)
index(true, feiId)
index(false, vereinsNummer)
index(false, istAktiv)
index(false, lizenzKlasse)
}
}
@@ -0,0 +1,87 @@
-- Migration V001: Create Reiter (Riders) table
-- Speichert alle Reiter-Daten inkl. Lizenz, Sparten (JSON) und ZNS-Identifikation.
CREATE TABLE IF NOT EXISTS reiter
(
id
UUID
PRIMARY
KEY
DEFAULT
gen_random_uuid
(
),
satznummer VARCHAR
(
20
),
fei_id VARCHAR
(
20
),
vorname VARCHAR
(
100
) NOT NULL,
nachname VARCHAR
(
100
) NOT NULL,
geburtsdatum DATE,
nation VARCHAR
(
3
) DEFAULT 'AUT',
vereins_nummer VARCHAR
(
20
),
vereins_name VARCHAR
(
200
),
lizenz_klasse VARCHAR
(
50
),
lizenziert_fuer_sparten TEXT NOT NULL DEFAULT '[]',
ist_aktiv BOOLEAN NOT NULL DEFAULT true,
ist_gastreiter BOOLEAN NOT NULL DEFAULT false,
bemerkungen TEXT,
daten_quelle VARCHAR
(
50
) NOT NULL DEFAULT 'IMPORT_ZNS',
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Unique Indizes für ZNS-Identifikation
CREATE UNIQUE INDEX IF NOT EXISTS uk_reiter_satznummer ON reiter(satznummer) WHERE satznummer IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uk_reiter_fei_id ON reiter(fei_id) WHERE fei_id IS NOT NULL;
-- Performance-Indizes
CREATE INDEX IF NOT EXISTS idx_reiter_nachname ON reiter(nachname);
CREATE INDEX IF NOT EXISTS idx_reiter_vorname ON reiter(vorname);
CREATE INDEX IF NOT EXISTS idx_reiter_vereins_nummer ON reiter(vereins_nummer);
CREATE INDEX IF NOT EXISTS idx_reiter_ist_aktiv ON reiter(ist_aktiv);
CREATE INDEX IF NOT EXISTS idx_reiter_lizenz_klasse ON reiter(lizenz_klasse);
CREATE INDEX IF NOT EXISTS idx_reiter_ist_gastreiter ON reiter(ist_gastreiter);
-- Dokumentation
COMMENT
ON TABLE reiter IS 'Reiter/Teilnehmer gemäß OEPS-Mitgliederregister (ZNS)';
COMMENT
ON COLUMN reiter.id IS 'Eindeutige interne ID (UUID)';
COMMENT
ON COLUMN reiter.satznummer IS 'OEPS-Satznummer (Mitgliedsnummer, eindeutig)';
COMMENT
ON COLUMN reiter.fei_id IS 'FEI-ID für internationale Starts (eindeutig)';
COMMENT
ON COLUMN reiter.lizenz_klasse IS 'Lizenzklasse (LizenzKlasseE): LIZENZFREI, AMATEUR, PROFI, etc.';
COMMENT
ON COLUMN reiter.lizenziert_fuer_sparten IS 'JSON-Array der lizenzierten Sparten (SparteE)';
COMMENT
ON COLUMN reiter.ist_gastreiter IS 'Gastreiter ohne OEPS-Mitgliedschaft (z.B. ausländische Starter)';
COMMENT
ON COLUMN reiter.daten_quelle IS 'Datenherkunft: MANUELL, IMPORT_ZNS, IMPORT_FEI';