From 616976bbce248b151adfc6683fcdd6eaed9c6b9a Mon Sep 17 00:00:00 2001 From: hoangtuzami Date: Sat, 16 May 2026 13:17:19 +0700 Subject: [PATCH] Refactor Keycloak client, user repos, gRPC impl; add Kafka consumer config --- .../configurations/KafkaConsumerConfig.java | 70 ++++ .../domains/events/CreateUserPlacedEvent.java | 2 + .../domains/events/UserActivatedEvent.java | 1 + .../abstracts/KeycloakClient.java | 16 +- .../abstracts/UserService.java | 3 + .../client/KeycloakClientImpl.java | 208 +++++---- .../grpc/UserSerivceGrpcImpl.java | 2 +- .../listeners/EContractEventListener.java | 203 ++++----- .../repositories/UserRepository.java | 31 +- .../repositories/UserRoleRepository.java | 4 + .../userservice/services/UserServiceImpl.java | 396 ++++++++++++------ .../controllers/UserControllerTest.java | 6 +- .../listeners/EContractEventListenerTest.java | 9 +- .../services/UserServiceImplTest.java | 349 +++++++++++++-- 14 files changed, 904 insertions(+), 396 deletions(-) create mode 100644 src/main/java/com/isums/userservice/configurations/KafkaConsumerConfig.java diff --git a/src/main/java/com/isums/userservice/configurations/KafkaConsumerConfig.java b/src/main/java/com/isums/userservice/configurations/KafkaConsumerConfig.java new file mode 100644 index 0000000..fc8dc04 --- /dev/null +++ b/src/main/java/com/isums/userservice/configurations/KafkaConsumerConfig.java @@ -0,0 +1,70 @@ +package com.isums.userservice.configurations; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; +import org.springframework.kafka.listener.DefaultErrorHandler; +import org.springframework.util.backoff.ExponentialBackOff; + +import java.util.Map; + +@Configuration +public class KafkaConsumerConfig { + + @Value("${spring.kafka.bootstrap-servers}") + private String bootstrapServers; + + @Bean + public KafkaTemplate objectKafkaTemplate() { + Map props = Map.of( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, + org.springframework.kafka.support.serializer.JsonSerializer.class, + org.springframework.kafka.support.serializer.JsonSerializer.ADD_TYPE_INFO_HEADERS, false + ); + return new KafkaTemplate<>(new DefaultKafkaProducerFactory<>(props)); + } + + @Bean + public KafkaTemplate dltKafkaTemplate() { + Map props = Map.of( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class + ); + return new KafkaTemplate<>(new DefaultKafkaProducerFactory<>(props)); + } + + @Bean + public DefaultErrorHandler kafkaErrorHandler(KafkaTemplate dltKafkaTemplate) { + DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer( + dltKafkaTemplate, + (record, ex) -> new TopicPartition(record.topic() + ".DLT", record.partition()) + ); + + ExponentialBackOff backOff = new ExponentialBackOff(1_000L, 2.0); + backOff.setMaxInterval(60_000L); + backOff.setMaxAttempts(Long.MAX_VALUE); + + DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, backOff); + + handler.addNotRetryableExceptions( + com.fasterxml.jackson.core.JsonProcessingException.class, + com.fasterxml.jackson.databind.exc.InvalidDefinitionException.class, + com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException.class, + IllegalArgumentException.class, + org.springframework.messaging.converter.MessageConversionException.class, + org.springframework.dao.DataIntegrityViolationException.class, + org.hibernate.exception.ConstraintViolationException.class + ); + + return handler; + } +} diff --git a/src/main/java/com/isums/userservice/domains/events/CreateUserPlacedEvent.java b/src/main/java/com/isums/userservice/domains/events/CreateUserPlacedEvent.java index 30202f3..91fcdd3 100644 --- a/src/main/java/com/isums/userservice/domains/events/CreateUserPlacedEvent.java +++ b/src/main/java/com/isums/userservice/domains/events/CreateUserPlacedEvent.java @@ -36,5 +36,7 @@ public class CreateUserPlacedEvent { private String nationality; private String visaType; private String visaExpiryDate; + + private String language; } diff --git a/src/main/java/com/isums/userservice/domains/events/UserActivatedEvent.java b/src/main/java/com/isums/userservice/domains/events/UserActivatedEvent.java index f572299..0fdc2a7 100644 --- a/src/main/java/com/isums/userservice/domains/events/UserActivatedEvent.java +++ b/src/main/java/com/isums/userservice/domains/events/UserActivatedEvent.java @@ -17,6 +17,7 @@ public record UserActivatedEvent( String email, String name, String password, + String locale, String firstRentPaymentUrl, Long firstRentAmount, Instant firstRentDueDate diff --git a/src/main/java/com/isums/userservice/infrastructures/abstracts/KeycloakClient.java b/src/main/java/com/isums/userservice/infrastructures/abstracts/KeycloakClient.java index b1c53d6..889fd16 100644 --- a/src/main/java/com/isums/userservice/infrastructures/abstracts/KeycloakClient.java +++ b/src/main/java/com/isums/userservice/infrastructures/abstracts/KeycloakClient.java @@ -2,7 +2,7 @@ import com.isums.userservice.domains.dtos.KeycloakCreateUserRequest; -import java.util.List; +import java.util.Optional; public interface KeycloakClient { String createUser(KeycloakCreateUserRequest req); @@ -13,15 +13,7 @@ public interface KeycloakClient { String activateAndResetPassword(String keycloakId); - /** - * Trigger Keycloak's "execute actions" email flow — sends a one-click - * link to the user so they can perform required actions (typically - * UPDATE_PASSWORD + VERIFY_EMAIL) before first login. Keycloak realm - * SMTP must be configured for this to actually deliver. - * - * @param keycloakId Keycloak user UUID - * @param actions required actions to perform (e.g. ["UPDATE_PASSWORD"]) - * @param lifespanSec link TTL in seconds (null = Keycloak default ≈ 12h) - */ - void sendExecuteActionsEmail(String keycloakId, List actions, Integer lifespanSec); + Optional findUserIdByEmail(String email); + + boolean isUserEnabled(String keycloakId); } diff --git a/src/main/java/com/isums/userservice/infrastructures/abstracts/UserService.java b/src/main/java/com/isums/userservice/infrastructures/abstracts/UserService.java index a9b81fe..f6b3ceb 100644 --- a/src/main/java/com/isums/userservice/infrastructures/abstracts/UserService.java +++ b/src/main/java/com/isums/userservice/infrastructures/abstracts/UserService.java @@ -1,6 +1,7 @@ package com.isums.userservice.infrastructures.abstracts; import com.isums.userservice.domains.dtos.*; +import com.isums.userservice.domains.events.CreateUserPlacedEvent; import com.isums.userservice.domains.events.DepositPaidEvent; import java.util.List; @@ -12,6 +13,8 @@ public interface UserService { String createUser(KeycloakCreateUserRequest req); + void applyProfileFromEvent(CreateUserPlacedEvent event); + UserDto getUserByEmail(String email); UserProfileDto getMe(String keycloakId); diff --git a/src/main/java/com/isums/userservice/infrastructures/client/KeycloakClientImpl.java b/src/main/java/com/isums/userservice/infrastructures/client/KeycloakClientImpl.java index d533965..84a86b2 100644 --- a/src/main/java/com/isums/userservice/infrastructures/client/KeycloakClientImpl.java +++ b/src/main/java/com/isums/userservice/infrastructures/client/KeycloakClientImpl.java @@ -5,6 +5,8 @@ import com.isums.userservice.domains.dtos.KeycloakCreateUserRequest; import com.isums.userservice.domains.dtos.KeycloakTokenResponse; import com.isums.userservice.domains.dtos.KeycloakUserRepresentation; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; @@ -14,6 +16,7 @@ import org.springframework.util.StreamUtils; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientResponseException; +import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; import java.nio.charset.StandardCharsets; @@ -28,6 +31,7 @@ public class KeycloakClientImpl implements KeycloakClient { private final RestClient keycloakRestClient; private final KeycloakProperties props; + private final ObjectMapper objectMapper = new ObjectMapper(); private record CachedToken(String token, Instant expiresAt) { } @@ -82,50 +86,121 @@ private KeycloakTokenResponse requestAccessToken() { } public String createUser(KeycloakCreateUserRequest req) { - Objects.requireNonNull(req, "KeycloakCreateUserRequest cannot be null"); + Objects.requireNonNull(req, "KeycloakCreateUserRequest cannot be null"); + + final String uri = "/admin/realms/" + props.getRealm() + "/users"; + String token = getAccessToken(); + + String firstName = req.name() != null && !req.name().isBlank() ? req.name().trim() : null; + KeycloakUserRepresentation payload = new KeycloakUserRepresentation( + req.id() != null ? req.id() : null, + req.email(), + req.email(), + firstName, + req.isEnabled(), + req.emailVerified() != null ? req.emailVerified() : true, + mergeAttributes(req.attributes(), req.identityNumber(), req.name()), + req.requiredActions() + ); + + try { + return keycloakRestClient.post() + .uri(uri) + .contentType(MediaType.APPLICATION_JSON) + .headers(h -> h.setBearerAuth(token)) + .body(payload) + .exchange((request, response) -> { + int code = response.getStatusCode().value(); + + if (code == 201) { + URI location = response.getHeaders().getLocation(); + if (location == null) { + throw new IllegalStateException("Keycloak created user but missing Location header"); + } + + String path = location.getPath(); + return path.substring(path.lastIndexOf("/") + 1); + } + + if (code == 409) { + String body = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8); + log.warn("Keycloak createUser returned 409 conflict, recovering by lookup email={} body={}", req.email(), body); + return findUserIdByEmail(req.email()) + .orElseThrow(() -> new IllegalStateException( + "Keycloak 409 conflict but user not found by email=" + req.email())); + } + + String body = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8); + throw new IllegalStateException("Keycloak create user failed: HTTP " + code + + (body.isBlank() ? "" : "\n" + body)); + }); + } catch (RestClientResponseException ex) { + if (ex.getStatusCode().value() == 409) { + log.warn("Keycloak createUser conflict (RestClientResponseException) email={}, recovering by lookup", req.email()); + return findUserIdByEmail(req.email()) + .orElseThrow(() -> new IllegalStateException( + "Keycloak 409 conflict but user not found by email=" + req.email(), ex)); + } + String body = ex.getResponseBodyAsString(); + throw new IllegalStateException( + "Keycloak admin call failed: HTTP " + ex.getStatusCode().value() + " " + ex.getStatusText() + + (body.isBlank() ? "" : "\n" + body), ex); + } + } - final String uri = "/admin/realms/" + props.getRealm() + "/users"; + @Override + public Optional findUserIdByEmail(String email) { + if (email == null || email.isBlank()) return Optional.empty(); + String uri = UriComponentsBuilder + .fromPath("/admin/realms/" + props.getRealm() + "/users") + .queryParam("email", email) + .queryParam("exact", "true") + .build(true) + .toUriString(); String token = getAccessToken(); + try { + String json = keycloakRestClient.get() + .uri(uri) + .headers(h -> h.setBearerAuth(token)) + .retrieve() + .body(String.class); + if (json == null || json.isBlank()) return Optional.empty(); + JsonNode arr = objectMapper.readTree(json); + if (!arr.isArray() || arr.isEmpty()) return Optional.empty(); + JsonNode id = arr.get(0).get("id"); + return id != null && id.isTextual() ? Optional.of(id.asText()) : Optional.empty(); + } catch (RestClientResponseException ex) { + log.error("Keycloak findUserIdByEmail failed email={} status={} body={}", + email, ex.getStatusCode().value(), ex.getResponseBodyAsString()); + return Optional.empty(); + } catch (Exception ex) { + log.error("Keycloak findUserIdByEmail unexpected error email={}", email, ex); + return Optional.empty(); + } + } - KeycloakUserRepresentation payload = new KeycloakUserRepresentation( - req.id() != null ? req.id() : null, - req.email(), - req.email(), - normalizeName(req.name()), - req.isEnabled(), - req.emailVerified() != null ? req.emailVerified() : true, - mergeAttributes(req.attributes(), req.identityNumber(), req.name()), - req.requiredActions() - ); - + @Override + public boolean isUserEnabled(String keycloakId) { + if (keycloakId == null || keycloakId.isBlank()) return false; + final String uri = "/admin/realms/" + props.getRealm() + "/users/" + keycloakId; + String token = getAccessToken(); try { - return keycloakRestClient.post() + String json = keycloakRestClient.get() .uri(uri) - .contentType(MediaType.APPLICATION_JSON) .headers(h -> h.setBearerAuth(token)) - .body(payload) - .exchange((request, response) -> { - int code = response.getStatusCode().value(); - - if (code == 201) { - URI location = response.getHeaders().getLocation(); - if (location == null) { - throw new IllegalStateException("Keycloak created user but missing Location header"); - } - - String path = location.getPath(); - return path.substring(path.lastIndexOf("/") + 1); - } - - String body = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8); - throw new IllegalStateException("Keycloak create user failed: HTTP " + code - + (body.isBlank() ? "" : "\n" + body)); - }); + .retrieve() + .body(String.class); + if (json == null || json.isBlank()) return false; + JsonNode node = objectMapper.readTree(json); + JsonNode enabled = node.get("enabled"); + return enabled != null && enabled.asBoolean(false); } catch (RestClientResponseException ex) { - String body = ex.getResponseBodyAsString(); - throw new IllegalStateException( - "Keycloak admin call failed: HTTP " + ex.getStatusCode().value() + " " + ex.getStatusText() - + (body.isBlank() ? "" : "\n" + body), ex); + log.error("Keycloak isUserEnabled failed keycloakId={} status={}", + keycloakId, ex.getStatusCode().value()); + return false; + } catch (Exception ex) { + log.error("Keycloak isUserEnabled unexpected error keycloakId={}", keycloakId, ex); + return false; } } @@ -133,10 +208,7 @@ public void activeUser(String keycloakId) { final String uri = "/admin/realms/" + props.getRealm() + "/users/" + keycloakId; String token = getAccessToken(); - Map body = new HashMap<>(); - body.put("enabled", true); - body.put("emailVerified", true); - body.put("requiredActions", List.of()); + Map body = Map.of("enabled", true); try { keycloakRestClient.put() @@ -147,7 +219,7 @@ public void activeUser(String keycloakId) { .retrieve() .toBodilessEntity(); - log.info("Keycloak user activated keycloakId={} requiredActions=cleared", keycloakId); + log.info("Keycloak user activated keycloakId={}", keycloakId); } catch (RestClientResponseException ex) { String responseBody = ex.getResponseBodyAsString(); throw new IllegalStateException( @@ -178,56 +250,18 @@ private static Map> mergeAttributes( return merged.isEmpty() ? null : merged; } - private static String normalizeName(String name) { - if (name == null) { - return null; - } - String normalized = name.trim(); - return normalized.isEmpty() ? null : normalized; - } - - /** - * PUT /admin/realms/{realm}/users/{id}/execute-actions-email - * Body: JSON array of required actions, e.g. ["UPDATE_PASSWORD"]. - * Optional query params: lifespan, client_id, redirect_uri. - */ - @Override - public void sendExecuteActionsEmail(String keycloakId, List actions, Integer lifespanSec) { - if (actions == null || actions.isEmpty()) { - log.warn("sendExecuteActionsEmail: no actions, skip keycloakId={}", keycloakId); - return; - } - final String base = "/admin/realms/" + props.getRealm() - + "/users/" + keycloakId + "/execute-actions-email"; - final String uri = (lifespanSec != null) - ? base + "?lifespan=" + lifespanSec - : base; - String token = getAccessToken(); - - try { - keycloakRestClient.put() - .uri(uri) - .contentType(MediaType.APPLICATION_JSON) - .headers(h -> h.setBearerAuth(token)) - .body(actions) - .retrieve() - .toBodilessEntity(); - - log.info("Keycloak execute-actions-email sent keycloakId={} actions={}", keycloakId, actions); - } catch (RestClientResponseException ex) { - String responseBody = ex.getResponseBodyAsString(); - // Don't block user creation if SMTP is misconfigured — log and continue. - log.error("Keycloak execute-actions-email failed keycloakId={} status={} body={}", - keycloakId, ex.getStatusCode().value(), responseBody); - } - } - public String resetPassword(String keycloakId) { String tempPassword = UUID.randomUUID().toString().substring(0, 8) + "@Aa1"; final String uri = "/admin/realms/" + props.getRealm() + "/users/" + keycloakId + "/reset-password"; String token = getAccessToken(); +// Map body = Map.of( +// "type", "password", +// "value", tempPassword, +// "temporary", true +// ); + Map body = Map.of( "type", "password", "value", tempPassword, @@ -243,7 +277,7 @@ public String resetPassword(String keycloakId) { .retrieve() .toBodilessEntity(); - log.info("Keycloak password reset keycloakId={} temporary=false", keycloakId); + log.info("Keycloak password reset keycloakId={}", keycloakId); return tempPassword; } catch (RestClientResponseException ex) { throw new IllegalStateException("Reset password failed: HTTP " diff --git a/src/main/java/com/isums/userservice/infrastructures/grpc/UserSerivceGrpcImpl.java b/src/main/java/com/isums/userservice/infrastructures/grpc/UserSerivceGrpcImpl.java index 3b23d29..249b914 100644 --- a/src/main/java/com/isums/userservice/infrastructures/grpc/UserSerivceGrpcImpl.java +++ b/src/main/java/com/isums/userservice/infrastructures/grpc/UserSerivceGrpcImpl.java @@ -64,7 +64,7 @@ public void getUserById(GetUserByIdRequest request, StreamObserver public void getUserByEmail(GetUserByEmailRequest request, StreamObserver responseObserver) { try { String email = request.getEmail(); - User user = userRepository.findByEmail(email); + User user = userRepository.findByEmailIgnoreCase(email).orElse(null); if (user == null) { responseObserver.onError(Status.NOT_FOUND diff --git a/src/main/java/com/isums/userservice/infrastructures/listeners/EContractEventListener.java b/src/main/java/com/isums/userservice/infrastructures/listeners/EContractEventListener.java index 30b2cef..8185fb4 100644 --- a/src/main/java/com/isums/userservice/infrastructures/listeners/EContractEventListener.java +++ b/src/main/java/com/isums/userservice/infrastructures/listeners/EContractEventListener.java @@ -1,21 +1,20 @@ package com.isums.userservice.infrastructures.listeners; -import com.isums.userservice.domains.entities.User; import com.isums.userservice.domains.events.DepositPaidEvent; +import com.isums.userservice.exceptions.NotFoundException; import com.isums.userservice.infrastructures.abstracts.UserService; -import com.isums.userservice.infrastructures.repositories.UserRepository; import com.isums.userservice.domains.dtos.KeycloakCreateUserRequest; import com.isums.userservice.domains.events.CreateUserPlacedEvent; +import com.isums.userservice.exceptions.ConflictException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.consumer.ConsumerRecord; import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.core.KafkaTemplate; import org.springframework.kafka.support.Acknowledgment; import org.springframework.stereotype.Component; import tools.jackson.databind.ObjectMapper; -import java.time.LocalDate; -import java.time.format.DateTimeParseException; import java.util.List; import java.util.Map; @@ -25,146 +24,118 @@ public class EContractEventListener { private final UserService userService; - private final UserRepository userRepository; private final ObjectMapper objectMapper; + private final KafkaTemplate kafka; + + private static final String CREATE_USER_DLQ = "createUser-dlq-topic"; + private static final String DEPOSIT_PAID_DLQ = "deposit-paid-enriched-dlq-topic"; @KafkaListener(topics = "createUser-topic", groupId = "user-group") public void handleCreateUserEvent(ConsumerRecord record, Acknowledgment ack) { + CreateUserPlacedEvent event = null; + try { + event = objectMapper.readValue(record.value(), CreateUserPlacedEvent.class); + } catch (tools.jackson.core.JacksonException e) { + log.error("[User] createUser deserialize failed, ack to skip poison message: {}", e.getMessage()); + ack.acknowledge(); + return; + } catch (Exception e) { + log.error("[User] createUser parse error, ack to skip: {}", e.getMessage(), e); + ack.acknowledge(); + return; + } + try { - CreateUserPlacedEvent event = objectMapper.readValue(record.value(), CreateUserPlacedEvent.class); KeycloakCreateUserRequest request = new KeycloakCreateUserRequest( event.getId(), event.getEmail(), event.getIsEnabled(), false, event.getIdentityNumber(), event.getPhoneNumber(), event.getName(), Map.of("roles", List.of("USER")), List.of("UPDATE_PASSWORD") ); + userService.createUser(request); + userService.applyProfileFromEvent(event); + ack.acknowledge(); + } catch (ConflictException ex) { + log.warn("[User] createUser conflict (already exists), ack email={}", event.getEmail()); try { - userService.createUser(request); - } catch (IllegalStateException ex) { - if (ex.getMessage() != null && ex.getMessage().contains("409")) { - log.warn("User already exists, skip email={}", event.getEmail()); - ack.acknowledge(); - return; - } - throw ex; + userService.applyProfileFromEvent(event); + } catch (Exception inner) { + log.warn("[User] applyProfileFromEvent after conflict failed email={}: {}", + event.getEmail(), inner.getMessage()); } - - // Post-create: fill CCCD + permanent-address metadata captured on - // the contract wizard. Separate from createUser() because those - // fields are User-domain state, not Keycloak credentials. - applyProfileFromEvent(event); - ack.acknowledge(); + } catch (NotFoundException ex) { + log.error("[User] createUser config error (role missing?) email={} - sending to DLQ", event.getEmail(), ex); + sendToDlq(CREATE_USER_DLQ, record.value(), ex); + ack.acknowledge(); + } catch (IllegalStateException ex) { + String msg = ex.getMessage() != null ? ex.getMessage() : ""; + if (msg.contains("HTTP 4")) { + log.error("[User] createUser permanent 4xx error email={} body={} - sending to DLQ", + event.getEmail(), msg); + sendToDlq(CREATE_USER_DLQ, record.value(), ex); + ack.acknowledge(); + return; + } + log.warn("[User] createUser transient error email={} - will retry: {}", event.getEmail(), msg); + throw new RuntimeException(ex); } catch (Exception e) { - log.error("handleCreateUserEvent failed: {}", e.getMessage(), e); + log.warn("[User] createUser unknown error email={} - will retry: {}", event.getEmail(), e.getMessage()); throw new RuntimeException(e); } } - private void applyProfileFromEvent(CreateUserPlacedEvent event) { - boolean hasAnyProfile = - isPresent(event.getDateOfIssue()) - || isPresent(event.getPlaceOfIssue()) - || isPresent(event.getPermanentAddress()) - || isPresent(event.getDateOfBirth()) - || isPresent(event.getGender()) - // Foreigner block — also trigger profile write when any - // passport field is present. - || isPresent(event.getPassportNumber()) - || isPresent(event.getPassportIssueDate()) - || isPresent(event.getPassportExpiryDate()) - || isPresent(event.getNationality()) - || isPresent(event.getVisaType()) - || isPresent(event.getVisaExpiryDate()); - if (!hasAnyProfile) return; - - User user = userRepository.findById(event.getId()).orElse(null); - if (user == null) { - log.warn("applyProfileFromEvent: user {} not found after createUser, skip", event.getId()); + @KafkaListener(topics = "deposit-paid-enriched-topic", groupId = "user-group") + public void handleDepositPaid(ConsumerRecord record, Acknowledgment ack) { + DepositPaidEvent event; + try { + event = objectMapper.readValue(record.value(), DepositPaidEvent.class); + } catch (tools.jackson.core.JacksonException e) { + log.error("[User] deposit-paid deserialize failed, ack to skip poison: {}", e.getMessage()); + ack.acknowledge(); + return; + } catch (Exception e) { + log.error("[User] deposit-paid parse error, ack to skip: {}", e.getMessage(), e); + ack.acknowledge(); return; } - // --- VN tenant block (CCCD + permanent address) --- - if (isPresent(event.getDateOfIssue())) { - try { - user.setDateOfIssue(LocalDate.parse(event.getDateOfIssue())); - } catch (DateTimeParseException ex) { - log.warn("Invalid dateOfIssue '{}' in event, skipping", event.getDateOfIssue()); - } - } - if (isPresent(event.getPlaceOfIssue())) { - user.setPlaceOfIssue(event.getPlaceOfIssue().trim()); - } - if (isPresent(event.getPermanentAddress())) { - user.setPermanentAddress(event.getPermanentAddress().trim()); - } - - // --- Shared (both tenant types) --- - if (isPresent(event.getDateOfBirth())) { - try { - user.setDateOfBirth(LocalDate.parse(event.getDateOfBirth())); - } catch (DateTimeParseException ex) { - log.warn("Invalid dateOfBirth '{}' in event, skipping", event.getDateOfBirth()); - } - } - if (isPresent(event.getGender())) { - user.setGender(event.getGender().trim()); - } - - // --- Foreign tenant block (passport + visa) --- - if (isPresent(event.getPassportNumber())) { - user.setPassportNumber(event.getPassportNumber().trim().toUpperCase()); - } - if (isPresent(event.getPassportIssueDate())) { - try { - user.setPassportIssueDate(LocalDate.parse(event.getPassportIssueDate())); - } catch (DateTimeParseException ex) { - log.warn("Invalid passportIssueDate '{}' in event, skipping", event.getPassportIssueDate()); - } - } - if (isPresent(event.getPassportExpiryDate())) { - try { - user.setPassportExpiryDate(LocalDate.parse(event.getPassportExpiryDate())); - } catch (DateTimeParseException ex) { - log.warn("Invalid passportExpiryDate '{}' in event, skipping", event.getPassportExpiryDate()); - } - } - if (isPresent(event.getNationality())) { - user.setNationality(event.getNationality().trim()); - } - if (isPresent(event.getVisaType())) { - user.setVisaType(event.getVisaType().trim()); - } - if (isPresent(event.getVisaExpiryDate())) { - try { - user.setVisaExpiryDate(LocalDate.parse(event.getVisaExpiryDate())); - } catch (DateTimeParseException ex) { - log.warn("Invalid visaExpiryDate '{}' in event, skipping", event.getVisaExpiryDate()); - } - } - - userRepository.save(user); - log.info("applyProfileFromEvent: saved profile metadata for user {}", event.getId()); - } - - private static boolean isPresent(String s) { - return s != null && !s.isBlank(); - } - - @KafkaListener(topics = "deposit-paid-enriched-topic", groupId = "user-group") - public void handleDepositPaid(ConsumerRecord record, Acknowledgment ack) { try { - DepositPaidEvent event = objectMapper.readValue(record.value(), DepositPaidEvent.class); userService.activateIfNewUser(event); ack.acknowledge(); - log.info("[User] handleDepositPaid processed tenantId={}", event.tenantId()); - } catch (tools.jackson.core.JacksonException e) { - log.error("[User] Deserialize failed: {}", e.getMessage()); + log.info("[User] handleDepositPaid processed tenantId={} email={}", + event.tenantId(), event.tenantEmail()); + } catch (NotFoundException ex) { + log.error("[User] activate user not found tenantId={} email={} - DLQ + ack", + event.tenantId(), event.tenantEmail(), ex); + sendToDlq(DEPOSIT_PAID_DLQ, record.value(), ex); ack.acknowledge(); + } catch (IllegalStateException ex) { + String msg = ex.getMessage() != null ? ex.getMessage() : ""; + if (msg.contains("HTTP 4")) { + log.error("[User] activate permanent 4xx tenantId={} body={} - DLQ + ack", + event.tenantId(), msg); + sendToDlq(DEPOSIT_PAID_DLQ, record.value(), ex); + ack.acknowledge(); + return; + } + log.warn("[User] activate transient error tenantId={} - retry: {}", event.tenantId(), msg); + throw new RuntimeException(ex); } catch (Exception e) { - log.error("[User] handleDepositPaid failed, will retry: {}", e.getMessage(), e); + log.warn("[User] activate unknown error tenantId={} - retry: {}", event.tenantId(), e.getMessage()); throw new RuntimeException(e); } } -} + private void sendToDlq(String dlqTopic, String payload, Throwable cause) { + try { + kafka.send(dlqTopic, Map.of( + "payload", payload, + "error", cause.getClass().getSimpleName() + ": " + (cause.getMessage() != null ? cause.getMessage() : ""), + "timestamp", java.time.Instant.now().toString() + )); + } catch (Exception kafkaEx) { + log.error("[User] Failed to publish to DLQ {}: {}", dlqTopic, kafkaEx.getMessage()); + } + } +} diff --git a/src/main/java/com/isums/userservice/infrastructures/repositories/UserRepository.java b/src/main/java/com/isums/userservice/infrastructures/repositories/UserRepository.java index 76b7bd8..1de9ece 100644 --- a/src/main/java/com/isums/userservice/infrastructures/repositories/UserRepository.java +++ b/src/main/java/com/isums/userservice/infrastructures/repositories/UserRepository.java @@ -1,21 +1,16 @@ package com.isums.userservice.infrastructures.repositories; import com.isums.userservice.domains.entities.User; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -public interface UserRepository extends JpaRepository { - boolean existsByEmail(String email); - User findByEmail(String email); - Optional findByKeycloakId(String keycloakId); - - @Query(""" - SELECT ur.user FROM UserRole ur - WHERE ur.role.code = :roleCode - AND ur.user.isEnabled = true -""") - List findUsersByRoleCode(String roleCode);} +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository extends JpaRepository { + boolean existsByEmail(String email); + User findByEmail(String email); + Optional findByEmailIgnoreCase(String email); + List findAllByEmailIgnoreCaseOrderByUpdatedAtDesc(String email); + Optional findByKeycloakId(String keycloakId); +} diff --git a/src/main/java/com/isums/userservice/infrastructures/repositories/UserRoleRepository.java b/src/main/java/com/isums/userservice/infrastructures/repositories/UserRoleRepository.java index c85cc3e..bb8b238 100644 --- a/src/main/java/com/isums/userservice/infrastructures/repositories/UserRoleRepository.java +++ b/src/main/java/com/isums/userservice/infrastructures/repositories/UserRoleRepository.java @@ -7,8 +7,12 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.UUID; public interface UserRoleRepository extends JpaRepository { @Query("SELECT ur.role.code FROM UserRole ur WHERE ur.user.keycloakId = :keycloakId") List findRoleCodesByKeycloakId(@Param("keycloakId") String keycloakId); + + @Query("SELECT ur FROM UserRole ur WHERE ur.role.id = :roleId") + List findAllByRoleId(@Param("roleId") UUID roleId); } diff --git a/src/main/java/com/isums/userservice/services/UserServiceImpl.java b/src/main/java/com/isums/userservice/services/UserServiceImpl.java index 687352f..8802029 100644 --- a/src/main/java/com/isums/userservice/services/UserServiceImpl.java +++ b/src/main/java/com/isums/userservice/services/UserServiceImpl.java @@ -58,15 +58,24 @@ public List getAllUsers() { @CacheEvict(cacheNames = "allUsers", allEntries = true) public String createUser(KeycloakCreateUserRequest req) { - boolean isExistEmail = userRepository.existsByEmail(req.email()); - if (isExistEmail) { - log.warn("Email already exists: {}", req.email()); - throw new ConflictException("Email " + req.email() + " already exists"); + var existing = userRepository.findByEmailIgnoreCase(req.email()); + if (existing.isPresent()) { + User u = existing.get(); + log.warn("createUser idempotent hit: email already in DB email={} keycloakId={}", req.email(), u.getKeycloakId()); + if (u.getKeycloakId() == null || u.getKeycloakId().isBlank()) { + String recovered = keycloakClient.findUserIdByEmail(req.email()) + .orElseGet(() -> keycloakClient.createUser(req)); + u.setKeycloakId(recovered); + u.setUpdatedAt(Instant.now()); + userRepository.save(u); + return recovered; + } + return u.getKeycloakId(); } String keycloakUserId = keycloakClient.createUser(req); if (keycloakUserId == null || keycloakUserId.isBlank()) { - throw new IllegalStateException("Keycloak did not return user id"); + throw new IllegalStateException("Keycloak did not return user id email=" + req.email()); } User user = User.builder() @@ -94,12 +103,96 @@ public String createUser(KeycloakCreateUserRequest req) { .build(); userRepository.save(user); - log.info("User created: {}", user); + log.info("User created email={} keycloakId={}", user.getEmail(), keycloakUserId); userRoleRepository.save(userRole); - log.info("User role created: {}", userRole); return keycloakUserId; } + @Override + @Transactional + public void applyProfileFromEvent(com.isums.userservice.domains.events.CreateUserPlacedEvent event) { + if (event == null || event.getId() == null) return; + User user = userRepository.findById(event.getId()).orElse(null); + if (user == null) { + log.warn("[ProfileSync] User not found for profile sync userId={} email={}", + event.getId(), event.getEmail()); + return; + } + boolean dirty = false; + + if (isNonBlank(event.getDateOfIssue())) { + user.setDateOfIssue(parseLocalDate(event.getDateOfIssue())); + dirty = true; + } + if (isNonBlank(event.getPlaceOfIssue())) { + user.setPlaceOfIssue(event.getPlaceOfIssue().trim()); + dirty = true; + } + if (isNonBlank(event.getPermanentAddress())) { + user.setPermanentAddress(event.getPermanentAddress().trim()); + dirty = true; + } + if (isNonBlank(event.getDateOfBirth())) { + user.setDateOfBirth(parseLocalDate(event.getDateOfBirth())); + dirty = true; + } + if (isNonBlank(event.getGender())) { + user.setGender(event.getGender().trim()); + dirty = true; + } + if (isNonBlank(event.getPassportNumber())) { + user.setPassportNumber(event.getPassportNumber().trim()); + dirty = true; + } + if (isNonBlank(event.getPassportIssueDate())) { + user.setPassportIssueDate(parseLocalDate(event.getPassportIssueDate())); + dirty = true; + } + if (isNonBlank(event.getPassportExpiryDate())) { + user.setPassportExpiryDate(parseLocalDate(event.getPassportExpiryDate())); + dirty = true; + } + if (isNonBlank(event.getNationality())) { + user.setNationality(event.getNationality().trim()); + dirty = true; + } + if (isNonBlank(event.getVisaType())) { + user.setVisaType(event.getVisaType().trim()); + dirty = true; + } + if (isNonBlank(event.getVisaExpiryDate())) { + user.setVisaExpiryDate(parseLocalDate(event.getVisaExpiryDate())); + dirty = true; + } + if (isNonBlank(event.getLanguage())) { + user.setLanguage(event.getLanguage().trim()); + dirty = true; + } else if (user.getLanguage() == null || user.getLanguage().isBlank()) { + user.setLanguage("vi_VN"); + dirty = true; + } + + if (dirty) { + user.setUpdatedAt(Instant.now()); + userRepository.save(user); + log.info("[ProfileSync] User profile synced userId={} email={} language={}", + user.getId(), user.getEmail(), user.getLanguage()); + } + } + + private static boolean isNonBlank(String s) { + return s != null && !s.isBlank(); + } + + private static java.time.LocalDate parseLocalDate(String iso) { + try { + return java.time.LocalDate.parse(iso); + } catch (Exception ex) { + log.warn("[ProfileSync] Invalid date '{}' — skipping field", iso); + return null; + } + } + @Override @Transactional public void activeUser(UUID userId) { @@ -138,7 +231,7 @@ public void updateLanguage(String keycloakId, String language) { user.setLanguage(language); user.setUpdatedAt(Instant.now()); userRepository.save(user); - log.info("User language updated userId={} language={}", user.getId(), language); + log.info("[User] Language updated keycloakId={} language={}", keycloakId, language); } @Override @@ -153,40 +246,133 @@ public void updatePhone(String keycloakId, String phoneNumber) { user.setPhoneNumber(normalised); user.setUpdatedAt(Instant.now()); userRepository.save(user); - log.info("User phone updated userId={} phone={}", user.getId(), normalised); + log.info("[User] Phone updated keycloakId={} phone={}", keycloakId, normalised); } @Override @Transactional public void activateIfNewUser(DepositPaidEvent event) { - User user = userRepository.findById(event.tenantId()) - .orElseThrow(() -> new NotFoundException("User not found: " + event.tenantId())); + User user = resolveOrRecoverUser(event); + if (user == null) { + log.error("[Activation] Cannot resolve user — skipping mail tenantId={} email={}", + event.tenantId(), event.tenantEmail()); + return; + } - String tempPassword = null; - boolean wasNewlyActivated = false; + boolean keycloakEnabled = keycloakClient.isUserEnabled(user.getKeycloakId()); + boolean dbEnabled = Boolean.TRUE.equals(user.getIsEnabled()); - if (!user.getIsEnabled()) { - tempPassword = keycloakClient.activateAndResetPassword(user.getKeycloakId()); - user.setIsEnabled(true); - user.setUpdatedAt(Instant.now()); - userRepository.save(user); - wasNewlyActivated = true; - log.info("[Activation] User activated userId={}", user.getId()); - } else { - log.info("[Activation] User already enabled userId={} — skipping welcome email", user.getId()); + if (keycloakEnabled && dbEnabled) { + if (Boolean.TRUE.equals(event.isNewAccount())) { + String tempPassword = keycloakClient.resetPassword(user.getKeycloakId()); + log.info("[Activation] New-account user already enabled — password reset + mail userId={} email={}", + user.getId(), user.getEmail()); + publishUserActivated(user, tempPassword, event); + return; + } + log.info("[Activation] Existing user already enabled — keeping existing password userId={} email={}", + user.getId(), user.getEmail()); + return; } - if (wasNewlyActivated && event.firstRentPaymentUrl() != null) { - kafka.send("user-activated-topic", UserActivatedEvent.builder() - .userId(user.getId()) - .email(user.getEmail()) - .name(user.getName()) - .password(tempPassword) - .firstRentPaymentUrl(event.firstRentPaymentUrl()) - .firstRentAmount(event.firstRentAmount()) - .firstRentDueDate(event.firstRentDueDate()) - .build()); + String tempPassword = keycloakClient.activateAndResetPassword(user.getKeycloakId()); + user.setIsEnabled(true); + user.setUpdatedAt(Instant.now()); + userRepository.save(user); + log.info("[Activation] User activated + password reset userId={} email={}", user.getId(), user.getEmail()); + + publishUserActivated(user, tempPassword, event); + } + + private void publishUserActivated(User user, String tempPassword, DepositPaidEvent event) { + String locale = user.getLanguage() != null && !user.getLanguage().isBlank() + ? user.getLanguage() + : "vi_VN"; + kafka.send("user-activated-topic", UserActivatedEvent.builder() + .userId(user.getId()) + .email(user.getEmail()) + .name(user.getName()) + .password(tempPassword) + .locale(locale) + .firstRentPaymentUrl(event.firstRentPaymentUrl()) + .firstRentAmount(event.firstRentAmount()) + .firstRentDueDate(event.firstRentDueDate()) + .build()); + } + + private User resolveOrRecoverUser(DepositPaidEvent event) { + if (event.tenantId() != null) { + var byId = userRepository.findById(event.tenantId()); + if (byId.isPresent()) return byId.get(); + } + + if (event.tenantEmail() == null || event.tenantEmail().isBlank()) { + log.error("[Activation] User not found in DB and no email in event tenantId={}", event.tenantId()); + return null; } + + var byEmail = userRepository.findByEmailIgnoreCase(event.tenantEmail()); + if (byEmail.isPresent()) { + log.warn("[Activation] Recovered user via email lookup tenantId={} email={}", + event.tenantId(), event.tenantEmail()); + return byEmail.get(); + } + + String resolvedKeycloakId = keycloakClient.findUserIdByEmail(event.tenantEmail()).orElse(null); + + if (resolvedKeycloakId == null) { + log.warn("[Activation] User missing in DB AND in Keycloak — recreating from event email={} tenantId={}", + event.tenantEmail(), event.tenantId()); + try { + String localPart = event.tenantEmail().contains("@") + ? event.tenantEmail().substring(0, event.tenantEmail().indexOf('@')) + : event.tenantEmail(); + KeycloakCreateUserRequest request = new KeycloakCreateUserRequest( + event.tenantId(), + event.tenantEmail(), + false, + true, + null, + null, + localPart, + Map.of("roles", List.of("USER")), + List.of("UPDATE_PASSWORD") + ); + resolvedKeycloakId = keycloakClient.createUser(request); + log.info("[Activation] Recreated Keycloak user email={} keycloakId={}", + event.tenantEmail(), resolvedKeycloakId); + } catch (Exception ex) { + log.error("[Activation] Failed to recreate user in Keycloak email={}: {}", + event.tenantEmail(), ex.getMessage(), ex); + return null; + } + } else { + log.warn("[Activation] User exists in Keycloak but not in DB — backfilling email={} keycloakId={}", + event.tenantEmail(), resolvedKeycloakId); + } + + User backfilled = User.builder() + .id(event.tenantId() != null ? event.tenantId() : UUID.randomUUID()) + .keycloakId(resolvedKeycloakId) + .email(event.tenantEmail()) + .isEnabled(false) + .createdAt(Instant.now()) + .updatedAt(Instant.now()) + .build(); + userRepository.save(backfilled); + + Role role = roleRepository.findByCode(Roles.TENANT) + .orElseThrow(() -> new NotFoundException("Tenant role not found")); + UserRoleId userRoleId = new UserRoleId(backfilled.getId(), role.getId()); + UserRole userRole = UserRole.builder() + .id(userRoleId) + .user(backfilled) + .role(role) + .createdAt(Instant.now()) + .build(); + userRoleRepository.save(userRole); + + return backfilled; } @Override @@ -199,6 +385,7 @@ public UserDto createTechnicalStaff(CreateTechnicalStaffRequest req) { UUID internalId = UUID.randomUUID(); + // Tạo Keycloak user với enabled=true, tempPassword ngay KeycloakCreateUserRequest keycloakReq = new KeycloakCreateUserRequest( internalId, req.email(), @@ -259,6 +446,41 @@ public UserDto createTechnicalStaff(CreateTechnicalStaffRequest req) { } @Override + @Cacheable(value = "userByEmail", key = "#email") + public UserDto getUserByEmail(String email) { + User user = userRepository.findByEmailIgnoreCase(email) + .orElseThrow(() -> new NotFoundException("User not found")); + return userMapper.mapUser(user); + } + + @Override + public UserProfileDto getMe(String keycloakId) { + User user = userRepository.findByKeycloakId(keycloakId) + .orElseThrow(() -> new NotFoundException("User not found")); + + List roles = userRoleCacheServiceImpl.getRolesCached(keycloakId); + + if (user.getMainHouseId() == null) { + var houses = houseGrpcClient.getAllHouseByUser(user.getId()); + if (houses.size() == 1) { + user.setMainHouseId(UUID.fromString(houses.getFirst().getId())); + } + } + + return UserProfileDto.builder() + .id(user.getId()) + .name(user.getName()) + .email(user.getEmail()) + .identityNumber(user.getIdentityNumber()) + .mainHouseId(user.getMainHouseId()) + .phoneNumber(user.getPhoneNumber()) + .roles(roles) + .build(); + } + + @Override + @Transactional + @CacheEvict(cacheNames = "allUsers", allEntries = true) public UserDto createManger(CreateManagerRequest req) { if (userRepository.existsByEmail(req.email())) { throw new ConflictException("Email " + req.email() + " already exists"); @@ -326,82 +548,36 @@ public UserDto createManger(CreateManagerRequest req) { } @Override + @Transactional(readOnly = true) public List getAllStaff() { - List users = userRepository.findUsersByRoleCode("TECHNICAL_STAFF"); - - return users.stream() - .map(u -> new StaffDto( - u.getId(), - u.getName(), - u.getEmail(), - u.getPhoneNumber() - )) - .toList(); + return findStaffByRole(Roles.TECHNICAL_STAFF); } @Override + @Transactional(readOnly = true) public List getAllManagers() { - List users = userRepository.findUsersByRoleCode("MANAGER"); - - return users.stream() - .sorted(java.util.Comparator.comparing( - u -> u.getName() == null ? "" : u.getName(), - String.CASE_INSENSITIVE_ORDER)) - .map(u -> new StaffDto( - u.getId(), - u.getName(), - u.getEmail(), - u.getPhoneNumber() - )) + return findStaffByRole(Roles.MANAGER); + } + + private List findStaffByRole(String roleCode) { + Role role = roleRepository.findByCode(roleCode).orElse(null); + if (role == null) return List.of(); + List rels = userRoleRepository.findAllByRoleId(role.getId()); + if (rels.isEmpty()) return List.of(); + List userIds = rels.stream().map(r -> r.getId().getUserId()).toList(); + return userRepository.findAllById(userIds).stream() + .map(u -> new StaffDto(u.getId(), u.getName(), u.getEmail(), u.getPhoneNumber())) .toList(); } @Override + @Transactional(readOnly = true) public UserProfileDto getUserById(UUID userId) { User user = userRepository.findById(userId) - .orElseThrow(() -> new RuntimeException("User not found")); - - String keycloakId = user.getKeycloakId(); - - List roles = userRoleCacheServiceImpl.getRolesCached(keycloakId); - - return UserProfileDto.builder() - .id(user.getId()) - .name(user.getName()) - .email(user.getEmail()) - .identityNumber(user.getIdentityNumber()) - .mainHouseId(user.getMainHouseId()) - .phoneNumber(user.getPhoneNumber()) - .roles(roles) - .language(user.getLanguage()) - .build(); - } - - @Override - @Cacheable(value = "userByEmail", key = "#email") - public UserDto getUserByEmail(String email) { - User user = userRepository.findByEmail(email); - - if (user == null) { - throw new NotFoundException("User not found"); - } - return userMapper.mapUser(user); - } - - @Override - public UserProfileDto getMe(String keycloakId) { - User user = userRepository.findByKeycloakId(keycloakId) - .orElseThrow(() -> new NotFoundException("User not found")); - - List roles = userRoleCacheServiceImpl.getRolesCached(keycloakId); - - if (user.getMainHouseId() == null) { - var houses = houseGrpcClient.getAllHouseByUser(user.getId()); - if (houses.size() == 1) { - user.setMainHouseId(UUID.fromString(houses.getFirst().getId())); - } - } - + .orElseThrow(() -> new NotFoundException("User not found: " + userId)); + List roles = user.getKeycloakId() != null + ? userRoleCacheServiceImpl.getRolesCached(user.getKeycloakId()) + : List.of(); return UserProfileDto.builder() .id(user.getId()) .name(user.getName()) @@ -410,7 +586,6 @@ public UserProfileDto getMe(String keycloakId) { .mainHouseId(user.getMainHouseId()) .phoneNumber(user.getPhoneNumber()) .roles(roles) - .language(user.getLanguage()) .build(); } @@ -419,28 +594,11 @@ public UserProfileDto getMe(String keycloakId) { public String adminResetPassword(UUID userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new NotFoundException("User not found: " + userId)); - - String tempPassword = keycloakClient.activateAndResetPassword(user.getKeycloakId()); - - if (!Boolean.TRUE.equals(user.getIsEnabled())) { - user.setIsEnabled(true); - user.setUpdatedAt(Instant.now()); - userRepository.save(user); + if (user.getKeycloakId() == null || user.getKeycloakId().isBlank()) { + throw new IllegalStateException("User has no Keycloak link userId=" + userId); } - - log.info("[Admin] Password reset for userId={} email={}", userId, user.getEmail()); - - kafka.send("notification-email", SendEmailEvent.builder() - .to(user.getEmail()) - .templateCode("user_activated") - .params(Map.of( - "name", user.getName() != null ? user.getName() : user.getEmail(), - "email", user.getEmail(), - "password", tempPassword, - "hasInvoice", false - )) - .build()); - + String tempPassword = keycloakClient.resetPassword(user.getKeycloakId()); + log.info("[Admin] Password reset issued userId={} email={}", userId, user.getEmail()); return tempPassword; } } diff --git a/src/test/java/com/isums/userservice/controllers/UserControllerTest.java b/src/test/java/com/isums/userservice/controllers/UserControllerTest.java index e8c2f86..7ae8df8 100644 --- a/src/test/java/com/isums/userservice/controllers/UserControllerTest.java +++ b/src/test/java/com/isums/userservice/controllers/UserControllerTest.java @@ -89,7 +89,7 @@ void setUp() { @DisplayName("GET /api/users returns 200 with list") void getAll() throws Exception { UserDto dto = new UserDto("id", "Alice", "kc", "a@b.com", "ID", "0900000000", - null, null, null); + null, null, null, null, null, null, null, null, null, null, null); when(userService.getAllUsers()).thenReturn(List.of(dto)); mvc.perform(get("/api/users")) @@ -133,7 +133,7 @@ void createUserConflict() throws Exception { @DisplayName("GET /api/users/{email} returns 200 with user dto") void getByEmail() throws Exception { UserDto dto = new UserDto("id", "Alice", "kc", "a@b.com", "ID", "0900000000", - null, null, null); + null, null, null, null, null, null, null, null, null, null, null); when(userService.getUserByEmail("a@b.com")).thenReturn(dto); mvc.perform(get("/api/users/{email}", "a@b.com")) @@ -233,7 +233,7 @@ void createStaff() throws Exception { CreateTechnicalStaffRequest req = new CreateTechnicalStaffRequest("Bob", "bob@b.com", "0900", "ID"); UserDto dto = new UserDto("id", "Bob", "kc-2", "bob@b.com", "ID", "0999999999", - null, null, null); + null, null, null, null, null, null, null, null, null, null, null); when(userService.createTechnicalStaff(any(CreateTechnicalStaffRequest.class))).thenReturn(dto); mvc.perform(post("/api/users/technical-staff") diff --git a/src/test/java/com/isums/userservice/infrastructures/listeners/EContractEventListenerTest.java b/src/test/java/com/isums/userservice/infrastructures/listeners/EContractEventListenerTest.java index 03d188a..00dbfbf 100644 --- a/src/test/java/com/isums/userservice/infrastructures/listeners/EContractEventListenerTest.java +++ b/src/test/java/com/isums/userservice/infrastructures/listeners/EContractEventListenerTest.java @@ -100,16 +100,15 @@ void nonConflictRethrows() throws Exception { } @Test - @DisplayName("rethrows RuntimeException when deserialization fails") - void jsonFailureRethrows() throws Exception { + @DisplayName("acks-and-skips poison message when deserialization fails (no DLQ retry)") + void jsonFailureAcksAndSkips() throws Exception { when(objectMapper.readValue(any(String.class), eq(CreateUserPlacedEvent.class))) .thenThrow(new RuntimeException("bad json")); - assertThatThrownBy(() -> listener.handleCreateUserEvent(record, ack)) - .isInstanceOf(RuntimeException.class); + listener.handleCreateUserEvent(record, ack); verifyNoInteractions(userService); - verify(ack, never()).acknowledge(); + verify(ack).acknowledge(); } } diff --git a/src/test/java/com/isums/userservice/services/UserServiceImplTest.java b/src/test/java/com/isums/userservice/services/UserServiceImplTest.java index 46c9735..c802476 100644 --- a/src/test/java/com/isums/userservice/services/UserServiceImplTest.java +++ b/src/test/java/com/isums/userservice/services/UserServiceImplTest.java @@ -116,7 +116,7 @@ class GetAllUsers { void returnsMappedList() { User u1 = buildUser(true); UserDto dto = new UserDto(userId.toString(), "Alice", keycloakId, email, "0123456789", "0900000000", - null, null, null); // dateOfIssue, placeOfIssue, permanentAddress + null, null, null, null, null, null, null, null, null, null, null); when(userRepository.findAll()).thenReturn(List.of(u1)); when(userMapper.mapUsers(List.of(u1))).thenReturn(List.of(dto)); @@ -146,7 +146,7 @@ class CreateUser { void happyPath() { KeycloakCreateUserRequest req = buildCreateReq(); Role tenantRole = buildRole(Roles.TENANT); - when(userRepository.existsByEmail(email)).thenReturn(false); + when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.empty()); when(keycloakClient.createUser(req)).thenReturn("kc-123"); when(roleRepository.findByCode(Roles.TENANT)).thenReturn(Optional.of(tenantRole)); @@ -167,25 +167,59 @@ void happyPath() { } @Test - @DisplayName("throws ConflictException when email already exists") - void emailAlreadyExists() { + @DisplayName("returns existing Keycloak id when email already exists") + void emailAlreadyExistsReturnsExistingKeycloakId() { KeycloakCreateUserRequest req = buildCreateReq(); - when(userRepository.existsByEmail(email)).thenReturn(true); + User existing = buildUser(false); + existing.setKeycloakId("kc-existing"); + when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.of(existing)); - assertThatThrownBy(() -> service.createUser(req)) - .isInstanceOf(ConflictException.class) - .hasMessageContaining(email); + assertThat(service.createUser(req)).isEqualTo("kc-existing"); verifyNoInteractions(keycloakClient); verify(userRepository, never()).save(any()); verify(userRoleRepository, never()).save(any()); } + @Test + @DisplayName("recovers Keycloak id when existing DB user has no Keycloak link") + void existingUserWithoutKeycloakIdRecoversLink() { + KeycloakCreateUserRequest req = buildCreateReq(); + User existing = buildUser(false); + existing.setKeycloakId(null); + when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.of(existing)); + when(keycloakClient.findUserIdByEmail(email)).thenReturn(Optional.of("kc-recovered")); + + assertThat(service.createUser(req)).isEqualTo("kc-recovered"); + + assertThat(existing.getKeycloakId()).isEqualTo("kc-recovered"); + verify(userRepository).save(existing); + verify(keycloakClient, never()).createUser(any()); + verify(userRoleRepository, never()).save(any()); + } + + @Test + @DisplayName("creates Keycloak user when existing DB user has no Keycloak link and Keycloak lookup misses") + void existingUserWithoutKeycloakIdCreatesMissingKeycloakUser() { + KeycloakCreateUserRequest req = buildCreateReq(); + User existing = buildUser(false); + existing.setKeycloakId(" "); + when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.of(existing)); + when(keycloakClient.findUserIdByEmail(email)).thenReturn(Optional.empty()); + when(keycloakClient.createUser(req)).thenReturn("kc-created"); + + assertThat(service.createUser(req)).isEqualTo("kc-created"); + + assertThat(existing.getKeycloakId()).isEqualTo("kc-created"); + verify(userRepository).save(existing); + verify(userRoleRepository, never()).save(any()); + } + @Test @DisplayName("throws IllegalStateException when Keycloak returns null id") void keycloakReturnsNull() { KeycloakCreateUserRequest req = buildCreateReq(); - when(userRepository.existsByEmail(email)).thenReturn(false); + when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.empty()); when(keycloakClient.createUser(req)).thenReturn(null); assertThatThrownBy(() -> service.createUser(req)) @@ -198,7 +232,7 @@ void keycloakReturnsNull() { @DisplayName("throws IllegalStateException when Keycloak returns blank id") void keycloakReturnsBlank() { KeycloakCreateUserRequest req = buildCreateReq(); - when(userRepository.existsByEmail(email)).thenReturn(false); + when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.empty()); when(keycloakClient.createUser(req)).thenReturn(" "); assertThatThrownBy(() -> service.createUser(req)) @@ -209,7 +243,7 @@ void keycloakReturnsBlank() { @DisplayName("throws NotFoundException when tenant role missing") void tenantRoleMissing() { KeycloakCreateUserRequest req = buildCreateReq(); - when(userRepository.existsByEmail(email)).thenReturn(false); + when(userRepository.findByEmailIgnoreCase(email)).thenReturn(Optional.empty()); when(keycloakClient.createUser(req)).thenReturn("kc-1"); when(roleRepository.findByCode(Roles.TENANT)).thenReturn(Optional.empty()); @@ -318,20 +352,19 @@ private DepositPaidEvent eventWith(UUID tenantId, String firstRentPaymentUrl) { } @Test - @DisplayName("activates disabled user (no password reset) and publishes UserActivatedEvent") + @DisplayName("activates disabled user, resets Keycloak password, publishes UserActivatedEvent with locale") void activatesAndPublishes() { User user = buildUser(false); + user.setLanguage("vi_VN"); DepositPaidEvent event = eventWith(userId, "https://pay.example/1"); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(keycloakClient.isUserEnabled(keycloakId)).thenReturn(false); + when(keycloakClient.activateAndResetPassword(keycloakId)).thenReturn("Tmp@123"); service.activateIfNewUser(event); verify(userRepository).save(any(User.class)); - verify(keycloakClient).activeUser(keycloakId); - // Password reset MUST NOT happen here — tenant uses Keycloak "Forgot - // password" flow to avoid plaintext credentials on Kafka. - verify(keycloakClient, never()).activateAndResetPassword(anyString()); - verify(keycloakClient, never()).resetPassword(anyString()); + verify(keycloakClient).activateAndResetPassword(keycloakId); assertThat(user.getIsEnabled()).isTrue(); ArgumentCaptor msgCap = ArgumentCaptor.forClass(Object.class); @@ -339,46 +372,129 @@ void activatesAndPublishes() { UserActivatedEvent msg = (UserActivatedEvent) msgCap.getValue(); assertThat(msg.userId()).isEqualTo(userId); assertThat(msg.firstRentPaymentUrl()).isEqualTo("https://pay.example/1"); + assertThat(msg.password()).isEqualTo("Tmp@123"); + assertThat(msg.locale()).isEqualTo("vi_VN"); + } + + @Test + @DisplayName("foreign tenant with en_US language → UserActivatedEvent.locale = en_US") + void foreignTenantLocale() { + User user = buildUser(false); + user.setLanguage("en_US"); + DepositPaidEvent event = eventWith(userId, "https://pay.example/1"); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(keycloakClient.isUserEnabled(keycloakId)).thenReturn(false); + when(keycloakClient.activateAndResetPassword(keycloakId)).thenReturn("Tmp@xyz"); + + service.activateIfNewUser(event); + + ArgumentCaptor msgCap = ArgumentCaptor.forClass(Object.class); + verify(kafka).send(eq("user-activated-topic"), msgCap.capture()); + UserActivatedEvent msg = (UserActivatedEvent) msgCap.getValue(); + assertThat(msg.locale()).isEqualTo("en_US"); + } + + @Test + @DisplayName("foreign tenant with ja_JP language → UserActivatedEvent.locale = ja_JP") + void japaneseTenantLocale() { + User user = buildUser(false); + user.setLanguage("ja_JP"); + DepositPaidEvent event = eventWith(userId, "https://pay.example/1"); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(keycloakClient.isUserEnabled(keycloakId)).thenReturn(false); + when(keycloakClient.activateAndResetPassword(keycloakId)).thenReturn("Tmp@jp"); + + service.activateIfNewUser(event); + + ArgumentCaptor msgCap = ArgumentCaptor.forClass(Object.class); + verify(kafka).send(eq("user-activated-topic"), msgCap.capture()); + UserActivatedEvent msg = (UserActivatedEvent) msgCap.getValue(); + assertThat(msg.locale()).isEqualTo("ja_JP"); + } + + @Test + @DisplayName("user with null language → UserActivatedEvent.locale defaults to vi_VN") + void nullLanguageDefaultsToViVn() { + User user = buildUser(false); + user.setLanguage(null); + DepositPaidEvent event = eventWith(userId, "https://pay.example/1"); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(keycloakClient.isUserEnabled(keycloakId)).thenReturn(false); + when(keycloakClient.activateAndResetPassword(keycloakId)).thenReturn("Tmp@123"); + + service.activateIfNewUser(event); + + ArgumentCaptor msgCap = ArgumentCaptor.forClass(Object.class); + verify(kafka).send(eq("user-activated-topic"), msgCap.capture()); + UserActivatedEvent msg = (UserActivatedEvent) msgCap.getValue(); + assertThat(msg.locale()).isEqualTo("vi_VN"); } @Test - @DisplayName("does not activate already-enabled user and skips welcome email") - void alreadyEnabledSkipsWelcomeEmail() { + @DisplayName("already-enabled new-account user resets password and publishes activation email") + void alreadyEnabledNewAccountStillGetsPasswordEmail() { User user = buildUser(true); DepositPaidEvent event = eventWith(userId, "https://pay.example/1"); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(keycloakClient.isUserEnabled(keycloakId)).thenReturn(true); + when(keycloakClient.resetPassword(keycloakId)).thenReturn("Tmp@enabled"); service.activateIfNewUser(event); - verify(keycloakClient, never()).activeUser(anyString()); verify(keycloakClient, never()).activateAndResetPassword(anyString()); + verify(keycloakClient).resetPassword(keycloakId); verify(userRepository, never()).save(any()); - verifyNoInteractions(kafka); + ArgumentCaptor msgCap = ArgumentCaptor.forClass(Object.class); + verify(kafka).send(eq("user-activated-topic"), msgCap.capture()); + UserActivatedEvent msg = (UserActivatedEvent) msgCap.getValue(); + assertThat(msg.password()).isEqualTo("Tmp@enabled"); + assertThat(msg.email()).isEqualTo(email); } @Test - @DisplayName("skips Kafka publish when firstRentPaymentUrl is null") - void nullUrlSkipsKafka() { - User user = buildUser(false); - DepositPaidEvent event = eventWith(userId, null); + @DisplayName("already-enabled existing user keeps existing password and skips activation email") + void alreadyEnabledExistingUserKeepsExistingPassword() { + User user = buildUser(true); + DepositPaidEvent event = DepositPaidEvent.builder() + .invoiceId(UUID.randomUUID()) + .contractId(UUID.randomUUID()) + .tenantId(userId) + .houseId(UUID.randomUUID()) + .amount(1_000_000L) + .invoiceType("DEPOSIT") + .txnNo("TXN1") + .paidAt(Instant.now()) + .tenantEmail(email) + .isNewAccount(false) + .build(); when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(keycloakClient.isUserEnabled(keycloakId)).thenReturn(true); service.activateIfNewUser(event); - verify(userRepository).save(any(User.class)); - verify(keycloakClient).activeUser(keycloakId); + verify(keycloakClient, never()).activateAndResetPassword(anyString()); + verify(keycloakClient, never()).resetPassword(anyString()); + verify(userRepository, never()).save(any()); verifyNoInteractions(kafka); } @Test - @DisplayName("throws NotFoundException when tenant missing") - void tenantMissing() { - DepositPaidEvent event = eventWith(userId, "https://pay.example/1"); - when(userRepository.findById(userId)).thenReturn(Optional.empty()); + @DisplayName("publishes activation email even when firstRentPaymentUrl is null") + void nullUrlStillPublishesActivationEmail() { + User user = buildUser(false); + DepositPaidEvent event = eventWith(userId, null); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(keycloakClient.isUserEnabled(keycloakId)).thenReturn(false); + when(keycloakClient.activateAndResetPassword(keycloakId)).thenReturn("Tmp@123"); - assertThatThrownBy(() -> service.activateIfNewUser(event)) - .isInstanceOf(NotFoundException.class); - verifyNoInteractions(kafka, keycloakClient); + service.activateIfNewUser(event); + + verify(userRepository).save(any(User.class)); + ArgumentCaptor msgCap = ArgumentCaptor.forClass(Object.class); + verify(kafka).send(eq("user-activated-topic"), msgCap.capture()); + UserActivatedEvent msg = (UserActivatedEvent) msgCap.getValue(); + assertThat(msg.password()).isEqualTo("Tmp@123"); + assertThat(msg.firstRentPaymentUrl()).isNull(); } } @@ -396,7 +512,7 @@ void happyPath() { CreateTechnicalStaffRequest r = req(); Role role = buildRole(Roles.TECHNICAL_STAFF); UserDto dto = new UserDto(userId.toString(), "Bob", "kc-2", "bob@example.com", "X1", "0999999999", - null, null, null); // dateOfIssue, placeOfIssue, permanentAddress + null, null, null, null, null, null, null, null, null, null, null); when(userRepository.existsByEmail(r.email())).thenReturn(false); when(keycloakClient.createUser(any(KeycloakCreateUserRequest.class))).thenReturn("kc-2"); @@ -580,4 +696,167 @@ void zeroHouses() { assertThat(dto.mainHouseId()).isNull(); } } + + @Nested + @DisplayName("applyProfileFromEvent") + class ApplyProfileFromEvent { + + private com.isums.userservice.domains.events.CreateUserPlacedEvent baseEvent() { + com.isums.userservice.domains.events.CreateUserPlacedEvent ev = + new com.isums.userservice.domains.events.CreateUserPlacedEvent(); + ev.setId(userId); + ev.setName("John Smith"); + ev.setEmail("john@example.com"); + ev.setPhoneNumber("14155550142"); + return ev; + } + + @Test + @DisplayName("persists full foreigner passport + visa + nationality + en_US language") + void foreignerProfile() { + User user = buildUser(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + com.isums.userservice.domains.events.CreateUserPlacedEvent ev = baseEvent(); + ev.setPassportNumber("A1234567"); + ev.setPassportIssueDate("2020-01-15"); + ev.setPassportExpiryDate("2030-01-15"); + ev.setNationality("USA"); + ev.setVisaType("DN1"); + ev.setVisaExpiryDate("2027-12-31"); + ev.setDateOfBirth("1990-05-20"); + ev.setGender("MALE"); + ev.setPermanentAddress("123 Main St, San Francisco, USA"); + ev.setLanguage("en_US"); + + service.applyProfileFromEvent(ev); + + ArgumentCaptor cap = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(cap.capture()); + User saved = cap.getValue(); + assertThat(saved.getPassportNumber()).isEqualTo("A1234567"); + assertThat(saved.getPassportIssueDate()).isEqualTo(java.time.LocalDate.of(2020, 1, 15)); + assertThat(saved.getPassportExpiryDate()).isEqualTo(java.time.LocalDate.of(2030, 1, 15)); + assertThat(saved.getNationality()).isEqualTo("USA"); + assertThat(saved.getVisaType()).isEqualTo("DN1"); + assertThat(saved.getVisaExpiryDate()).isEqualTo(java.time.LocalDate.of(2027, 12, 31)); + assertThat(saved.getDateOfBirth()).isEqualTo(java.time.LocalDate.of(1990, 5, 20)); + assertThat(saved.getGender()).isEqualTo("MALE"); + assertThat(saved.getPermanentAddress()).isEqualTo("123 Main St, San Francisco, USA"); + assertThat(saved.getLanguage()).isEqualTo("en_US"); + } + + @Test + @DisplayName("persists VN tenant CCCD profile + dateOfIssue + vi_VN language") + void vietnameseProfile() { + User user = buildUser(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + com.isums.userservice.domains.events.CreateUserPlacedEvent ev = baseEvent(); + ev.setDateOfIssue("2018-03-10"); + ev.setPlaceOfIssue("Cục CSQLHC về TTXH"); + ev.setPermanentAddress("Số 1 Phố Huế, Hà Nội"); + ev.setDateOfBirth("1995-07-15"); + ev.setGender("FEMALE"); + ev.setLanguage("vi_VN"); + + service.applyProfileFromEvent(ev); + + ArgumentCaptor cap = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(cap.capture()); + User saved = cap.getValue(); + assertThat(saved.getDateOfIssue()).isEqualTo(java.time.LocalDate.of(2018, 3, 10)); + assertThat(saved.getPlaceOfIssue()).isEqualTo("Cục CSQLHC về TTXH"); + assertThat(saved.getPermanentAddress()).isEqualTo("Số 1 Phố Huế, Hà Nội"); + assertThat(saved.getDateOfBirth()).isEqualTo(java.time.LocalDate.of(1995, 7, 15)); + assertThat(saved.getGender()).isEqualTo("FEMALE"); + assertThat(saved.getLanguage()).isEqualTo("vi_VN"); + assertThat(saved.getPassportNumber()).isNull(); + assertThat(saved.getNationality()).isNull(); + } + + @Test + @DisplayName("defaults language to vi_VN when event omits it (legacy producers)") + void defaultsLanguageWhenMissing() { + User user = buildUser(false); + user.setLanguage(null); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + com.isums.userservice.domains.events.CreateUserPlacedEvent ev = baseEvent(); + + service.applyProfileFromEvent(ev); + + ArgumentCaptor cap = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(cap.capture()); + assertThat(cap.getValue().getLanguage()).isEqualTo("vi_VN"); + } + + @Test + @DisplayName("does NOT clobber existing language when event omits it but DB already has it") + void preservesExistingLanguage() { + User user = buildUser(false); + user.setLanguage("ja_JP"); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + com.isums.userservice.domains.events.CreateUserPlacedEvent ev = baseEvent(); + + service.applyProfileFromEvent(ev); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("skips silently when user not found (idempotent against out-of-order events)") + void noUserNoOp() { + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + com.isums.userservice.domains.events.CreateUserPlacedEvent ev = baseEvent(); + ev.setPassportNumber("A1234567"); + service.applyProfileFromEvent(ev); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + @DisplayName("ignores invalid date string and continues with other fields") + void invalidDateGracefulSkip() { + User user = buildUser(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + com.isums.userservice.domains.events.CreateUserPlacedEvent ev = baseEvent(); + ev.setPassportIssueDate("not-a-date"); + ev.setNationality("FRA"); + ev.setLanguage("en_US"); + + service.applyProfileFromEvent(ev); + + ArgumentCaptor cap = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(cap.capture()); + User saved = cap.getValue(); + assertThat(saved.getPassportIssueDate()).isNull(); + assertThat(saved.getNationality()).isEqualTo("FRA"); + assertThat(saved.getLanguage()).isEqualTo("en_US"); + } + + @Test + @DisplayName("trims whitespace from string fields") + void trimsWhitespace() { + User user = buildUser(false); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + com.isums.userservice.domains.events.CreateUserPlacedEvent ev = baseEvent(); + ev.setNationality(" USA "); + ev.setVisaType(" DN1 "); + ev.setLanguage(" en_US "); + + service.applyProfileFromEvent(ev); + + ArgumentCaptor cap = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(cap.capture()); + User saved = cap.getValue(); + assertThat(saved.getNationality()).isEqualTo("USA"); + assertThat(saved.getVisaType()).isEqualTo("DN1"); + assertThat(saved.getLanguage()).isEqualTo("en_US"); + } + } }