From fe05e2ffa490f9a0a2152e1a27a9a07ed1f479bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Mon, 15 Dec 2025 10:25:40 +0100 Subject: [PATCH] Follow Users on the instance --- .../fitpub/config/SecurityConfig.java | 3 + .../fitpub/controller/UserController.java | 157 ++++++++++++++++++ .../operaton/fitpub/model/dto/UserDTO.java | 3 + .../resources/templates/profile/public.html | 101 ++++++++++- 4 files changed, 262 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java index 1f827f9..d60542c 100644 --- a/src/main/java/org/operaton/fitpub/config/SecurityConfig.java +++ b/src/main/java/org/operaton/fitpub/config/SecurityConfig.java @@ -123,6 +123,9 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/users/browse").permitAll() // Browse all users .requestMatchers(HttpMethod.GET, "/api/users/*/followers").permitAll() // User followers list .requestMatchers(HttpMethod.GET, "/api/users/*/following").permitAll() // User following list + .requestMatchers(HttpMethod.GET, "/api/users/*/follow-status").permitAll() // Follow status check + .requestMatchers(HttpMethod.POST, "/api/users/*/follow").authenticated() // Follow user + .requestMatchers(HttpMethod.DELETE, "/api/users/*/follow").authenticated() // Unfollow user // All other requests require authentication .anyRequest().authenticated() diff --git a/src/main/java/org/operaton/fitpub/controller/UserController.java b/src/main/java/org/operaton/fitpub/controller/UserController.java index 39bfd81..1d58591 100644 --- a/src/main/java/org/operaton/fitpub/controller/UserController.java +++ b/src/main/java/org/operaton/fitpub/controller/UserController.java @@ -23,6 +23,8 @@ import org.springframework.web.bind.annotation.*; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.UUID; /** @@ -271,4 +273,159 @@ public class UserController { log.debug("Found {} following for user {}", actorDTOs.size(), username); return ResponseEntity.ok(actorDTOs); } + + /** + * Follow a user. + * + * @param username the username to follow + * @param userDetails the authenticated user + * @return success response + */ + @PostMapping("/{username}/follow") + public ResponseEntity> followUser( + @PathVariable String username, + @AuthenticationPrincipal UserDetails userDetails + ) { + log.info("User {} attempting to follow {}", userDetails.getUsername(), username); + + // Get the current user + User currentUser = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("Current user not found")); + + // Get the user to follow + User userToFollow = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + // Cannot follow yourself + if (currentUser.getId().equals(userToFollow.getId())) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Cannot follow yourself")); + } + + String followingActorUri = userToFollow.getActorUri(baseUrl); + + // Check if already following + Optional existingFollow = followRepository.findByFollowerIdAndFollowingActorUri( + currentUser.getId(), followingActorUri + ); + + if (existingFollow.isPresent()) { + if (existingFollow.get().getStatus() == Follow.FollowStatus.ACCEPTED) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Already following this user")); + } else { + // Update existing pending follow to accepted + Follow follow = existingFollow.get(); + follow.setStatus(Follow.FollowStatus.ACCEPTED); + followRepository.save(follow); + log.info("Updated pending follow to accepted: {} -> {}", currentUser.getUsername(), username); + } + } else { + // Create new follow relationship + String activityId = baseUrl + "/activities/follow/" + UUID.randomUUID(); + Follow follow = Follow.builder() + .followerId(currentUser.getId()) + .followingActorUri(followingActorUri) + .status(Follow.FollowStatus.ACCEPTED) // Auto-accept for local-to-local follows + .activityId(activityId) + .build(); + followRepository.save(follow); + log.info("Created new follow: {} -> {}", currentUser.getUsername(), username); + } + + // Get updated follower count + long followersCount = followRepository.countAcceptedFollowersByActorUri(followingActorUri); + + return ResponseEntity.ok(Map.of( + "message", "Successfully followed " + username, + "followersCount", followersCount + )); + } + + /** + * Unfollow a user. + * + * @param username the username to unfollow + * @param userDetails the authenticated user + * @return success response + */ + @DeleteMapping("/{username}/follow") + public ResponseEntity> unfollowUser( + @PathVariable String username, + @AuthenticationPrincipal UserDetails userDetails + ) { + log.info("User {} attempting to unfollow {}", userDetails.getUsername(), username); + + // Get the current user + User currentUser = userRepository.findByUsername(userDetails.getUsername()) + .orElseThrow(() -> new UsernameNotFoundException("Current user not found")); + + // Get the user to unfollow + User userToUnfollow = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + String followingActorUri = userToUnfollow.getActorUri(baseUrl); + + // Find the follow relationship + Optional follow = followRepository.findByFollowerIdAndFollowingActorUri( + currentUser.getId(), followingActorUri + ); + + if (follow.isEmpty()) { + return ResponseEntity.badRequest() + .body(Map.of("error", "Not following this user")); + } + + // Delete the follow relationship + followRepository.delete(follow.get()); + log.info("Deleted follow: {} -> {}", currentUser.getUsername(), username); + + // Get updated follower count + long followersCount = followRepository.countAcceptedFollowersByActorUri(followingActorUri); + + return ResponseEntity.ok(Map.of( + "message", "Successfully unfollowed " + username, + "followersCount", followersCount + )); + } + + /** + * Check if the current user is following a specific user. + * + * @param username the username to check + * @param userDetails the authenticated user + * @return follow status + */ + @GetMapping("/{username}/follow-status") + public ResponseEntity> getFollowStatus( + @PathVariable String username, + @AuthenticationPrincipal UserDetails userDetails + ) { + if (userDetails == null) { + return ResponseEntity.ok(Map.of("isFollowing", false)); + } + + User currentUser = userRepository.findByUsername(userDetails.getUsername()) + .orElse(null); + + if (currentUser == null) { + return ResponseEntity.ok(Map.of("isFollowing", false)); + } + + User targetUser = userRepository.findByUsername(username) + .orElse(null); + + if (targetUser == null) { + return ResponseEntity.ok(Map.of("isFollowing", false)); + } + + String followingActorUri = targetUser.getActorUri(baseUrl); + Optional follow = followRepository.findByFollowerIdAndFollowingActorUri( + currentUser.getId(), followingActorUri + ); + + boolean isFollowing = follow.isPresent() && follow.get().getStatus() == Follow.FollowStatus.ACCEPTED; + + return ResponseEntity.ok(Map.of("isFollowing", isFollowing)); + } } diff --git a/src/main/java/org/operaton/fitpub/model/dto/UserDTO.java b/src/main/java/org/operaton/fitpub/model/dto/UserDTO.java index 3fc1407..a12b453 100644 --- a/src/main/java/org/operaton/fitpub/model/dto/UserDTO.java +++ b/src/main/java/org/operaton/fitpub/model/dto/UserDTO.java @@ -32,6 +32,9 @@ public class UserDTO { private Long followersCount; private Long followingCount; + // Follow status (populated separately, indicates if the current user is following this user) + private Boolean isFollowing; + /** * Creates a DTO from a User entity. * Note: email should only be included when user is viewing their own profile. diff --git a/src/main/resources/templates/profile/public.html b/src/main/resources/templates/profile/public.html index e26c3e2..b93aea9 100644 --- a/src/main/resources/templates/profile/public.html +++ b/src/main/resources/templates/profile/public.html @@ -50,7 +50,8 @@
@@ -196,7 +197,103 @@ document.getElementById('joinedDate').textContent = joinedDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); // Show follow button if viewing another user's profile - // TODO: implement follow functionality + checkAndShowFollowButton(); + } + + async function checkAndShowFollowButton() { + // Check if user is authenticated + if (!FitPubAuth.isAuthenticated()) { + return; + } + + // Get current user's username + const currentUsername = FitPubAuth.getUsername(); + + // If viewing own profile, don't show follow button + if (!currentUsername || currentUsername === targetUsername) { + return; + } + + // Show the follow button container + document.getElementById('followButtonContainer').classList.remove('d-none'); + + // Add click event listener to follow button + const followBtn = document.getElementById('followBtn'); + followBtn.addEventListener('click', handleFollowToggle); + + // Check follow status + await updateFollowButtonState(); + } + + async function updateFollowButtonState() { + try { + const response = await fetch(`/api/users/${targetUsername}/follow-status`, { + headers: { + 'Authorization': 'Bearer ' + localStorage.getItem('token') + } + }); + + if (response.ok) { + const data = await response.json(); + const isFollowing = data.isFollowing; + + const followBtn = document.getElementById('followBtn'); + const followBtnIcon = document.getElementById('followBtnIcon'); + const followBtnText = document.getElementById('followBtnText'); + + if (isFollowing) { + followBtn.className = 'btn btn-outline-danger'; + followBtnIcon.innerHTML = ''; + followBtnText.textContent = 'Unfollow'; + followBtn.dataset.following = 'true'; + } else { + followBtn.className = 'btn btn-primary'; + followBtnIcon.innerHTML = ''; + followBtnText.textContent = 'Follow'; + followBtn.dataset.following = 'false'; + } + } + } catch (error) { + console.error('Error checking follow status:', error); + } + } + + async function handleFollowToggle() { + const followBtn = document.getElementById('followBtn'); + const isFollowing = followBtn.dataset.following === 'true'; + + // Disable button during request + followBtn.disabled = true; + + try { + const method = isFollowing ? 'DELETE' : 'POST'; + const response = await FitPubAuth.authenticatedFetch(`/api/users/${targetUsername}/follow`, { + method: method + }); + + if (response.ok) { + const data = await response.json(); + + // Update follower count + if (data.followersCount !== undefined) { + document.getElementById('followersCount').textContent = data.followersCount; + } + + // Update button state + await updateFollowButtonState(); + + // Show success message + FitPub.showAlert(data.message, 'success'); + } else { + const errorData = await response.json(); + FitPub.showAlert(errorData.error || 'Failed to update follow status', 'danger'); + } + } catch (error) { + console.error('Error toggling follow:', error); + FitPub.showAlert('An error occurred. Please try again.', 'danger'); + } finally { + followBtn.disabled = false; + } } let currentPage = 0;