findAllByFollowingUserOrderByCreatedAtDesc(User followingUser, Pageable pageable);
+
+ long countByFollowedUser(User followedUser);
+ long countByFollowingUser(User followingUser);
}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/EmailUpdatePayload.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/EmailUpdatePayload.java
new file mode 100644
index 0000000..1330187
--- /dev/null
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/EmailUpdatePayload.java
@@ -0,0 +1,3 @@
+package nl.andrewlalis.gymboard_api.domains.auth.dto;
+
+public record EmailUpdatePayload(String newEmail) {}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserFollowRequestApproval.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserFollowRequestApproval.java
new file mode 100644
index 0000000..99290a6
--- /dev/null
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserFollowRequestApproval.java
@@ -0,0 +1,3 @@
+package nl.andrewlalis.gymboard_api.domains.auth.dto;
+
+public record UserFollowRequestApproval(boolean approve) {}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserFollowResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserFollowResponse.java
new file mode 100644
index 0000000..16ee005
--- /dev/null
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserFollowResponse.java
@@ -0,0 +1,24 @@
+package nl.andrewlalis.gymboard_api.domains.auth.dto;
+
+/**
+ * A response that's sent when a user requests to follow another.
+ */
+public record UserFollowResponse(
+ String result
+) {
+ private static final String RESULT_FOLLOWED = "FOLLOWED";
+ private static final String RESULT_REQUESTED = "REQUESTED";
+ private static final String RESULT_ALREADY_FOLLOWED = "ALREADY_FOLLOWED";
+
+ public static UserFollowResponse followed() {
+ return new UserFollowResponse(RESULT_FOLLOWED);
+ }
+
+ public static UserFollowResponse requested() {
+ return new UserFollowResponse(RESULT_REQUESTED);
+ }
+
+ public static UserFollowResponse alreadyFollowed() {
+ return new UserFollowResponse(RESULT_ALREADY_FOLLOWED);
+ }
+}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/EmailResetCode.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/EmailResetCode.java
new file mode 100644
index 0000000..b8c4ceb
--- /dev/null
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/EmailResetCode.java
@@ -0,0 +1,54 @@
+package nl.andrewlalis.gymboard_api.domains.auth.model;
+
+import jakarta.persistence.*;
+import org.hibernate.annotations.CreationTimestamp;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+
+/**
+ * A code that's sent to a user's new email address to confirm that they own
+ * it. Once confirmed, the user's email address will be updated.
+ */
+@Table(name = "auth_email_reset_code")
+@Entity
+public class EmailResetCode {
+ public static final Duration VALID_FOR = Duration.ofMinutes(30);
+
+ @Id
+ @Column(nullable = false, updatable = false, length = 127)
+ private String code;
+
+ @Column(nullable = false, updatable = false, unique = true)
+ private String newEmail;
+
+ @CreationTimestamp
+ private LocalDateTime createdAt;
+
+ @ManyToOne(optional = false, fetch = FetchType.LAZY)
+ private User user;
+
+ public EmailResetCode() {}
+
+ public EmailResetCode(String code, String newEmail, User user) {
+ this.code = code;
+ this.newEmail = newEmail;
+ this.user = user;
+ }
+
+ public String getCode() {
+ return code;
+ }
+
+ public String getNewEmail() {
+ return newEmail;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public User getUser() {
+ return user;
+ }
+}
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 b7823a0..71c5370 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
@@ -6,6 +6,10 @@ import org.hibernate.annotations.CreationTimestamp;
import java.time.Duration;
import java.time.LocalDateTime;
+/**
+ * A code that's sent to a user's email address to grant them access to change
+ * their password without needing to log in.
+ */
@Entity
@Table(name = "auth_user_password_reset_code")
public class PasswordResetCode {
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/TokenAuthentication.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/TokenAuthentication.java
index 45d8933..c670907 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/TokenAuthentication.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/TokenAuthentication.java
@@ -6,6 +6,13 @@ import org.springframework.security.core.GrantedAuthority;
import java.util.Collection;
import java.util.Collections;
+/**
+ * The authentication instance that's used to represent a user who has
+ * authenticated with an API token (so most users).
+ * @param user The user who authenticated. The user entity has its roles eagerly
+ * loaded.
+ * @param token The token that was used.
+ */
public record TokenAuthentication(
User user,
String token
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 0908f9f..3e0fa15 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
@@ -76,6 +76,10 @@ public class User {
return email;
}
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
public String getPasswordHash() {
return passwordHash;
}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserFollowRequest.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserFollowRequest.java
new file mode 100644
index 0000000..a20d842
--- /dev/null
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserFollowRequest.java
@@ -0,0 +1,73 @@
+package nl.andrewlalis.gymboard_api.domains.auth.model;
+
+import jakarta.persistence.*;
+import org.hibernate.annotations.CreationTimestamp;
+
+import java.time.Duration;
+import java.time.LocalDateTime;
+
+/**
+ * A request that one user sends to ask to follow another user.
+ */
+@Table(
+ name = "auth_user_follow_request",
+ uniqueConstraints = @UniqueConstraint(columnNames = {"requesting_user_id", "user_to_follow_id"})
+)
+@Entity
+public class UserFollowRequest {
+ public static final Duration VALID_FOR = Duration.ofDays(7);
+
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @CreationTimestamp
+ private LocalDateTime createdAt;
+
+ @ManyToOne(optional = false, fetch = FetchType.LAZY)
+ private User requestingUser;
+
+ @ManyToOne(optional = false, fetch = FetchType.LAZY)
+ private User userToFollow;
+
+ @Column
+ private Boolean approved;
+
+ @Column
+ private LocalDateTime decidedAt;
+
+ public UserFollowRequest() {}
+
+ public UserFollowRequest(User requestingUser, User userToFollow) {
+ this.requestingUser = requestingUser;
+ this.userToFollow = userToFollow;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public User getRequestingUser() {
+ return requestingUser;
+ }
+
+ public User getUserToFollow() {
+ return userToFollow;
+ }
+
+ public Boolean getApproved() {
+ return approved;
+ }
+
+ public LocalDateTime getDecidedAt() {
+ return decidedAt;
+ }
+
+ public void setApproved(boolean approved) {
+ this.approved = approved;
+ this.decidedAt = LocalDateTime.now();
+ }
+}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserReport.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserReport.java
index 86dc774..cde0887 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserReport.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserReport.java
@@ -5,6 +5,10 @@ import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
+/**
+ * A user report is submitted by one user to report inappropriate actions of
+ * another user.
+ */
@Entity
@Table(name = "auth_user_report")
public class UserReport {
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 0c5404d..f1da87c 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
@@ -28,6 +28,8 @@ import java.time.LocalDateTime;
import java.util.Random;
import java.util.concurrent.TimeUnit;
+import static nl.andrewlalis.gymboard_api.util.DataUtils.findByIdOrThrow;
+
@Service
public class UserService {
private static final Logger log = LoggerFactory.getLogger(UserService.class);
@@ -37,7 +39,9 @@ public class UserService {
private final UserPreferencesRepository userPreferencesRepository;
private final UserActivationCodeRepository activationCodeRepository;
private final PasswordResetCodeRepository passwordResetCodeRepository;
+ private final EmailResetCodeRepository emailResetCodeRepository;
private final UserFollowingRepository userFollowingRepository;
+ private final UserFollowRequestRepository followRequestRepository;
private final UserAccessService userAccessService;
private final ULID ulid;
private final PasswordEncoder passwordEncoder;
@@ -52,7 +56,10 @@ public class UserService {
UserPreferencesRepository userPreferencesRepository,
UserActivationCodeRepository activationCodeRepository,
PasswordResetCodeRepository passwordResetCodeRepository,
- UserFollowingRepository userFollowingRepository, UserAccessService userAccessService, ULID ulid,
+ EmailResetCodeRepository emailResetCodeRepository, UserFollowingRepository userFollowingRepository,
+ UserFollowRequestRepository followRequestRepository,
+ UserAccessService userAccessService,
+ ULID ulid,
PasswordEncoder passwordEncoder,
JavaMailSender mailSender
) {
@@ -61,7 +68,9 @@ public class UserService {
this.userPreferencesRepository = userPreferencesRepository;
this.activationCodeRepository = activationCodeRepository;
this.passwordResetCodeRepository = passwordResetCodeRepository;
+ this.emailResetCodeRepository = emailResetCodeRepository;
this.userFollowingRepository = userFollowingRepository;
+ this.followRequestRepository = followRequestRepository;
this.userAccessService = userAccessService;
this.ulid = ulid;
this.passwordEncoder = passwordEncoder;
@@ -218,6 +227,59 @@ public class UserService {
userRepository.save(user);
}
+ @Transactional
+ public void generateEmailResetCode(String id, EmailUpdatePayload payload) {
+ User user = findByIdOrThrow(id, userRepository);
+ if (userRepository.existsByEmail(payload.newEmail())) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Email is taken.");
+ }
+ EmailResetCode emailResetCode = emailResetCodeRepository.save(new EmailResetCode(
+ StringGenerator.randomString(127, StringGenerator.Alphabet.ALPHANUMERIC),
+ payload.newEmail(),
+ user
+ ));
+ String emailContent = String.format(
+ """
+ Hello %s,
+
+
+ You've just requested to change your email from %s to this email address.
+
+
+
+ Please click enter this code to reset your email: %s
+
+ """,
+ user.getName(),
+ user.getEmail(),
+ emailResetCode.getCode()
+ );
+ MimeMessage msg = mailSender.createMimeMessage();
+ try {
+ MimeMessageHelper helper = new MimeMessageHelper(msg, "UTF-8");
+ helper.setFrom("Gymboard ");
+ helper.setSubject("Gymboard Account Email Update");
+ helper.setTo(emailResetCode.getNewEmail());
+ helper.setText(emailContent, true);
+ mailSender.send(msg);
+ } catch (MessagingException e) {
+ log.error("Error sending user email update email.", e);
+ throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
+ }
+ }
+
+ @Transactional
+ public void updateEmail(String userId, String code) {
+ User user = findByIdOrThrow(userId, userRepository);
+ EmailResetCode emailResetCode = findByIdOrThrow(code, emailResetCodeRepository);
+ if (!emailResetCode.getUser().getId().equals(user.getId())) {
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND);
+ }
+ user.setEmail(emailResetCode.getNewEmail());
+ userRepository.save(user);
+ emailResetCodeRepository.delete(emailResetCode);
+ }
+
/**
* Scheduled task that periodically removes all old authentication entities
* so that they don't clutter up the system.
@@ -229,6 +291,10 @@ public class UserService {
passwordResetCodeRepository.deleteAllByCreatedAtBefore(passwordResetCodeCutoff);
LocalDateTime activationCodeCutoff = LocalDateTime.now().minus(UserActivationCode.VALID_FOR);
activationCodeRepository.deleteAllByCreatedAtBefore(activationCodeCutoff);
+ LocalDateTime followRequestCutoff = LocalDateTime.now().minus(UserFollowRequest.VALID_FOR);
+ followRequestRepository.deleteAllByCreatedAtBefore(followRequestCutoff);
+ LocalDateTime emailResetCodeCutoff = LocalDateTime.now().minus(EmailResetCode.VALID_FOR);
+ emailResetCodeRepository.deleteAllByCreatedAtBefore(emailResetCodeCutoff);
}
@Transactional
@@ -280,30 +346,64 @@ public class UserService {
return new UserPreferencesResponse(p);
}
+ /**
+ * When a user indicates that they'd like to follow another, this method is
+ * invoked. If the person they want to follow is private, we create a new
+ * {@link UserFollowRequest} that the person must approve. Otherwise, the
+ * user just starts following the person. A 400 bad request is thrown if the
+ * user tries to follow themselves.
+ * @param followerId The id of the user that's trying to follow a user.
+ * @param followedId The id of the user that's being followed.
+ * @return A response that indicates the outcome.
+ */
@Transactional
- public void followUser(String followerId, String followedId) {
- if (followerId.equals(followedId)) return;
- User follower = userRepository.findById(followerId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
- User followed = userRepository.findById(followedId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
+ public UserFollowResponse followUser(String followerId, String followedId) {
+ if (followerId.equals(followedId)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "You can't follow yourself.");
+ User follower = findByIdOrThrow(followerId, userRepository);
+ User followed = findByIdOrThrow(followedId, userRepository);
if (!userFollowingRepository.existsByFollowedUserAndFollowingUser(followed, follower)) {
- userFollowingRepository.save(new UserFollowing(followed, follower));
+ if (followed.getPreferences().isAccountPrivate()) {
+ userFollowingRepository.save(new UserFollowing(followed, follower));
+ return UserFollowResponse.requested();
+ } else {
+ followRequestRepository.save(new UserFollowRequest(follower, followed));
+ return UserFollowResponse.followed();
+ }
}
+ return UserFollowResponse.alreadyFollowed();
}
@Transactional
public void unfollowUser(String followerId, String followedId) {
if (followerId.equals(followedId)) return;
- User follower = userRepository.findById(followerId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
- User followed = userRepository.findById(followedId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
+ User follower = findByIdOrThrow(followerId, userRepository);
+ User followed = findByIdOrThrow(followedId, userRepository);
userFollowingRepository.deleteByFollowedUserAndFollowingUser(followed, follower);
}
+ @Transactional
+ public void respondToFollowRequest(String userId, long followRequestId, boolean approved) {
+ User followedUser = findByIdOrThrow(userId, userRepository);
+ UserFollowRequest followRequest = findByIdOrThrow(followRequestId, followRequestRepository);
+ if (!followRequest.getUserToFollow().getId().equals(followedUser.getId())) {
+ throw new ResponseStatusException(HttpStatus.NOT_FOUND);
+ }
+ if (followRequest.getApproved() != null) {
+ throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Request already decided.");
+ }
+ followRequest.setApproved(approved);
+ followRequestRepository.save(followRequest);
+ if (approved) {
+ userFollowingRepository.save(new UserFollowing(followedUser, followRequest.getRequestingUser()));
+ // TODO: Send notification to the user who requested to follow.
+ }
+ }
+
@Transactional(readOnly = true)
public Page getFollowers(String userId, Pageable pageable) {
- User user = userRepository.findById(userId)
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
+ User user = findByIdOrThrow(userId, userRepository);
userAccessService.enforceUserAccess(user);
return userFollowingRepository.findAllByFollowedUserOrderByCreatedAtDesc(user, pageable)
.map(UserFollowing::getFollowingUser)
@@ -312,20 +412,25 @@ public class UserService {
@Transactional(readOnly = true)
public Page getFollowing(String userId, Pageable pageable) {
- User user = userRepository.findById(userId)
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
+ User user = findByIdOrThrow(userId, userRepository);
userAccessService.enforceUserAccess(user);
return userFollowingRepository.findAllByFollowingUserOrderByCreatedAtDesc(user, pageable)
.map(UserFollowing::getFollowedUser)
.map(UserResponse::new);
}
+ public long getFollowerCount(String userId) {
+ return userFollowingRepository.countByFollowedUser(findByIdOrThrow(userId, userRepository));
+ }
+
+ public long getFollowingCount(String userId) {
+ return userFollowingRepository.countByFollowingUser(findByIdOrThrow(userId, userRepository));
+ }
+
@Transactional(readOnly = true)
public UserRelationshipResponse getRelationship(String user1Id, String user2Id) {
- User user1 = userRepository.findById(user1Id)
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
- User user2 = userRepository.findById(user2Id)
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
+ User user1 = findByIdOrThrow(user1Id, userRepository);
+ User user2 = findByIdOrThrow(user2Id, userRepository);
userAccessService.enforceUserAccess(user1);
boolean user1FollowingUser2 = userFollowingRepository.existsByFollowedUserAndFollowingUser(user2, user1);
boolean user1FollowedByUser2 = userFollowingRepository.existsByFollowedUserAndFollowingUser(user1, user2);
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/DataUtils.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/DataUtils.java
new file mode 100644
index 0000000..c0ae671
--- /dev/null
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/DataUtils.java
@@ -0,0 +1,20 @@
+package nl.andrewlalis.gymboard_api.util;
+
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+
+public class DataUtils {
+ /**
+ * Finds an entity by its id, or throws a 404 not found exception.
+ * @param id The id to look for.
+ * @param repo The repository to search in.
+ * @return The entity that was found.
+ * @param The entity type.
+ * @param The id type.
+ * @throws ResponseStatusException If the entity wasn't found.
+ */
+ public static T findByIdOrThrow(ID id, CrudRepository repo) throws ResponseStatusException {
+ return repo.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
+ }
+}