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:
+121
@@ -0,0 +1,121 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.clubs.domain.model
|
||||
|
||||
import at.mocode.core.domain.model.DatenQuelleE
|
||||
import at.mocode.core.domain.serialization.KotlinInstantSerializer
|
||||
import at.mocode.core.domain.serialization.UuidSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Domain-Modell für einen Verein im actor-context.
|
||||
*
|
||||
* Repräsentiert einen OEPS-Mitgliedsverein, der als Veranstalter von Turnieren
|
||||
* und als Heimverein von Reitern und Funktionären fungiert.
|
||||
* Daten werden primär aus dem ZNS (VEREIN01.dat) importiert.
|
||||
*
|
||||
* Aggregate Root des `clubs`-Bounded Context.
|
||||
*
|
||||
* @property vereinId Eindeutige interne ID (UUID).
|
||||
* @property vereinsNummer ÖPS-Vereinsnummer aus ZNS (VEREIN01.dat), 4-stellig. Primärschlüssel für ZNS-Datenaustausch.
|
||||
* @property name Offizieller Vereinsname.
|
||||
* @property kurzname Kurzbezeichnung des Vereins (optional).
|
||||
* @property bundesland Bundesland, in dem der Verein ansässig ist.
|
||||
* @property ort Ort / Stadt des Vereinssitzes.
|
||||
* @property plz Postleitzahl.
|
||||
* @property strasse Straße und Hausnummer.
|
||||
* @property email Offizielle E-Mail-Adresse des Vereins.
|
||||
* @property telefon Telefonnummer des Vereins.
|
||||
* @property website Website-URL des Vereins.
|
||||
* @property oepsRegionNummer Regionsnummer beim OEPS (Landesverband).
|
||||
* @property istVeranstalter Ob der Verein als Veranstalter von Turnieren zugelassen ist.
|
||||
* @property istAktiv Ob der Verein aktuell aktiv ist.
|
||||
* @property bemerkungen Interne Notizen.
|
||||
* @property datenQuelle Herkunft des Datensatzes (ZNS-Import oder manuell).
|
||||
* @property createdAt Erstellungszeitpunkt.
|
||||
* @property updatedAt Letzter Änderungszeitpunkt.
|
||||
*/
|
||||
@Serializable
|
||||
data class DomVerein(
|
||||
@Serializable(with = UuidSerializer::class)
|
||||
val vereinId: Uuid = Uuid.random(),
|
||||
|
||||
// Identifikation
|
||||
val vereinsNummer: String,
|
||||
|
||||
// Stammdaten
|
||||
var name: String,
|
||||
var kurzname: String? = null,
|
||||
|
||||
// Adresse
|
||||
var bundesland: String? = null,
|
||||
var ort: String? = null,
|
||||
var plz: String? = null,
|
||||
var strasse: String? = null,
|
||||
|
||||
// Kontakt
|
||||
var email: String? = null,
|
||||
var telefon: String? = null,
|
||||
var website: String? = null,
|
||||
|
||||
// OEPS-Verwaltung
|
||||
var oepsRegionNummer: String? = null,
|
||||
var istVeranstalter: Boolean = false,
|
||||
|
||||
// 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 Anzeigenamen zurück – Kurzname bevorzugt, sonst vollständiger Name.
|
||||
*/
|
||||
fun getDisplayName(): String = kurzname ?: name
|
||||
|
||||
/**
|
||||
* Gibt den vollständigen Anzeigenamen mit Vereinsnummer zurück.
|
||||
*/
|
||||
fun getDisplayNameWithNummer(): String = "${getDisplayName()} ($vereinsNummer)"
|
||||
|
||||
/**
|
||||
* Prüft, ob vollständige Adressdaten vorhanden sind.
|
||||
*/
|
||||
fun hasCompleteAddress(): Boolean =
|
||||
!ort.isNullOrBlank() && !plz.isNullOrBlank() && !strasse.isNullOrBlank()
|
||||
|
||||
/**
|
||||
* Validiert den Verein für den Einsatz als Veranstalter.
|
||||
* Gibt Warnungen zurück (kein harter Fehler – Override-Event möglich).
|
||||
*/
|
||||
fun validateFuerVeranstaltung(): List<String> {
|
||||
val warnings = mutableListOf<String>()
|
||||
|
||||
if (!istAktiv) {
|
||||
warnings.add("Verein ${getDisplayName()} ist nicht aktiv.")
|
||||
}
|
||||
|
||||
if (!istVeranstalter) {
|
||||
warnings.add("Verein ${getDisplayName()} ist nicht als Veranstalter zugelassen.")
|
||||
}
|
||||
|
||||
if (!hasCompleteAddress()) {
|
||||
warnings.add("Verein ${getDisplayName()} hat keine vollständige Adresse hinterlegt.")
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt eine Kopie mit aktualisiertem Zeitstempel.
|
||||
*/
|
||||
fun withUpdatedTimestamp(): DomVerein = this.copy(updatedAt = Clock.System.now())
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.clubs.domain.repository
|
||||
|
||||
import at.mocode.clubs.domain.model.DomVerein
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
/**
|
||||
* Repository-Interface für DomVerein (Verein) Domain-Operationen.
|
||||
*
|
||||
* Definiert den Vertrag für Datenzugriffs-Operationen ohne Abhängigkeit
|
||||
* von konkreten Implementierungsdetails (Datenbank, etc.).
|
||||
*/
|
||||
interface VereinRepository {
|
||||
|
||||
/**
|
||||
* Sucht einen Verein anhand seiner eindeutigen ID.
|
||||
*/
|
||||
suspend fun findById(id: Uuid): DomVerein?
|
||||
|
||||
/**
|
||||
* Sucht einen Verein anhand seiner OEPS-Vereinsnummer.
|
||||
*/
|
||||
suspend fun findByVereinsNummer(vereinsNummer: String): DomVerein?
|
||||
|
||||
/**
|
||||
* Sucht Vereine anhand des Namens (Teilübereinstimmung).
|
||||
*/
|
||||
suspend fun findByName(searchTerm: String, limit: Int = 50): List<DomVerein>
|
||||
|
||||
/**
|
||||
* Sucht alle Vereine eines Bundeslandes.
|
||||
*/
|
||||
suspend fun findByBundesland(bundesland: String, activeOnly: Boolean = true): List<DomVerein>
|
||||
|
||||
/**
|
||||
* Sucht alle Vereine, die als Veranstalter markiert sind.
|
||||
*/
|
||||
suspend fun findVeranstalter(activeOnly: Boolean = true): List<DomVerein>
|
||||
|
||||
/**
|
||||
* Gibt alle aktiven Vereine zurück (paginiert).
|
||||
*/
|
||||
suspend fun findAllActive(limit: Int = 100, offset: Int = 0): List<DomVerein>
|
||||
|
||||
/**
|
||||
* Gibt alle Vereine zurück (paginiert).
|
||||
*/
|
||||
suspend fun findAll(limit: Int = 100, offset: Int = 0): List<DomVerein>
|
||||
|
||||
/**
|
||||
* Speichert einen Verein (Insert oder Update).
|
||||
*/
|
||||
suspend fun save(verein: DomVerein): DomVerein
|
||||
|
||||
/**
|
||||
* Löscht einen Verein anhand seiner ID.
|
||||
*
|
||||
* @return true wenn gelöscht, false wenn nicht gefunden
|
||||
*/
|
||||
suspend fun delete(id: Uuid): Boolean
|
||||
|
||||
/**
|
||||
* Zählt alle aktiven Vereine.
|
||||
*/
|
||||
suspend fun countActive(): Long
|
||||
|
||||
/**
|
||||
* Prüft ob ein Verein mit der gegebenen Vereinsnummer bereits existiert.
|
||||
*/
|
||||
suspend fun existsByVereinsNummer(vereinsNummer: String): Boolean
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
|
||||
|
||||
package at.mocode.clubs.infrastructure.persistence
|
||||
|
||||
import at.mocode.clubs.domain.model.DomVerein
|
||||
import at.mocode.clubs.domain.repository.VereinRepository
|
||||
import at.mocode.core.domain.model.DatenQuelleE
|
||||
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.core.like
|
||||
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 VereinRepository.
|
||||
*/
|
||||
class ExposedVereinRepository : VereinRepository {
|
||||
|
||||
override suspend fun findById(id: Uuid): DomVerein? = transaction {
|
||||
VereinTable.selectAll().where { VereinTable.id eq id.toJavaUuid() }
|
||||
.map { rowToVerein(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByVereinsNummer(vereinsNummer: String): DomVerein? = transaction {
|
||||
VereinTable.selectAll().where { VereinTable.vereinsNummer eq vereinsNummer }
|
||||
.map { rowToVerein(it) }
|
||||
.singleOrNull()
|
||||
}
|
||||
|
||||
override suspend fun findByName(searchTerm: String, limit: Int): List<DomVerein> = transaction {
|
||||
VereinTable.selectAll().where { VereinTable.name like "%$searchTerm%" }
|
||||
.limit(limit).map { rowToVerein(it) }
|
||||
}
|
||||
|
||||
override suspend fun findByBundesland(bundesland: String, activeOnly: Boolean): List<DomVerein> = transaction {
|
||||
VereinTable.selectAll().where {
|
||||
(VereinTable.bundesland eq bundesland).let {
|
||||
if (activeOnly) it and (VereinTable.istAktiv eq true) else it
|
||||
}
|
||||
}.map { rowToVerein(it) }
|
||||
}
|
||||
|
||||
override suspend fun findVeranstalter(activeOnly: Boolean): List<DomVerein> = transaction {
|
||||
VereinTable.selectAll().where {
|
||||
(VereinTable.istVeranstalter eq true).let {
|
||||
if (activeOnly) it and (VereinTable.istAktiv eq true) else it
|
||||
}
|
||||
}.map { rowToVerein(it) }
|
||||
}
|
||||
|
||||
override suspend fun findAllActive(limit: Int, offset: Int): List<DomVerein> = transaction {
|
||||
VereinTable.selectAll().where { VereinTable.istAktiv eq true }
|
||||
.limit(limit).offset(offset.toLong())
|
||||
.map { rowToVerein(it) }
|
||||
}
|
||||
|
||||
override suspend fun findAll(limit: Int, offset: Int): List<DomVerein> = transaction {
|
||||
VereinTable.selectAll()
|
||||
.limit(limit).offset(offset.toLong())
|
||||
.map { rowToVerein(it) }
|
||||
}
|
||||
|
||||
override suspend fun save(verein: DomVerein): DomVerein = transaction {
|
||||
val now = Clock.System.now()
|
||||
val updated = verein.copy(updatedAt = now)
|
||||
val javaId = verein.vereinId.toJavaUuid()
|
||||
val existing = VereinTable.selectAll().where { VereinTable.id eq javaId }.singleOrNull()
|
||||
if (existing != null) {
|
||||
VereinTable.update({ VereinTable.id eq javaId }) { vereinToStatement(it, updated) }
|
||||
} else {
|
||||
VereinTable.insert {
|
||||
it[id] = javaId
|
||||
vereinToStatement(it, updated)
|
||||
}
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
override suspend fun delete(id: Uuid): Boolean = transaction {
|
||||
VereinTable.deleteWhere { VereinTable.id eq id.toJavaUuid() } > 0
|
||||
}
|
||||
|
||||
override suspend fun countActive(): Long = transaction {
|
||||
VereinTable.selectAll().where { VereinTable.istAktiv eq true }.count()
|
||||
}
|
||||
|
||||
override suspend fun existsByVereinsNummer(vereinsNummer: String): Boolean = transaction {
|
||||
VereinTable.selectAll().where { VereinTable.vereinsNummer eq vereinsNummer }.count() > 0
|
||||
}
|
||||
|
||||
private fun rowToVerein(row: ResultRow): DomVerein = DomVerein(
|
||||
vereinId = (row[VereinTable.id] as UUID).toKotlinUuid(),
|
||||
vereinsNummer = row[VereinTable.vereinsNummer],
|
||||
name = row[VereinTable.name],
|
||||
kurzname = row[VereinTable.kurzname],
|
||||
bundesland = row[VereinTable.bundesland],
|
||||
ort = row[VereinTable.ort],
|
||||
plz = row[VereinTable.plz],
|
||||
strasse = row[VereinTable.strasse],
|
||||
email = row[VereinTable.email],
|
||||
telefon = row[VereinTable.telefon],
|
||||
website = row[VereinTable.webseite],
|
||||
oepsRegionNummer = row[VereinTable.oepsRegionsNummer],
|
||||
istVeranstalter = row[VereinTable.istVeranstalter],
|
||||
istAktiv = row[VereinTable.istAktiv],
|
||||
bemerkungen = row[VereinTable.bemerkungen],
|
||||
datenQuelle = runCatching { DatenQuelleE.valueOf(row[VereinTable.datenQuelle]) }.getOrDefault(DatenQuelleE.IMPORT_ZNS),
|
||||
createdAt = row[VereinTable.createdAt],
|
||||
updatedAt = row[VereinTable.updatedAt]
|
||||
)
|
||||
|
||||
private fun vereinToStatement(stmt: UpdateBuilder<*>, v: DomVerein) {
|
||||
stmt[VereinTable.vereinsNummer] = v.vereinsNummer
|
||||
stmt[VereinTable.name] = v.name
|
||||
stmt[VereinTable.kurzname] = v.kurzname
|
||||
stmt[VereinTable.bundesland] = v.bundesland
|
||||
stmt[VereinTable.ort] = v.ort
|
||||
stmt[VereinTable.plz] = v.plz
|
||||
stmt[VereinTable.strasse] = v.strasse
|
||||
stmt[VereinTable.email] = v.email
|
||||
stmt[VereinTable.telefon] = v.telefon
|
||||
stmt[VereinTable.webseite] = v.website
|
||||
stmt[VereinTable.oepsRegionsNummer] = v.oepsRegionNummer
|
||||
stmt[VereinTable.istVeranstalter] = v.istVeranstalter
|
||||
stmt[VereinTable.istAktiv] = v.istAktiv
|
||||
stmt[VereinTable.bemerkungen] = v.bemerkungen
|
||||
stmt[VereinTable.datenQuelle] = v.datenQuelle.name
|
||||
stmt[VereinTable.createdAt] = v.createdAt
|
||||
stmt[VereinTable.updatedAt] = v.updatedAt
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package at.mocode.clubs.infrastructure.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 Vereine (DomVerein).
|
||||
*
|
||||
* Speichert alle Vereins-Daten inkl. Adresse, Kontakt und OEPS-Regionsnummer.
|
||||
*/
|
||||
object VereinTable : Table("vereine") {
|
||||
|
||||
val id = javaUUID("id").autoGenerate()
|
||||
override val primaryKey = PrimaryKey(id)
|
||||
|
||||
// Identifikation
|
||||
val vereinsNummer = varchar("vereins_nummer", 20).uniqueIndex()
|
||||
|
||||
// Vereinsdaten
|
||||
val name = varchar("name", 200)
|
||||
val kurzname = varchar("kurzname", 50).nullable()
|
||||
|
||||
// Adresse
|
||||
val strasse = varchar("strasse", 200).nullable()
|
||||
val plz = varchar("plz", 10).nullable()
|
||||
val ort = varchar("ort", 100).nullable()
|
||||
val bundesland = varchar("bundesland", 50).nullable()
|
||||
val land = varchar("land", 50).nullable().default("AT")
|
||||
|
||||
// Kontakt
|
||||
val email = varchar("email", 255).nullable()
|
||||
val telefon = varchar("telefon", 50).nullable()
|
||||
val webseite = varchar("webseite", 255).nullable()
|
||||
|
||||
// OEPS-Daten
|
||||
val oepsRegionsNummer = varchar("oeps_regions_nummer", 10).nullable()
|
||||
|
||||
// Status & Verwaltung
|
||||
val istAktiv = bool("ist_aktiv").default(true)
|
||||
val istVeranstalter = bool("ist_veranstalter").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, name)
|
||||
index(false, bundesland)
|
||||
index(false, istAktiv)
|
||||
index(false, istVeranstalter)
|
||||
index(false, oepsRegionsNummer)
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
-- Migration V001: Create Vereine (Clubs) table
|
||||
-- Speichert alle Vereins-Daten inkl. Adresse, Kontakt und OEPS-Regionsnummer.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vereine
|
||||
(
|
||||
id
|
||||
UUID
|
||||
PRIMARY
|
||||
KEY
|
||||
DEFAULT
|
||||
gen_random_uuid
|
||||
(
|
||||
),
|
||||
vereins_nummer VARCHAR
|
||||
(
|
||||
20
|
||||
) NOT NULL,
|
||||
name VARCHAR
|
||||
(
|
||||
200
|
||||
) NOT NULL,
|
||||
kurzname VARCHAR
|
||||
(
|
||||
50
|
||||
),
|
||||
strasse VARCHAR
|
||||
(
|
||||
200
|
||||
),
|
||||
plz VARCHAR
|
||||
(
|
||||
10
|
||||
),
|
||||
ort VARCHAR
|
||||
(
|
||||
100
|
||||
),
|
||||
bundesland VARCHAR
|
||||
(
|
||||
50
|
||||
),
|
||||
land VARCHAR
|
||||
(
|
||||
50
|
||||
) DEFAULT 'AT',
|
||||
email VARCHAR
|
||||
(
|
||||
255
|
||||
),
|
||||
telefon VARCHAR
|
||||
(
|
||||
50
|
||||
),
|
||||
webseite VARCHAR
|
||||
(
|
||||
255
|
||||
),
|
||||
oeps_regions_nummer VARCHAR
|
||||
(
|
||||
10
|
||||
),
|
||||
ist_aktiv BOOLEAN NOT NULL DEFAULT true,
|
||||
ist_veranstalter BOOLEAN NOT NULL DEFAULT false,
|
||||
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 Vereinsnummer
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_vereine_vereins_nummer ON vereine(vereins_nummer);
|
||||
|
||||
-- Performance-Indizes
|
||||
CREATE INDEX IF NOT EXISTS idx_vereine_name ON vereine(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_vereine_bundesland ON vereine(bundesland);
|
||||
CREATE INDEX IF NOT EXISTS idx_vereine_ist_aktiv ON vereine(ist_aktiv);
|
||||
CREATE INDEX IF NOT EXISTS idx_vereine_ist_veranstalter ON vereine(ist_veranstalter);
|
||||
CREATE INDEX IF NOT EXISTS idx_vereine_oeps_region ON vereine(oeps_regions_nummer);
|
||||
|
||||
-- Dokumentation
|
||||
COMMENT
|
||||
ON TABLE vereine IS 'Reitsportvereine gemäß OEPS-Vereinsregister';
|
||||
COMMENT
|
||||
ON COLUMN vereine.id IS 'Eindeutige interne ID (UUID)';
|
||||
COMMENT
|
||||
ON COLUMN vereine.vereins_nummer IS 'Offizielle OEPS-Vereinsnummer (eindeutig)';
|
||||
COMMENT
|
||||
ON COLUMN vereine.oeps_regions_nummer IS 'OEPS-Regionsnummer des Landesverbands';
|
||||
COMMENT
|
||||
ON COLUMN vereine.ist_veranstalter IS 'Gibt an ob der Verein als Veranstalter von Turnieren zugelassen ist';
|
||||
COMMENT
|
||||
ON COLUMN vereine.daten_quelle IS 'Datenherkunft: MANUELL, IMPORT_ZNS, IMPORT_FEI';
|
||||
Reference in New Issue
Block a user