diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/ErrorResponseHandler.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/ErrorResponseHandler.java new file mode 100644 index 0000000..9fe8f23 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/ErrorResponseHandler.java @@ -0,0 +1,38 @@ +package nl.andrewlalis.gymboard_api.config; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; + +import java.util.HashMap; +import java.util.Map; + +/** + * Handler for properly formatting error responses for exceptions that rest + * controllers in this service may throw. + */ +@RestControllerAdvice +public class ErrorResponseHandler { + @ExceptionHandler + public ResponseEntity handle(ResponseStatusException e) { + Map responseContent = new HashMap<>(1); + String message; + if (e.getReason() != null) { + message = e.getReason(); + } else { + if (e.getStatusCode() == HttpStatus.NOT_FOUND) { + message = "Resource not found."; + } else if (e.getStatusCode() == HttpStatus.FORBIDDEN) { + message = "Access to this resource is forbidden."; + } else if (e.getStatusCode() == HttpStatus.INTERNAL_SERVER_ERROR) { + message = "An internal error occurred. Please try again later."; + } else { + message = "The request could not be completed."; + } + } + responseContent.put("message", message); + return ResponseEntity.status(e.getStatusCode()).body(responseContent); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/WeightUnit.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/WeightUnit.java new file mode 100644 index 0000000..942dacf --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/WeightUnit.java @@ -0,0 +1,22 @@ +package nl.andrewlalis.gymboard_api.domains.api.model; + +import java.math.BigDecimal; + +public enum WeightUnit { + KILOGRAMS, + POUNDS; + + public static WeightUnit parse(String s) { + if (s == null || s.isBlank()) return KILOGRAMS; + s = s.strip().toUpperCase(); + if (s.equals("LB") || s.equals("LBS") || s.equals("POUND") || s.equals("POUNDS")) { + return POUNDS; + } + return KILOGRAMS; + } + + public static BigDecimal toKilograms(BigDecimal pounds) { + BigDecimal metric = new BigDecimal("0.45359237"); + return metric.multiply(pounds); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/exercise/ExerciseSubmission.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/exercise/ExerciseSubmission.java index fd81c8f..7f8fc86 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/exercise/ExerciseSubmission.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/exercise/ExerciseSubmission.java @@ -2,6 +2,7 @@ package nl.andrewlalis.gymboard_api.domains.api.model.exercise; import jakarta.persistence.*; import nl.andrewlalis.gymboard_api.domains.api.model.Gym; +import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit; import org.hibernate.annotations.CreationTimestamp; import java.math.BigDecimal; @@ -10,11 +11,6 @@ import java.time.LocalDateTime; @Entity @Table(name = "exercise_submission") public class ExerciseSubmission { - public enum WeightUnit { - KG, - LBS - } - @Id @Column(nullable = false, updatable = false, length = 26) private String id; diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java index 75c7b12..c08a34e 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java @@ -1,12 +1,13 @@ package nl.andrewlalis.gymboard_api.domains.api.service.submission; -import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId; -import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionPayload; -import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionResponse; import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseSubmissionRepository; +import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId; +import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionPayload; +import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionResponse; import nl.andrewlalis.gymboard_api.domains.api.model.Gym; +import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission; import nl.andrewlalis.gymboard_api.util.ULID; @@ -66,10 +67,10 @@ public class ExerciseSubmissionService { // Create the submission. BigDecimal rawWeight = BigDecimal.valueOf(payload.weight()); - ExerciseSubmission.WeightUnit unit = ExerciseSubmission.WeightUnit.valueOf(payload.weightUnit().toUpperCase()); + WeightUnit weightUnit = WeightUnit.parse(payload.weightUnit()); BigDecimal metricWeight = BigDecimal.valueOf(payload.weight()); - if (unit == ExerciseSubmission.WeightUnit.LBS) { - metricWeight = metricWeight.multiply(new BigDecimal("0.45359237")); + if (weightUnit == WeightUnit.POUNDS) { + metricWeight = WeightUnit.toKilograms(rawWeight); } ExerciseSubmission submission = exerciseSubmissionRepository.saveAndFlush(new ExerciseSubmission( ulid.nextULID(), @@ -78,7 +79,7 @@ public class ExerciseSubmissionService { payload.videoFileId(), payload.name(), rawWeight, - unit, + weightUnit, metricWeight, payload.reps() )); diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserActivationCodeRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserActivationCodeRepository.java index 4488d40..05fdb52 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserActivationCodeRepository.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserActivationCodeRepository.java @@ -2,11 +2,16 @@ package nl.andrewlalis.gymboard_api.domains.auth.dao; import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.Optional; @Repository public interface UserActivationCodeRepository extends JpaRepository { Optional findByCode(String code); + + @Modifying + void deleteAllByCreatedAtBefore(LocalDateTime cutoff); } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/PasswordResetCode.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/PasswordResetCode.java index 81867c8..b7823a0 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/PasswordResetCode.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/PasswordResetCode.java @@ -3,11 +3,14 @@ package nl.andrewlalis.gymboard_api.domains.auth.model; import jakarta.persistence.*; import org.hibernate.annotations.CreationTimestamp; +import java.time.Duration; import java.time.LocalDateTime; @Entity @Table(name = "auth_user_password_reset_code") public class PasswordResetCode { + public static final Duration VALID_FOR = Duration.ofMinutes(30); + @Id @Column(nullable = false, updatable = false, length = 127) private String code; 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 8d5c801..4d747ff 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 @@ -37,6 +37,9 @@ public class User { ) private Set roles; + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, optional = false) + private UserPersonalDetails personalDetails; + public User() {} public User(String id, boolean activated, String email, String passwordHash, String name) { diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserActivationCode.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserActivationCode.java index c3c3a24..c63507b 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserActivationCode.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserActivationCode.java @@ -3,11 +3,14 @@ package nl.andrewlalis.gymboard_api.domains.auth.model; import jakarta.persistence.*; import org.hibernate.annotations.CreationTimestamp; +import java.time.Duration; import java.time.LocalDateTime; @Entity @Table(name = "auth_user_activation_code") public class UserActivationCode { + public static final Duration VALID_FOR = Duration.ofHours(24); + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserFollowing.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserFollowing.java new file mode 100644 index 0000000..42bb450 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserFollowing.java @@ -0,0 +1,50 @@ +package nl.andrewlalis.gymboard_api.domains.auth.model; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +/** + * Stores information about a "following" relationship between users, which can + * have impacts on the notifications that a user can receive. + */ +@Entity +@Table(name = "auth_user_following") +public class UserFollowing { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @CreationTimestamp + private LocalDateTime createdAt; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + private User followedUser; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + private User followingUser; + + public UserFollowing() {} + + public UserFollowing(User followedUser, User followingUser) { + this.followedUser = followedUser; + this.followingUser = followingUser; + } + + public Long getId() { + return id; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public User getFollowedUser() { + return followedUser; + } + + public User getFollowingUser() { + return followingUser; + } +} 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 new file mode 100644 index 0000000..3b8a60d --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserPersonalDetails.java @@ -0,0 +1,96 @@ +package nl.andrewlalis.gymboard_api.domains.auth.model; + +import jakarta.persistence.*; +import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * Personal details that belong to a user. + */ +@Entity +@Table(name = "auth_user_personal_details") +public class UserPersonalDetails { + public enum PersonSex { + MALE, + FEMALE, + UNKNOWN + } + + @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; + + @Column + private LocalDate birthDate; + + @Column(precision = 7, scale = 2) + private BigDecimal currentWeight; + + @Column + @Enumerated(EnumType.STRING) + private WeightUnit currentWeightUnit; + + @Column(precision = 7, scale = 2) + private BigDecimal currentMetricWeight; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private PersonSex sex = PersonSex.UNKNOWN; + + public UserPersonalDetails() {} + + public UserPersonalDetails(User user) { + this.user = user; + this.userId = user.getId(); + } + + public User getUser() { + return user; + } + + public LocalDate getBirthDate() { + return birthDate; + } + + public BigDecimal getCurrentWeight() { + return currentWeight; + } + + public WeightUnit getCurrentWeightUnit() { + return currentWeightUnit; + } + + public BigDecimal getCurrentMetricWeight() { + return currentMetricWeight; + } + + public PersonSex getSex() { + return sex; + } + + public void setBirthDate(LocalDate birthDate) { + this.birthDate = birthDate; + } + + public void setCurrentWeight(BigDecimal currentWeight) { + this.currentWeight = currentWeight; + } + + public void setCurrentWeightUnit(WeightUnit currentWeightUnit) { + this.currentWeightUnit = currentWeightUnit; + } + + public void setCurrentMetricWeight(BigDecimal currentMetricWeight) { + this.currentMetricWeight = currentMetricWeight; + } + + public void setSex(PersonSex sex) { + this.sex = sex; + } +} 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 9913d87..90f4ad9 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 @@ -17,6 +17,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,6 +26,7 @@ import org.springframework.web.server.ResponseStatusException; import java.security.SecureRandom; import java.time.LocalDateTime; import java.util.Random; +import java.util.concurrent.TimeUnit; @Service public class UserService { @@ -124,7 +126,7 @@ public class UserService { .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); User user = activationCode.getUser(); if (!user.isActivated()) { - LocalDateTime cutoff = LocalDateTime.now().minusDays(1); + LocalDateTime cutoff = LocalDateTime.now().minus(UserActivationCode.VALID_FOR); if (activationCode.getCreatedAt().isBefore(cutoff)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Code is expired."); } @@ -182,7 +184,7 @@ public class UserService { public void resetUserPassword(PasswordResetPayload payload) { PasswordResetCode code = passwordResetCodeRepository.findById(payload.code()) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); - LocalDateTime cutoff = LocalDateTime.now().minusMinutes(30); + LocalDateTime cutoff = LocalDateTime.now().minus(PasswordResetCode.VALID_FOR); if (code.getCreatedAt().isBefore(cutoff)) { throw new ResponseStatusException(HttpStatus.NOT_FOUND); } @@ -205,4 +207,17 @@ public class UserService { user.setPasswordHash(passwordEncoder.encode(payload.newPassword())); userRepository.save(user); } + + /** + * Scheduled task that periodically removes all old authentication entities + * so that they don't clutter up the system. + */ + @Scheduled(fixedDelay = 1, timeUnit = TimeUnit.HOURS) + @Transactional + public void removeOldAuthEntities() { + LocalDateTime passwordResetCodeCutoff = LocalDateTime.now().minus(PasswordResetCode.VALID_FOR); + passwordResetCodeRepository.deleteAllByCreatedAtBefore(passwordResetCodeCutoff); + LocalDateTime activationCodeCutoff = LocalDateTime.now().minus(UserActivationCode.VALID_FOR); + activationCodeRepository.deleteAllByCreatedAtBefore(activationCodeCutoff); + } }