diff --git a/clients/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/LoginViewModel.kt b/clients/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/LoginViewModel.kt index ff6ce3f4..b6686039 100644 --- a/clients/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/LoginViewModel.kt +++ b/clients/auth-feature/src/commonMain/kotlin/at/mocode/clients/authfeature/LoginViewModel.kt @@ -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, diff --git a/services/members/members-api/build.gradle.kts b/services/members/members-api/build.gradle.kts index b1b7ea89..c29ae742 100644 --- a/services/members/members-api/build.gradle.kts +++ b/services/members/members-api/build.gradle.kts @@ -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) } diff --git a/services/members/members-api/src/main/kotlin/at/mocode/members/api/rest/MemberController.kt b/services/members/members-api/src/main/kotlin/at/mocode/members/api/rest/MemberController.kt index 876842e5..fa5fed37 100644 --- a/services/members/members-api/src/main/kotlin/at/mocode/members/api/rest/MemberController.kt +++ b/services/members/members-api/src/main/kotlin/at/mocode/members/api/rest/MemberController.kt @@ -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> = 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 */ diff --git a/services/members/members-application/src/main/kotlin/at/mocode/members/application/usecase/EnsureMemberProfileExistsUseCase.kt b/services/members/members-application/src/main/kotlin/at/mocode/members/application/usecase/EnsureMemberProfileExistsUseCase.kt new file mode 100644 index 00000000..3be613d5 --- /dev/null +++ b/services/members/members-application/src/main/kotlin/at/mocode/members/application/usecase/EnsureMemberProfileExistsUseCase.kt @@ -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 { + 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 + } +}