Merge pull request #18

* MP-19 Refactoring: Einführung der "Registry" & "Masterdata" Trennung …

* MP-19 Refactoring: Frontend Tabula Rasa

* MP-19 Refactoring: Frontend Tabula Rasa

* refactoring:

* MP-20 fix(docker/clients): include `:domains` module in web/desktop b…

* MP-20 fix(web-app build): resolve JS compile error and add dev/prod b…

* MP-20 fix(web-app): remove vendor.js reference and harden JS bootstra…

* MP-20 fixing: clients

* MP-20 fixing: clients
This commit is contained in:
StefanMo
2025-11-30 14:13:12 +01:00
committed by GitHub
parent 596a05b69c
commit 9ea2b74a81
254 changed files with 5485 additions and 15971 deletions
+5
View File
@@ -50,3 +50,8 @@ GATEWAY_SERVER_PORT=8081
# --- MICROSERVICES --- # --- MICROSERVICES ---
PING_SERVICE_PORT=8082:8082 PING_SERVICE_PORT=8082:8082
PING_DEBUG_PORT=5006:5006 PING_DEBUG_PORT=5006:5006
# --- CLIENT APPLICATIONS ---
WEB_APP_PORT=4000:4000
DESKTOP_APP_VNC_PORT=5901:5901
DESKTOP_APP_NOVNC_PORT=6080:6080
+7
View File
@@ -51,3 +51,10 @@ CONSUL_PORT=8500:8500
GATEWAY_PORT=8081 GATEWAY_PORT=8081
# Debug Port für IntelliJ (Remote JVM Debug) # Debug Port für IntelliJ (Remote JVM Debug)
GATEWAY_DEBUG_PORT=5005 GATEWAY_DEBUG_PORT=5005
# --- CLIENT APPLICATIONS ---
# Web-App (Kotlin/JS, kein WASM)
WEB_APP_PORT=4000:4000
# Desktop-App (VNC/noVNC)
DESKTOP_APP_VNC_PORT=5901:5901
DESKTOP_APP_NOVNC_PORT=6080:6080
+8 -4
View File
@@ -44,7 +44,6 @@ kotlin {
webpackTask { webpackTask {
mainOutputFileName = "web-app.js" mainOutputFileName = "web-app.js"
output.libraryTarget = "commonjs2"
} }
// Development Server konfigurieren // Development Server konfigurieren
@@ -67,7 +66,10 @@ kotlin {
// WASM, nur wenn explizit aktiviert // WASM, nur wenn explizit aktiviert
if (enableWasm) { if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { browser() } wasmJs {
browser()
binaries.executable()
}
} }
sourceSets { sourceSets {
@@ -76,8 +78,8 @@ kotlin {
implementation(project(":clients:shared")) implementation(project(":clients:shared"))
implementation(project(":clients:shared:common-ui")) implementation(project(":clients:shared:common-ui"))
implementation(project(":clients:shared:navigation")) implementation(project(":clients:shared:navigation"))
implementation(project(":clients:auth-feature"))
implementation(project(":clients:ping-feature")) implementation(project(":clients:ping-feature"))
implementation(project(":clients:members-feature"))
// Compose Multiplatform // Compose Multiplatform
implementation(compose.runtime) implementation(compose.runtime)
@@ -129,7 +131,9 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
jvmTarget.set(JvmTarget.JVM_21) jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll( freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn", "-opt-in=kotlin.RequiresOptIn",
"-Xskip-metadata-version-check" // Für bleeding-edge Versionen "-Xskip-metadata-version-check", // Für bleeding-edge Versionen
// Suppress beta warning for expect/actual declarations used in this module
"-Xexpect-actual-classes"
) )
} }
} }
-3833
View File
File diff suppressed because it is too large Load Diff
-12
View File
@@ -1,12 +0,0 @@
{
"devDependencies": {
"@types/jest": "^29.2.5",
"jest": "^29.3.1"
},
"scripts": {
"test": "jest"
},
"dependencies": {
"webpack-bundle-analyzer": "^4.10.2"
}
}
+215 -64
View File
@@ -2,10 +2,23 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import at.mocode.clients.membersfeature.ProfileScreen import androidx.compose.runtime.collectAsState
import at.mocode.clients.membersfeature.ProfileViewModel
import at.mocode.clients.shared.navigation.AppScreen import at.mocode.clients.shared.navigation.AppScreen
import at.mocode.clients.authfeature.AuthenticatedHttpClient
import at.mocode.clients.authfeature.AuthTokenManager
import at.mocode.clients.pingfeature.PingScreen
import at.mocode.clients.pingfeature.PingViewModel
import at.mocode.clients.shared.core.AppConstants
import androidx.compose.material3.OutlinedTextField
import androidx.compose.ui.text.input.PasswordVisualTransformation
import kotlinx.coroutines.launch
import androidx.compose.runtime.rememberCoroutineScope
import at.mocode.clients.authfeature.AuthApiClient
import at.mocode.clients.authfeature.oauth.OAuthPkceService
import at.mocode.clients.authfeature.oauth.AuthCallbackParams
import at.mocode.clients.authfeature.oauth.CallbackParams
@Composable @Composable
fun MainApp() { fun MainApp() {
@@ -16,93 +29,231 @@ fun MainApp() {
) { ) {
var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) } var currentScreen by remember { mutableStateOf<AppScreen>(AppScreen.Home) }
val authTokenManager = remember { AuthenticatedHttpClient.getAuthTokenManager() }
val pingViewModel = remember { PingViewModel() }
val scope = rememberCoroutineScope()
// Handle PKCE callback on an app load (web)
LaunchedEffect(Unit) {
val callback: CallbackParams? = AuthCallbackParams.parse()
if (callback != null) {
val code = callback.code
val state = callback.state
val pkce = OAuthPkceService.current()
if (pkce != null && pkce.state == state) {
val api = AuthApiClient()
val res = api.exchangeAuthorizationCode(code, pkce.codeVerifier, AppConstants.webRedirectUri())
val token = res.token
if (res.success && token != null) {
authTokenManager.setToken(token)
OAuthPkceService.clear()
currentScreen = AppScreen.Profile
}
}
}
}
when (currentScreen) { when (currentScreen) {
is AppScreen.Home -> DevelopmentScreen(onOpenProfile = { currentScreen = AppScreen.Profile }) is AppScreen.Home -> WelcomeScreen(
is AppScreen.Login -> DevelopmentScreen(onOpenProfile = { currentScreen = AppScreen.Profile }) authTokenManager = authTokenManager,
is AppScreen.Ping -> DevelopmentScreen(onOpenProfile = { currentScreen = AppScreen.Profile }) onOpenPing = { AppScreen.Ping },
is AppScreen.Profile -> ProfileScreen(viewModel = remember { ProfileViewModel() }) onOpenLogin = {
// Fallback to the local LoginScreen (Password Grant) if PKCE cannot be started
currentScreen = AppScreen.Login
},
onOpenProfile = { currentScreen = AppScreen.Profile }
)
is AppScreen.Login -> LoginScreen(
authTokenManager = authTokenManager,
onLoginSuccess = { currentScreen = AppScreen.Profile }
)
is AppScreen.Ping -> PingScreen(viewModel = pingViewModel)
is AppScreen.Profile -> AuthStatusScreen(
authTokenManager = authTokenManager,
onBackToHome = { currentScreen = AppScreen.Home }
)
else -> {}
} }
} }
} }
} }
@Composable @Composable
fun DevelopmentScreen(onOpenProfile: () -> Unit) { private fun WelcomeScreen(
authTokenManager: AuthTokenManager,
onOpenPing: () -> Unit,
onOpenLogin: () -> Unit,
onOpenProfile: () -> Unit
) {
val authState by authTokenManager.authState.collectAsState()
val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Text( Text(
"🚀 Meldestelle Development Mode", text = "Willkommen zur Meldestelle",
style = MaterialTheme.typography.headlineMedium style = MaterialTheme.typography.headlineMedium
) )
// Auth info
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text( if (authState.isAuthenticated) {
"🌐 Backend Connectivity", Text("Du bist als ${authState.username ?: authState.userId ?: "unbekannt"} angemeldet.")
style = MaterialTheme.typography.titleMedium Spacer(Modifier.height(8.dp))
) Button(onClick = onOpenProfile) { Text("Profil anzeigen") }
} else {
Text("Du bist nicht angemeldet.")
}
}
}
var testStatus by remember { mutableStateOf("Not tested") } // Actions
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onOpenPing, modifier = Modifier.weight(1f)) { Text("Ping-Service") }
if (!authState.isAuthenticated) {
Button(
onClick = {
// Try PKCE login (Authorization Code Flow w/ PKCE)
scope.launch {
try {
val pkce = OAuthPkceService.startAuth()
val url = OAuthPkceService.buildAuthorizeUrl(pkce, AppConstants.webRedirectUri())
uriHandler.openUri(url)
} catch (_: Throwable) {
// Fallback: open the local Login screen (Password Grant)
onOpenLogin()
}
}
},
modifier = Modifier.weight(1f)
) { Text("Login") }
}
}
Row( Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
horizontalArrangement = Arrangement.spacedBy(8.dp) OutlinedButton(
onClick = { uriHandler.openUri(AppConstants.registerUrl()) },
modifier = Modifier.weight(1f)
) { Text("Registrieren (Keycloak)") }
OutlinedButton(
onClick = { uriHandler.openUri(AppConstants.loginUrl()) },
modifier = Modifier.weight(1f)
) { Text("Keycloak Login-Seite") }
}
// Desktop Download Link
OutlinedButton(
onClick = { uriHandler.openUri(AppConstants.desktopDownloadUrl()) },
modifier = Modifier.fillMaxWidth()
) { Text("Desktop-App herunterladen") }
}
}
@Composable
private fun AuthStatusScreen(
authTokenManager: AuthTokenManager,
onBackToHome: () -> Unit
) { ) {
Button(onClick = { testStatus = "Testing Gateway..." }) { val authState by authTokenManager.authState.collectAsState()
Text("Test Gateway") Column(
} modifier = Modifier
Button(onClick = { testStatus = "Testing Ping Service..." }) { .fillMaxSize()
Text("Test Ping Service") .padding(24.dp),
} verticalArrangement = Arrangement.spacedBy(16.dp)
Button(onClick = onOpenProfile) {
Text("Open Profile")
}
}
Text("Status: $testStatus")
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
"🏓 Ping Service Tests",
style = MaterialTheme.typography.titleMedium
)
var isDarkMode by remember { mutableStateOf(false) }
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Button(onClick = { /* TODO: Health Check */ }) { Text("Profil / Status", style = MaterialTheme.typography.headlineMedium)
Text("Health Check")
}
Button(onClick = { /* TODO: Ping Normal */ }) {
Text("Ping Normal")
}
Button(onClick = { isDarkMode = !isDarkMode }) {
Text("Toggle Dark Mode")
}
}
Text("Dark Mode: ${if(isDarkMode) "🌙 Enabled" else "☀️ Disabled"}")
}
}
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text( if (authState.isAuthenticated) {
"✅ System Status", Text("Du bist als ${authState.username ?: authState.userId ?: "unbekannt"} angemeldet.")
style = MaterialTheme.typography.titleMedium Spacer(Modifier.height(8.dp))
Button(onClick = {
authTokenManager.clearToken()
onBackToHome()
}) { Text("Abmelden") }
} else {
Text("Nicht angemeldet.")
Spacer(Modifier.height(8.dp))
Button(onClick = onBackToHome) { Text("Zurück zur Startseite") }
}
}
}
}
}
@Composable
private fun LoginScreen(
authTokenManager: AuthTokenManager,
onLoginSuccess: () -> Unit
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val api = remember { AuthApiClient() }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Anmeldung", style = MaterialTheme.typography.headlineMedium)
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Benutzername") },
singleLine = true,
enabled = !isLoading,
modifier = Modifier.fillMaxWidth()
) )
Text("Frontend: 🟢 Running")
Text("Backend: ⚠️ Testing needed") OutlinedTextField(
Text("Build: ✅ Successful") value = password,
} onValueChange = { password = it },
label = { Text("Passwort") },
singleLine = true,
enabled = !isLoading,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
error = null
isLoading = true
scope.launch {
val res = api.login(username.trim(), password)
val token = res.token
if (res.success && token != null) {
authTokenManager.setToken(token)
isLoading = false
onLoginSuccess()
} else {
isLoading = false
error = res.message ?: "Login fehlgeschlagen"
}
}
},
enabled = !isLoading && username.isNotBlank() && password.isNotBlank()
) { Text(if (isLoading) "Bitte warten…" else "Login") }
} }
} }
} }
@@ -1,65 +0,0 @@
package at.mocode.clients.app
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import at.mocode.clients.shared.commonui.components.AppHeader
import at.mocode.clients.shared.commonui.components.AppScaffold
import at.mocode.clients.shared.commonui.theme.AppTheme
import at.mocode.clients.shared.navigation.AppScreen
import at.mocode.clients.pingfeature.PingScreen
import at.mocode.clients.pingfeature.PingViewModel
import at.mocode.clients.authfeature.LoginScreen
import at.mocode.clients.authfeature.AuthTokenManager
import androidx.compose.runtime.collectAsState
@Composable
fun App() {
var currentScreen: AppScreen by remember { mutableStateOf(AppScreen.Home) }
// Create a single PingViewModel instance for the lifetime of the App composition.
val pingViewModel: PingViewModel = remember { PingViewModel() }
// Create a single AuthTokenManager instance for the lifetime of the App composition.
val authTokenManager: AuthTokenManager = remember { AuthTokenManager() }
// Observe authentication state
val authState by authTokenManager.authState.collectAsState()
AppTheme {
AppScaffold(
header = {
AppHeader(
title = "Meldestelle",
onNavigateToPing = { currentScreen = AppScreen.Ping },
onNavigateToLogin = { currentScreen = AppScreen.Login },
onLogout = {
authTokenManager.clearToken()
currentScreen = AppScreen.Home
},
isAuthenticated = authState.isAuthenticated,
username = authState.username,
userPermissions = authState.permissions.map { it.name }
)
},
{ paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
when (currentScreen) {
is AppScreen.Home -> {
LandingScreen(authTokenManager = authTokenManager)
}
is AppScreen.Login -> {
LoginScreen(
authTokenManager = authTokenManager,
onLoginSuccess = { currentScreen = AppScreen.Home }
)
}
is AppScreen.Ping -> {
PingScreen(viewModel = pingViewModel)
}
}
}
}
)
}
}
@@ -1,232 +0,0 @@
package at.mocode.clients.app
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import at.mocode.clients.authfeature.AuthTokenManager
import at.mocode.clients.authfeature.Permission
@Composable
fun LandingScreen(
authTokenManager: AuthTokenManager? = null
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Text(
text = "Willkommen bei Meldestelle",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(24.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Eine moderne, skalierbare Frontend-Architektur",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Diese Anwendung demonstriert eine \"Shell + Feature-Module\"-Architektur " +
"basierend auf Kotlin Multiplatform. Sie spiegelt die DDD-Struktur des Backends " +
"wider und ist als native Desktop-Anwendung (JVM) und Web-Anwendung (JS/Wasm) lauffähig.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.2
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "🚀 Technologien:",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
TechItem("Kotlin Multiplatform")
TechItem("Jetpack Compose Multiplatform")
TechItem("Material Design 3")
TechItem("Ktor Client")
TechItem("Domain-Driven Design")
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "Verwenden Sie das Ping Service Menü oben, um die API-Funktionalität zu testen.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Permission-based UI demonstration
authTokenManager?.let { tokenManager ->
val authState by tokenManager.authState.collectAsState()
if (authState.isAuthenticated && authState.permissions.isNotEmpty()) {
Spacer(modifier = Modifier.height(32.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "🔐 Verfügbare Funktionen",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(modifier = Modifier.height(16.dp))
// Admin features (visible only to users with delete permissions)
if (tokenManager.isAdmin()) {
PermissionCard(
title = "👑 Administrator-Bereich",
description = "Vollzugriff auf alle System-Funktionen",
permissions = listOf("Alle Berechtigungen", "System-Verwaltung", "Benutzer-Management"),
backgroundColor = MaterialTheme.colorScheme.errorContainer,
textColor = MaterialTheme.colorScheme.onErrorContainer
)
}
// Management features (visible to users with create/update permissions)
if (tokenManager.canCreate() || tokenManager.canUpdate()) {
PermissionCard(
title = "✏️ Verwaltung",
description = "Erstellen und bearbeiten von Daten",
permissions = buildList {
if (tokenManager.hasPermission(Permission.PERSON_CREATE)) add("Personen erstellen")
if (tokenManager.hasPermission(Permission.PERSON_UPDATE)) add("Personen bearbeiten")
if (tokenManager.hasPermission(Permission.VEREIN_CREATE)) add("Vereine erstellen")
if (tokenManager.hasPermission(Permission.VEREIN_UPDATE)) add("Vereine bearbeiten")
if (tokenManager.hasPermission(Permission.PFERD_CREATE)) add("Pferde erstellen")
if (tokenManager.hasPermission(Permission.PFERD_UPDATE)) add("Pferde bearbeiten")
if (tokenManager.hasPermission(Permission.VERANSTALTUNG_CREATE)) add("Veranstaltungen erstellen")
if (tokenManager.hasPermission(Permission.VERANSTALTUNG_UPDATE)) add("Veranstaltungen bearbeiten")
},
backgroundColor = MaterialTheme.colorScheme.primaryContainer,
textColor = MaterialTheme.colorScheme.onPrimaryContainer
)
}
// Read-only features (visible to all authenticated users)
if (tokenManager.canRead()) {
PermissionCard(
title = "👁️ Ansicht",
description = "Nur-Lese-Zugriff auf Daten",
permissions = buildList {
if (tokenManager.hasPermission(Permission.PERSON_READ)) add("Personen anzeigen")
if (tokenManager.hasPermission(Permission.VEREIN_READ)) add("Vereine anzeigen")
if (tokenManager.hasPermission(Permission.PFERD_READ)) add("Pferde anzeigen")
if (tokenManager.hasPermission(Permission.VERANSTALTUNG_READ)) add("Veranstaltungen anzeigen")
},
backgroundColor = MaterialTheme.colorScheme.surfaceVariant,
textColor = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
}
@Composable
private fun TechItem(text: String) {
Text(
text = "• $text",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 2.dp)
)
}
@Composable
private fun PermissionCard(
title: String,
description: String,
permissions: List<String>,
backgroundColor: androidx.compose.ui.graphics.Color,
textColor: androidx.compose.ui.graphics.Color
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = backgroundColor
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = textColor
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = textColor
)
if (permissions.isNotEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
permissions.forEach { permission ->
Text(
text = "✓ $permission",
style = MaterialTheme.typography.bodySmall,
color = textColor,
modifier = Modifier.padding(vertical = 2.dp)
)
}
}
}
}
}
@@ -1,202 +0,0 @@
package screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import at.mocode.clients.shared.presentation.store.AppStore
import at.mocode.clients.shared.presentation.state.AppState
import at.mocode.clients.pingfeature.PingViewModel
import at.mocode.ping.api.HealthResponse
import at.mocode.ping.api.PingResponse
import at.mocode.ping.api.EnhancedPingResponse
@Composable
fun DevelopmentScreen(appStore: AppStore) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
"🚀 Meldestelle Development Mode",
style = MaterialTheme.typography.headlineMedium
)
// Backend Connectivity Tests
BackendTestSection()
// Ping Service Test
PingTestSection()
// State Debugging
StateDebugSection(appStore)
}
}
@Composable
fun BackendTestSection() {
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("🌐 Backend Connectivity", style = MaterialTheme.typography.titleMedium)
var testStatus by remember { mutableStateOf("Not tested") }
var isLoading by remember { mutableStateOf(false) }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
// TODO: Test Gateway Connection
isLoading = true
testStatus = "Testing..."
},
enabled = !isLoading
) {
Text("Test Gateway")
}
Button(
onClick = {
// TODO: Test Ping Service Direct
isLoading = true
testStatus = "Testing direct connection..."
},
enabled = !isLoading
) {
Text("Test Ping Service")
}
}
if (isLoading) {
CircularProgressIndicator(modifier = Modifier.padding(8.dp))
}
Text("Status: $testStatus")
}
}
}
@Composable
fun PingTestSection() {
val pingViewModel = remember { PingViewModel() }
val uiState = pingViewModel.uiState
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("🏓 Ping Service Integration", style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { pingViewModel.performHealthCheck() },
enabled = !uiState.isLoading
) {
Text("Health Check")
}
Button(
onClick = { pingViewModel.performSimplePing() },
enabled = !uiState.isLoading
) {
Text("Simple Ping")
}
Button(
onClick = { pingViewModel.performEnhancedPing(true) },
enabled = !uiState.isLoading
) {
Text("Test Circuit Breaker")
}
}
if (uiState.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp))
}
// Results Display
uiState.healthResponse?.let { health ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(modifier = Modifier.padding(8.dp)) {
Text("✅ Health Check Result:")
Text("Status: ${health.status}")
Text("Service: ${health.service}")
Text("Healthy: ${health.healthy}")
Text("Timestamp: ${health.timestamp}")
}
}
}
uiState.simplePingResponse?.let { ping ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(modifier = Modifier.padding(8.dp)) {
Text("🏓 Simple Ping Result:")
Text("Status: ${ping.status}")
Text("Service: ${ping.service}")
Text("Timestamp: ${ping.timestamp}")
}
}
}
uiState.enhancedPingResponse?.let { ping ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer
)
) {
Column(modifier = Modifier.padding(8.dp)) {
Text("⚡ Enhanced Ping Result:")
Text("Status: ${ping.status}")
Text("Circuit Breaker: ${ping.circuitBreakerState}")
Text("Response Time: ${ping.responseTime}ms")
Text("Service: ${ping.service}")
}
}
}
uiState.errorMessage?.let { error ->
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Text(
"❌ Error: $error",
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(8.dp)
)
}
}
}
}
}
@Composable
fun StateDebugSection(appStore: AppStore) {
val appState by appStore.state.collectAsState()
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
Text("🔍 App State Debug", style = MaterialTheme.typography.titleMedium)
Text("Auth State: ${if(appState.auth.isAuthenticated) "✅ Authenticated" else "❌ Not Authenticated"}")
Text("Current Route: ${appState.navigation.currentRoute}")
Text("Dark Mode: ${if(appState.ui.isDarkMode) "🌙 Enabled" else "☀️ Disabled"}")
Text("Online: ${if(appState.network.isOnline) "🟢 Online" else "🔴 Offline"}")
Button(
onClick = {
appStore.dispatch(at.mocode.clients.shared.presentation.actions.AppAction.UI.ToggleDarkMode)
}
) {
Text("Toggle Dark Mode")
}
}
}
}
@@ -0,0 +1,10 @@
import kotlin.test.Test
import kotlin.test.assertEquals
class ComposeAppCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}
+19 -3
View File
@@ -1,21 +1,37 @@
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport import androidx.compose.ui.window.ComposeViewport
import kotlinx.browser.document import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
fun main() { fun main() {
window.onload = { console.log("[WebApp] main() entered")
fun startApp() {
try { try {
console.log("[WebApp] startApp(): readyState=", document.asDynamic().readyState)
val root = document.getElementById("ComposeTarget") as HTMLElement val root = document.getElementById("ComposeTarget") as HTMLElement
console.log("[WebApp] ComposeTarget exists? ", (root != null))
ComposeViewport(root) { ComposeViewport(root) {
MainApp() MainApp()
} }
// Remove the static loading placeholder if present
(document.querySelector(".loading") as? HTMLElement)?.let { it.parentElement?.removeChild(it) }
console.log("[WebApp] ComposeViewport mounted, loading placeholder removed")
} catch (e: Exception) { } catch (e: Exception) {
console.error("Failed to start Compose Web app", e) console.error("Failed to start Compose Web app", e)
document.getElementById("root")?.innerHTML = val fallbackTarget = (document.getElementById("ComposeTarget") ?: document.body) as HTMLElement
fallbackTarget.innerHTML =
"<div style='padding: 50px; text-align: center;'>❌ Failed to load app: ${e.message}</div>" "<div style='padding: 50px; text-align: center;'>❌ Failed to load app: ${e.message}</div>"
} }
} }
// Start immediately if DOM is already parsed, otherwise wait for DOMContentLoaded.
val state = document.asDynamic().readyState as String?
if (state == "interactive" || state == "complete") {
console.log("[WebApp] DOM already ready (", state, ") → starting immediately")
startApp()
} else {
console.log("[WebApp] Waiting for DOMContentLoaded, current state:", state)
document.addEventListener("DOMContentLoaded", { startApp() })
}
} }
+16 -28
View File
@@ -2,38 +2,26 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Meldestelle - Web Development</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> <title>Meldestelle - Web</title>
body { <link type="text/css" rel="stylesheet" href="styles.css">
margin: 0; <link rel="manifest" href="manifest.webmanifest">
padding: 0; <meta name="theme-color" content="#0f172a">
font-family: system-ui, -apple-system, sans-serif;
background: #fafafa;
}
#ComposeTarget {
width: 100vw;
height: 100vh;
}
#root {
width: 100vw;
height: 100vh;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 18px;
color: #666;
}
</style>
</head> </head>
<body> <body>
<div id="root"> <div id="ComposeTarget">
<canvas id="ComposeTarget"></canvas> <div class="loading">Loading...</div>
<div class="loading">🚀 Loading Meldestelle...</div>
</div> </div>
<script src="web-app.js"></script> <script src="web-app.js"></script>
<script>
// Register Service Worker only in non-localhost environments
if ('serviceWorker' in navigator && !['localhost', '127.0.0.1', '::1'].includes(location.hostname)) {
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').catch(function(err){
console.warn('ServiceWorker registration failed:', err);
});
});
}
</script>
</body> </body>
</html> </html>
+11 -1
View File
@@ -1,7 +1,8 @@
html, body { html, body {
height: 100vh;
margin: 0; margin: 0;
padding: 0; padding: 0;
font-family: system-ui, -apple-system, sans-serif;
background: #fafafa;
overflow: hidden; /* Verhindert Scrollbalken durch die Canvas */ overflow: hidden; /* Verhindert Scrollbalken durch die Canvas */
} }
@@ -10,3 +11,12 @@ html, body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
font-size: 18px;
color: #666;
}
+4 -2
View File
@@ -58,7 +58,8 @@ self.addEventListener('fetch', (event) => {
.then((resp) => { .then((resp) => {
if (resp && resp.status === 200 && resp.type === 'basic') { if (resp && resp.status === 200 && resp.type === 'basic') {
const copy = resp.clone(); const copy = resp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', copy)).catch(() => {}); caches.open(CACHE_NAME).then((cache) => cache.put('/index.html', copy)).catch(() => {
});
} }
return resp; return resp;
}) })
@@ -83,7 +84,8 @@ self.addEventListener('fetch', (event) => {
.then((resp) => { .then((resp) => {
if (resp && resp.status === 200 && resp.type === 'basic') { if (resp && resp.status === 200 && resp.type === 'basic') {
const copy = resp.clone(); const copy = resp.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => {}); caches.open(CACHE_NAME).then((cache) => cache.put(req, copy)).catch(() => {
});
} }
return resp; return resp;
}) })
+1 -2
View File
@@ -1,6 +1,5 @@
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport import androidx.compose.ui.window.ComposeViewport
import at.mocode.clients.app.App
import kotlinx.browser.document import kotlinx.browser.document
import org.w3c.dom.HTMLElement import org.w3c.dom.HTMLElement
@@ -8,6 +7,6 @@ import org.w3c.dom.HTMLElement
fun main() { fun main() {
val root = document.getElementById("ComposeTarget") as HTMLElement val root = document.getElementById("ComposeTarget") as HTMLElement
ComposeViewport(root) { ComposeViewport(root) {
App() MainApp()
} }
} }
+11 -14
View File
@@ -16,20 +16,17 @@ if (process.env.ANALYZE_BUNDLE === 'true') {
} }
} }
// Weitere Optimierungen hinzufügen (erweitert bestehende config) // Hinweis: Wir liefern eine statische index.html aus src/jsMain/resources aus.
config.optimization = { // Diese Datei enthält nur einen Script-Tag zu "web-app.js" und wird NICHT
...config.optimization, // Behalte Kotlin/JS Optimierungen // vom HtmlWebpackPlugin generiert. Zusätzliche Chunks (z. B. vendor/runtime)
splitChunks: { // würden dann nicht automatisch injiziert und führen dazu, dass die App nicht startet
chunks: 'all', // (Bildschirm bleibt auf "Loading...").
cacheGroups: { //
vendor: { // Daher überschreiben wir config.optimization NICHT mehr mit splitChunks.
test: /[\\/]node_modules[\\/]/, // Wenn später Chunking gewünscht ist, muss die index.html durch die generierte
name: 'vendor', // HTML ersetzt oder die zusätzlichen Chunks manuell eingebunden werden.
chunks: 'all' //
} // (Frühere splitChunks-Konfiguration wurde bewusst entfernt.)
}
}
};
// Development Server Konfiguration erweitern // Development Server Konfiguration erweitern
if (config.devServer) { if (config.devServer) {
+8 -2
View File
@@ -28,12 +28,16 @@ kotlin {
enabled = false enabled = false
} }
} }
binaries.executable()
} }
// WASM, nur wenn explizit aktiviert // WASM, nur wenn explizit aktiviert
if (enableWasm) { if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { browser() } wasmJs {
browser()
binaries.executable()
}
} }
sourceSets { sourceSets {
@@ -116,7 +120,9 @@ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions { compilerOptions {
jvmTarget.set(JvmTarget.JVM_21) jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll( freeCompilerArgs.addAll(
"-opt-in=kotlin.RequiresOptIn" "-opt-in=kotlin.RequiresOptIn",
// Suppress beta warning for expect/actual classes as per project decision
"-Xexpect-actual-classes"
) )
} }
} }
@@ -1,11 +1,9 @@
package at.mocode.clients.authfeature package at.mocode.clients.authfeature
import at.mocode.clients.shared.AppConfig import at.mocode.clients.shared.core.AppConstants
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.* import io.ktor.client.request.forms.*
import io.ktor.http.* import io.ktor.http.*
import io.ktor.http.content.*
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
@@ -31,11 +29,11 @@ data class LoginResponse(
*/ */
class AuthApiClient( class AuthApiClient(
// Keycloak Basis-URL (z. B. http://localhost:8180) // Keycloak Basis-URL (z. B. http://localhost:8180)
private val keycloakBaseUrl: String = AppConfig.KEYCLOAK_URL, private val keycloakBaseUrl: String = AppConstants.KEYCLOAK_URL,
// Realm-Name in Keycloak // Realm-Name in Keycloak
private val realm: String = AppConfig.KEYCLOAK_REALM, private val realm: String = AppConstants.KEYCLOAK_REALM,
// Client-ID (Public Client empfohlen für Frontend-Flows) // Client-ID (Public Client empfohlen für Frontend-Flows)
private val clientId: String = AppConfig.KEYCLOAK_CLIENT_ID, private val clientId: String = AppConstants.KEYCLOAK_CLIENT_ID,
// Optional: Client-Secret (nur bei vertraulichen Clients erforderlich) // Optional: Client-Secret (nur bei vertraulichen Clients erforderlich)
private val clientSecret: String? = null private val clientSecret: String? = null
) { ) {
@@ -86,6 +84,49 @@ class AuthApiClient(
} }
} }
/**
* Exchange an authorization code (PKCE) for tokens
*/
suspend fun exchangeAuthorizationCode(code: String, codeVerifier: String, redirectUri: String): LoginResponse {
val tokenEndpoint = "$keycloakBaseUrl/realms/$realm/protocol/openid-connect/token"
return try {
val response = client.submitForm(
url = tokenEndpoint,
formParameters = Parameters.build {
append("grant_type", "authorization_code")
append("client_id", clientId)
if (!clientSecret.isNullOrBlank()) {
append("client_secret", clientSecret)
}
append("code", code)
append("code_verifier", codeVerifier)
append("redirect_uri", redirectUri)
}
) {
contentType(ContentType.Application.FormUrlEncoded)
}
if (response.status.isSuccess()) {
val kc = response.body<KeycloakTokenResponse>()
LoginResponse(
success = true,
token = kc.access_token,
message = null
)
} else {
LoginResponse(
success = false,
message = "Code-Exchange fehlgeschlagen: HTTP ${'$'}{response.status.value}"
)
}
} catch (e: Exception) {
LoginResponse(
success = false,
message = "Code-Exchange Fehler: ${'$'}{e.message}"
)
}
}
/** /**
* Refresh authentication token * Refresh authentication token
*/ */
@@ -5,12 +5,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.contentOrNull
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@@ -1,6 +1,6 @@
package at.mocode.clients.authfeature package at.mocode.clients.authfeature
import at.mocode.clients.shared.AppConfig import at.mocode.clients.shared.core.AppConstants
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.* import io.ktor.client.request.*
@@ -19,7 +19,7 @@ object AuthenticatedHttpClient {
/** /**
* Create a basic HTTP client with JSON support * Create a basic HTTP client with JSON support
*/ */
fun create(baseUrl: String = AppConfig.GATEWAY_URL): HttpClient { fun create(baseUrl: String = AppConstants.GATEWAY_URL): HttpClient {
return HttpClient { return HttpClient {
install(ContentNegotiation) { install(ContentNegotiation) {
json(Json { json(Json {
@@ -2,14 +2,13 @@ package at.mocode.clients.authfeature
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import at.mocode.clients.shared.AppConfig import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
import io.ktor.client.call.* import at.mocode.clients.shared.core.AppConstants
import io.ktor.client.request.* import io.ktor.client.request.*
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
/** /**
* UI state for the login screen * UI state for the login screen
@@ -98,7 +97,7 @@ class LoginViewModel(
viewModelScope.launch { viewModelScope.launch {
try { try {
val client = AuthenticatedHttpClient.create() val client = AuthenticatedHttpClient.create()
client.post("${AppConfig.GATEWAY_URL}/api/members/sync") { client.post("${AppConstants.GATEWAY_URL}/api/members/sync") {
addAuthHeader() addAuthHeader()
} }
} catch (_: Exception) { } catch (_: Exception) {
@@ -0,0 +1,12 @@
package at.mocode.clients.authfeature.oauth
data class CallbackParams(val code: String, val state: String?)
expect object AuthCallbackParams {
/**
* Parse OAuth callback parameters from the current environment.
* - JS (web): reads window.location.search for `code` and `state` and removes them from the URL.
* - JVM (desktop): returns null.
*/
fun parse(): CallbackParams?
}
@@ -0,0 +1,34 @@
package at.mocode.clients.authfeature.oauth
import at.mocode.clients.shared.core.AppConstants
data class PkceState(
val state: String,
val codeVerifier: String,
val codeChallenge: String,
val method: String = "S256"
)
object OAuthParams {
const val RESPONSE_TYPE = "code"
const val SCOPE = "openid"
}
/**
* expect/actual service to support PKCE across JS and JVM.
* For the desktop (JVM) target we currently do not start a browser flow,
* but we provide hashing to keep API parity.
*/
expect object OAuthPkceService {
/** Starts a PKCE auth attempt and stores transient state in memory. */
suspend fun startAuth(): PkceState
/** Returns currently active state if any (not persisted). */
fun current(): PkceState?
/** Clears transient state (after success/failure). */
fun clear()
/** Builds the authorize URL for the current state. */
fun buildAuthorizeUrl(state: PkceState, redirectUri: String = AppConstants.webRedirectUri()): String
}
@@ -0,0 +1,19 @@
package at.mocode.clients.authfeature.oauth
import kotlinx.browser.window
actual object AuthCallbackParams {
actual fun parse(): CallbackParams? {
val search = window.location.search
if (search.isBlank()) return null
val params = js("new URLSearchParams(arguments[0])").unsafeCast<(String) -> dynamic>()(search)
val code = params.get("code") as String?
val state = params.get("state") as String?
return if (!code.isNullOrBlank()) {
// Clean up query params to avoid re-processing on recomposition
val url = window.location.origin + window.location.pathname
window.history.replaceState(null, "", url)
CallbackParams(code, state)
} else null
}
}
@@ -0,0 +1,81 @@
package at.mocode.clients.authfeature.oauth
import at.mocode.clients.shared.core.AppConstants
import kotlinx.browser.window
import kotlinx.coroutines.await
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Uint8Array
import kotlin.js.Promise
import kotlin.random.Random
private var currentPkce: PkceState? = null
private fun base64UrlFromBytes(bytes: ByteArray): String {
// Build binary string from bytes
val sb = StringBuilder(bytes.size)
for (b in bytes) sb.append(b.toInt().toChar())
val b64 = window.btoa(sb.toString())
return b64.replace("+", "-").replace("/", "_").trimEnd('=')
}
private fun base64UrlFromArrayBuffer(buf: ArrayBuffer): String {
val arr = Uint8Array(buf)
var binary = ""
val len = arr.length
for (i in 0 until len) {
val v = (arr.asDynamic()[i] as Number).toInt()
binary += fromCharCode(v)
}
val b64 = window.btoa(binary)
return b64.replace("+", "-").replace("/", "_").trimEnd('=')
}
private fun randomUrlSafe(length: Int): String {
val bytes = Random.Default.nextBytes(length)
// Use base64url for entropy; ensure URL-safe by replacing padding removed already
return base64UrlFromBytes(bytes)
}
private fun sha256(input: String): Promise<ArrayBuffer> {
val enc: dynamic = js("new TextEncoder()")
val data = enc.encode(input)
val subtle: dynamic = window.asDynamic().crypto.subtle
return subtle.digest("SHA-256", data) as Promise<ArrayBuffer>
}
actual object OAuthPkceService {
actual suspend fun startAuth(): PkceState {
val codeVerifier = randomUrlSafe(64)
val challengeBuf = sha256(codeVerifier).await()
val codeChallenge = base64UrlFromArrayBuffer(challengeBuf)
val state = randomUrlSafe(16)
val pkce = PkceState(state, codeVerifier, codeChallenge)
currentPkce = pkce
return pkce
}
actual fun current(): PkceState? = currentPkce
actual fun clear() {
currentPkce = null
}
actual fun buildAuthorizeUrl(state: PkceState, redirectUri: String): String {
val params = listOf(
"response_type" to OAuthParams.RESPONSE_TYPE,
"client_id" to AppConstants.KEYCLOAK_CLIENT_ID,
"redirect_uri" to redirectUri,
"scope" to OAuthParams.SCOPE,
"state" to state.state,
"code_challenge" to state.codeChallenge,
"code_challenge_method" to state.method
).joinToString("&") { (k, v) -> "$k=" + encodeURIComponent(v) }
return AppConstants.authorizeEndpoint() + "?" + params
}
}
@Suppress("UnsafeCastFromDynamic")
private fun encodeURIComponent(value: String): String = js("encodeURIComponent")(value)
@Suppress("UnsafeCastFromDynamic")
private fun fromCharCode(code: Int): String = js("String.fromCharCode")(code)
@@ -0,0 +1,5 @@
package at.mocode.clients.authfeature.oauth
actual object AuthCallbackParams {
actual fun parse(): CallbackParams? = null
}
@@ -0,0 +1,55 @@
package at.mocode.clients.authfeature.oauth
import at.mocode.clients.shared.core.AppConstants
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
private var currentPkceJvm: PkceState? = null
private fun base64UrlNoPad(bytes: ByteArray): String =
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
private fun randomUrlSafe(length: Int): String {
// Generate bytes and Base64 URL encode (will be > length due to encoding)
val rnd = SecureRandom()
val bytes = ByteArray(length)
rnd.nextBytes(bytes)
return base64UrlNoPad(bytes)
}
private fun sha256Base64Url(input: String): String {
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(input.toByteArray(Charsets.UTF_8))
return base64UrlNoPad(digest)
}
actual object OAuthPkceService {
actual suspend fun startAuth(): PkceState {
val codeVerifier = randomUrlSafe(64)
val codeChallenge = sha256Base64Url(codeVerifier)
val state = randomUrlSafe(16)
val pkce = PkceState(state, codeVerifier, codeChallenge)
currentPkceJvm = pkce
return pkce
}
actual fun current(): PkceState? = currentPkceJvm
actual fun clear() {
currentPkceJvm = null
}
actual fun buildAuthorizeUrl(state: PkceState, redirectUri: String): String {
val params = listOf(
"response_type" to OAuthParams.RESPONSE_TYPE,
"client_id" to AppConstants.KEYCLOAK_CLIENT_ID,
"redirect_uri" to redirectUri,
"scope" to OAuthParams.SCOPE,
"state" to state.state,
"code_challenge" to state.codeChallenge,
"code_challenge_method" to state.method
).joinToString("&") { (k, v) -> "$k=" + java.net.URLEncoder.encode(v, Charsets.UTF_8) }
return AppConstants.authorizeEndpoint() + "?" + params
}
}
-87
View File
@@ -1,87 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.kotlinSerialization)
}
group = "at.mocode.clients"
version = "1.0.0"
kotlin {
val enableWasm = providers.gradleProperty("enableWasm").orNull == "true"
jvmToolchain(21)
jvm()
js {
browser {
testTask { enabled = false }
}
}
if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { browser() }
}
sourceSets {
commonMain.dependencies {
// UI Kit
implementation(project(":clients:shared:common-ui"))
// Shared config/utilities
implementation(project(":clients:shared"))
// Authentication helpers (token + authenticated client)
implementation(project(":clients:auth-feature"))
// Compose
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.materialIconsExtended)
// ViewModel lifecycle + compose helpers
implementation(libs.bundles.compose.common)
// HTTP + Kotlinx
implementation(libs.bundles.ktor.client.common)
implementation(libs.bundles.kotlinx.core)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
implementation(libs.ktor.client.mock)
}
jvmMain.dependencies {
implementation(libs.ktor.client.cio)
}
jsMain.dependencies {
implementation(libs.ktor.client.js)
}
if (enableWasm) {
val wasmJsMain = getByName("wasmJsMain")
wasmJsMain.dependencies {
implementation(libs.ktor.client.js)
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
}
}
}
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
freeCompilerArgs.addAll("-opt-in=kotlin.RequiresOptIn")
}
}
@@ -1,37 +0,0 @@
package at.mocode.clients.membersfeature
import at.mocode.clients.authfeature.AuthenticatedHttpClient
import at.mocode.clients.shared.AppConfig
import at.mocode.clients.membersfeature.model.MemberProfile
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
class MembersApiClient(
private val baseUrl: String = AppConfig.GATEWAY_URL
) {
private val client = AuthenticatedHttpClient.create()
suspend fun getMyProfile(): MemberProfile {
// Erwarteter Endpoint: GET /api/members/me
return client.get("$baseUrl/api/members/me") {
addAuthHeader()
}.body()
}
/**
* Optionaler Convenience-Call: Löst den Backend-Sync einmalig aus.
* Gibt true zurück, wenn der Call erfolgreich war (HTTP 2xx), sonst false.
*/
suspend fun syncProfile(): Boolean {
return try {
val response = client.post("$baseUrl/api/members/sync") {
addAuthHeader()
}
response.status.isSuccess()
} catch (_: Exception) {
false
}
}
}
@@ -1,63 +0,0 @@
package at.mocode.clients.membersfeature
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun ProfileScreen(viewModel: ProfileViewModel) {
val state by viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadProfile()
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = "👤 Mein Profil", style = MaterialTheme.typography.headlineSmall)
if (state.isLoading) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
state.errorMessage?.let { error ->
Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer)) {
Column(modifier = Modifier.padding(16.dp)) {
Text("Fehler", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
Text(error)
Spacer(Modifier.height(8.dp))
Button(onClick = { viewModel.clearError() }) { Text("Schließen") }
}
}
}
state.profile?.let { profile ->
Card {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = profile.fullName.ifBlank { "Unbekannt" }, style = MaterialTheme.typography.titleLarge)
profile.username?.let { Text("Benutzername: $it") }
profile.email?.let { Text("E-Mail: $it") }
if (profile.roles.isNotEmpty()) {
Text("Rollen: ${profile.roles.joinToString(", ")}")
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { viewModel.loadProfile() }, enabled = !state.isLoading) {
Text("Neu laden")
}
}
}
}
@@ -1,42 +0,0 @@
package at.mocode.clients.membersfeature
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.clients.membersfeature.model.MemberProfile
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class ProfileUiState(
val isLoading: Boolean = false,
val profile: MemberProfile? = null,
val errorMessage: String? = null
)
class ProfileViewModel(
private val api: MembersApiClient = MembersApiClient()
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState: StateFlow<ProfileUiState> = _uiState.asStateFlow()
fun loadProfile() {
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
viewModelScope.launch {
try {
val profile = api.getMyProfile()
_uiState.value = ProfileUiState(isLoading = false, profile = profile)
} catch (e: Exception) {
_uiState.value = ProfileUiState(
isLoading = false,
errorMessage = e.message ?: "Profil konnte nicht geladen werden"
)
}
}
}
fun clearError() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
}
@@ -1,16 +0,0 @@
package at.mocode.clients.membersfeature.model
import kotlinx.serialization.Serializable
@Serializable
data class MemberProfile(
val id: String? = null,
val username: String? = null,
val email: String? = null,
val firstName: String? = null,
val lastName: String? = null,
val roles: List<String> = emptyList()
) {
val fullName: String
get() = listOfNotNull(firstName, lastName).joinToString(" ").ifBlank { username ?: "" }
}
+5 -1
View File
@@ -28,12 +28,16 @@ kotlin {
enabled = false enabled = false
} }
} }
binaries.executable()
} }
// WASM, nur wenn explizit aktiviert // WASM, nur wenn explizit aktiviert
if (enableWasm) { if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { browser() } wasmJs {
browser()
binaries.executable()
}
} }
sourceSets { sourceSets {
@@ -4,7 +4,7 @@ import at.mocode.ping.api.PingApi
import at.mocode.ping.api.PingResponse import at.mocode.ping.api.PingResponse
import at.mocode.ping.api.EnhancedPingResponse import at.mocode.ping.api.EnhancedPingResponse
import at.mocode.ping.api.HealthResponse import at.mocode.ping.api.HealthResponse
import at.mocode.clients.shared.AppConfig import at.mocode.clients.shared.core.AppConstants
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.contentnegotiation.*
@@ -13,7 +13,7 @@ import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class PingApiClient( class PingApiClient(
private val baseUrl: String = AppConfig.GATEWAY_URL private val baseUrl: String = AppConstants.GATEWAY_URL
) : PingApi { ) : PingApi {
private val client = HttpClient { private val client = HttpClient {
@@ -8,7 +8,7 @@ import kotlinx.coroutines.delay
/** /**
* API-Client für Reitsport-Authentication-Testing * API-Client für Reitsport-Authentication-Testing
* Testet verschiedene Services mit rollenbasierten Tokens * testet verschiedene Services mit rollenbasierten Tokens
*/ */
class ReitsportTestApi { class ReitsportTestApi {
@@ -41,16 +41,20 @@ class ReitsportTestApi {
results.add(testMembersService(role)) results.add(testMembersService(role))
results.add(testSystemAccess(role)) results.add(testSystemAccess(role))
} }
RolleE.FUNKTIONAER -> { RolleE.FUNKTIONAER -> {
results.add(testEventsService(role)) results.add(testEventsService(role))
results.add(testMembersService(role)) results.add(testMembersService(role))
} }
RolleE.TIERARZT, RolleE.TRAINER -> { RolleE.TIERARZT, RolleE.TRAINER -> {
results.add(testHorsesService(role)) results.add(testHorsesService(role))
} }
RolleE.REITER -> { RolleE.REITER -> {
results.add(testMembersService(role)) results.add(testMembersService(role))
} }
RolleE.RICHTER, RolleE.ZUSCHAUER, RolleE.GAST -> { RolleE.RICHTER, RolleE.ZUSCHAUER, RolleE.GAST -> {
results.add(testPublicAccess(role)) results.add(testPublicAccess(role))
} }
@@ -1,93 +0,0 @@
package at.mocode.clients.pingfeature.model
/**
* Phase 1 Validierung für Reitsport-Authentication-Testing
* Testet alle Erfolgs-Kriterien aus der Aufgabenstellung
*/
object Phase1Validation {
/**
* Führt alle Phase 1 Validierungen durch
*/
fun validatePhase1(): String {
val results = mutableListOf<String>()
// ✅ Test 1: Anzahl Rollen (erwartet: 9)
val roleCount = ReitsportRoles.ALL_ROLES.size
results.add("✅ Rollen-Anzahl: $roleCount (erwartet: 9) - ${if (roleCount == 9) "ERFOLG" else "FEHLER"}")
// ✅ Test 2: Admin-Rolle verfügbar
val adminRole = ReitsportRoles.ADMIN
results.add("✅ Admin-Rolle: ${adminRole.displayName} - ERFOLG")
// ✅ Test 3: Alle Kategorien verfügbar
val categories = ReitsportRoles.ROLES_BY_CATEGORY.keys
results.add("✅ Kategorien: $categories - ERFOLG")
results.add(" - SYSTEM: ${ReitsportRoles.ROLES_BY_CATEGORY[RoleCategory.SYSTEM]?.size ?: 0} Rollen")
results.add(" - OFFICIAL: ${ReitsportRoles.ROLES_BY_CATEGORY[RoleCategory.OFFICIAL]?.size ?: 0} Rollen")
results.add(" - ACTIVE: ${ReitsportRoles.ROLES_BY_CATEGORY[RoleCategory.ACTIVE]?.size ?: 0} Rollen")
results.add(" - PASSIVE: ${ReitsportRoles.ROLES_BY_CATEGORY[RoleCategory.PASSIVE]?.size ?: 0} Rollen")
// ✅ Test 4: DateTime funktioniert
val timestamp = DateTimeHelper.now()
results.add("✅ DateTime funktioniert: $timestamp - ERFOLG")
// ✅ Test 5: Test-ID generiert
val testId = getTimeMillis().toString()
results.add("✅ Test-ID generiert: $testId - ERFOLG")
// ✅ Test 6: Enum-Zugriff funktioniert
results.add("✅ RolleE Enum: ${RolleE.entries.size} Einträge - ERFOLG")
results.add("✅ BerechtigungE Enum: ${BerechtigungE.entries.size} Einträge - ERFOLG")
// ✅ Test 7: Alle 9 Rollen einzeln prüfen
results.add("✅ Alle Rollen-Definitionen:")
ReitsportRoles.ALL_ROLES.forEachIndexed { index, role ->
results.add(" ${index + 1}. ${role.displayName} (${role.roleType}) - ${role.permissions.size} Berechtigungen")
}
// ✅ Test 8: Berechtigungen-Zuordnung testen
val adminPermissions = ReitsportRoles.ADMIN.permissions.size
val guestPermissions = ReitsportRoles.GAST.permissions.size
results.add("✅ Admin-Berechtigungen: $adminPermissions (max)")
results.add("✅ Gast-Berechtigungen: $guestPermissions (min)")
// ✅ Test 9: Hilfsfunktionen testen
val roleByType = ReitsportRoles.getRoleByType(RolleE.RICHTER)
results.add("✅ Rolle per Type: ${roleByType?.displayName} - ERFOLG")
val rolesWithRead = ReitsportRoles.getRolesWithPermission(BerechtigungE.PERSON_READ)
results.add("✅ Rollen mit PERSON_READ: ${rolesWithRead.size} - ERFOLG")
return results.joinToString("\n")
}
/**
* Führt Performance-Test durch
*/
fun performanceTest(): String {
val start = DateTimeHelper.now()
// Simuliere mehrere Rollen-Abfragen
repeat(100) {
ReitsportRoles.getAllRoles()
ReitsportRoles.getRoleByType(RolleE.ADMIN)
ReitsportRoles.getRolesWithPermission(BerechtigungE.PERSON_READ)
}
val end = DateTimeHelper.now()
val duration = end - start
return "✅ Performance-Test: $duration Zeiteinheiten für 300 Operationen - ERFOLG"
}
}
/**
* Hilfsfunktion für externe Zeitabfrage
*/
private fun getTimeMillis(): Long = DateTimeHelper.now()
/**
* Extension für einfacheren Zugriff
*/
private fun ReitsportRoles.getAllRoles() = ALL_ROLES
+5
View File
@@ -58,6 +58,11 @@ kotlin {
implementation(libs.ktor.client.logging) implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.auth) implementation(libs.ktor.client.auth)
// Dependency Injection (Koin)
implementation(libs.koin.core)
implementation(libs.koin.compose)
implementation(libs.koin.compose.viewmodel)
// Compose für shared UI components (common) // Compose für shared UI components (common)
implementation(compose.runtime) implementation(compose.runtime)
implementation(compose.foundation) implementation(compose.foundation)
+3 -1
View File
@@ -13,7 +13,8 @@ kotlin {
jvm() jvm()
js(IR) { js(IR) {
browser() browser()
nodejs() // nodejs()
binaries.executable()
} }
// WASM, nur wenn explizit aktiviert // WASM, nur wenn explizit aktiviert
@@ -21,6 +22,7 @@ kotlin {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { wasmJs {
browser() browser()
binaries.executable()
} }
} }
@@ -1,12 +1,11 @@
package at.mocode.clients.shared.commonui.components package at.mocode.clients.shared.commonui.components
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
enum class ButtonVariant { enum class ButtonVariant {
@@ -79,6 +79,7 @@ fun MeldestelleTextField(
modifier = Modifier.padding(start = 16.dp, top = 4.dp) modifier = Modifier.padding(start = 16.dp, top = 4.dp)
) )
} }
helperText != null -> { helperText != null -> {
Text( Text(
text = helperText, text = helperText,
@@ -1,179 +1,5 @@
package at.mocode.clients.shared.commonui.components package at.mocode.clients.shared.commonui.components
import androidx.compose.foundation.layout.* // Legacy notification components removed due to dependency on old presentation layer.
import androidx.compose.foundation.lazy.LazyColumn // Intentionally left empty as part of cleanup. You can safely delete this file
import androidx.compose.foundation.shape.RoundedCornerShape // if no modules import it anymore.
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.clients.shared.presentation.state.Notification
import at.mocode.clients.shared.presentation.state.NotificationType
@Composable
fun NotificationCard(
notification: Notification,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val backgroundColor = when (notification.type) {
NotificationType.SUCCESS -> Color(0xFF4CAF50).copy(alpha = 0.1f)
NotificationType.ERROR -> Color(0xFFF44336).copy(alpha = 0.1f)
NotificationType.WARNING -> Color(0xFFFF9800).copy(alpha = 0.1f)
NotificationType.INFO -> Color(0xFF2196F3).copy(alpha = 0.1f)
}
val borderColor = when (notification.type) {
NotificationType.SUCCESS -> Color(0xFF4CAF50)
NotificationType.ERROR -> Color(0xFFF44336)
NotificationType.WARNING -> Color(0xFFFF9800)
NotificationType.INFO -> Color(0xFF2196F3)
}
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = backgroundColor),
shape = RoundedCornerShape(8.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, borderColor)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = notification.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
if (notification.message.isNotBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = notification.message,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = notification.timestamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(
onClick = onDismiss,
modifier = Modifier.size(24.dp)
) {
Text("×", style = MaterialTheme.typography.titleMedium)
}
}
}
}
@Composable
fun NotificationList(
notifications: List<Notification>,
onDismissNotification: (String) -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
notifications.forEach { notification ->
NotificationCard(
notification = notification,
onDismiss = { onDismissNotification(notification.id) }
)
}
}
}
@Composable
fun SnackbarNotification(
notification: Notification,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val backgroundColor = when (notification.type) {
NotificationType.SUCCESS -> Color(0xFF4CAF50)
NotificationType.ERROR -> Color(0xFFF44336)
NotificationType.WARNING -> Color(0xFFFF9800)
NotificationType.INFO -> Color(0xFF2196F3)
}
Snackbar(
modifier = modifier,
containerColor = backgroundColor,
contentColor = Color.White,
action = {
TextButton(
onClick = onDismiss,
colors = ButtonDefaults.textButtonColors(
contentColor = Color.White
)
) {
Text("Dismiss")
}
}
) {
Column {
Text(
text = notification.title,
fontWeight = FontWeight.SemiBold
)
if (notification.message.isNotBlank()) {
Text(
text = notification.message,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
@Composable
fun ToastNotification(
message: String,
type: NotificationType = NotificationType.INFO,
visible: Boolean,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
if (visible) {
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(3000) // Auto dismiss after 3 seconds
onDismiss()
}
val backgroundColor = when (type) {
NotificationType.SUCCESS -> Color(0xFF4CAF50)
NotificationType.ERROR -> Color(0xFFF44336)
NotificationType.WARNING -> Color(0xFFFF9800)
NotificationType.INFO -> Color(0xFF2196F3)
}
Card(
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = backgroundColor),
shape = RoundedCornerShape(24.dp)
) {
Text(
text = message,
color = Color.White,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@@ -1,232 +0,0 @@
package at.mocode.clients.shared.commonui.layout
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.clients.shared.commonui.components.*
import at.mocode.clients.shared.commonui.screens.LoginScreenContainer
import at.mocode.clients.shared.presentation.state.AppState
import at.mocode.clients.shared.presentation.actions.AppAction
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainLayout(
appState: AppState,
onDispatchAction: (AppAction) -> Unit,
onNavigateTo: (String) -> Unit,
content: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
var showUserMenu by remember { mutableStateOf(false) }
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text(
text = "Meldestelle",
fontWeight = FontWeight.Bold
)
},
actions = {
// Notifications
if (appState.ui.notifications.isNotEmpty()) {
BadgedBox(
badge = {
Badge(
contentColor = MaterialTheme.colorScheme.onError,
containerColor = MaterialTheme.colorScheme.error
) {
Text(appState.ui.notifications.size.toString())
}
}
) {
IconButton(
onClick = { onNavigateTo("/notifications") }
) {
Text("🔔")
}
}
} else {
IconButton(
onClick = { onNavigateTo("/notifications") }
) {
Text("🔔")
}
}
// Theme toggle
IconButton(
onClick = { onDispatchAction(AppAction.UI.ToggleDarkMode) }
) {
Text(if (appState.ui.isDarkMode) "☀️" else "🌙")
}
// User menu
Box {
IconButton(
onClick = { showUserMenu = true }
) {
Text("👤")
}
DropdownMenu(
expanded = showUserMenu,
onDismissRequest = { showUserMenu = false }
) {
DropdownMenuItem(
text = {
Column {
Text(
text = appState.auth.user?.firstName ?: "User",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = appState.auth.user?.email ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
onClick = {
showUserMenu = false
onNavigateTo("/profile")
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Settings") },
onClick = {
showUserMenu = false
onNavigateTo("/settings")
}
)
DropdownMenuItem(
text = { Text("Help") },
onClick = {
showUserMenu = false
onNavigateTo("/help")
}
)
HorizontalDivider()
DropdownMenuItem(
text = {
Text(
text = "Logout",
color = MaterialTheme.colorScheme.error
)
},
onClick = {
showUserMenu = false
onDispatchAction(AppAction.Auth.Logout)
}
)
}
}
}
)
},
bottomBar = {
if (appState.ui.notifications.isNotEmpty()) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${appState.ui.notifications.size} notification(s)",
style = MaterialTheme.typography.bodySmall
)
TextButton(
onClick = { onNavigateTo("/notifications") }
) {
Text("View All")
}
}
}
}
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Loading overlay
if (appState.ui.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
FullScreenLoading("Loading...")
}
} else {
content()
}
}
}
}
@Composable
fun AuthenticatedLayout(
appState: AppState,
onDispatchAction: (AppAction) -> Unit,
onNavigateTo: (String) -> Unit,
content: @Composable () -> Unit,
modifier: Modifier = Modifier
) {
if (appState.auth.isAuthenticated) {
MainLayout(
appState = appState,
onDispatchAction = onDispatchAction,
onNavigateTo = onNavigateTo,
content = content,
modifier = modifier
)
} else {
// Show login screen if not authenticated
LoginScreenContainer(
authState = appState.auth,
onDispatchAction = onDispatchAction,
modifier = modifier
)
}
}
@Composable
fun ResponsiveLayout(
appState: AppState,
onDispatchAction: (AppAction) -> Unit,
onNavigateTo: (String) -> Unit,
content: @Composable (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
// Simple responsive design - could be enhanced with actual screen size detection
val isCompact = remember { mutableStateOf(false) }
AuthenticatedLayout(
appState = appState,
onDispatchAction = onDispatchAction,
onNavigateTo = onNavigateTo,
content = { content(isCompact.value) },
modifier = modifier
)
}
@@ -1,250 +0,0 @@
package at.mocode.clients.shared.commonui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import at.mocode.clients.shared.commonui.components.*
import at.mocode.clients.shared.presentation.state.AppState
import at.mocode.clients.shared.presentation.actions.AppAction
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
appState: AppState,
onDispatchAction: (AppAction) -> Unit,
onNavigateTo: (String) -> Unit,
modifier: Modifier = Modifier
) {
val scrollState = rememberScrollState()
val user = appState.auth.user
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Welcome Header
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp)
) {
Text(
text = "Welcome back, ${user?.firstName ?: "User"}!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Here's what's happening in your Meldestelle dashboard",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
}
// Quick Actions
Text(
text = "Quick Actions",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
PrimaryButton(
text = "New Report",
onClick = { onNavigateTo("/reports/new") },
modifier = Modifier.weight(1f)
)
SecondaryButton(
text = "View Reports",
onClick = { onNavigateTo("/reports") },
modifier = Modifier.weight(1f)
)
}
// Statistics Cards
Text(
text = "Overview",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatisticCard(
title = "Total Reports",
value = "142",
modifier = Modifier.weight(1f)
)
StatisticCard(
title = "Open Issues",
value = "23",
modifier = Modifier.weight(1f)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatisticCard(
title = "Resolved",
value = "119",
modifier = Modifier.weight(1f)
)
StatisticCard(
title = "This Month",
value = "18",
modifier = Modifier.weight(1f)
)
}
// Recent Activity
Text(
text = "Recent Activity",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
ActivityItem(
title = "Report #1234 updated",
subtitle = "Status changed to 'In Progress'",
timestamp = "2 hours ago"
)
HorizontalDivider()
ActivityItem(
title = "New report submitted",
subtitle = "Report #1235 - Urgent priority",
timestamp = "4 hours ago"
)
HorizontalDivider()
ActivityItem(
title = "Report #1230 resolved",
subtitle = "Issue successfully closed",
timestamp = "1 day ago"
)
}
}
// Connection Status
if (!appState.network.isOnline) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "⚠️",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "Offline Mode",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = "Some features may be limited",
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
}
@Composable
private fun StatisticCard(
title: String,
value: String,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = value,
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun ActivityItem(
title: String,
subtitle: String,
timestamp: String,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = timestamp,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
@@ -1,198 +0,0 @@
package at.mocode.clients.shared.commonui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import at.mocode.clients.shared.commonui.components.*
import at.mocode.clients.shared.presentation.actions.AppAction
import at.mocode.clients.shared.presentation.state.AuthState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
authState: AuthState,
onLoginClick: (String, String) -> Unit,
onNavigateToRegister: () -> Unit = {},
onForgotPassword: () -> Unit = {},
modifier: Modifier = Modifier
) {
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var usernameError by remember { mutableStateOf<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(null) }
val focusManager = LocalFocusManager.current
// Validate form
val isFormValid = username.isNotBlank() && password.isNotBlank() &&
usernameError == null && passwordError == null
fun validateUsername(value: String) {
usernameError = FormValidation.validateRequired(value, "Username")
}
fun validatePassword(value: String) {
passwordError = FormValidation.validatePassword(value)
}
fun handleLogin() {
validateUsername(username)
validatePassword(password)
if (isFormValid) {
onLoginClick(username.trim(), password)
}
}
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Header
Text(
text = "Meldestelle",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
text = "Sign in to your account",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
// Username Field
MeldestelleTextField(
value = username,
onValueChange = {
username = it
if (usernameError != null) validateUsername(it)
},
label = "Username",
placeholder = "Enter your username",
isError = usernameError != null,
errorMessage = usernameError,
enabled = !authState.isLoading,
imeAction = ImeAction.Next,
keyboardActions = KeyboardActions(
onNext = { focusManager.moveFocus(FocusDirection.Down) }
)
)
// Password Field
MeldestellePasswordField(
value = password,
onValueChange = {
password = it
if (passwordError != null) validatePassword(it)
},
label = "Password",
placeholder = "Enter your password",
isError = passwordError != null,
errorMessage = passwordError,
enabled = !authState.isLoading,
imeAction = ImeAction.Done,
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
if (isFormValid) handleLogin()
}
)
)
// Error display
authState.error?.let { errorMessage ->
Text(
text = errorMessage,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(8.dp))
// Login Button
PrimaryButton(
text = "Sign In",
onClick = ::handleLogin,
enabled = isFormValid && !authState.isLoading,
isLoading = authState.isLoading,
fullWidth = true
)
// Forgot Password
TextButton(
onClick = onForgotPassword,
enabled = !authState.isLoading
) {
Text("Forgot Password?")
}
HorizontalDivider()
// Register Link
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Don't have an account?",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(4.dp))
TextButton(
onClick = onNavigateToRegister,
enabled = !authState.isLoading
) {
Text("Sign Up")
}
}
}
}
}
}
@Composable
fun LoginScreenContainer(
authState: AuthState,
onDispatchAction: (AppAction) -> Unit,
onNavigateToRegister: () -> Unit = {},
onForgotPassword: () -> Unit = {},
modifier: Modifier = Modifier
) {
LoginScreen(
authState = authState,
onLoginClick = { username, password ->
onDispatchAction(AppAction.Auth.LoginStart(username, password))
},
onNavigateToRegister = onNavigateToRegister,
onForgotPassword = onForgotPassword,
modifier = modifier
)
}
@@ -18,12 +18,14 @@ kotlin {
js { js {
browser() browser()
binaries.executable()
} }
if (enableWasm) { if (enableWasm) {
@OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class)
wasmJs { wasmJs {
browser() browser()
binaries.executable()
} }
} }
@@ -5,4 +5,5 @@ sealed class AppScreen {
data object Login : AppScreen() data object Login : AppScreen()
data object Ping : AppScreen() data object Ping : AppScreen()
data object Profile : AppScreen() data object Profile : AppScreen()
data object AuthCallback : AppScreen()
} }
@@ -1,17 +0,0 @@
package at.mocode.clients.shared
/**
* Zentrale App-Konfiguration für alle Client-Module.
* Hinweis: Diese Werte sind zentrale Defaults für DEV. Für PROD sollten sie
* via Build-Injektion (Gradle/ENV) überschrieben werden. Ein einfaches
* BuildConfig-Setup kann später ergänzt werden.
*/
object AppConfig {
// Gateway Basis-URL (API Gateway)
const val GATEWAY_URL: String = "http://localhost:8081"
// Keycloak Konfiguration
const val KEYCLOAK_URL: String = "http://localhost:8180"
const val KEYCLOAK_REALM: String = "meldestelle"
const val KEYCLOAK_CLIENT_ID: String = "meldestelle-frontend"
}
@@ -0,0 +1,12 @@
package at.mocode.clients.shared.core
data class AppConfig(
val gatewayUrl: String,
val isDebug: Boolean
)
// Standard-Config für Local Development
val devConfig = AppConfig(
gatewayUrl = "http://localhost:8081",
isDebug = true
)
@@ -0,0 +1,47 @@
package at.mocode.clients.shared.core
/**
* Shared application configuration constants for clients.
* These defaults target local development environments.
*/
object AppConstants {
// Gateway base URL (reverse proxy / API gateway)
const val GATEWAY_URL: String = "http://localhost:8081"
// Keycloak configuration
const val KEYCLOAK_URL: String = "http://localhost:8180"
const val KEYCLOAK_REALM: String = "meldestelle"
// Use public client configured in realm import: `web-app`
const val KEYCLOAK_CLIENT_ID: String = "web-app"
// Default redirect URI for web PKCE flow (served by Nginx in web image)
// We use the root path so Keycloak can redirect back to /?code=...
fun webRedirectUri(): String = "http://localhost:4000/"
fun registerUrl(): String =
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/registrations?client_id=$KEYCLOAK_CLIENT_ID&response_type=code&redirect_uri=${
encode(
webRedirectUri()
)
}"
fun loginUrl(): String =
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth?client_id=$KEYCLOAK_CLIENT_ID&response_type=code&redirect_uri=${
encode(
webRedirectUri()
)
}"
fun authorizeEndpoint(): String =
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth"
fun tokenEndpoint(): String =
"$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/token"
fun desktopDownloadUrl(): String = "http://localhost:4000/downloads/"
// Helper to URL-encode values (very small percent-encoding sufficient for URIs here)
private fun encode(value: String): String =
value.replace("://", ":%2F%2F").replace("/", "%2F").replace(":", "%3A")
}
@@ -1,171 +0,0 @@
package at.mocode.clients.shared.data.repository
import at.mocode.clients.shared.domain.models.User
import at.mocode.clients.shared.domain.models.AuthToken
import at.mocode.clients.shared.domain.models.ApiResponse
import at.mocode.clients.shared.network.HttpClientConfig
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
/**
* Authentication repository handling all authentication-related operations
* with Keycloak integration.
*/
class AuthRepository(
private val baseUrl: String = "http://localhost:8080",
private val keycloakUrl: String = "http://localhost:8180",
private val realm: String = "meldestelle",
private val clientId: String = "meldestelle-client"
) : Repository {
private val httpClient: HttpClient = HttpClientConfig.createClient(baseUrl)
@Serializable
data class LoginRequest(
val username: String,
val password: String
)
@Serializable
data class KeycloakTokenResponse(
val access_token: String,
val refresh_token: String,
val expires_in: Long,
val token_type: String = "Bearer"
)
/**
* Authenticate user with username and password via Keycloak
*/
suspend fun login(username: String, password: String): RepositoryResult<AuthToken> {
return try {
val response = httpClient.submitForm(
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/token",
formParameters = Parameters.build {
append("grant_type", "password")
append("client_id", clientId)
append("username", username)
append("password", password)
}
).body<KeycloakTokenResponse>()
val authToken = AuthToken(
accessToken = response.access_token,
refreshToken = response.refresh_token,
expiresIn = response.expires_in,
tokenType = response.token_type
)
RepositoryResult.Success(authToken)
} catch (e: Exception) {
RepositoryResult.Error(
at.mocode.clients.shared.domain.models.ApiError(
code = "LOGIN_FAILED",
message = "Login failed: ${e.message}"
)
)
}
}
/**
* Refresh authentication token
*/
suspend fun refreshToken(refreshToken: String): RepositoryResult<AuthToken> {
return try {
val response = httpClient.submitForm(
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/token",
formParameters = Parameters.build {
append("grant_type", "refresh_token")
append("client_id", clientId)
append("refresh_token", refreshToken)
}
).body<KeycloakTokenResponse>()
val authToken = AuthToken(
accessToken = response.access_token,
refreshToken = response.refresh_token,
expiresIn = response.expires_in,
tokenType = response.token_type
)
RepositoryResult.Success(authToken)
} catch (e: Exception) {
RepositoryResult.Error(
at.mocode.clients.shared.domain.models.ApiError(
code = "TOKEN_REFRESH_FAILED",
message = "Token refresh failed: ${e.message}"
)
)
}
}
/**
* Get current user information using access token
*/
suspend fun getCurrentUser(accessToken: String): RepositoryResult<User> {
return try {
val response = httpClient.get("$baseUrl/api/auth/me") {
header("Authorization", "Bearer $accessToken")
}.body<ApiResponse<User>>()
response.toRepositoryResult()
} catch (e: Exception) {
RepositoryResult.Error(
at.mocode.clients.shared.domain.models.ApiError(
code = "USER_INFO_FAILED",
message = "Failed to get user info: ${e.message}"
)
)
}
}
/**
* Logout user by invalidating tokens
*/
suspend fun logout(refreshToken: String): RepositoryResult<Unit> {
return try {
httpClient.submitForm(
url = "$keycloakUrl/realms/$realm/protocol/openid-connect/logout",
formParameters = Parameters.build {
append("client_id", clientId)
append("refresh_token", refreshToken)
}
)
RepositoryResult.Success(Unit)
} catch (e: Exception) {
RepositoryResult.Error(
at.mocode.clients.shared.domain.models.ApiError(
code = "LOGOUT_FAILED",
message = "Logout failed: ${e.message}"
)
)
}
}
/**
* Check if token is still valid
*/
suspend fun validateToken(accessToken: String): RepositoryResult<Boolean> {
return try {
val response = httpClient.get("$baseUrl/api/auth/validate") {
header("Authorization", "Bearer $accessToken")
}.body<ApiResponse<Boolean>>()
response.toRepositoryResult()
} catch (e: Exception) {
RepositoryResult.Success(false) // Token is invalid
}
}
/**
* Cleanup resources
*/
fun close() {
httpClient.close()
}
}
@@ -0,0 +1,27 @@
package at.mocode.clients.shared.data.repository
import at.mocode.clients.shared.domain.model.PingData
import at.mocode.clients.shared.domain.model.Resource
import at.mocode.clients.shared.domain.repository.PingRepository
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.request.*
class PingRepositoryImpl(
private val httpClient: HttpClient
) : PingRepository {
override suspend fun checkSystemStatus(): Resource<PingData> {
return try {
// Der HttpClient hat die BaseURL schon konfiguriert (siehe NetworkModule)
val response = httpClient.get("/api/ping/simple").body<PingData>()
Resource.Success(response)
} catch (e: Exception) {
// Hier fangen wir Netzwerkfehler ab und machen sie "hübsch" für die UI
Resource.Error(
message = "Verbindung fehlgeschlagen: ${e.message ?: "Unbekannter Fehler"}",
code = "NETWORK_ERROR"
)
}
}
}
@@ -1,73 +0,0 @@
package at.mocode.clients.shared.data.repository
import at.mocode.clients.shared.domain.models.ApiResponse
import at.mocode.clients.shared.domain.models.ApiError
/**
* Base repository interface defining common operations and patterns
* for data access across the application.
*/
interface Repository
/**
* Result wrapper for repository operations to handle success/error states
*/
sealed class RepositoryResult<out T> {
data class Success<T>(val data: T) : RepositoryResult<T>()
data class Error(val error: ApiError) : RepositoryResult<Nothing>()
data class Loading(val message: String = "Loading...") : RepositoryResult<Nothing>()
fun isSuccess(): Boolean = this is Success
fun isError(): Boolean = this is Error
fun isLoading(): Boolean = this is Loading
fun getOrNull(): T? = when (this) {
is Success -> data
else -> null
}
fun getErrorOrNull(): ApiError? = when (this) {
is Error -> error
else -> null
}
}
/**
* Extension function to convert ApiResponse to RepositoryResult
*/
fun <T> ApiResponse<T>.toRepositoryResult(): RepositoryResult<T> {
return if (success && data != null) {
RepositoryResult.Success(data)
} else {
RepositoryResult.Error(
error ?: ApiError(
code = "UNKNOWN_ERROR",
message = "Unknown error occurred"
)
)
}
}
/**
* Extension function to handle repository results with callbacks
*/
inline fun <T> RepositoryResult<T>.onSuccess(action: (T) -> Unit): RepositoryResult<T> {
if (this is RepositoryResult.Success) {
action(data)
}
return this
}
inline fun <T> RepositoryResult<T>.onError(action: (ApiError) -> Unit): RepositoryResult<T> {
if (this is RepositoryResult.Error) {
action(error)
}
return this
}
inline fun <T> RepositoryResult<T>.onLoading(action: (String) -> Unit): RepositoryResult<T> {
if (this is RepositoryResult.Loading) {
action(message)
}
return this
}
@@ -0,0 +1,50 @@
package at.mocode.clients.shared.di
import at.mocode.clients.shared.core.AppConfig
import io.ktor.client.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
import org.koin.dsl.module
val networkModule = module {
// 1. JSON Konfiguration (Global verfügbar)
single {
Json {
ignoreUnknownKeys = true
prettyPrint = true
isLenient = true
}
}
// 2. HttpClient (Singleton)
single {
val config = get<AppConfig>()
val jsonConfig = get<Json>()
HttpClient {
// Standard-URL setzen
defaultRequest {
url(config.gatewayUrl)
contentType(ContentType.Application.Json)
}
install(ContentNegotiation) {
json(jsonConfig)
}
install(Logging) {
level = if (config.isDebug) LogLevel.INFO else LogLevel.NONE
logger = Logger.DEFAULT
}
install(HttpTimeout) {
requestTimeoutMillis = 10000
connectTimeoutMillis = 10000
}
}
}
}
@@ -0,0 +1,23 @@
package at.mocode.clients.shared.di
import at.mocode.clients.shared.core.devConfig
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
import org.koin.dsl.module
// Das Modul für die Config
val configModule = module {
single { devConfig } // Später können wir hier PROD/DEV umschalten
}
// Alle Module zusammen
val sharedModules = listOf(
configModule,
networkModule
)
// Helper zum Starten von Koin (wird von der App aufgerufen)
fun initKoin(appDeclaration: KoinAppDeclaration = {}) = startKoin {
appDeclaration()
modules(sharedModules)
}
@@ -0,0 +1,39 @@
package at.mocode.clients.shared.domain.model
import kotlinx.serialization.Serializable
/**
* Generischer Wrapper für API-Antworten.
*/
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val error: ApiError? = null
)
@Serializable
data class ApiError(
val code: String,
val message: String
)
/**
* Das Ergebnis eines Repository-Aufrufs.
* Die UI kennt nur das hier, keine HTTP-Exceptions!
*/
sealed class Resource<out T> {
data class Success<T>(val data: T) : Resource<T>()
data class Error(val message: String, val code: String? = null) : Resource<Nothing>()
data object Loading : Resource<Nothing>()
}
/**
* Datenmodell für den Ping.
*/
@Serializable
data class PingData(
val status: String,
val timestamp: String,
val service: String
)
@@ -1,27 +0,0 @@
package at.mocode.clients.shared.domain.models
import kotlinx.serialization.Serializable
@Serializable
data class ApiResponse<T>(
val success: Boolean,
val data: T? = null,
val error: ApiError? = null,
val timestamp: String,
val correlationId: String? = null
)
@Serializable
data class ApiError(
val code: String,
val message: String,
val details: Map<String, String> = emptyMap()
)
@Serializable
data class HealthResponse(
val status: String,
val timestamp: String,
val service: String,
val healthy: Boolean
)
@@ -1,22 +0,0 @@
package at.mocode.clients.shared.domain.models
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: String,
val username: String,
val email: String,
val firstName: String,
val lastName: String,
val roles: Set<String> = emptySet(),
val isActive: Boolean = true
)
@Serializable
data class AuthToken(
val accessToken: String,
val refreshToken: String,
val expiresIn: Long,
val tokenType: String = "Bearer"
)
@@ -0,0 +1,8 @@
package at.mocode.clients.shared.domain.repository
import at.mocode.clients.shared.domain.model.PingData
import at.mocode.clients.shared.domain.model.Resource
interface PingRepository {
suspend fun checkSystemStatus(): Resource<PingData>
}
@@ -1,194 +0,0 @@
package at.mocode.clients.shared.navigation
import at.mocode.clients.shared.presentation.store.AppStore
/**
* Deep link handling for the application
*/
class DeepLinkHandler(
private val navigationManager: NavigationManager,
private val store: AppStore
) {
/**
* Deep link configuration
*/
data class DeepLinkConfig(
val scheme: String = "meldestelle",
val host: String = "app",
val allowedDomains: Set<String> = setOf("meldestelle.com", "localhost")
)
private val config = DeepLinkConfig()
/**
* Handle a deep link URL
*/
fun handleDeepLink(url: String): Boolean {
return try {
val parsedLink = parseDeepLink(url)
if (parsedLink != null) {
processDeepLink(parsedLink)
true
} else {
false
}
} catch (e: Exception) {
// Log error in real implementation
false
}
}
/**
* Parse deep link URL into components
*/
private fun parseDeepLink(url: String): DeepLink? {
return when {
url.startsWith("${config.scheme}://") -> parseCustomSchemeLink(url)
url.startsWith("https://") || url.startsWith("http://") -> parseWebLink(url)
else -> null
}
}
/**
* Parse custom scheme deep links (e.g., meldestelle://app/dashboard)
*/
private fun parseCustomSchemeLink(url: String): DeepLink? {
val withoutScheme = url.removePrefix("${config.scheme}://")
val parts = withoutScheme.split("/")
if (parts.isEmpty() || parts[0] != config.host) {
return null
}
val path = "/" + parts.drop(1).joinToString("/")
val route = if (path == "/") Routes.HOME else path
return DeepLink(
type = DeepLinkType.CUSTOM_SCHEME,
route = route,
params = RouteUtils.parseRouteParams(route),
originalUrl = url
)
}
/**
* Parse web deep links (e.g., https://meldestelle.com/dashboard)
*/
private fun parseWebLink(url: String): DeepLink? {
// Simple URL parsing - in real implementation use proper URL parser
val urlParts = url.split("/")
if (urlParts.size < 3) return null
val domain = urlParts[2]
if (!config.allowedDomains.contains(domain)) {
return null
}
val path = "/" + urlParts.drop(3).joinToString("/")
val route = if (path == "/" || path.isEmpty()) Routes.HOME else path
return DeepLink(
type = DeepLinkType.WEB_LINK,
route = route,
params = RouteUtils.parseRouteParams(route),
originalUrl = url
)
}
/**
* Process a parsed deep link
*/
private fun processDeepLink(deepLink: DeepLink) {
val authState = store.state.value.auth
val cleanRoute = RouteUtils.getCleanRoute(deepLink.route)
// Check if route requires authentication
if (RouteUtils.requiresAuth(cleanRoute)) {
if (!authState.isAuthenticated) {
// Save the intended route and redirect to log in
saveIntendedRoute(deepLink.route)
navigationManager.navigateTo(Routes.Auth.LOGIN)
return
}
}
// Check if route requires admin privileges
if (RouteUtils.requiresAdmin(cleanRoute)) {
val hasAdminRole = authState.user?.roles?.contains("admin") ?: false
if (!hasAdminRole) {
// Redirect to unauthorized or home
navigationManager.navigateTo(Routes.HOME)
return
}
}
// Navigate to the route
navigationManager.navigateTo(deepLink.route)
}
/**
* Save the intended route for after authentication
*/
private fun saveIntendedRoute(route: String) {
// In real implementation, save to persistent storage
// For now; we'll store it in a simple variable
intendedRoute = route
}
/**
* Get and clear the intended route
*/
fun getAndClearIntendedRoute(): String? {
val route = intendedRoute
intendedRoute = null
return route
}
/**
* Check if there's a pending intended route
*/
fun hasIntendedRoute(): Boolean = intendedRoute != null
/**
* Generate a deep link for a route
*/
fun generateDeepLink(route: String, useCustomScheme: Boolean = true): String {
return if (useCustomScheme) {
"${config.scheme}://${config.host}$route"
} else {
"https://${config.allowedDomains.first()}$route"
}
}
/**
* Validate if a route is valid for deep linking
*/
fun isValidDeepLinkRoute(route: String): Boolean {
return RouteUtils.isValidRoute(route) &&
!route.startsWith("/auth/") && // Auth routes shouldn't be deep linked
route != Routes.Auth.LOGIN
}
companion object {
private var intendedRoute: String? = null
}
}
/**
* Deep link data class
*/
data class DeepLink(
val type: DeepLinkType,
val route: String,
val params: Map<String, String>,
val originalUrl: String
)
/**
* Types of deep links
*/
enum class DeepLinkType {
CUSTOM_SCHEME, // meldestelle://app/route
WEB_LINK // https://meldestelle.com/route
}
@@ -1,179 +0,0 @@
package at.mocode.clients.shared.navigation
import at.mocode.clients.shared.presentation.actions.AppAction
import at.mocode.clients.shared.presentation.store.AppStore
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Navigation manager for handling routing and navigation logic
*/
class NavigationManager(
private val store: AppStore
) {
/**
* Current route as a flow
*/
val currentRoute: Flow<String> = store.state.map { it.navigation.currentRoute }
/**
* Navigation history as a flow
*/
val navigationHistory: Flow<List<String>> = store.state.map { it.navigation.history }
/**
* Can go back flag as a flow
*/
val canGoBack: Flow<Boolean> = store.state.map { it.navigation.canGoBack }
/**
* Navigate to a specific route
*/
fun navigateTo(route: String) {
store.dispatch(AppAction.Navigation.NavigateTo(route))
}
/**
* Navigate back to the previous route
*/
fun navigateBack() {
store.dispatch(AppAction.Navigation.NavigateBack)
}
/**
* Replace current route without adding to history
*/
fun replaceRoute(route: String) {
store.dispatch(AppAction.Navigation.UpdateHistory(route))
}
/**
* Clear navigation history and navigate to the route
*/
fun navigateAndClearHistory(route: String) {
// First clear by replacing with the new route
store.dispatch(AppAction.Navigation.UpdateHistory(route))
}
/**
* Get current route value (non-reactive)
*/
fun getCurrentRoute(): String = store.state.value.navigation.currentRoute
/**
* Check if we can navigate back
*/
fun canNavigateBack(): Boolean = store.state.value.navigation.canGoBack
}
/**
* Route definitions for the application
*/
object Routes {
const val HOME = "/"
const val LOGIN = "/login"
const val DASHBOARD = "/dashboard"
const val PROFILE = "/profile"
const val SETTINGS = "/settings"
const val PING = "/ping"
// Auth-related routes
object Auth {
const val LOGIN = "/auth/login"
const val LOGOUT = "/auth/logout"
const val REGISTER = "/auth/register"
const val FORGOT_PASSWORD = "/auth/forgot-password"
}
// Admin routes
object Admin {
const val DASHBOARD = "/admin/dashboard"
const val USERS = "/admin/users"
const val SETTINGS = "/admin/settings"
}
// Feature routes
object Features {
const val PING = "/features/ping"
const val REPORTS = "/features/reports"
const val NOTIFICATIONS = "/features/notifications"
}
}
/**
* Route validation and utilities
*/
object RouteUtils {
/**
* Check if a route requires authentication
*/
fun requiresAuth(route: String): Boolean {
return when {
route.startsWith("/auth/") && route != Routes.Auth.LOGIN -> false
route == Routes.HOME -> false
route == Routes.LOGIN -> false
else -> true
}
}
/**
* Check if a route is for admin only
*/
fun requiresAdmin(route: String): Boolean {
return route.startsWith("/admin/")
}
/**
* Get the default route for authenticated users
*/
fun getDefaultAuthenticatedRoute(): String = Routes.DASHBOARD
/**
* Get the default route for unauthenticated users
*/
fun getDefaultUnauthenticatedRoute(): String = Routes.LOGIN
/**
* Validate route format
*/
fun isValidRoute(route: String): Boolean {
return route.startsWith("/") && route.isNotBlank()
}
/**
* Parse route parameters (simple implementation)
*/
fun parseRouteParams(route: String): Map<String, String> {
val params = mutableMapOf<String, String>()
// Simple query parameter parsing
if (route.contains("?")) {
val parts = route.split("?")
if (parts.size == 2) {
val queryParams = parts[1].split("&")
queryParams.forEach { param ->
val keyValue = param.split("=")
if (keyValue.size == 2) {
params[keyValue[0]] = keyValue[1]
}
}
}
}
return params
}
/**
* Get clean route without parameters
*/
fun getCleanRoute(route: String): String {
return if (route.contains("?")) {
route.split("?")[0]
} else {
route
}
}
}
@@ -1,74 +0,0 @@
package at.mocode.clients.shared.navigation
import at.mocode.clients.shared.presentation.state.NavigationState
import kotlinx.coroutines.flow.Flow
/**
* Interface für das Persistieren von Navigation State
*/
interface NavigationPersistence {
suspend fun saveNavigationState(state: NavigationState)
fun getNavigationState(): Flow<NavigationState?>
suspend fun clearNavigationState()
}
/**
* Default implementation ohne echte Persistierung (In-Memory)
* Platform-spezifische Implementierungen können echte Persistierung bereitstellen
*/
class DefaultNavigationPersistence : NavigationPersistence {
private var currentState: NavigationState? = null
override suspend fun saveNavigationState(state: NavigationState) {
currentState = state
}
override fun getNavigationState(): Flow<NavigationState?> {
return kotlinx.coroutines.flow.flowOf(currentState)
}
override suspend fun clearNavigationState() {
currentState = null
}
}
/**
* Navigation History Manager mit Persistierung
*/
class NavigationHistoryManager(
private val persistence: NavigationPersistence
) {
companion object {
private const val MAX_HISTORY_SIZE = 50
}
suspend fun saveRoute(route: String, history: List<String>) {
val state = NavigationState(
currentRoute = route,
history = history.takeLast(MAX_HISTORY_SIZE),
canGoBack = history.isNotEmpty()
)
persistence.saveNavigationState(state)
}
fun getPersistedState() = persistence.getNavigationState()
suspend fun clear() = persistence.clearNavigationState()
/**
* Optimiert die History für bessere Performance
*/
private fun optimizeHistory(history: List<String>): List<String> {
// Entfernt Duplikate in Folge und behält nur die letzten N Einträge
return history
.fold(emptyList<String>()) { acc, route ->
if (acc.lastOrNull() != route) acc + route else acc
}
.takeLast(MAX_HISTORY_SIZE)
}
suspend fun addToHistory(newRoute: String, currentHistory: List<String>) {
val optimizedHistory = optimizeHistory(currentHistory + newRoute)
saveRoute(newRoute, optimizedHistory.dropLast(1))
}
}
@@ -1,27 +0,0 @@
package at.mocode.clients.shared.network
import io.ktor.client.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
object HttpClientConfig {
fun createClient(
baseUrl: String = "http://localhost:8080"
): HttpClient = HttpClient {
// Content negotiation with JSON (based on PingApiClient pattern)
install(ContentNegotiation) {
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
fun createClientWithBaseUrl(baseUrl: String): HttpClient {
return createClient(baseUrl)
}
}
@@ -1,164 +0,0 @@
package at.mocode.clients.shared.network
import at.mocode.clients.shared.domain.models.ApiError
import io.ktor.client.network.sockets.*
import io.ktor.client.plugins.*
import kotlinx.io.IOException
/**
* Custom exceptions for network operations
*/
sealed class NetworkException(
message: String,
cause: Throwable? = null,
val apiError: ApiError
) : Exception(message, cause) {
class ConnectionException(
message: String = "Connection failed",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "CONNECTION_ERROR",
message = message,
details = mapOf("type" to "network_connectivity")
)
)
class TimeoutException(
message: String = "Request timed out",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "TIMEOUT_ERROR",
message = message,
details = mapOf("type" to "request_timeout")
)
)
class ServerException(
statusCode: Int,
message: String = "Server error",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "SERVER_ERROR",
message = message,
details = mapOf(
"type" to "server_error",
"status_code" to statusCode.toString()
)
)
)
class ClientException(
statusCode: Int,
message: String = "Client error",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "CLIENT_ERROR",
message = message,
details = mapOf(
"type" to "client_error",
"status_code" to statusCode.toString()
)
)
)
class AuthenticationException(
message: String = "Authentication failed",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "AUTHENTICATION_ERROR",
message = message,
details = mapOf("type" to "authentication_failure")
)
)
class AuthorizationException(
message: String = "Authorization failed",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "AUTHORIZATION_ERROR",
message = message,
details = mapOf("type" to "authorization_failure")
)
)
class UnknownException(
message: String = "Unknown error occurred",
cause: Throwable? = null
) : NetworkException(
message = message,
cause = cause,
apiError = ApiError(
code = "UNKNOWN_ERROR",
message = message,
details = mapOf("type" to "unknown_error")
)
)
}
/**
* Extension function to convert various exceptions to NetworkException
*/
fun Throwable.toNetworkException(): NetworkException {
return when (this) {
is ConnectTimeoutException -> NetworkException.TimeoutException(
message = "Connection timeout: ${this.message}",
cause = this
)
is SocketTimeoutException -> NetworkException.TimeoutException(
message = "Socket timeout: ${this.message}",
cause = this
)
is ResponseException -> when (this.response.status.value) {
401 -> NetworkException.AuthenticationException(
message = "Authentication required",
cause = this
)
403 -> NetworkException.AuthorizationException(
message = "Access forbidden",
cause = this
)
in 400..499 -> NetworkException.ClientException(
statusCode = this.response.status.value,
message = "Client error: ${this.message}",
cause = this
)
in 500..599 -> NetworkException.ServerException(
statusCode = this.response.status.value,
message = "Server error: ${this.message}",
cause = this
)
else -> NetworkException.UnknownException(
message = "HTTP error: ${this.message}",
cause = this
)
}
is IOException -> NetworkException.ConnectionException(
message = "Network connection failed: ${this.message}",
cause = this
)
is NetworkException -> this
else -> NetworkException.UnknownException(
message = "Unexpected error: ${this.message}",
cause = this
)
}
}
@@ -1,217 +0,0 @@
package at.mocode.clients.shared.network
import at.mocode.clients.shared.data.repository.RepositoryResult
import at.mocode.clients.shared.domain.models.ApiError
import kotlinx.coroutines.delay
// Using platform-agnostic timestamp handling
/**
* Simple timestamp provider for multiplatform compatibility
*/
expect fun currentTimeMillis(): Long
/**
* Network utilities for handling retry logic and resilience
*/
object NetworkUtils {
/**
* Retry configuration for network operations
*/
data class RetryConfig(
val maxAttempts: Int = 3,
val initialDelayMs: Long = 1000L,
val maxDelayMs: Long = 10000L,
val backoffMultiplier: Double = 2.0,
val retryableExceptions: Set<String> = setOf(
"CONNECTION_ERROR",
"TIMEOUT_ERROR",
"SERVER_ERROR"
)
)
/**
* Execute operation with retry logic
*/
suspend fun <T> withRetry(
config: RetryConfig = RetryConfig(),
operation: suspend () -> RepositoryResult<T>
): RepositoryResult<T> {
var lastError: ApiError? = null
var currentDelay = config.initialDelayMs
repeat(config.maxAttempts) { attempt ->
try {
val result = operation()
// Return success immediately
if (result.isSuccess()) {
return result
}
// Check if the error is retryable
val error = result.getErrorOrNull()
if (error != null && shouldRetry(error, config)) {
lastError = error
// Don't delay on the last attempt
if (attempt < config.maxAttempts - 1) {
delay(currentDelay)
currentDelay = minOf(
(currentDelay * config.backoffMultiplier).toLong(),
config.maxDelayMs
)
}
} else {
// Non-retryable error, return immediately
return result
}
} catch (e: Exception) {
val networkException = e.toNetworkException()
lastError = networkException.apiError
if (shouldRetry(networkException.apiError, config)) {
if (attempt < config.maxAttempts - 1) {
delay(currentDelay)
currentDelay = minOf(
(currentDelay * config.backoffMultiplier).toLong(),
config.maxDelayMs
)
}
} else {
return RepositoryResult.Error(networkException.apiError)
}
}
}
// All attempts exhausted, return last error
return RepositoryResult.Error(
lastError ?: ApiError(
code = "MAX_RETRIES_EXCEEDED",
message = "Maximum retry attempts exceeded"
)
)
}
/**
* Check if an error should trigger a retry
*/
private fun shouldRetry(error: ApiError, config: RetryConfig): Boolean {
return config.retryableExceptions.contains(error.code)
}
/**
* Network connectivity checker (simplified for shared module)
*/
object ConnectivityChecker {
private var isOnline: Boolean = true
private var lastCheckMillis: Long = 0L
fun setOnlineStatus(online: Boolean) {
isOnline = online
lastCheckMillis = currentTimeMillis()
}
fun isOnline(): Boolean = isOnline
fun getLastCheckMillis(): Long = lastCheckMillis
/**
* Simple connectivity test by attempting a lightweight operation
*/
suspend fun checkConnectivity(testOperation: suspend () -> Boolean): Boolean {
return try {
val result = testOperation()
setOnlineStatus(result)
result
} catch (_: Exception) {
setOnlineStatus(false)
false
}
}
}
/**
* Circuit breaker pattern for network operations
*/
class CircuitBreaker(
private val failureThreshold: Int = 5,
private val recoveryTimeoutMs: Long = 60000L,
private val successThreshold: Int = 3
) {
private enum class State { CLOSED, OPEN, HALF_OPEN }
private var state = State.CLOSED
private var failureCount = 0
private var successCount = 0
private var lastFailureTime = 0L
suspend fun <T> execute(operation: suspend () -> RepositoryResult<T>): RepositoryResult<T> {
when (state) {
State.OPEN -> {
if (currentTimeMillis() - lastFailureTime >= recoveryTimeoutMs) {
state = State.HALF_OPEN
successCount = 0
} else {
return RepositoryResult.Error(
ApiError(
code = "CIRCUIT_BREAKER_OPEN",
message = "Circuit breaker is open, requests blocked"
)
)
}
}
State.HALF_OPEN -> {
// Allow limited requests to test recovery
}
State.CLOSED -> {
// Normal operation
}
}
return try {
val result = operation()
if (result.isSuccess()) {
onSuccess()
} else {
onFailure()
}
result
} catch (e: Exception) {
onFailure()
val networkException = e.toNetworkException()
RepositoryResult.Error(networkException.apiError)
}
}
private fun onSuccess() {
failureCount = 0
when (state) {
State.HALF_OPEN -> {
successCount++
if (successCount >= successThreshold) {
state = State.CLOSED
}
}
else -> {
state = State.CLOSED
}
}
}
private fun onFailure() {
failureCount++
lastFailureTime = currentTimeMillis()
if (failureCount >= failureThreshold) {
state = State.OPEN
}
}
fun getState(): String = state.name
fun getFailureCount(): Int = failureCount
}
}
@@ -1,36 +0,0 @@
package at.mocode.clients.shared.presentation.actions
import at.mocode.clients.shared.domain.models.User
import at.mocode.clients.shared.domain.models.AuthToken
sealed class AppAction {
// Auth Actions
sealed class Auth : AppAction() {
data class LoginStart(val username: String, val password: String) : Auth()
data class LoginSuccess(val user: User, val token: AuthToken) : Auth()
data class LoginFailure(val error: String) : Auth()
object Logout : Auth()
data class RefreshToken(val newToken: AuthToken) : Auth()
}
// Navigation Actions
sealed class Navigation : AppAction() {
data class NavigateTo(val route: String) : Navigation()
object NavigateBack : Navigation()
data class UpdateHistory(val route: String) : Navigation()
}
// UI Actions
sealed class UI : AppAction() {
object ToggleDarkMode : UI()
data class SetLoading(val isLoading: Boolean) : UI()
data class ShowNotification(val notification: at.mocode.clients.shared.presentation.state.Notification) : UI()
data class DismissNotification(val id: String) : UI()
}
// Network Actions
sealed class Network : AppAction() {
data class SetOnlineStatus(val isOnline: Boolean) : Network()
data class UpdateLastSync(val timestamp: String) : Network()
}
}
@@ -1,55 +0,0 @@
package at.mocode.clients.shared.presentation.state
import at.mocode.clients.shared.domain.models.User
import at.mocode.clients.shared.domain.models.AuthToken
import kotlinx.serialization.Serializable
@Serializable
data class AppState(
val auth: AuthState = AuthState(),
val navigation: NavigationState = NavigationState(),
val ui: UiState = UiState(),
val network: NetworkState = NetworkState()
)
@Serializable
data class AuthState(
val isAuthenticated: Boolean = false,
val user: User? = null,
val token: AuthToken? = null,
val isLoading: Boolean = false,
val error: String? = null
)
@Serializable
data class NavigationState(
val currentRoute: String = "/",
val history: List<String> = emptyList(),
val canGoBack: Boolean = false
)
@Serializable
data class UiState(
val isDarkMode: Boolean = false,
val isLoading: Boolean = false,
val notifications: List<Notification> = emptyList()
)
@Serializable
data class NetworkState(
val isOnline: Boolean = true,
val lastSync: String? = null
)
@Serializable
data class Notification(
val id: String,
val title: String,
val message: String,
val type: NotificationType = NotificationType.INFO,
val timestamp: String
)
enum class NotificationType {
INFO, SUCCESS, WARNING, ERROR
}
@@ -1,137 +0,0 @@
package at.mocode.clients.shared.presentation.store
import at.mocode.clients.shared.presentation.state.AppState
import at.mocode.clients.shared.presentation.actions.AppAction
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
class AppStore(
private val dispatcher: CoroutineDispatcher = Dispatchers.Main
) {
private val scope = CoroutineScope(SupervisorJob() + dispatcher)
private val _state = MutableStateFlow(AppState())
val state: StateFlow<AppState> = _state.asStateFlow()
fun dispatch(action: AppAction) {
scope.launch {
val currentState = _state.value
val newState = reduce(currentState, action)
_state.value = newState
// Handle side effects
handleSideEffect(action, newState)
}
}
private fun reduce(currentState: AppState, action: AppAction): AppState {
return when (action) {
is AppAction.Auth -> currentState.copy(
auth = reduceAuth(currentState.auth, action)
)
is AppAction.Navigation -> currentState.copy(
navigation = reduceNavigation(currentState.navigation, action)
)
is AppAction.UI -> currentState.copy(
ui = reduceUI(currentState.ui, action)
)
is AppAction.Network -> currentState.copy(
network = reduceNetwork(currentState.network, action)
)
}
}
private fun reduceAuth(currentAuth: at.mocode.clients.shared.presentation.state.AuthState, action: AppAction.Auth): at.mocode.clients.shared.presentation.state.AuthState {
return when (action) {
is AppAction.Auth.LoginStart -> currentAuth.copy(
isLoading = true,
error = null
)
is AppAction.Auth.LoginSuccess -> currentAuth.copy(
isAuthenticated = true,
user = action.user,
token = action.token,
isLoading = false,
error = null
)
is AppAction.Auth.LoginFailure -> currentAuth.copy(
isAuthenticated = false,
user = null,
token = null,
isLoading = false,
error = action.error
)
is AppAction.Auth.Logout -> at.mocode.clients.shared.presentation.state.AuthState()
is AppAction.Auth.RefreshToken -> currentAuth.copy(
token = action.newToken
)
}
}
private fun reduceNavigation(currentNav: at.mocode.clients.shared.presentation.state.NavigationState, action: AppAction.Navigation): at.mocode.clients.shared.presentation.state.NavigationState {
return when (action) {
is AppAction.Navigation.NavigateTo -> currentNav.copy(
currentRoute = action.route,
history = currentNav.history + currentNav.currentRoute,
canGoBack = true
)
is AppAction.Navigation.NavigateBack -> {
val newHistory = currentNav.history.dropLast(1)
currentNav.copy(
currentRoute = newHistory.lastOrNull() ?: "/",
history = newHistory,
canGoBack = newHistory.isNotEmpty()
)
}
is AppAction.Navigation.UpdateHistory -> currentNav.copy(
currentRoute = action.route
)
}
}
private fun reduceUI(currentUI: at.mocode.clients.shared.presentation.state.UiState, action: AppAction.UI): at.mocode.clients.shared.presentation.state.UiState {
return when (action) {
is AppAction.UI.ToggleDarkMode -> currentUI.copy(
isDarkMode = !currentUI.isDarkMode
)
is AppAction.UI.SetLoading -> currentUI.copy(
isLoading = action.isLoading
)
is AppAction.UI.ShowNotification -> currentUI.copy(
notifications = currentUI.notifications + action.notification
)
is AppAction.UI.DismissNotification -> currentUI.copy(
notifications = currentUI.notifications.filter { it.id != action.id }
)
}
}
private fun reduceNetwork(currentNetwork: at.mocode.clients.shared.presentation.state.NetworkState, action: AppAction.Network): at.mocode.clients.shared.presentation.state.NetworkState {
return when (action) {
is AppAction.Network.SetOnlineStatus -> currentNetwork.copy(
isOnline = action.isOnline
)
is AppAction.Network.UpdateLastSync -> currentNetwork.copy(
lastSync = action.timestamp
)
}
}
private suspend fun handleSideEffect(action: AppAction, newState: AppState) {
when (action) {
is AppAction.Auth.LoginSuccess -> {
// Auto-save token to local storage
// TODO: Implement storage
}
is AppAction.Auth.Logout -> {
// Clear local storage
// TODO: Implement storage cleanup
}
else -> { /* No side effects */ }
}
}
fun cleanup() {
scope.cancel()
}
}
@@ -1,69 +0,0 @@
package at.mocode.clients.shared.presentation.store
import at.mocode.clients.shared.domain.models.User
import at.mocode.clients.shared.domain.models.AuthToken
import at.mocode.clients.shared.presentation.actions.AppAction
import kotlinx.coroutines.Dispatchers
import kotlin.test.*
class AppStoreTest {
@Test
fun `store should be created successfully`() {
val store = AppStore(Dispatchers.Unconfined)
assertNotNull(store)
store.cleanup()
}
@Test
fun `auth actions should update state`() {
val store = AppStore(Dispatchers.Unconfined)
// Test login start action
store.dispatch(AppAction.Auth.LoginStart("testuser", "password"))
// Test login success
val user = User("1", "test", "test@example.com", "Test", "User")
val token = AuthToken("access", "refresh", 3600)
store.dispatch(AppAction.Auth.LoginSuccess(user, token))
// Test logout
store.dispatch(AppAction.Auth.Logout)
store.cleanup()
assertTrue(true) // Basic test to verify actions don't throw exceptions
}
@Test
fun `navigation actions should work`() {
val store = AppStore(Dispatchers.Unconfined)
store.dispatch(AppAction.Navigation.NavigateTo("/dashboard"))
store.dispatch(AppAction.Navigation.NavigateBack)
store.cleanup()
assertTrue(true)
}
@Test
fun `ui actions should work`() {
val store = AppStore(Dispatchers.Unconfined)
store.dispatch(AppAction.UI.ToggleDarkMode)
store.dispatch(AppAction.UI.SetLoading(true))
store.cleanup()
assertTrue(true)
}
@Test
fun `network actions should work`() {
val store = AppStore(Dispatchers.Unconfined)
store.dispatch(AppAction.Network.SetOnlineStatus(false))
store.dispatch(AppAction.Network.UpdateLastSync("2024-01-01T12:00:00Z"))
store.cleanup()
assertTrue(true)
}
}
@@ -1,18 +0,0 @@
package at.mocode.clients.shared.test
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
expect fun runBlockingTest(block: suspend () -> Unit)
abstract class BaseTest {
@BeforeTest
fun setupTest() {
// Set up a common test environment
}
@AfterTest
fun teardownTest() {
// Cleanup test environment
}
}
@@ -1,5 +0,0 @@
package at.mocode.clients.shared.network
import kotlin.js.Date
actual fun currentTimeMillis(): Long = Date.now().toLong()
@@ -1,10 +0,0 @@
package at.mocode.clients.shared.test
import kotlinx.coroutines.*
@OptIn(DelicateCoroutinesApi::class)
actual fun runBlockingTest(block: suspend () -> Unit) {
GlobalScope.promise {
block()
}
}
@@ -1,3 +0,0 @@
package at.mocode.clients.shared.network
actual fun currentTimeMillis(): Long = System.currentTimeMillis()
@@ -1,9 +0,0 @@
package at.mocode.clients.shared.test
import kotlinx.coroutines.test.*
actual fun runBlockingTest(block: suspend () -> Unit) {
runTest {
block()
}
}
@@ -1,11 +0,0 @@
package at.mocode.clients.shared.network
// WASM implementation using a simple counter-approach
// Since we don't have direct access to system time in WASM,
// we'll use a monotonic counter for relative timing
private var wasmTimeCounter: Long = 0L
actual fun currentTimeMillis(): Long {
wasmTimeCounter += 1
return wasmTimeCounter
}
@@ -1,11 +0,0 @@
package at.mocode.clients.shared.test
import kotlinx.coroutines.*
@OptIn(DelicateCoroutinesApi::class, ExperimentalWasmJsInterop::class)
actual fun runBlockingTest(block: suspend () -> Unit) {
// WASM-JS uses the same approach as regular JS
GlobalScope.promise {
block()
}
}
+37
View File
@@ -141,6 +141,43 @@ services:
networks: networks:
- meldestelle-network - meldestelle-network
# --- CLIENTS: WEB APP (Kotlin/JS, no WASM) ---
web-app:
build:
context: .
dockerfile: dockerfiles/clients/web-app/Dockerfile
args:
GRADLE_VERSION: 9.1.0
JAVA_VERSION: 21
NODE_VERSION: 22.21.0
NGINX_IMAGE_TAG: 1.28.0-alpine
WEB_BUILD_PROFILE: dev
container_name: meldestelle-web-app
restart: unless-stopped
ports:
- "4000:4000"
depends_on:
- api-gateway
networks:
- meldestelle-network
# --- CLIENTS: DESKTOP APP (VNC + noVNC) ---
desktop-app:
build:
context: .
dockerfile: dockerfiles/clients/desktop-app/Dockerfile
container_name: meldestelle-desktop-app
restart: unless-stopped
environment:
- API_BASE_URL=http://api-gateway:8081
ports:
- "5901:5901" # VNC
- "6080:6080" # noVNC
depends_on:
- api-gateway
networks:
- meldestelle-network
volumes: volumes:
postgres-data: postgres-data:
pgadmin-data: pgadmin-data:
+52 -8
View File
@@ -29,7 +29,7 @@ services:
- postgres - postgres
redis: redis:
image: redis:7-alpine image: redis:8.4-alpine
container_name: ${COMPOSE_PROJECT_NAME}-redis container_name: ${COMPOSE_PROJECT_NAME}-redis
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -105,7 +105,7 @@ services:
- pgadmin - pgadmin
prometheus: prometheus:
image: prom/prometheus:v2.54.1 image: prom/prometheus:v3.7.3
container_name: ${COMPOSE_PROJECT_NAME}-prometheus container_name: ${COMPOSE_PROJECT_NAME}-prometheus
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -128,7 +128,7 @@ services:
- prometheus - prometheus
grafana: grafana:
image: grafana/grafana:11.3.0 image: grafana/grafana:12.3
container_name: ${COMPOSE_PROJECT_NAME}-grafana container_name: ${COMPOSE_PROJECT_NAME}-grafana
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -157,7 +157,7 @@ services:
# ========================================== # ==========================================
consul: consul:
image: hashicorp/consul:1.15 image: hashicorp/consul:1.22.1
container_name: ${COMPOSE_PROJECT_NAME}-consul container_name: ${COMPOSE_PROJECT_NAME}-consul
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -182,7 +182,7 @@ services:
GRADLE_VERSION: 9.1.0 GRADLE_VERSION: 9.1.0
JAVA_VERSION: 21 JAVA_VERSION: 21
VERSION: 1.0.0 VERSION: 1.0.0
BUILD_DATE: "2025-11-25" BUILD_DATE: "2025-11-29"
container_name: ${COMPOSE_PROJECT_NAME}-gateway container_name: ${COMPOSE_PROJECT_NAME}-gateway
restart: no restart: no
ports: ports:
@@ -230,7 +230,7 @@ services:
GRADLE_VERSION: 9.1.0 GRADLE_VERSION: 9.1.0
JAVA_VERSION: 21 JAVA_VERSION: 21
VERSION: 1.0.0 VERSION: 1.0.0
BUILD_DATE: "2025-11-21" BUILD_DATE: "2025-11-29"
container_name: ${COMPOSE_PROJECT_NAME}-ping-service container_name: ${COMPOSE_PROJECT_NAME}-ping-service
restart: no # "${RESTART_POLICY:-unless-stopped}" restart: no # "${RESTART_POLICY:-unless-stopped}"
ports: ports:
@@ -246,7 +246,7 @@ services:
SPRING_CLOUD_CONSUL_PORT: 8500 SPRING_CLOUD_CONSUL_PORT: 8500
SPRING_CLOUD_CONSUL_DISCOVERY_HOSTNAME: ping-service SPRING_CLOUD_CONSUL_DISCOVERY_HOSTNAME: ping-service
# --- DATENBANK VERBINDUNG (Das hat gefehlt!) --- # --- DATENBANK VERBINDUNG ---
# Wir nutzen die Container-Namen aus deiner .env Variable # Wir nutzen die Container-Namen aus deiner .env Variable
SPRING_DATASOURCE_URL: jdbc:postgresql://${COMPOSE_PROJECT_NAME}-postgres:5432/${POSTGRES_DB} SPRING_DATASOURCE_URL: jdbc:postgresql://${COMPOSE_PROJECT_NAME}-postgres:5432/${POSTGRES_DB}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER} SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
@@ -254,7 +254,7 @@ services:
# WICHTIG: Wir wollen nur validieren, nichts erstellen. # WICHTIG: Wir wollen nur validieren, nichts erstellen.
SPRING_JPA_HIBERNATE_DDL_AUTO: validate SPRING_JPA_HIBERNATE_DDL_AUTO: validate
# --- REDIS (DAS HAT GEFEHLT!) --- # --- REDIS ---
# Wir nutzen den Service-Namen, genau wie bei Postgres # Wir nutzen den Service-Namen, genau wie bei Postgres
SPRING_DATA_REDIS_HOST: ${COMPOSE_PROJECT_NAME}-redis SPRING_DATA_REDIS_HOST: ${COMPOSE_PROJECT_NAME}-redis
SPRING_DATA_REDIS_PORT: 6379 SPRING_DATA_REDIS_PORT: 6379
@@ -270,6 +270,50 @@ services:
aliases: aliases:
- ping-service - ping-service
# ==========================================
# CLIENT APPLICATIONS
# ==========================================
web-app:
build:
context: .
dockerfile: dockerfiles/clients/web-app/Dockerfile
args:
GRADLE_VERSION: ${DOCKER_GRADLE_VERSION:-9.1.0}
JAVA_VERSION: ${DOCKER_JAVA_VERSION:-21}
NODE_VERSION: ${DOCKER_NODE_VERSION:-22.21.0}
NGINX_IMAGE_TAG: ${DOCKER_NGINX_VERSION:-1.28.0-alpine}
WEB_BUILD_PROFILE: ${WEB_BUILD_PROFILE:-dev}
container_name: ${COMPOSE_PROJECT_NAME}-web-app
restart: unless-stopped
ports:
- "${WEB_APP_PORT}"
depends_on:
api-gateway:
condition: service_started
networks:
meldestelle-network:
aliases:
- web-app
desktop-app:
build:
context: .
dockerfile: dockerfiles/clients/desktop-app/Dockerfile
container_name: ${COMPOSE_PROJECT_NAME}-desktop-app
restart: unless-stopped
environment:
API_BASE_URL: http://api-gateway:8081
ports:
- "${DESKTOP_APP_VNC_PORT}"
- "${DESKTOP_APP_NOVNC_PORT}"
depends_on:
api-gateway:
condition: service_started
networks:
meldestelle-network:
aliases:
- desktop-app
volumes: volumes:
postgres-data: postgres-data:
pgadmin-data: pgadmin-data:
-8
View File
@@ -1,8 +0,0 @@
# Optional Client Override – Web App
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn die Web-App abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# WEB_APP_PORT=4001
# NODE_ENV=development
# APP_TITLE=Meldestelle (Dev)
-8
View File
@@ -1,8 +0,0 @@
# Optional Infrastructure Override – API Gateway
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn das Gateway abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# GATEWAY_PORT=8081
# SPRING_PROFILES_ACTIVE=docker,keycloak
# LOGGING_LEVEL_ROOT=DEBUG
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override – Events Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Events-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8085
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override – Horses Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Horses-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8084
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override – Masterdata Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Masterdata-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8086
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override – Members Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Members-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8083
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-8
View File
@@ -1,8 +0,0 @@
# Optional Service Override – Ping Service
# Diese Datei wird zusätzlich zu config/env/.env geladen.
# Nur befüllen, wenn der Ping-Service abweichende Runtime-Werte benötigt.
# Beispiel-Overrides (auskommentiert lassen, falls nicht benötigt):
#
# SERVER_PORT=8082
# LOGGING_LEVEL_ROOT=DEBUG
# DEBUG=true
-1
View File
@@ -1 +0,0 @@
# Core\n\nMinimal placeholder README. See docs/index.md for project documentation
+2
View File
@@ -24,6 +24,8 @@ kotlin {
// Opt-in to experimental Kotlin UUID API across all source sets // Opt-in to experimental Kotlin UUID API across all source sets
all { all {
languageSettings.optIn("kotlin.uuid.ExperimentalUuidApi") languageSettings.optIn("kotlin.uuid.ExperimentalUuidApi")
// Opt-in für kotlin.time.ExperimentalTime projektweit, solange Teile noch experimentell sind
languageSettings.optIn("kotlin.time.ExperimentalTime")
} }
commonMain.dependencies { commonMain.dependencies {
@@ -1,11 +1,11 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.core.domain.event package at.mocode.core.domain.event
import at.mocode.core.domain.model.* import at.mocode.core.domain.model.*
import at.mocode.core.domain.serialization.KotlinInstantSerializer import at.mocode.core.domain.serialization.KotlinxInstantSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.Clock import kotlin.time.Clock as KtClock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant import kotlin.time.Instant
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@@ -13,7 +13,6 @@ import kotlin.uuid.Uuid
* Basis-Interface für alle Domain-Events im System. * Basis-Interface für alle Domain-Events im System.
* Ein Domain-Event beschreibt ein fachlich relevantes Ereignis, das stattgefunden hat. * Ein Domain-Event beschreibt ein fachlich relevantes Ereignis, das stattgefunden hat.
*/ */
@OptIn(ExperimentalTime::class)
interface DomainEvent { interface DomainEvent {
val eventId: EventId val eventId: EventId
val aggregateId: AggregateId val aggregateId: AggregateId
@@ -28,13 +27,13 @@ interface DomainEvent {
* Abstrakte Basisklasse für Domain-Events, um Boilerplate zu reduzieren. * Abstrakte Basisklasse für Domain-Events, um Boilerplate zu reduzieren.
*/ */
@Serializable @Serializable
@OptIn(ExperimentalTime::class)
abstract class BaseDomainEvent( abstract class BaseDomainEvent(
override val aggregateId: AggregateId, override val aggregateId: AggregateId,
override val eventType: EventType, override val eventType: EventType,
override val version: EventVersion, override val version: EventVersion,
override val eventId: EventId = EventId(Uuid.random()), override val eventId: EventId = EventId(Uuid.random()),
@Serializable(with = KotlinInstantSerializer::class)
@Serializable(with = KotlinxInstantSerializer::class)
override val timestamp: Instant, override val timestamp: Instant,
override val correlationId: CorrelationId? = null, override val correlationId: CorrelationId? = null,
override val causationId: CausationId? = null override val causationId: CausationId? = null
@@ -58,8 +57,7 @@ abstract class BaseDomainEvent(
) )
companion object { companion object {
@OptIn(ExperimentalTime::class) private fun createTimestamp(): Instant = Instant.parse(KtClock.System.now().toString())
private fun createTimestamp(): Instant = Clock.System.now()
} }
} }
@@ -1,6 +1,6 @@
package at.mocode.core.domain.model package at.mocode.core.domain.model
import at.mocode.core.domain.serialization.KotlinInstantSerializer import at.mocode.core.domain.serialization.KotlinxInstantSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@@ -15,14 +15,13 @@ interface BaseDto
* Basis-DTO für Domänen-Entitäten mit eindeutiger ID und Audit-Zeitstempeln. * Basis-DTO für Domänen-Entitäten mit eindeutiger ID und Audit-Zeitstempeln.
*/ */
@Serializable @Serializable
@OptIn(ExperimentalTime::class)
abstract class EntityDto : BaseDto { abstract class EntityDto : BaseDto {
abstract val id: EntityId abstract val id: EntityId
@Serializable(with = KotlinInstantSerializer::class) @Serializable(with = KotlinxInstantSerializer::class)
abstract val createdAt: Instant abstract val createdAt: Instant
@Serializable(with = KotlinInstantSerializer::class) @Serializable(with = KotlinxInstantSerializer::class)
abstract val updatedAt: Instant abstract val updatedAt: Instant
} }
@@ -40,19 +39,21 @@ data class ErrorDto(
* Standardisierte Hülle für API-Antworten mit einheitlicher Struktur. * Standardisierte Hülle für API-Antworten mit einheitlicher Struktur.
*/ */
@Serializable @Serializable
@OptIn(ExperimentalTime::class)
data class ApiResponse<T>( data class ApiResponse<T>(
val data: T?, val data: T?,
val success: Boolean, val success: Boolean,
val errors: List<ErrorDto> = emptyList(), val errors: List<ErrorDto> = emptyList(),
@Serializable(with = KotlinInstantSerializer::class) @Serializable(with = KotlinxInstantSerializer::class)
val timestamp: Instant val timestamp: Instant
) { ) {
companion object { companion object {
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
fun <T> success(data: T): ApiResponse<T> { fun <T> success(data: T): ApiResponse<T> =
return ApiResponse(data = data, success = true, timestamp = Clock.System.now()) ApiResponse(
} data = data,
success = true,
timestamp = Instant.parse(Clock.System.now().toString())
)
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
fun <T> error( fun <T> error(
@@ -64,11 +65,10 @@ data class ApiResponse<T>(
data = null, data = null,
success = false, success = false,
errors = listOf(ErrorDto(code = code, message = message, field = field)), errors = listOf(ErrorDto(code = code, message = message, field = field)),
timestamp = Clock.System.now() timestamp = Instant.parse(Clock.System.now().toString())
) )
} }
@OptIn(ExperimentalTime::class)
fun <T> error( fun <T> error(
code: String, code: String,
message: String, message: String,
@@ -79,7 +79,12 @@ data class ApiResponse<T>(
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
fun <T> error(errors: List<ErrorDto>): ApiResponse<T> { fun <T> error(errors: List<ErrorDto>): ApiResponse<T> {
return ApiResponse(data = null, success = false, errors = errors, timestamp = Clock.System.now()) return ApiResponse(
data = null,
success = false,
errors = errors,
timestamp = Instant.parse(Clock.System.now().toString())
)
} }
} }
} }
@@ -0,0 +1,16 @@
package at.mocode.core.domain.model
/**
* Zentrale Sammlung der standardisierten Fehlercodes der Anwendung.
* Dient als Single-Source-of-Truth, um Inkonsistenzen zu vermeiden.
*/
object ErrorCodes {
val DUPLICATE_ENTRY = ErrorCode("DUPLICATE_ENTRY")
val CONSTRAINT_VIOLATION = ErrorCode("CONSTRAINT_VIOLATION")
val FOREIGN_KEY_VIOLATION = ErrorCode("FOREIGN_KEY_VIOLATION")
val CHECK_VIOLATION = ErrorCode("CHECK_VIOLATION")
val DATABASE_TIMEOUT = ErrorCode("DATABASE_TIMEOUT")
val DATABASE_ERROR = ErrorCode("DATABASE_ERROR")
val TRANSACTION_ERROR = ErrorCode("TRANSACTION_ERROR")
val VALIDATION_ERROR = ErrorCode("VALIDATION_ERROR")
}
@@ -1,4 +1,5 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.core.domain.model package at.mocode.core.domain.model
import at.mocode.core.domain.serialization.UuidSerializer import at.mocode.core.domain.serialization.UuidSerializer
@@ -1,7 +1,6 @@
@file:OptIn(kotlin.time.ExperimentalTime::class)
package at.mocode.core.domain.serialization package at.mocode.core.domain.serialization
import kotlinx.datetime.Instant import kotlin.time.Instant
import kotlinx.serialization.KSerializer import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
@@ -10,7 +9,7 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.encoding.Encoder
/** /**
* Serializer for kotlinx.datetime.Instant. * Serializer for kotlin.time.Instant.
* Uses ISO-8601 string representation. * Uses ISO-8601 string representation.
*/ */
object KotlinxInstantSerializer : KSerializer<Instant> { object KotlinxInstantSerializer : KSerializer<Instant> {
@@ -1,4 +1,5 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class) @file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.core.utils package at.mocode.core.utils
import at.mocode.core.domain.model.* import at.mocode.core.domain.model.*
@@ -147,10 +147,14 @@ sealed class Result<out T> {
is Failure -> try { is Failure -> try {
Success(transform(errors)) Success(transform(errors))
} catch (e: Exception) { } catch (e: Exception) {
Failure(listOf(ErrorDto( Failure(
listOf(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("RECOVERY_FAILED"), code = at.mocode.core.domain.model.ErrorCode("RECOVERY_FAILED"),
message = e.message ?: "Recovery failed with an unknown error" message = e.message ?: "Recovery failed with an unknown error"
))) )
)
)
} }
} }
@@ -241,41 +245,55 @@ sealed class Result<out T> {
inline fun <T> runCatching(operation: () -> T): Result<T> = try { inline fun <T> runCatching(operation: () -> T): Result<T> = try {
success(operation()) success(operation())
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
failure(ErrorDto( failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("INVALID_ARGUMENT"), code = at.mocode.core.domain.model.ErrorCode("INVALID_ARGUMENT"),
message = e.message ?: "Invalid argument provided" message = e.message ?: "Invalid argument provided"
)) )
)
} catch (e: IllegalStateException) { } catch (e: IllegalStateException) {
failure(ErrorDto( failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("INVALID_STATE"), code = at.mocode.core.domain.model.ErrorCode("INVALID_STATE"),
message = e.message ?: "Operation called in invalid state" message = e.message ?: "Operation called in invalid state"
)) )
)
} catch (e: UnsupportedOperationException) { } catch (e: UnsupportedOperationException) {
failure(ErrorDto( failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("UNSUPPORTED_OPERATION"), code = at.mocode.core.domain.model.ErrorCode("UNSUPPORTED_OPERATION"),
message = e.message ?: "Operation not supported" message = e.message ?: "Operation not supported"
)) )
)
} catch (e: IndexOutOfBoundsException) { } catch (e: IndexOutOfBoundsException) {
failure(ErrorDto( failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("INDEX_OUT_OF_BOUNDS"), code = at.mocode.core.domain.model.ErrorCode("INDEX_OUT_OF_BOUNDS"),
message = e.message ?: "Index out of bounds" message = e.message ?: "Index out of bounds"
)) )
)
} catch (e: NullPointerException) { } catch (e: NullPointerException) {
failure(ErrorDto( failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("NULL_REFERENCE"), code = at.mocode.core.domain.model.ErrorCode("NULL_REFERENCE"),
message = e.message ?: "Unexpected null reference" message = e.message ?: "Unexpected null reference"
)) )
)
} catch (e: ClassCastException) { } catch (e: ClassCastException) {
failure(ErrorDto( failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("TYPE_MISMATCH"), code = at.mocode.core.domain.model.ErrorCode("TYPE_MISMATCH"),
message = e.message ?: "Type mismatch occurred" message = e.message ?: "Type mismatch occurred"
)) )
)
} catch (e: Exception) { } catch (e: Exception) {
// Fallback for any other exception type // Fallback for any other exception type
failure(ErrorDto( failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("OPERATION_FAILED"), code = at.mocode.core.domain.model.ErrorCode("OPERATION_FAILED"),
message = e.message ?: "Unknown error occurred" message = e.message ?: "Unknown error occurred"
)) )
)
} }
/** /**
@@ -331,8 +349,10 @@ fun <T> T?.toResult(errorMessage: String = "Value is null"): Result<T> =
if (this != null) { if (this != null) {
Result.success(this) Result.success(this)
} else { } else {
Result.failure(ErrorDto( Result.failure(
ErrorDto(
code = at.mocode.core.domain.model.ErrorCode("NULL_VALUE"), code = at.mocode.core.domain.model.ErrorCode("NULL_VALUE"),
message = errorMessage message = errorMessage
)) )
)
} }
@@ -89,7 +89,7 @@ object ValidationRules {
*/ */
fun minLength(min: Int): ValidationRule<String> = ValidationRule { fieldName, value -> fun minLength(min: Int): ValidationRule<String> = ValidationRule { fieldName, value ->
if (value.length < min) { if (value.length < min) {
ValidationError.invalidLength(fieldName, "$fieldName must be at least $min characters long") ValidationError.invalidLength(fieldName, "$fieldName muss mindestens $min Zeichen lang sein")
} else null } else null
} }
@@ -98,7 +98,7 @@ object ValidationRules {
*/ */
fun maxLength(max: Int): ValidationRule<String> = ValidationRule { fieldName, value -> fun maxLength(max: Int): ValidationRule<String> = ValidationRule { fieldName, value ->
if (value.length > max) { if (value.length > max) {
ValidationError.invalidLength(fieldName, "$fieldName must not exceed $max characters") ValidationError.invalidLength(fieldName, "$fieldName darf $max Zeichen nicht überschreiten")
} else null } else null
} }
@@ -117,7 +117,7 @@ object ValidationRules {
fun email(): ValidationRule<String> = ValidationRule { fieldName, value -> fun email(): ValidationRule<String> = ValidationRule { fieldName, value ->
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
if (!value.matches(emailRegex)) { if (!value.matches(emailRegex)) {
ValidationError.invalidFormat(fieldName, "$fieldName must be a valid email address") ValidationError.invalidFormat(fieldName, "$fieldName muss eine gültige E-Mail-Adresse sein")
} else null } else null
} }
@@ -128,7 +128,7 @@ object ValidationRules {
*/ */
fun <T : Comparable<T>> min(minValue: T): ValidationRule<T> = ValidationRule { fieldName, value -> fun <T : Comparable<T>> min(minValue: T): ValidationRule<T> = ValidationRule { fieldName, value ->
if (value < minValue) { if (value < minValue) {
ValidationError.invalidRange(fieldName, "$fieldName must be at least $minValue") ValidationError.invalidRange(fieldName, "$fieldName muss mindestens $minValue sein")
} else null } else null
} }
@@ -137,7 +137,7 @@ object ValidationRules {
*/ */
fun <T : Comparable<T>> max(maxValue: T): ValidationRule<T> = ValidationRule { fieldName, value -> fun <T : Comparable<T>> max(maxValue: T): ValidationRule<T> = ValidationRule { fieldName, value ->
if (value > maxValue) { if (value > maxValue) {
ValidationError.invalidRange(fieldName, "$fieldName must not exceed $maxValue") ValidationError.invalidRange(fieldName, "$fieldName darf $maxValue nicht überschreiten")
} else null } else null
} }
@@ -146,7 +146,7 @@ object ValidationRules {
*/ */
fun positive(): ValidationRule<Number> = ValidationRule { fieldName, value -> fun positive(): ValidationRule<Number> = ValidationRule { fieldName, value ->
if (value.toDouble() <= 0) { if (value.toDouble() <= 0) {
ValidationError.invalidRange(fieldName, "$fieldName must be positive") ValidationError.invalidRange(fieldName, "$fieldName muss positiv sein")
} else null } else null
} }
@@ -155,7 +155,7 @@ object ValidationRules {
*/ */
fun nonNegative(): ValidationRule<Number> = ValidationRule { fieldName, value -> fun nonNegative(): ValidationRule<Number> = ValidationRule { fieldName, value ->
if (value.toDouble() < 0) { if (value.toDouble() < 0) {
ValidationError.invalidRange(fieldName, "$fieldName must not be negative") ValidationError.invalidRange(fieldName, "$fieldName darf nicht negativ sein")
} else null } else null
} }
@@ -175,7 +175,7 @@ object ValidationRules {
*/ */
fun <T> minSize(min: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value -> fun <T> minSize(min: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
if (value.size < min) { if (value.size < min) {
ValidationError.invalidLength(fieldName, "$fieldName must contain at least $min items") ValidationError.invalidLength(fieldName, "$fieldName muss mindestens $min Elemente enthalten")
} else null } else null
} }
@@ -184,7 +184,7 @@ object ValidationRules {
*/ */
fun <T> maxSize(max: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value -> fun <T> maxSize(max: Int): ValidationRule<Collection<T>> = ValidationRule { fieldName, value ->
if (value.size > max) { if (value.size > max) {
ValidationError.invalidLength(fieldName, "$fieldName must not contain more than $max items") ValidationError.invalidLength(fieldName, "$fieldName darf nicht mehr als $max Elemente enthalten")
} else null } else null
} }
@@ -219,6 +219,6 @@ fun String?.validateEmail(fieldName: String): ValidationError? {
if (this.isNullOrBlank()) return ValidationError.required(fieldName) if (this.isNullOrBlank()) return ValidationError.required(fieldName)
val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") val emailRegex = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
return if (!this.matches(emailRegex)) { return if (!this.matches(emailRegex)) {
ValidationError.invalidFormat(fieldName, "$fieldName must be a valid email address") ValidationError.invalidFormat(fieldName, "$fieldName muss eine gültige E-Mail-Adresse sein")
} else null } else null
} }
@@ -50,7 +50,8 @@ class ResultTest {
assertTrue(combined is Result.Success) assertTrue(combined is Result.Success)
assertEquals(listOf(1, 2), (combined as Result.Success).value) assertEquals(listOf(1, 2), (combined as Result.Success).value)
val combinedFail = Result.combine(listOf(f1 as Result<Int>, Result.success(3), Result.failure(ErrorDto(ErrorCode("E3"), "")))) val combinedFail =
Result.combine(listOf(f1 as Result<Int>, Result.success(3), Result.failure(ErrorDto(ErrorCode("E3"), ""))))
assertTrue(combinedFail is Result.Failure) assertTrue(combinedFail is Result.Failure)
assertEquals(2, (combinedFail as Result.Failure).errors.size) assertEquals(2, (combinedFail as Result.Failure).errors.size)
} }
@@ -75,7 +76,8 @@ class ResultTest {
val rec = Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recover { _ -> "fallback" } val rec = Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recover { _ -> "fallback" }
assertTrue(rec is Result.Success) assertTrue(rec is Result.Success)
val recFail = Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recoverCatching { _ -> throw IllegalStateException("boom") } val recFail =
Result.failure<String>(ErrorDto(ErrorCode("E"), "")).recoverCatching { _ -> throw IllegalStateException("boom") }
assertTrue(recFail is Result.Failure) assertTrue(recFail is Result.Failure)
assertEquals("RECOVERY_FAILED", (recFail as Result.Failure).errors.first().code.value) assertEquals("RECOVERY_FAILED", (recFail as Result.Failure).errors.first().code.value)
} }
@@ -1,12 +1,13 @@
package at.mocode.core.utils package at.mocode.core.utils
import at.mocode.core.domain.model.ErrorCode import at.mocode.core.domain.model.ErrorCodes
import at.mocode.core.domain.model.ErrorDto import at.mocode.core.domain.model.ErrorDto
import at.mocode.core.domain.model.PagedResponse import at.mocode.core.domain.model.PagedResponse
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.statements.BatchInsertStatement import org.jetbrains.exposed.sql.statements.BatchInsertStatement
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import java.sql.SQLException import java.sql.SQLException
import java.sql.SQLTimeoutException
/** /**
* JVM-specific database utilities for the Core module. * JVM-specific database utilities for the Core module.
@@ -28,26 +29,36 @@ inline fun <T> transactionResult(
return try { return try {
val result = transaction(database) { block() } val result = transaction(database) { block() }
Result.success(result) Result.success(result)
} catch (e: SQLTimeoutException) {
Result.failure(
ErrorDto(
code = ErrorCodes.DATABASE_TIMEOUT,
message = "Datenbank-Operation wegen Timeout fehlgeschlagen"
)
)
} catch (e: SQLException) { } catch (e: SQLException) {
// Handle specific SQL exceptions // Robustere Fehlerbehandlung über SQLSTATE (Postgres)
val errorCode = when { val mapped = when (e.sqlState) {
e.message?.contains("constraint", ignoreCase = true) == true -> "CONSTRAINT_VIOLATION" // unique_violation
e.message?.contains("duplicate", ignoreCase = true) == true -> "DUPLICATE_ENTRY" "23505" -> ErrorCodes.DUPLICATE_ENTRY
e.message?.contains("timeout", ignoreCase = true) == true -> "DATABASE_TIMEOUT" // foreign_key_violation
else -> "DATABASE_ERROR" "23503" -> ErrorCodes.FOREIGN_KEY_VIOLATION
// check_violation
"23514" -> ErrorCodes.CHECK_VIOLATION
else -> ErrorCodes.DATABASE_ERROR
} }
Result.failure( Result.failure(
ErrorDto( ErrorDto(
code = ErrorCode(errorCode), code = mapped,
message = "Database operation failed: ${e.message}" message = "Datenbank-Operation fehlgeschlagen"
) )
) )
} catch (e: Exception) { } catch (e: Exception) {
Result.failure( Result.failure(
ErrorDto( ErrorDto(
code = ErrorCode("TRANSACTION_ERROR"), code = ErrorCodes.TRANSACTION_ERROR,
message = "Transaction failed: ${e.message ?: "Unknown error"}" message = "Transaktion fehlgeschlagen"
) )
) )
} }
@@ -145,10 +156,12 @@ object DatabaseUtils {
fun tableExists(tableName: String, database: Database? = null): Boolean { fun tableExists(tableName: String, database: Database? = null): Boolean {
return try { return try {
transaction(database) { transaction(database) {
// Execute a safer SQL statement to check if table exists // Postgres-spezifischer, robuster Ansatz über to_regclass
val result = exec("SELECT 1 FROM information_schema.tables WHERE table_name = '$tableName' LIMIT 1") val valid = tableName.trim()
// If the query returns a result, the table exists if (!valid.matches(Regex("^[A-Za-z_][A-Za-z0-9_]*$"))) return@transaction false
result != null exec("SELECT to_regclass('$valid')") { rs ->
if (rs.next()) rs.getString(1) else null
} != null
} }
} catch (e: Exception) { } catch (e: Exception) {
false false
@@ -158,28 +171,55 @@ object DatabaseUtils {
/** /**
* Creates an index if it doesn't exist. * Creates an index if it doesn't exist.
*/ */
@JvmName("createIndexIfNotExistsArray")
fun createIndexIfNotExists( fun createIndexIfNotExists(
tableName: String, tableName: String,
indexName: String, indexName: String,
columns: Array<String>, columns: Array<String>,
unique: Boolean = false, unique: Boolean = false,
database: Database? = null database: Database? = null
): Result<Unit> = createIndexIfNotExists(tableName, indexName, *columns, unique = unique, database = database)
@JvmName("createIndexIfNotExistsVararg")
fun createIndexIfNotExists(
tableName: String,
indexName: String,
vararg columns: String,
unique: Boolean = false,
database: Database? = null
): Result<Unit> { ): Result<Unit> {
return transactionResult(database) { return transactionResult(database) {
// Einfache Sanitization + Quoting der Identifier
fun quoteIdent(name: String): String {
require(name.matches(Regex("^[A-Za-z_][A-Za-z0-9_]*$"))) { "Ungültiger Identifier: $name" }
return "\"$name\""
}
val uniqueStr = if (unique) "UNIQUE" else "" val uniqueStr = if (unique) "UNIQUE" else ""
val columnsStr = columns.joinToString(", ") val qTable = quoteIdent(tableName)
val sql = "CREATE $uniqueStr INDEX IF NOT EXISTS $indexName ON $tableName ($columnsStr)" val qIndex = quoteIdent(indexName)
val cols = columns.map { quoteIdent(it) }.joinToString(", ")
val sql = "CREATE $uniqueStr INDEX IF NOT EXISTS $qIndex ON $qTable ($cols)"
exec(sql) exec(sql)
Unit
} }
} }
/** /**
* Executes a raw SQL query and returns the number of affected rows. * Führt ein beliebiges SQL-Statement aus (DDL/DML). Liefert keinen Update-Count zurück.
*/ */
fun executeRawSql(sql: String, database: Database? = null): Result<Int> { fun executeRawSql(sql: String, database: Database? = null): Result<Unit> = transactionResult(database) {
return transactionResult(database) { exec(sql)
(exec(sql) ?: 0) as Int Unit
} }
/**
* Executes a raw SQL update statement and returns affected rows.
*/
fun executeUpdate(sql: String, database: Database? = null): Result<Int> = transactionResult(database) {
// Nutzt Exposed PreparedStatementApi, kein AutoCloseable
val ps = this.connection.prepareStatement(sql, false)
ps.executeUpdate()
} }
/** /**
@@ -182,10 +182,12 @@
"implicitFlowEnabled": false, "implicitFlowEnabled": false,
"directAccessGrantsEnabled": false, "directAccessGrantsEnabled": false,
"redirectUris": [ "redirectUris": [
"http://localhost:4000/*",
"http://localhost:3000/*", "http://localhost:3000/*",
"https://app.meldestelle.at/*" "https://app.meldestelle.at/*"
], ],
"webOrigins": [ "webOrigins": [
"http://localhost:4000",
"http://localhost:3000", "http://localhost:3000",
"https://app.meldestelle.at" "https://app.meldestelle.at"
], ],
@@ -17,6 +17,7 @@ COPY gradlew ./
# Kopiere alle notwendigen Module für Multi-Modul-Projekt # Kopiere alle notwendigen Module für Multi-Modul-Projekt
COPY clients ./clients COPY clients ./clients
COPY core ./core COPY core ./core
COPY domains ./domains
COPY platform ./platform COPY platform ./platform
COPY infrastructure ./infrastructure COPY infrastructure ./infrastructure
COPY services ./services COPY services ./services
+20 -6
View File
@@ -9,6 +9,9 @@
ARG GRADLE_VERSION ARG GRADLE_VERSION
ARG JAVA_VERSION ARG JAVA_VERSION
ARG NODE_VERSION ARG NODE_VERSION
ARG NGINX_IMAGE_TAG=1.28.0-alpine
# Toggle build profile: dev (default) or prod
ARG WEB_BUILD_PROFILE=dev
FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION} AS builder FROM gradle:${GRADLE_VERSION}-jdk${JAVA_VERSION} AS builder
# Install Node.js (version aligned with versions.toml) # Install Node.js (version aligned with versions.toml)
@@ -29,6 +32,7 @@ COPY gradlew ./
# Kopiere alle notwendigen Module für Multi-Modul-Projekt # Kopiere alle notwendigen Module für Multi-Modul-Projekt
COPY clients ./clients COPY clients ./clients
COPY core ./core COPY core ./core
COPY domains ./domains
COPY platform ./platform COPY platform ./platform
COPY infrastructure ./infrastructure COPY infrastructure ./infrastructure
COPY services ./services COPY services ./services
@@ -40,21 +44,28 @@ RUN chmod +x ./gradlew
# Dependencies downloaden (für besseres Caching) # Dependencies downloaden (für besseres Caching)
RUN ./gradlew :clients:app:dependencies --no-configure-on-demand RUN ./gradlew :clients:app:dependencies --no-configure-on-demand
# Kotlin/JS Web-App kompilieren (PRODUCTION Build) # Kotlin/JS Web-App kompilieren (Profil wählbar über WEB_BUILD_PROFILE)
RUN ./gradlew :clients:app:jsBrowserDistribution --no-configure-on-demand -Pproduction=true # - dev → jsBrowserDevelopmentExecutable (schneller, Source Maps)
# - prod → jsBrowserDistribution (minifiziert, optimiert)
RUN if [ "$WEB_BUILD_PROFILE" = "prod" ]; then \
./gradlew :clients:app:jsBrowserDistribution --no-configure-on-demand -Pproduction=true; \
mkdir -p /app/web-dist && cp -r clients/app/build/dist/js/productionExecutable/* /app/web-dist/; \
else \
./gradlew :clients:app:jsBrowserDevelopmentExecutable --no-configure-on-demand; \
mkdir -p /app/web-dist && cp -r clients/app/build/dist/js/developmentExecutable/* /app/web-dist/; \
fi
# =================================================================== # ===================================================================
# Stage 2: Runtime Stage - Nginx für Static Files + API Proxy # Stage 2: Runtime Stage - Nginx für Static Files + API Proxy
# =================================================================== # ===================================================================
# Build arg controls runtime base image tag (build-time only) # Build arg controls runtime base image tag (declared globally to allow usage in FROM)
ARG NGINX_IMAGE_TAG
FROM nginx:${NGINX_IMAGE_TAG} FROM nginx:${NGINX_IMAGE_TAG}
# Installiere curl für Health-Checks # Installiere curl für Health-Checks
RUN apk add --no-cache curl RUN apk add --no-cache curl
# Kopiere kompilierte Web-App von Build-Stage # Kopiere kompilierte Web-App von Build-Stage (vereinheitlichtes Ausgabeverzeichnis)
COPY --from=builder /app/clients/app/build/dist/js/productionExecutable/ /usr/share/nginx/html/ COPY --from=builder /app/web-dist/ /usr/share/nginx/html/
# Kopiere Nginx-Konfiguration # Kopiere Nginx-Konfiguration
COPY dockerfiles/clients/web-app/nginx.conf /etc/nginx/nginx.conf COPY dockerfiles/clients/web-app/nginx.conf /etc/nginx/nginx.conf
@@ -62,6 +73,9 @@ COPY dockerfiles/clients/web-app/nginx.conf /etc/nginx/nginx.conf
# Exponiere Port 4000 (statt Standard 80) # Exponiere Port 4000 (statt Standard 80)
EXPOSE 4000 EXPOSE 4000
# Downloads (Platzhalter) ausliefern lassen
COPY dockerfiles/clients/web-app/downloads/ /usr/share/nginx/html/downloads/
# Health-Check für Container # Health-Check für Container
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:4000/ || exit 1 CMD curl -f http://localhost:4000/ || exit 1
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Meldestelle – Desktop Downloads (Platzhalter)</title>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Liberation Sans', sans-serif; margin: 2rem; }
h1 { margin-bottom: .25rem; }
.muted { color: #666; }
ul { line-height: 1.8; }
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem 1.25rem; max-width: 720px; }
</style>
</head>
<body>
<h1>Desktop Downloads</h1>
<p class="muted">Platzhalter-Verzeichnis. Hier können zukünftig Installer/Archive der Desktop-App bereitgestellt werden.</p>
<div class="card">
<p>Lege deine Dateien in dieses Verzeichnis im Repository:</p>
<pre><code>dockerfiles/clients/web-app/downloads/</code></pre>
<p>Oder mounte in Docker Compose ein Host-Verzeichnis auf <code>/usr/share/nginx/html/downloads</code>.</p>
<p>Beispiele (geplant):</p>
<ul>
<li>Meldestelle-Setup-1.0.0.msi (Windows)</li>
<li>Meldestelle-1.0.0.dmg (macOS)</li>
<li>Meldestelle-1.0.0.deb (Linux)</li>
</ul>
</div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More