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:
+16
@@ -2,10 +2,14 @@ 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 io.ktor.client.call.*
|
||||||
|
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
|
||||||
@@ -89,6 +93,18 @@ class LoginViewModel(
|
|||||||
isAuthenticated = true,
|
isAuthenticated = true,
|
||||||
errorMessage = null
|
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 {
|
} else {
|
||||||
_uiState.value = _uiState.value.copy(
|
_uiState.value = _uiState.value.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ dependencies {
|
|||||||
|
|
||||||
implementation("org.springframework:spring-web")
|
implementation("org.springframework:spring-web")
|
||||||
implementation("org.springdoc:springdoc-openapi-starter-common")
|
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)
|
testImplementation(projects.platform.platformTesting)
|
||||||
}
|
}
|
||||||
|
|||||||
+30
@@ -17,6 +17,8 @@ import org.springframework.http.HttpStatus
|
|||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
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
|
import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerApiResponse
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +57,7 @@ class MemberController(
|
|||||||
private val findExpiringMembershipsUseCase = FindExpiringMembershipsUseCase(memberRepository)
|
private val findExpiringMembershipsUseCase = FindExpiringMembershipsUseCase(memberRepository)
|
||||||
private val findMembersByDateRangeUseCase = FindMembersByDateRangeUseCase(memberRepository)
|
private val findMembersByDateRangeUseCase = FindMembersByDateRangeUseCase(memberRepository)
|
||||||
private val validateMemberDataUseCase = ValidateMemberDataUseCase(memberRepository)
|
private val validateMemberDataUseCase = ValidateMemberDataUseCase(memberRepository)
|
||||||
|
private val ensureMemberProfileExistsUseCase = EnsureMemberProfileExistsUseCase(memberRepository, eventPublisher)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hilfsmethode zur Behandlung gemeinsamer Antwortmuster für Use-Case-Ausführung
|
* 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
|
* Hilfsmethode zur Behandlung von Repository-Operationen mit gemeinsamer Fehlerbehandlung
|
||||||
*/
|
*/
|
||||||
|
|||||||
+107
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user