feat(core+frontend): add P2P sync infrastructure with WebSocket support

- **Core Updates:**
  - Implemented `P2pSyncService` interface with platform-specific WebSocket implementations (`JvmP2pSyncService` and no-op for JS).
  - Developed `SyncEvent` sealed class hierarchy to handle peer synchronization events (e.g., `PingEvent`, `PongEvent`, `DataChangedEvent`, etc.).

- **Frontend Integration:**
  - Introduced `SyncManager` to manage peer discovery and synchronization, coupled with `NetworkDiscoveryService`.
  - Updated dependency injection to include `syncModule` for platform-specific sync service initialization.
  - Enhanced `BewerbViewModel` to support new sync capabilities, including observing sync events and UI updates for connected peers.

- **Backend Enhancements:**
  - Added ZNS-specific fields (`zns_nummer`, `zns_abteilung`) to Bewerb table for idempotent imports.
  - Introduced import ZNS logic to handle duplicates and align with SyncManager updates.

- **UI Improvements:**
  - Enhanced `TurnierBewerbeTab` with updated dialogs (ZNS imports, sync status) and dynamic previews.
  - Improved network syncing feedback and error handling in frontend components.

- **DB Changes:**
  - Added migration for new column fields in the Bewerb table with relevant indexing for ZNS import optimizations.
This commit is contained in:
2026-04-10 10:54:56 +02:00
parent 6b6965bbbb
commit 8726129b96
21 changed files with 454 additions and 18 deletions
@@ -36,6 +36,9 @@ data class Bewerb(
// Finanzen
val startgeldCent: Long? = null,
val geldpreisAusbezahlt: Boolean = false,
// ZNS-Integration
val znsNummer: Int? = null,
val znsAbteilung: Int? = null,
)
interface BewerbRepository {
@@ -72,7 +72,10 @@ class BewerbRepositoryImpl : BewerbRepository {
stechenGeplant = row[BewerbTable.stechenGeplant],
// Finanzen
startgeldCent = row[BewerbTable.startgeldCent],
geldpreisAusbezahlt = row[BewerbTable.geldpreisAusbezahlt]
geldpreisAusbezahlt = row[BewerbTable.geldpreisAusbezahlt],
// ZNS-Integration
znsNummer = row[BewerbTable.znsNummer],
znsAbteilung = row[BewerbTable.znsAbteilung]
)
}
@@ -104,6 +107,9 @@ class BewerbRepositoryImpl : BewerbRepository {
// Finanzen
s[BewerbTable.startgeldCent] = b.startgeldCent
s[BewerbTable.geldpreisAusbezahlt] = b.geldpreisAusbezahlt
// ZNS-Integration
s[BewerbTable.znsNummer] = b.znsNummer
s[BewerbTable.znsAbteilung] = b.znsAbteilung
s[BewerbTable.createdAt] = now
s[BewerbTable.updatedAt] = now
}
@@ -148,6 +154,9 @@ class BewerbRepositoryImpl : BewerbRepository {
// Finanzen
s[BewerbTable.startgeldCent] = b.startgeldCent
s[BewerbTable.geldpreisAusbezahlt] = b.geldpreisAusbezahlt
// ZNS-Integration
s[BewerbTable.znsNummer] = b.znsNummer
s[BewerbTable.znsAbteilung] = b.znsAbteilung
s[BewerbTable.updatedAt] = now
}
persistRichterEinsaetze(b.id, b.richterEinsaetze)
@@ -57,6 +57,50 @@ class BewerbService(
suspend fun list(turnierId: Uuid, klasse: String?, q: String?): List<Bewerb> = repo.findByTurnierId(turnierId, klasse, q)
suspend fun importZns(turnierId: Uuid, reqs: List<CreateBewerbRequest>): List<Bewerb> {
if (isTurnierPublished(turnierId)) throw LockedException("Turnier ist PUBLISHED Import nicht möglich")
val existing = repo.findByTurnierId(turnierId)
val results = mutableListOf<Bewerb>()
reqs.forEach { req ->
// Idempotenz-Check: Wenn ZNS-Nummer und Abteilung bereits existieren, überspringen oder updaten?
// Hier: Überspringen, wenn bereits vorhanden (einfachste Logik für MVP)
val duplicate = existing.find { it.znsNummer == req.znsNummer && it.znsAbteilung == req.znsAbteilung }
if (duplicate == null) {
val b = Bewerb(
id = Uuid.random(),
turnierId = turnierId,
klasse = req.klasse,
hoeheCm = req.hoeheCm,
bezeichnung = req.bezeichnung,
teilungsTyp = req.teilungsTyp,
beschreibung = req.beschreibung,
aufgabe = req.aufgabe,
aufgabenNummer = req.aufgabenNummer,
paraGrade = req.paraGrade,
austragungsplatzId = req.austragungsplatzId?.let { Uuid.parse(it) },
richterEinsaetze = req.richterEinsaetze.map { RichterEinsatz(Uuid.parse(it.funktionaerId), it.position) },
geplantesDatum = req.geplantesDatum,
beginnZeitTyp = req.beginnZeitTyp,
beginnZeit = req.beginnZeit,
reitdauerMinuten = req.reitdauerMinuten,
umbauMinuten = req.umbauMinuten,
besichtigungMinuten = req.besichtigungMinuten,
stechenGeplant = req.stechenGeplant,
startgeldCent = req.startgeldCent,
geldpreisAusbezahlt = req.geldpreisAusbezahlt,
znsNummer = req.znsNummer,
znsAbteilung = req.znsAbteilung
)
results.add(repo.create(b))
} else {
results.add(duplicate)
}
}
return results
}
suspend fun get(id: Uuid): Bewerb = repo.findById(id) ?: throw NoSuchElementException("Bewerb $id nicht gefunden")
suspend fun update(id: Uuid, req: UpdateBewerbRequest): Bewerb {
@@ -48,6 +48,10 @@ data class CreateBewerbRequest(
// Finanzen
val startgeldCent: Long? = null,
val geldpreisAusbezahlt: Boolean = false,
// ZNS-Integration
val znsNummer: Int? = null,
val znsAbteilung: Int? = null,
)
data class UpdateBewerbRequest(
@@ -81,6 +85,10 @@ data class UpdateBewerbRequest(
// Finanzen
val startgeldCent: Long? = null,
val geldpreisAusbezahlt: Boolean = false,
// ZNS-Integration
val znsNummer: Int? = null,
val znsAbteilung: Int? = null,
)
data class BewerbResponse(
@@ -115,6 +123,10 @@ data class BewerbResponse(
// Finanzen
val startgeldCent: Long?,
val geldpreisAusbezahlt: Boolean,
// ZNS-Integration
val znsNummer: Int?,
val znsAbteilung: Int?,
)
private fun RichterEinsatzDto.toDomain(): RichterEinsatz =
@@ -145,6 +157,8 @@ private fun domainToDto(b: Bewerb): BewerbResponse = BewerbResponse(
stechenGeplant = b.stechenGeplant,
startgeldCent = b.startgeldCent,
geldpreisAusbezahlt = b.geldpreisAusbezahlt,
znsNummer = b.znsNummer,
znsAbteilung = b.znsAbteilung,
)
@RestController
@@ -164,6 +178,15 @@ class BewerbeController(
)
)
@PostMapping("/turniere/{turnierId}/bewerbe/import/zns")
suspend fun importZns(
@PathVariable turnierId: String,
@RequestBody body: List<CreateBewerbRequest>
): List<BewerbResponse> = service.importZns(
Uuid.parse(turnierId),
body
).map(::domainToDto)
@GetMapping("/turniere/{turnierId}/bewerbe")
suspend fun list(
@PathVariable turnierId: String,
@@ -38,6 +38,10 @@ object BewerbTable : Table("bewerbe") {
val startgeldCent = long("startgeld_cent").nullable()
val geldpreisAusbezahlt = bool("geldpreis_ausbezahlt").default(false)
// ZNS-Integration
val znsNummer = integer("zns_nummer").nullable()
val znsAbteilung = integer("zns_abteilung").nullable()
val createdAt = timestamp("created_at")
val updatedAt = timestamp("updated_at")
@@ -0,0 +1,9 @@
-- V7: ZNS-spezifische Felder für Bewerbe zur Vermeidung von Duplikaten beim Import
-- Context: Phase 8 ZNS Importer Erweiterung
ALTER TABLE bewerbe
ADD COLUMN IF NOT EXISTS zns_nummer INTEGER NULL,
ADD COLUMN IF NOT EXISTS zns_abteilung INTEGER NULL;
-- Index für schnelles Nachschlagen beim Import
CREATE INDEX IF NOT EXISTS idx_bewerbe_zns_ref ON bewerbe(turnier_id, zns_nummer, zns_abteilung);