refactoring:

Ein Backend-UseCase wurde implementiert, der nach Login prüft, ob ein Member-Profil existiert, und bei Bedarf ein neues Profil mit OEPS-Daten anlegt. Ein API-Endpunkt /api/members/sync wurde hinzugefügt, der vom Frontend nach Login aufgerufen wird. Der Gesamt-Build und die Tests laufen erfolgreich ohne Fehler.
This commit is contained in:
2025-11-24 22:27:39 +01:00
parent a6a35a2eda
commit 45109b5f9d
4 changed files with 156 additions and 0 deletions
@@ -2,10 +2,14 @@ package at.mocode.clients.authfeature
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.mocode.clients.shared.AppConfig
import io.ktor.client.call.*
import io.ktor.client.request.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import at.mocode.clients.authfeature.AuthenticatedHttpClient.addAuthHeader
/**
* UI state for the login screen
@@ -89,6 +93,18 @@ class LoginViewModel(
isAuthenticated = true,
errorMessage = null
)
// Fire-and-forget: Trigger Backend Sync so the user exists in Members
viewModelScope.launch {
try {
val client = AuthenticatedHttpClient.create()
client.post("${AppConfig.GATEWAY_URL}/api/members/sync") {
addAuthHeader()
}
} catch (_: Exception) {
// Non-fatal: Wir zeigen Sync-Fehler im Login nicht an
}
}
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
@@ -24,6 +24,9 @@ dependencies {
implementation("org.springframework:spring-web")
implementation("org.springdoc:springdoc-openapi-starter-common")
// Security/JWT for extracting claims from principal
implementation(libs.spring.boot.starter.security)
implementation(libs.spring.boot.starter.oauth2.resource.server)
testImplementation(projects.platform.platformTesting)
}
@@ -17,6 +17,8 @@ import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerApiResponse
/**
@@ -55,6 +57,7 @@ class MemberController(
private val findExpiringMembershipsUseCase = FindExpiringMembershipsUseCase(memberRepository)
private val findMembersByDateRangeUseCase = FindMembersByDateRangeUseCase(memberRepository)
private val validateMemberDataUseCase = ValidateMemberDataUseCase(memberRepository)
private val ensureMemberProfileExistsUseCase = EnsureMemberProfileExistsUseCase(memberRepository, eventPublisher)
/**
* Hilfsmethode zur Behandlung gemeinsamer Antwortmuster für Use-Case-Ausführung
@@ -88,6 +91,33 @@ class MemberController(
}
}
// ---------------------------------------------------------------------
// Synchronisation nach Login: Stellt sicher, dass Member-Profil existiert
// ---------------------------------------------------------------------
@Operation(
summary = "Synchronisiert das Member-Profil für den eingeloggten Benutzer",
description = "Erstellt bei Bedarf ein Mitglied basierend auf den JWT-Claims (mock OEPS fetch)"
)
@PostMapping("/sync")
fun syncMemberProfile(
@AuthenticationPrincipal jwt: Jwt
): ResponseEntity<ApiResponse<*>> = handleUseCaseExecution(
operation = {
val sub = jwt.subject
val email = jwt.getClaimAsString("email")
val username = jwt.getClaimAsString("preferred_username")
val response = ensureMemberProfileExistsUseCase.execute(
EnsureMemberProfileExistsUseCase.Request(
userSub = sub,
email = email,
username = username
)
)
ApiResponse.success(response)
},
successStatus = HttpStatus.OK
) { it }
/**
* Hilfsmethode zur Behandlung von Repository-Operationen mit gemeinsamer Fehlerbehandlung
*/
@@ -0,0 +1,107 @@
@file:OptIn(kotlin.uuid.ExperimentalUuidApi::class)
package at.mocode.members.application.usecase
import at.mocode.infrastructure.messaging.client.EventPublisher
import at.mocode.members.domain.events.MemberCreatedEvent
import at.mocode.members.domain.model.Member
import at.mocode.members.domain.repository.MemberRepository
import kotlinx.datetime.Clock
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import kotlin.uuid.Uuid
/**
* UseCase: Stellt sicher, dass für den authentifizierten Benutzer ein Member-Profil existiert.
*
* Ablauf:
* 1) Prüft, ob ein Member zu E-Mail existiert (Fallback: username-basiert via Dummy-E-Mail)
* 2) Wenn nicht vorhanden: erstellt minimalen Member-Datensatz (Mock für OEPS-Datenbezug)
* 3) Publiziert MemberCreatedEvent
*/
class EnsureMemberProfileExistsUseCase(
private val memberRepository: MemberRepository,
private val eventPublisher: EventPublisher
) {
data class Request(
val userSub: String,
val email: String?,
val username: String?
)
data class Response(
val ensured: Boolean, // true, wenn jetzt auf jeden Fall vorhanden (neu oder bereits vorhanden)
val created: Boolean, // true, wenn neu angelegt
val memberId: Uuid?,
val membershipNumber: String?
)
suspend fun execute(request: Request): Response {
// 1) Versuche per E-Mail zu finden (stabilster Identifier). Fallback: generierte Pseudo-E-Mail aus username/sub.
val lookupEmail = request.email ?: request.username?.let { "$it@local" }
?: "user-${request.userSub.takeLast(8)}@local"
val existing = memberRepository.findByEmail(lookupEmail)
if (existing != null) {
return Response(ensured = true, created = false, memberId = existing.memberId, membershipNumber = existing.membershipNumber)
}
// 2) Keine Daten vorhanden: OEPS-Daten mocken und Member erzeugen
val (firstName, lastName) = mockFetchOepsNames(request)
val today: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault())
val generatedMembershipNumber = "AUTO-${request.userSub.takeLast(8)}"
val newMember = Member(
firstName = firstName,
lastName = lastName,
email = lookupEmail,
membershipNumber = generatedMembershipNumber,
membershipStartDate = today,
// optionale Felder leer lassen
phone = null,
dateOfBirth = null,
membershipEndDate = null,
isActive = true,
address = null,
emergencyContact = null
)
val saved = memberRepository.save(newMember)
// 3) Domain-Event publizieren (best effort, Fehler sollen nicht verhindern)
runCatching {
val event = MemberCreatedEvent(
eventId = "evt-${saved.memberId}",
memberId = saved.memberId,
timestamp = Clock.System.now(),
firstName = saved.firstName,
lastName = saved.lastName,
email = saved.email,
membershipNumber = saved.membershipNumber,
membershipStartDate = saved.membershipStartDate,
isActive = saved.isActive
)
eventPublisher.publishEvent(
topic = "members.events",
key = saved.memberId.toString(),
event = event
)
}
return Response(ensured = true, created = true, memberId = saved.memberId, membershipNumber = saved.membershipNumber)
}
/**
* Mock für den späteren OEPS-Datenbezug. Leitet aus Username/E-Mail simple Namen ab.
*/
private fun mockFetchOepsNames(request: Request): Pair<String, String> {
val source = request.username ?: request.email ?: request.userSub
val base = source.substringBefore('@').replace(".", " ").trim().ifBlank { "Reiter" }
val parts = base.split(" ")
val first = parts.firstOrNull()?.replaceFirstChar { it.titlecase() } ?: "Reiter"
val last = parts.drop(1).joinToString(" ").ifBlank { "Unbekannt" }.replaceFirstChar { it.titlecase() }
return first to last
}
}