From 3653fe697e59c53d85cfe585ef2b090ff38bd6bf Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Fri, 24 Mar 2023 16:21:03 +0100 Subject: [PATCH] Added email reset code and follow request. --- gymboard-api/pom.xml | 12 +- .../config/TokenAuthenticationFilter.java | 5 + .../auth/controller/UserController.java | 31 +++- .../auth/dao/EmailResetCodeRepository.java | 14 ++ .../auth/dao/UserFollowRequestRepository.java | 14 ++ .../auth/dao/UserFollowingRepository.java | 3 + .../domains/auth/dto/EmailUpdatePayload.java | 3 + .../auth/dto/UserFollowRequestApproval.java | 3 + .../domains/auth/dto/UserFollowResponse.java | 24 +++ .../domains/auth/model/EmailResetCode.java | 54 +++++++ .../domains/auth/model/PasswordResetCode.java | 4 + .../auth/model/TokenAuthentication.java | 7 + .../gymboard_api/domains/auth/model/User.java | 4 + .../domains/auth/model/UserFollowRequest.java | 73 ++++++++++ .../domains/auth/model/UserReport.java | 4 + .../domains/auth/service/UserService.java | 137 ++++++++++++++++-- .../gymboard_api/util/DataUtils.java | 20 +++ 17 files changed, 385 insertions(+), 27 deletions(-) create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/EmailResetCodeRepository.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserFollowRequestRepository.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/EmailUpdatePayload.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserFollowRequestApproval.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dto/UserFollowResponse.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/EmailResetCode.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/model/UserFollowRequest.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/DataUtils.java diff --git a/gymboard-api/pom.xml b/gymboard-api/pom.xml index fe9f07f..992c909 100644 --- a/gymboard-api/pom.xml +++ b/gymboard-api/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.0.2 + 3.0.5 nl.andrewlalis @@ -51,25 +51,25 @@ org.apache.commons commons-csv - 1.9.0 + 1.10.0 io.jsonwebtoken jjwt-api - 0.11.2 + 0.11.5 io.jsonwebtoken jjwt-impl - 0.11.2 + 0.11.5 runtime io.jsonwebtoken jjwt-jackson - 0.11.2 + 0.11.5 runtime @@ -82,7 +82,7 @@ org.mockito mockito-core - 5.1.1 + 5.2.0 test diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/TokenAuthenticationFilter.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/TokenAuthenticationFilter.java index 510e6ee..3fd012b 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/TokenAuthenticationFilter.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/TokenAuthenticationFilter.java @@ -15,6 +15,11 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +/** + * A filter that performs token authentication on incoming requests, and if a + * user's token is valid, sets the security context's authentication to a new + * instance of {@link TokenAuthentication}. + */ @Component public class TokenAuthenticationFilter extends OncePerRequestFilter { private final TokenService tokenService; 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 index 7e2451a..e3ef697 100644 --- 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 @@ -53,6 +53,18 @@ public class UserController { return ResponseEntity.ok().build(); } + @PostMapping(path = "/auth/me/email-reset-code") + public ResponseEntity generateEmailResetCode(@AuthenticationPrincipal User user, @RequestBody EmailUpdatePayload payload) { + userService.generateEmailResetCode(user.getId(), payload); + return ResponseEntity.ok().build(); + } + + @PostMapping(path = "/auth/me/email") + public ResponseEntity updateMyEmail(@AuthenticationPrincipal User user, @RequestParam String code) { + userService.updateEmail(user.getId(), code); + return ResponseEntity.ok().build(); + } + @GetMapping(path = "/auth/me/personal-details") public UserPersonalDetailsResponse getMyPersonalDetails(@AuthenticationPrincipal User user) { return userService.getPersonalDetails(user.getId()); @@ -85,9 +97,8 @@ public class UserController { } @PostMapping(path = "/auth/users/{userId}/followers") - public ResponseEntity followUser(@AuthenticationPrincipal User myUser, @PathVariable String userId) { - userService.followUser(myUser.getId(), userId); - return ResponseEntity.ok().build(); + public UserFollowResponse followUser(@AuthenticationPrincipal User myUser, @PathVariable String userId) { + return userService.followUser(myUser.getId(), userId); } @DeleteMapping(path = "/auth/users/{userId}/followers") @@ -96,13 +107,23 @@ public class UserController { return ResponseEntity.ok().build(); } + @PostMapping(path = "/auth/me/follow-requests/{followRequestId}") + public ResponseEntity respondToFollowRequest( + @AuthenticationPrincipal User myUser, + @PathVariable long followRequestId, + @RequestBody UserFollowRequestApproval payload + ) { + userService.respondToFollowRequest(myUser.getId(), followRequestId, payload.approve()); + return ResponseEntity.ok().build(); + } + @GetMapping(path = "/auth/me/followers") - public Page getFollowers(@AuthenticationPrincipal User user, Pageable pageable) { + public Page getMyFollowers(@AuthenticationPrincipal User user, Pageable pageable) { return userService.getFollowers(user.getId(), pageable); } @GetMapping(path = "/auth/me/following") - public Page getFollowing(@AuthenticationPrincipal User user, Pageable pageable) { + public Page getMyFollowing(@AuthenticationPrincipal User user, Pageable pageable) { return userService.getFollowing(user.getId(), pageable); } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/EmailResetCodeRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/EmailResetCodeRepository.java new file mode 100644 index 0000000..fe43518 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/EmailResetCodeRepository.java @@ -0,0 +1,14 @@ +package nl.andrewlalis.gymboard_api.domains.auth.dao; + +import nl.andrewlalis.gymboard_api.domains.auth.model.EmailResetCode; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; + +@Repository +public interface EmailResetCodeRepository extends JpaRepository { + @Modifying + void deleteAllByCreatedAtBefore(LocalDateTime cutoff); +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserFollowRequestRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserFollowRequestRepository.java new file mode 100644 index 0000000..6d4d949 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserFollowRequestRepository.java @@ -0,0 +1,14 @@ +package nl.andrewlalis.gymboard_api.domains.auth.dao; + +import nl.andrewlalis.gymboard_api.domains.auth.model.UserFollowRequest; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; + +@Repository +public interface UserFollowRequestRepository extends JpaRepository { + @Modifying + void deleteAllByCreatedAtBefore(LocalDateTime cutoff); +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserFollowingRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserFollowingRepository.java index f48e434..e4ee06f 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserFollowingRepository.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/auth/dao/UserFollowingRepository.java @@ -17,4 +17,7 @@ public interface UserFollowingRepository extends JpaRepository findAllByFollowedUserOrderByCreatedAtDesc(User followedUser, Pageable pageable); Page 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)); + } +}