Added additional user data.

This commit is contained in:
Andrew Lalis 2023-02-04 13:26:29 +01:00
parent 3ec052b442
commit 3a0bae209b
11 changed files with 246 additions and 14 deletions

View File

@ -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<String, Object> 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);
}
}

View File

@ -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);
}
}

View File

@ -2,6 +2,7 @@ package nl.andrewlalis.gymboard_api.domains.api.model.exercise;
import jakarta.persistence.*; import jakarta.persistence.*;
import nl.andrewlalis.gymboard_api.domains.api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import java.math.BigDecimal; import java.math.BigDecimal;
@ -10,11 +11,6 @@ import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "exercise_submission") @Table(name = "exercise_submission")
public class ExerciseSubmission { public class ExerciseSubmission {
public enum WeightUnit {
KG,
LBS
}
@Id @Id
@Column(nullable = false, updatable = false, length = 26) @Column(nullable = false, updatable = false, length = 26)
private String id; private String id;

View File

@ -1,12 +1,13 @@
package nl.andrewlalis.gymboard_api.domains.api.service.submission; 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.GymRepository;
import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseRepository; 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.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.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.Exercise;
import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission;
import nl.andrewlalis.gymboard_api.util.ULID; import nl.andrewlalis.gymboard_api.util.ULID;
@ -66,10 +67,10 @@ public class ExerciseSubmissionService {
// Create the submission. // Create the submission.
BigDecimal rawWeight = BigDecimal.valueOf(payload.weight()); 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()); BigDecimal metricWeight = BigDecimal.valueOf(payload.weight());
if (unit == ExerciseSubmission.WeightUnit.LBS) { if (weightUnit == WeightUnit.POUNDS) {
metricWeight = metricWeight.multiply(new BigDecimal("0.45359237")); metricWeight = WeightUnit.toKilograms(rawWeight);
} }
ExerciseSubmission submission = exerciseSubmissionRepository.saveAndFlush(new ExerciseSubmission( ExerciseSubmission submission = exerciseSubmissionRepository.saveAndFlush(new ExerciseSubmission(
ulid.nextULID(), ulid.nextULID(),
@ -78,7 +79,7 @@ public class ExerciseSubmissionService {
payload.videoFileId(), payload.videoFileId(),
payload.name(), payload.name(),
rawWeight, rawWeight,
unit, weightUnit,
metricWeight, metricWeight,
payload.reps() payload.reps()
)); ));

View File

@ -2,11 +2,16 @@ package nl.andrewlalis.gymboard_api.domains.auth.dao;
import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode; import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.Optional; import java.util.Optional;
@Repository @Repository
public interface UserActivationCodeRepository extends JpaRepository<UserActivationCode, Long> { public interface UserActivationCodeRepository extends JpaRepository<UserActivationCode, Long> {
Optional<UserActivationCode> findByCode(String code); Optional<UserActivationCode> findByCode(String code);
@Modifying
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
} }

View File

@ -3,11 +3,14 @@ package nl.andrewlalis.gymboard_api.domains.auth.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "auth_user_password_reset_code") @Table(name = "auth_user_password_reset_code")
public class PasswordResetCode { public class PasswordResetCode {
public static final Duration VALID_FOR = Duration.ofMinutes(30);
@Id @Id
@Column(nullable = false, updatable = false, length = 127) @Column(nullable = false, updatable = false, length = 127)
private String code; private String code;

View File

@ -37,6 +37,9 @@ public class User {
) )
private Set<Role> roles; private Set<Role> roles;
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, optional = false)
private UserPersonalDetails personalDetails;
public User() {} public User() {}
public User(String id, boolean activated, String email, String passwordHash, String name) { public User(String id, boolean activated, String email, String passwordHash, String name) {

View File

@ -3,11 +3,14 @@ package nl.andrewlalis.gymboard_api.domains.auth.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@Entity @Entity
@Table(name = "auth_user_activation_code") @Table(name = "auth_user_activation_code")
public class UserActivationCode { public class UserActivationCode {
public static final Duration VALID_FOR = Duration.ofHours(24);
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; private Long id;

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -17,6 +17,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -25,6 +26,7 @@ import org.springframework.web.server.ResponseStatusException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Random; import java.util.Random;
import java.util.concurrent.TimeUnit;
@Service @Service
public class UserService { public class UserService {
@ -124,7 +126,7 @@ public class UserService {
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST));
User user = activationCode.getUser(); User user = activationCode.getUser();
if (!user.isActivated()) { if (!user.isActivated()) {
LocalDateTime cutoff = LocalDateTime.now().minusDays(1); LocalDateTime cutoff = LocalDateTime.now().minus(UserActivationCode.VALID_FOR);
if (activationCode.getCreatedAt().isBefore(cutoff)) { if (activationCode.getCreatedAt().isBefore(cutoff)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Code is expired."); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Code is expired.");
} }
@ -182,7 +184,7 @@ public class UserService {
public void resetUserPassword(PasswordResetPayload payload) { public void resetUserPassword(PasswordResetPayload payload) {
PasswordResetCode code = passwordResetCodeRepository.findById(payload.code()) PasswordResetCode code = passwordResetCodeRepository.findById(payload.code())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .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)) { if (code.getCreatedAt().isBefore(cutoff)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND); throw new ResponseStatusException(HttpStatus.NOT_FOUND);
} }
@ -205,4 +207,17 @@ public class UserService {
user.setPasswordHash(passwordEncoder.encode(payload.newPassword())); user.setPasswordHash(passwordEncoder.encode(payload.newPassword()));
userRepository.save(user); 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);
}
} }