diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/controller/UserController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/controller/UserController.java new file mode 100644 index 0000000..0fadbd9 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/controller/UserController.java @@ -0,0 +1,34 @@ +package nl.andrewlalis.gymboard_api.domains.auth.controller; + +import nl.andrewlalis.gymboard_api.domains.auth.dto.UserPersonalDetailsPayload; +import nl.andrewlalis.gymboard_api.domains.auth.dto.UserPersonalDetailsResponse; +import nl.andrewlalis.gymboard_api.domains.auth.dto.UserResponse; +import nl.andrewlalis.gymboard_api.domains.auth.model.User; +import nl.andrewlalis.gymboard_api.domains.auth.service.UserService; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class UserController { + private final UserService userService; + + public UserController(UserService userService) { + this.userService = userService; + } + + @GetMapping(path = "/auth/me/personal-details") + public UserPersonalDetailsResponse getMyPersonalDetails(@AuthenticationPrincipal User user) { + return userService.getPersonalDetails(user.getId()); + } + + @PostMapping(path = "/auth/me/personal-details") + public UserResponse updateMyPersonalDetails( + @AuthenticationPrincipal User user, + @RequestBody UserPersonalDetailsPayload payload + ) { + return userService.updatePersonalDetails(user.getId(), payload); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserPersonalDetailsRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserPersonalDetailsRepository.java new file mode 100644 index 0000000..b83c86d --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserPersonalDetailsRepository.java @@ -0,0 +1,8 @@ +package nl.andrewlalis.gymboard_api.domains.auth.dao; + +import nl.andrewlalis.gymboard_api.domains.auth.model.UserPersonalDetails; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserPersonalDetailsRepository extends JpaRepository {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserPersonalDetailsPayload.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserPersonalDetailsPayload.java new file mode 100644 index 0000000..348d1bc --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserPersonalDetailsPayload.java @@ -0,0 +1,10 @@ +package nl.andrewlalis.gymboard_api.domains.auth.dto; + +import java.time.LocalDate; + +public record UserPersonalDetailsPayload( + LocalDate birthDate, + Float currentWeight, + String currentWeightUnit, + String sex +) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserPersonalDetailsResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserPersonalDetailsResponse.java new file mode 100644 index 0000000..7ba7613 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserPersonalDetailsResponse.java @@ -0,0 +1,25 @@ +package nl.andrewlalis.gymboard_api.domains.auth.dto; + +import nl.andrewlalis.gymboard_api.domains.auth.model.UserPersonalDetails; + +import java.time.format.DateTimeFormatter; + +public record UserPersonalDetailsResponse( + String userId, + String birthDate, + float currentWeight, + String currentWeightUnit, + float currentMetricWeight, + String sex +) { + public UserPersonalDetailsResponse(UserPersonalDetails pd) { + this( + pd.getUserId(), + pd.getBirthDate().format(DateTimeFormatter.ISO_LOCAL_DATE), + pd.getCurrentWeight().floatValue(), + pd.getCurrentWeightUnit().name(), + pd.getCurrentMetricWeight().floatValue(), + pd.getSex().name() + ); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/User.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/User.java index 80b8b03..0908f9f 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/User.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/User.java @@ -40,6 +40,9 @@ public class User { @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, optional = false, fetch = FetchType.LAZY) private UserPersonalDetails personalDetails; + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, optional = false, fetch = FetchType.LAZY) + private UserPreferences preferences; + public User() {} public User(String id, boolean activated, String email, String passwordHash, String name) { @@ -50,6 +53,7 @@ public class User { this.name = name; this.roles = new HashSet<>(); this.personalDetails = new UserPersonalDetails(this); + this.preferences = new UserPreferences(this); } public String getId() { @@ -91,4 +95,8 @@ public class User { public UserPersonalDetails getPersonalDetails() { return personalDetails; } + + public UserPreferences getPreferences() { + return preferences; + } } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserPersonalDetails.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserPersonalDetails.java index 3b8a60d..66c3719 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserPersonalDetails.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserPersonalDetails.java @@ -15,7 +15,19 @@ public class UserPersonalDetails { public enum PersonSex { MALE, FEMALE, - UNKNOWN + UNKNOWN; + + public static PersonSex parse(String s) { + if (s != null && !s.isBlank()) { + s = s.strip().toUpperCase(); + if (s.equals("M") || s.equals("MALE") || s.equals("MAN")) { + return MALE; + } else if (s.equals("F") || s.equals("FEMALE") || s.equals("WOMAN")) { + return FEMALE; + } + } + return UNKNOWN; + } } @Id @@ -50,6 +62,10 @@ public class UserPersonalDetails { this.userId = user.getId(); } + public String getUserId() { + return userId; + } + public User getUser() { return user; } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserPreferences.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserPreferences.java new file mode 100644 index 0000000..cf1fc9e --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserPreferences.java @@ -0,0 +1,60 @@ +package nl.andrewlalis.gymboard_api.domains.auth.model; + +import jakarta.persistence.*; + +@Entity +@Table(name = "auth_user_preferences") +public class UserPreferences { + @Id + @Column(name = "user_id", length = 26) + private String userId; + + @OneToOne(optional = false, fetch = FetchType.LAZY) + @PrimaryKeyJoinColumn(name = "user_id", referencedColumnName = "id") + private User user; + + /** + * Flag which, if true, indicates that the user's account is private, and + * only approved users may view certain information about them. + */ + @Column(nullable = false) + private boolean accountPrivate = false; + + /** + * The user's preferred locale. This should always refer to one of the + * available locales offered by the front-end app. + */ + @Column(nullable = false) + private String locale = "en-US"; + + public UserPreferences() {} + + public UserPreferences(User user) { + this.user = user; + this.userId = user.getId(); + } + + public String getUserId() { + return userId; + } + + public User getUser() { + return user; + } + + public boolean isAccountPrivate() { + return accountPrivate; + } + + public void setAccountPrivate(boolean accountPrivate) { + this.accountPrivate = accountPrivate; + } + + public String getLocale() { + return locale; + } + + public void setLocale(String locale) { + this.locale = locale; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserService.java index 90f4ad9..ad751a3 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/service/UserService.java @@ -2,13 +2,16 @@ package nl.andrewlalis.gymboard_api.domains.auth.service; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit; import nl.andrewlalis.gymboard_api.domains.auth.dao.PasswordResetCodeRepository; -import nl.andrewlalis.gymboard_api.domains.auth.dto.*; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserActivationCodeRepository; +import nl.andrewlalis.gymboard_api.domains.auth.dao.UserPersonalDetailsRepository; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository; +import nl.andrewlalis.gymboard_api.domains.auth.dto.*; import nl.andrewlalis.gymboard_api.domains.auth.model.PasswordResetCode; import nl.andrewlalis.gymboard_api.domains.auth.model.User; import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode; +import nl.andrewlalis.gymboard_api.domains.auth.model.UserPersonalDetails; import nl.andrewlalis.gymboard_api.util.StringGenerator; import nl.andrewlalis.gymboard_api.util.ULID; import org.slf4j.Logger; @@ -23,6 +26,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.Random; @@ -33,6 +37,7 @@ public class UserService { private static final Logger log = LoggerFactory.getLogger(UserService.class); private final UserRepository userRepository; + private final UserPersonalDetailsRepository userPersonalDetailsRepository; private final UserActivationCodeRepository activationCodeRepository; private final PasswordResetCodeRepository passwordResetCodeRepository; private final ULID ulid; @@ -44,13 +49,14 @@ public class UserService { public UserService( UserRepository userRepository, - UserActivationCodeRepository activationCodeRepository, + UserPersonalDetailsRepository userPersonalDetailsRepository, UserActivationCodeRepository activationCodeRepository, PasswordResetCodeRepository passwordResetCodeRepository, ULID ulid, PasswordEncoder passwordEncoder, JavaMailSender mailSender ) { this.userRepository = userRepository; + this.userPersonalDetailsRepository = userPersonalDetailsRepository; this.activationCodeRepository = activationCodeRepository; this.passwordResetCodeRepository = passwordResetCodeRepository; this.ulid = ulid; @@ -220,4 +226,36 @@ public class UserService { LocalDateTime activationCodeCutoff = LocalDateTime.now().minus(UserActivationCode.VALID_FOR); activationCodeRepository.deleteAllByCreatedAtBefore(activationCodeCutoff); } + + @Transactional + public UserResponse updatePersonalDetails(String id, UserPersonalDetailsPayload payload) { + User user = userRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + var pd = user.getPersonalDetails(); + + pd.setBirthDate(payload.birthDate()); + BigDecimal currentWeight = payload.currentWeight() == null ? null : BigDecimal.valueOf(payload.currentWeight()); + WeightUnit currentWeightUnit = WeightUnit.parse(payload.currentWeightUnit()); + BigDecimal currentMetricWeight = null; + if (currentWeight != null) { + if (currentWeightUnit == WeightUnit.POUNDS) { + currentMetricWeight = WeightUnit.toKilograms(currentWeight); + } else { + currentMetricWeight = new BigDecimal(currentWeight.toString()); + } + } + pd.setCurrentWeight(currentWeight); + pd.setCurrentWeightUnit(currentWeightUnit); + pd.setCurrentMetricWeight(currentMetricWeight); + pd.setSex(UserPersonalDetails.PersonSex.parse(payload.sex())); + user = userRepository.save(user); + return new UserResponse(user); + } + + @Transactional(readOnly = true) + public UserPersonalDetailsResponse getPersonalDetails(String id) { + var pd = userPersonalDetailsRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + return new UserPersonalDetailsResponse(pd); + } } diff --git a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/config/WebConfig.java b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/config/WebConfig.java index 0a5e374..b6377de 100644 --- a/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/config/WebConfig.java +++ b/gymboard-search/src/main/java/nl/andrewlalis/gymboardsearch/config/WebConfig.java @@ -3,7 +3,6 @@ package nl.andrewlalis.gymboardsearch.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter;