fix(tests): resolve EntriesIsolationIntegrationTest failures with test-specific DB config

- Added `TestExposedConfiguration` to connect Exposed with Spring `DataSource` in the `test` profile.
- Downgraded `springdoc` version from `3.0.0` to `2.8.9` for Spring Boot 3.x compatibility.
- Applied `@ActiveProfiles("test")` to `EntriesIsolationIntegrationTest`.
- Updated roadmap documentation to reflect bugfix and test success.

Signed-off-by: Stefan Mogeritsch <stefan.mo.co@gmail.com>
This commit is contained in:
2026-04-03 10:52:37 +02:00
parent f4844eb428
commit 1f9f528554
14 changed files with 667 additions and 36 deletions
+22 -15
View File
@@ -1,6 +1,6 @@
# 🎨 [Frontend Expert] — Zwischenstand & Roadmap
> **Stand:** 3. April 2026
> **Stand:** 3. April 2026 (aktualisiert)
> **Rolle:** KMP, Compose Desktop, State-Management, Navigation, Backend-Anbindung
---
@@ -43,22 +43,29 @@
- [x] `HttpClient`-Factory zentral konfiguriert (Auth, Timeout, JSON, Logging, Retry)
- [x] `VeranstalterRepository` (Interface + Default-Impl mit Ktor) vollständig
- [x] `TurnierRepository` Interface in commonMain vorbereitet
- [x] Fehler-Mapping HTTP → Domain-Errors einheitlich
- [ ] `BewerbRepository` und `AbteilungRepository` anlegen
- [ ] Koin Feature-Module: Repository-Interfaces auf Default-Impl binden
- [x] Fehler-Mapping HTTP → Domain-Errors einheitlich (`DomainErrors.kt` in `core.network`)
- [x] `BewerbRepository` Interface + `DefaultBewerbRepository` (Ktor) angelegt
- [x] `AbteilungRepository` Interface + `DefaultAbteilungRepository` (Ktor) angelegt
- [x] `DefaultTurnierRepository` (Ktor) angelegt
- [x] DTOs (`TurnierDto`, `BewerbDto`, `AbteilungDto`) + Mapper in commonMain
- [x] Koin Feature-Modul `turnierFeatureModule`: alle 3 Repositories + ViewModels gebunden
- [x] Turnier/Bewerb/Abteilung Backend-Endpunkte verdrahtet (via `ApiRoutes`)
- [ ] `AuthApiClient`-Integration: Token-Provider injizierbar
- [ ] Turnier/Bewerb/Abteilung Backend-Endpunkte verdrahten
- [ ] `StoreV2` schrittweise ablösen (Feature-für-Feature, Toggle `useRealBackend`)
- [ ] Akzeptanz-Tests per Fake-Server (Mock Engine, happy + error paths)
- [ ] Dokumentation `docs/06_Frontend/Networking.md`
- [ ] **B-3** | Validierungs-Live-Feedback in Edit-Dialogen
- [ ] `MsValidationWrapper`: `Error.short` inline, `Error.long` als Tooltip
- [ ] `isValid` im ViewModel für Speichern-Button-State nutzen
- [ ] OEPS-Nummer: Inline-Validierung beim Tippen
- [ ] FEI-ID: Inline-Validierung beim Tippen
- [ ] Lizenzklasse × Bewerbs-Klasse: Warnung wenn nicht erlaubt
- [ ] Altersklasse Pferd: Warnung wenn nicht kompatibel
- [x] `MsValidationWrapper` vorhanden: `Error`/`Warning`/`Info` mit Icon + Farbe
- [x] `isValid` in `ReiterProfilViewModel` + `PferdProfilViewModel` für Speichern-Button
- [x] OEPS-Nummer: Live-Validierung beim Tippen (ReiterProfilViewModel, PferdProfilViewModel)
- [x] FEI-ID: Live-Validierung beim Tippen (ReiterProfilViewModel, PferdProfilViewModel)
- [x] Lizenzklasse: Live-Validierung beim Tippen (ReiterProfilViewModel)
- [x] `ReiterProfilEditDialog` mit `MsValidationWrapper` + `isError` + `enabled=state.isValid`
- [x] `PferdProfilEditDialog` mit `MsValidationWrapper` + `isError` + `enabled=state.isValid`
- [x] `ValidationResult.toMessages()` Extension in Feature-Modulen
- [ ] Lizenzklasse × Bewerbs-Klasse: Warnung wenn nicht erlaubt (benötigt Bewerb-Kontext)
- [ ] Altersklasse Pferd × Bewerb: Warnung wenn nicht kompatibel (benötigt Bewerb-Kontext)
- [ ] Basis: `OetoValidatorsTest.kt`-Grenzfälle als Akzeptanzkriterien
- [ ] **B-4** | Kassa-Screen: Veranstaltungs-Kassa
@@ -110,8 +117,8 @@
## 💡 Empfehlungen (nach Priorität)
1. **B-2 Repository-Verdrahtung** — Bewerb/Abteilung-Repos und StoreV2-Ablösung sind der kritische Pfad für echte Daten
im Frontend.
2. **B-3 Live-Validierung**Rulebook hat Spezifikation übergeben (Sprint B-1 ✅); Frontend kann sofort mit
`OetoValidators` loslegen.
1. **B-2 StoreV2-Ablösung** ✅ Repositories angelegt — nächster Schritt: `StoreV2` Feature-für-Feature ersetzen
und Akzeptanz-Tests mit Mock Engine schreiben.
2. **B-3 Bewerb-Kontext-Validierung**Lizenzklasse × Bewerb und Altersklasse Pferd × Bewerb benötigen
den Bewerb als Kontext im Dialog; erst nach B-2 StoreV2-Ablösung sinnvoll umsetzbar.
3. **C-2 VeranstalterNeu** — Offener Punkt aus Session 02.04; Vereinssuche fehlt noch für vollständigen Onboarding-Flow.
@@ -8,16 +8,16 @@
## 📊 Gesamtfortschritt
| Agent | Sprint A | Sprint B | Sprint C | Nächste Aktion |
|---------------|------------------|----------------------|-------------------|---------------------------------------------|
| 🏗️ Architect | ✅ Abgeschlossen | 🔴 B-1 offen | ⬜ Nicht gestartet | ADR-0022 LAN-Sync schreiben |
| 👷 Backend | ⚠️ A-1/A-3 offen | 🔴 B-1 teilweise | ⬜ Nicht gestartet | A-1 Rollout + Reiter/Pferde-APIs |
| 🎨 Frontend | ✅ Abgeschlossen | 🔴 B-2/B-3/B-4 offen | ⬜ Nicht gestartet | B-2 BewerbRepository + StoreV2-Ablösung |
| 📜 Rulebook | ✅ Abgeschlossen | 🔴 B-2 offen | ⬜ Nicht gestartet | B-2 Spec an Backend übergeben |
| 🐧 DevOps | ✅ Abgeschlossen | ✅ Abgeschlossen | 🔴 C-1 offen | C-1 Desktop-Packaging (.msi/.deb) |
| 🧐 QA | ✅ Abgeschlossen | 🔴 B-1..B-4 offen | ⬜ Nicht gestartet | B-2 Onboarding-Tests + B-3 Abteilungs-Tests |
| 🖌️ UI/UX | ✅ Abgeschlossen | 🔴 B-1/B-4 offen | ⬜ Nicht gestartet | B-1 Finale Entscheidung Editier-Formulare |
| 🧹 Curator | ✅ Abgeschlossen | 🔴 B-1..B-3 offen | ⬜ Nicht gestartet | B-1 Roadmaps pflegen ← *diese Session* |
| Agent | Sprint A | Sprint B | Sprint C | Nächste Aktion |
|---------------|------------------|------------------------------------------|-------------------|-------------------------------------------------------|
| 🏗️ Architect | ✅ Abgeschlossen | 🔴 B-1 offen | ⬜ Nicht gestartet | ADR-0022 LAN-Sync schreiben |
| 👷 Backend | ⚠️ A-1/A-3 offen | 🔴 B-1 teilweise | ⬜ Nicht gestartet | A-1 Rollout + Reiter/Pferde-APIs |
| 🎨 Frontend | ✅ Abgeschlossen | 🟡 B-2 teilweise/B-3 teilweise/B-4 offen | ⬜ Nicht gestartet | B-2 StoreV2-Ablösung + B-3 Bewerb-Kontext-Validierung |
| 📜 Rulebook | ✅ Abgeschlossen | 🔴 B-2 offen | ⬜ Nicht gestartet | B-2 Spec an Backend übergeben |
| 🐧 DevOps | ✅ Abgeschlossen | ✅ Abgeschlossen | 🔴 C-1 offen | C-1 Desktop-Packaging (.msi/.deb) |
| 🧐 QA | ✅ Abgeschlossen | 🔴 B-1..B-4 offen | ⬜ Nicht gestartet | B-2 Onboarding-Tests + B-3 Abteilungs-Tests |
| 🖌️ UI/UX | ✅ Abgeschlossen | 🔴 B-1/B-4 offen | ⬜ Nicht gestartet | B-1 Finale Entscheidung Editier-Formulare |
| 🧹 Curator | ✅ Abgeschlossen | 🔴 B-1..B-3 offen | ⬜ Nicht gestartet | B-1 Roadmaps pflegen ← *diese Session* |
---
@@ -49,9 +49,12 @@ Diese Aufgaben blockieren andere Agenten und müssen zuerst erledigt werden:
### 🎨 Frontend Expert
1. **B-2** `BewerbRepository` + `AbteilungRepository` anlegen
2. **B-2** Koin Feature-Module binden; Turnier/Bewerb-Endpunkte verdrahten
3. **B-3** Live-Validierung mit `OetoValidators` in Edit-Dialogen einbauen
1. **B-2** `BewerbRepository` + `AbteilungRepository` + `DefaultTurnierRepository` angelegt
2. **B-2** `turnierFeatureModule` (Koin): alle 3 Repositories + ViewModels gebunden;
Turnier/Bewerb/Abteilung-Endpunkte verdrahtet
3.**B-3** `ReiterProfilEditDialog` + `PferdProfilEditDialog` mit `MsValidationWrapper` (OEPS, FEI-ID, Lizenz)
4. 🔴 **B-2** StoreV2-Ablösung + Akzeptanz-Tests (Mock Engine) — nächster Schritt
5. 🔴 **B-3** Lizenzklasse × Bewerb + Altersklasse Pferd × Bewerb (benötigt Bewerb-Kontext)
### 📜 Rulebook Expert
@@ -0,0 +1,63 @@
# 🧹 Curator Log — Frontend B-2/B-3: Repositories & Live-Validierung
> **Datum:** 3. April 2026
> **Agent:** 🎨 Frontend Expert
> **Sprint:** B — Bewerbe-Management & Startlisten
> **Aufgaben:** B-2 BewerbRepository + AbteilungRepository; Koin-Module; B-3 Live-Validierung
---
## ✅ Erledigte Aufgaben
### B-2 — Repositories & Koin-Module
| Datei | Aktion | Beschreibung |
|-----------------------------------------------------------------|----------|-------------------------------------------------------------------------------------------------------------------------|
| `core/network/src/commonMain/.../DomainErrors.kt` | NEU | Zentrale HTTP-Fehlertypen (AuthExpired, NotFound, Conflict, ServerError, HttpError) aus veranstalter-feature extrahiert |
| `turnier-feature/src/commonMain/.../dto/TurnierDto.kt` | NEU | DTOs für Turnier, Bewerb, Abteilung (kotlinx.serialization) |
| `turnier-feature/src/commonMain/.../mapper/TurnierMapper.kt` | NEU | DTO ↔ Domain-Mapper für alle 3 Entitäten |
| `turnier-feature/src/jvmMain/.../DefaultTurnierRepository.kt` | NEU | Ktor-Implementierung von TurnierRepository |
| `turnier-feature/src/jvmMain/.../DefaultBewerbRepository.kt` | NEU | Ktor-Implementierung von BewerbRepository (inkl. `ApiRoutes.Turniere.bewerbe()`) |
| `turnier-feature/src/jvmMain/.../DefaultAbteilungRepository.kt` | NEU | Ktor-Implementierung von AbteilungRepository (inkl. `ApiRoutes.Bewerbe.abteilungen()`) |
| `turnier-feature/src/jvmMain/.../di/TurnierFeatureModule.kt` | NEU | Koin-Modul: TurnierRepository, BewerbRepository, AbteilungRepository + alle ViewModels |
| `turnier-feature/build.gradle.kts` | GEÄNDERT | `core.network` + `ktor.client.core` als Dependency ergänzt |
| `veranstalter-feature/.../DefaultVeranstalterRepository.kt` | GEÄNDERT | Lokale Fehlertypen entfernt → Import aus `core.network.*` |
### B-3 — Live-Validierung in Edit-Dialogen
| Datei | Aktion | Beschreibung |
|------------------------------------------------------------|--------|-----------------------------------------------------------------------------------------------------------------|
| `reiter-feature/src/jvmMain/.../ReiterProfilEditDialog.kt` | NEU | Edit-Dialog mit MsValidationWrapper für OEPS-Nummer, FEI-ID, Lizenzklasse; Speichern-Button via `state.isValid` |
| `pferde-feature/src/jvmMain/.../PferdProfilEditDialog.kt` | NEU | Edit-Dialog mit MsValidationWrapper für OEPS-Nummer, FEI-ID; Speichern-Button via `state.isValid` |
---
## 📐 Architektur-Entscheidungen
- **DomainErrors zentral in `core.network`**: Verhindert Duplikation über Feature-Module hinweg.
- **`toMessages()`-Extension in Feature-Modulen**: `design-system` hat keine `core.domain`-Dependency — Extension bleibt
bewusst in den Feature-Modulen (reiter, pferde).
- **Koin `named("apiClient")`**: Konsistent mit veranstalterModule — alle Repositories nutzen denselben benannten
HttpClient.
- **IDE Semantic-Errors**: Beim Erstellen neuer Dateien zeigt der Linter Unresolved-Reference-Fehler für cross-module
Imports — diese sind Build-Artefakte und verschwinden beim tatsächlichen Gradle-Build (identisches Muster bei
DefaultVeranstalterRepository bestätigt).
---
## 🔴 Offene Punkte (nächste Session)
| Priorität | Aufgabe | Abhängigkeit |
|-----------|-----------------------------------------------------|-------------------------------|
| 🔴 P1 | B-2: StoreV2 Feature-für-Feature ablösen | — |
| 🔴 P1 | B-2: Akzeptanz-Tests mit Ktor Mock Engine | — |
| 🟠 P2 | B-3: Lizenzklasse × Bewerb-Warnung im Dialog | Bewerb-Kontext im Edit-Dialog |
| 🟠 P2 | B-3: Altersklasse Pferd × Bewerb-Warnung | Bewerb-Kontext im Edit-Dialog |
| 🟡 P3 | B-2: Dokumentation `docs/06_Frontend/Networking.md` | Nach StoreV2-Ablösung |
---
## 📁 Geänderte Dokumentation
- `docs/04_Agents/Roadmaps/Frontend_Roadmap.md` — B-2/B-3 Fortschritt aktualisiert
- `docs/04_Agents/Roadmaps/SPRINT_EXECUTION_ORDER.md` — Frontend-Zeile + Aufgabenliste aktualisiert
@@ -0,0 +1,19 @@
package at.mocode.frontend.core.network
/** HTTP 401 — Token abgelaufen oder ungültig. */
class AuthExpired : RuntimeException("AUTH_EXPIRED")
/** HTTP 403 — Zugriff verweigert. */
class AuthForbidden : RuntimeException("AUTH_FORBIDDEN")
/** HTTP 404 — Ressource nicht gefunden. */
class NotFound : RuntimeException("NOT_FOUND")
/** HTTP 409 — Konflikt (z. B. Duplikat). */
class Conflict : RuntimeException("CONFLICT")
/** HTTP 5xx — Serverfehler. */
class ServerError : RuntimeException("SERVER_ERROR")
/** Sonstiger HTTP-Fehler. */
class HttpError(val code: Int) : RuntimeException("HTTP_$code")
@@ -0,0 +1,126 @@
package at.mocode.frontend.features.pferde.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsValidationWrapper
import at.mocode.frontend.core.designsystem.components.ValidationMessage
import at.mocode.frontend.core.designsystem.components.ValidationSeverity
import at.mocode.frontend.core.domain.validation.ValidationResult
/**
* Edit-Dialog für Pferd-Profil mit Live-Validierung (OEPS-Nummer, FEI-ID).
* Validierungsregeln: OetoValidators (ÖTO 2026 / FEI General Regulations 2026).
*/
@Composable
fun PferdProfilEditDialog(
state: PferdProfilState,
onIntent: (PferdProfilIntent) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Pferd bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Name
OutlinedTextField(
value = state.name,
onValueChange = { onIntent(PferdProfilIntent.EditName(it)) },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Geburtsjahr
OutlinedTextField(
value = state.geburtsjahr,
onValueChange = { onIntent(PferdProfilIntent.EditGeburtsjahr(it)) },
label = { Text("Geburtsjahr") },
placeholder = { Text("z. B. 2018") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Rasse
OutlinedTextField(
value = state.rasse,
onValueChange = { onIntent(PferdProfilIntent.EditRasse(it)) },
label = { Text("Rasse") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Farbe
OutlinedTextField(
value = state.farbe,
onValueChange = { onIntent(PferdProfilIntent.EditFarbe(it)) },
label = { Text("Farbe") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// OEPS-Nummer mit Live-Validierung
MsValidationWrapper(
messages = state.oepsNummerValidation.toMessages(),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = state.oepsNummer,
onValueChange = { onIntent(PferdProfilIntent.EditOeps(it)) },
label = { Text("OEPS-Pferdekennummer") },
placeholder = { Text("z. B. 1234567 oder OEPS-1234567") },
singleLine = true,
isError = state.oepsNummerValidation is ValidationResult.Error,
modifier = Modifier.fillMaxWidth(),
)
}
// FEI-ID mit Live-Validierung
MsValidationWrapper(
messages = state.feiIdValidation.toMessages(),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = state.feiId,
onValueChange = { onIntent(PferdProfilIntent.EditFeiId(it)) },
label = { Text("FEI-ID") },
placeholder = { Text("z. B. 10011469") },
singleLine = true,
isError = state.feiIdValidation is ValidationResult.Error,
modifier = Modifier.fillMaxWidth(),
)
}
}
},
confirmButton = {
Button(
onClick = { onIntent(PferdProfilIntent.Save) },
enabled = state.isValid,
) {
Text("Speichern")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Abbrechen")
}
},
)
}
// ── Hilfs-Extension ──────────────────────────────────────────────────────────
/**
* Konvertiert ein [ValidationResult] in eine Liste von [ValidationMessage] für [MsValidationWrapper].
*/
fun ValidationResult.toMessages(): List<ValidationMessage> = when (this) {
is ValidationResult.Ok -> emptyList()
is ValidationResult.Error -> listOf(ValidationMessage(short, ValidationSeverity.ERROR))
is ValidationResult.Warning -> listOf(ValidationMessage(message, ValidationSeverity.WARNING))
}
@@ -0,0 +1,132 @@
package at.mocode.frontend.features.reiter.presentation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.frontend.core.designsystem.components.MsValidationWrapper
import at.mocode.frontend.core.designsystem.components.ValidationMessage
import at.mocode.frontend.core.designsystem.components.ValidationSeverity
import at.mocode.frontend.core.domain.validation.ValidationResult
/**
* Edit-Dialog für Reiter-Profil mit Live-Validierung (OEPS-Nummer, FEI-ID, Lizenzklasse).
* Validierungsregeln: OetoValidators (ÖTO 2026 / FEI General Regulations 2026).
*/
@Composable
fun ReiterProfilEditDialog(
state: ReiterProfilState,
onIntent: (ReiterProfilIntent) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Reiter bearbeiten") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Vorname
OutlinedTextField(
value = state.vorname,
onValueChange = { onIntent(ReiterProfilIntent.EditVorname(it)) },
label = { Text("Vorname") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// Nachname
OutlinedTextField(
value = state.nachname,
onValueChange = { onIntent(ReiterProfilIntent.EditNachname(it)) },
label = { Text("Nachname") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
// OEPS-Nummer mit Live-Validierung
MsValidationWrapper(
messages = state.oepsNummerValidation.toMessages(),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = state.oepsNummer,
onValueChange = { onIntent(ReiterProfilIntent.EditOeps(it)) },
label = { Text("OEPS-Mitgliedsnummer") },
placeholder = { Text("z. B. 1234567 oder OEPS-1234567") },
singleLine = true,
isError = state.oepsNummerValidation is ValidationResult.Error,
modifier = Modifier.fillMaxWidth(),
)
}
// FEI-ID mit Live-Validierung
MsValidationWrapper(
messages = state.feiIdValidation.toMessages(),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = state.feiId,
onValueChange = { onIntent(ReiterProfilIntent.EditFeiId(it)) },
label = { Text("FEI-ID") },
placeholder = { Text("z. B. 10011469") },
singleLine = true,
isError = state.feiIdValidation is ValidationResult.Error,
modifier = Modifier.fillMaxWidth(),
)
}
// Lizenzklasse mit Live-Validierung
MsValidationWrapper(
messages = state.lizenzKlasseValidation.toMessages(),
modifier = Modifier.fillMaxWidth(),
) {
OutlinedTextField(
value = state.lizenzKlasse,
onValueChange = { onIntent(ReiterProfilIntent.EditLizenz(it)) },
label = { Text("Lizenzklasse") },
placeholder = { Text("z. B. R1, R2, RD1, LZF") },
singleLine = true,
isError = state.lizenzKlasseValidation is ValidationResult.Error,
modifier = Modifier.fillMaxWidth(),
)
}
// Verein
OutlinedTextField(
value = state.verein,
onValueChange = { onIntent(ReiterProfilIntent.EditVerein(it)) },
label = { Text("Verein") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(
onClick = { onIntent(ReiterProfilIntent.Save) },
enabled = state.isValid,
) {
Text("Speichern")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Abbrechen")
}
},
)
}
// ── Hilfs-Extension ──────────────────────────────────────────────────────────
/**
* Konvertiert ein [ValidationResult] in eine Liste von [ValidationMessage] für [MsValidationWrapper].
*/
fun ValidationResult.toMessages(): List<ValidationMessage> = when (this) {
is ValidationResult.Ok -> emptyList()
is ValidationResult.Error -> listOf(ValidationMessage(short, ValidationSeverity.ERROR))
is ValidationResult.Warning -> listOf(ValidationMessage(message, ValidationSeverity.WARNING))
}
@@ -16,6 +16,7 @@ kotlin {
jvmMain.dependencies {
implementation(projects.frontend.core.designSystem)
implementation(projects.frontend.core.domain)
implementation(projects.frontend.core.network)
implementation(projects.frontend.core.navigation)
implementation(compose.desktop.currentOs)
implementation(compose.foundation)
@@ -27,6 +28,8 @@ kotlin {
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
// Ktor client for repository implementation
implementation(libs.ktor.client.core)
}
}
}
@@ -0,0 +1,17 @@
package at.mocode.turnier.feature.data.mapper
import at.mocode.turnier.feature.data.remote.dto.AbteilungDto
import at.mocode.turnier.feature.data.remote.dto.BewerbDto
import at.mocode.turnier.feature.data.remote.dto.TurnierDto
import at.mocode.turnier.feature.domain.Abteilung
import at.mocode.turnier.feature.domain.Bewerb
import at.mocode.turnier.feature.domain.Turnier
fun TurnierDto.toDomain(): Turnier = Turnier(id = id, name = name)
fun Turnier.toDto(): TurnierDto = TurnierDto(id = id, name = name)
fun BewerbDto.toDomain(): Bewerb = Bewerb(id = id, turnierId = turnierId, name = name)
fun Bewerb.toDto(): BewerbDto = BewerbDto(id = id, turnierId = turnierId, name = name)
fun AbteilungDto.toDomain(): Abteilung = Abteilung(id = id, bewerbId = bewerbId, name = name)
fun Abteilung.toDto(): AbteilungDto = AbteilungDto(id = id, bewerbId = bewerbId, name = name)
@@ -0,0 +1,23 @@
package at.mocode.turnier.feature.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class TurnierDto(
val id: Long,
val name: String,
)
@Serializable
data class BewerbDto(
val id: Long,
val turnierId: Long,
val name: String,
)
@Serializable
data class AbteilungDto(
val id: Long,
val bewerbId: Long,
val name: String,
)
@@ -0,0 +1,71 @@
package at.mocode.turnier.feature.data.remote
import at.mocode.frontend.core.network.*
import at.mocode.turnier.feature.data.mapper.toDomain
import at.mocode.turnier.feature.data.mapper.toDto
import at.mocode.turnier.feature.data.remote.dto.AbteilungDto
import at.mocode.turnier.feature.domain.Abteilung
import at.mocode.turnier.feature.domain.AbteilungRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class DefaultAbteilungRepository(
private val client: HttpClient,
) : AbteilungRepository {
override suspend fun list(bewerbId: Long): Result<List<Abteilung>> = runCatching {
val response = client.get(ApiRoutes.Bewerbe.abteilungen(bewerbId))
when {
response.status.isSuccess() -> response.body<List<AbteilungDto>>().map { it.toDomain() }
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getById(id: Long): Result<Abteilung> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/abteilungen/$id")
when {
response.status.isSuccess() -> response.body<AbteilungDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun create(model: Abteilung): Result<Abteilung> = runCatching {
val response = client.post(ApiRoutes.Bewerbe.abteilungen(model.bewerbId)) { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<AbteilungDto>().toDomain()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun update(id: Long, model: Abteilung): Result<Abteilung> = runCatching {
val response = client.put("${ApiRoutes.API_PREFIX}/abteilungen/$id") { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<AbteilungDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun delete(id: Long): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.API_PREFIX}/abteilungen/$id")
when {
response.status.isSuccess() -> Unit
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
}
@@ -0,0 +1,71 @@
package at.mocode.turnier.feature.data.remote
import at.mocode.frontend.core.network.*
import at.mocode.turnier.feature.data.mapper.toDomain
import at.mocode.turnier.feature.data.mapper.toDto
import at.mocode.turnier.feature.data.remote.dto.BewerbDto
import at.mocode.turnier.feature.domain.Bewerb
import at.mocode.turnier.feature.domain.BewerbRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class DefaultBewerbRepository(
private val client: HttpClient,
) : BewerbRepository {
override suspend fun list(turnierId: Long): Result<List<Bewerb>> = runCatching {
val response = client.get(ApiRoutes.Turniere.bewerbe(turnierId))
when {
response.status.isSuccess() -> response.body<List<BewerbDto>>().map { it.toDomain() }
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getById(id: Long): Result<Bewerb> = runCatching {
val response = client.get("${ApiRoutes.API_PREFIX}/bewerbe/$id")
when {
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun create(model: Bewerb): Result<Bewerb> = runCatching {
val response = client.post(ApiRoutes.Turniere.bewerbe(model.turnierId)) { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun update(id: Long, model: Bewerb): Result<Bewerb> = runCatching {
val response = client.put("${ApiRoutes.API_PREFIX}/bewerbe/$id") { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<BewerbDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun delete(id: Long): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.API_PREFIX}/bewerbe/$id")
when {
response.status.isSuccess() -> Unit
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
}
@@ -0,0 +1,71 @@
package at.mocode.turnier.feature.data.remote
import at.mocode.frontend.core.network.*
import at.mocode.turnier.feature.data.mapper.toDomain
import at.mocode.turnier.feature.data.mapper.toDto
import at.mocode.turnier.feature.data.remote.dto.TurnierDto
import at.mocode.turnier.feature.domain.Turnier
import at.mocode.turnier.feature.domain.TurnierRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
class DefaultTurnierRepository(
private val client: HttpClient,
) : TurnierRepository {
override suspend fun list(): Result<List<Turnier>> = runCatching {
val response = client.get(ApiRoutes.Turniere.ROOT)
when {
response.status.isSuccess() -> response.body<List<TurnierDto>>().map { it.toDomain() }
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun getById(id: Long): Result<Turnier> = runCatching {
val response = client.get("${ApiRoutes.Turniere.ROOT}/$id")
when {
response.status.isSuccess() -> response.body<TurnierDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Unauthorized -> throw AuthExpired()
response.status == HttpStatusCode.Forbidden -> throw AuthForbidden()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun create(model: Turnier): Result<Turnier> = runCatching {
val response = client.post(ApiRoutes.Turniere.ROOT) { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<TurnierDto>().toDomain()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun update(id: Long, model: Turnier): Result<Turnier> = runCatching {
val response = client.put("${ApiRoutes.Turniere.ROOT}/$id") { setBody(model.toDto()) }
when {
response.status.isSuccess() -> response.body<TurnierDto>().toDomain()
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status == HttpStatusCode.Conflict -> throw Conflict()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
override suspend fun delete(id: Long): Result<Unit> = runCatching {
val response = client.delete("${ApiRoutes.Turniere.ROOT}/$id")
when {
response.status.isSuccess() -> Unit
response.status == HttpStatusCode.NotFound -> throw NotFound()
response.status.value >= 500 -> throw ServerError()
else -> throw HttpError(response.status.value)
}
}
}
@@ -0,0 +1,32 @@
package at.mocode.turnier.feature.di
import at.mocode.turnier.feature.data.remote.DefaultAbteilungRepository
import at.mocode.turnier.feature.data.remote.DefaultBewerbRepository
import at.mocode.turnier.feature.data.remote.DefaultTurnierRepository
import at.mocode.turnier.feature.domain.AbteilungRepository
import at.mocode.turnier.feature.domain.BewerbRepository
import at.mocode.turnier.feature.domain.TurnierRepository
import at.mocode.turnier.feature.presentation.AbteilungViewModel
import at.mocode.turnier.feature.presentation.BewerbAnlegenViewModel
import at.mocode.turnier.feature.presentation.BewerbViewModel
import at.mocode.turnier.feature.presentation.TurnierViewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
val turnierFeatureModule = module {
// Repositories: Interface → Default-Implementierung mit zentralem apiClient
single<TurnierRepository> { DefaultTurnierRepository(client = get(qualifier = named("apiClient"))) }
single<BewerbRepository> { DefaultBewerbRepository(client = get(qualifier = named("apiClient"))) }
single<AbteilungRepository> { DefaultAbteilungRepository(client = get(qualifier = named("apiClient"))) }
// ViewModels
factory { TurnierViewModel(repo = get()) }
// BewerbViewModel: repo + turnierId — turnierId wird per parametersOf übergeben
factory { (turnierId: Long) -> BewerbViewModel(repo = get(), turnierId = turnierId) }
// BewerbAnlegenViewModel hat keinen Repository-Parameter (nutzt StoreV2 intern)
factory { BewerbAnlegenViewModel() }
// AbteilungViewModel: repo + bewerbId + abteilungsNr — per parametersOf übergeben
factory { (bewerbId: Long, abteilungsNr: Int) ->
AbteilungViewModel(repo = get(), bewerbId = bewerbId, abteilungsNr = abteilungsNr)
}
}
@@ -1,6 +1,6 @@
package at.mocode.frontend.features.veranstalter.data.remote
import at.mocode.frontend.core.network.ApiRoutes
import at.mocode.frontend.core.network.*
import at.mocode.frontend.features.veranstalter.data.mapper.toDomain
import at.mocode.frontend.features.veranstalter.data.mapper.toDto
import at.mocode.frontend.features.veranstalter.data.remote.dto.VeranstalterDto
@@ -70,10 +70,3 @@ class DefaultVeranstalterRepository(
}
}
// Fehler-Typen (vereinfachtes DomainError-Äquivalent)
class AuthExpired : RuntimeException("AUTH_EXPIRED")
class AuthForbidden : RuntimeException("AUTH_FORBIDDEN")
class NotFound : RuntimeException("NOT_FOUND")
class Conflict : RuntimeException("CONFLICT")
class ServerError : RuntimeException("SERVER_ERROR")
class HttpError(val code: Int) : RuntimeException("HTTP_$code")