From 47fc78a603246ed90959d633b9ca914a1acb6612 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 2 Jul 2025 18:17:32 +0800 Subject: [PATCH] Add subscription feature --- .../controller/SubscriptionController.java | 44 ++++++++ .../openisle/controller/UserController.java | 20 ++++ .../openisle/model/CommentSubscription.java | 27 +++++ .../com/openisle/model/NotificationType.java | 6 +- .../com/openisle/model/PostSubscription.java | 27 +++++ .../com/openisle/model/UserSubscription.java | 27 +++++ .../CommentSubscriptionRepository.java | 15 +++ .../PostSubscriptionRepository.java | 15 +++ .../UserSubscriptionRepository.java | 16 +++ .../com/openisle/service/CommentService.java | 27 +++++ .../com/openisle/service/PostService.java | 13 ++- .../openisle/service/SubscriptionService.java | 102 ++++++++++++++++++ 12 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/openisle/controller/SubscriptionController.java create mode 100644 src/main/java/com/openisle/model/CommentSubscription.java create mode 100644 src/main/java/com/openisle/model/PostSubscription.java create mode 100644 src/main/java/com/openisle/model/UserSubscription.java create mode 100644 src/main/java/com/openisle/repository/CommentSubscriptionRepository.java create mode 100644 src/main/java/com/openisle/repository/PostSubscriptionRepository.java create mode 100644 src/main/java/com/openisle/repository/UserSubscriptionRepository.java create mode 100644 src/main/java/com/openisle/service/SubscriptionService.java diff --git a/src/main/java/com/openisle/controller/SubscriptionController.java b/src/main/java/com/openisle/controller/SubscriptionController.java new file mode 100644 index 000000000..1ed486c26 --- /dev/null +++ b/src/main/java/com/openisle/controller/SubscriptionController.java @@ -0,0 +1,44 @@ +package com.openisle.controller; + +import com.openisle.service.SubscriptionService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +/** Endpoints for subscribing to posts, comments and users. */ +@RestController +@RequestMapping("/api/subscriptions") +@RequiredArgsConstructor +public class SubscriptionController { + private final SubscriptionService subscriptionService; + + @PostMapping("/posts/{postId}") + public void subscribePost(@PathVariable Long postId, Authentication auth) { + subscriptionService.subscribePost(auth.getName(), postId); + } + + @DeleteMapping("/posts/{postId}") + public void unsubscribePost(@PathVariable Long postId, Authentication auth) { + subscriptionService.unsubscribePost(auth.getName(), postId); + } + + @PostMapping("/comments/{commentId}") + public void subscribeComment(@PathVariable Long commentId, Authentication auth) { + subscriptionService.subscribeComment(auth.getName(), commentId); + } + + @DeleteMapping("/comments/{commentId}") + public void unsubscribeComment(@PathVariable Long commentId, Authentication auth) { + subscriptionService.unsubscribeComment(auth.getName(), commentId); + } + + @PostMapping("/users/{username}") + public void subscribeUser(@PathVariable String username, Authentication auth) { + subscriptionService.subscribeUser(auth.getName(), username); + } + + @DeleteMapping("/users/{username}") + public void unsubscribeUser(@PathVariable String username, Authentication auth) { + subscriptionService.unsubscribeUser(auth.getName(), username); + } +} diff --git a/src/main/java/com/openisle/controller/UserController.java b/src/main/java/com/openisle/controller/UserController.java index ebca58bd1..2a9615240 100644 --- a/src/main/java/com/openisle/controller/UserController.java +++ b/src/main/java/com/openisle/controller/UserController.java @@ -5,6 +5,7 @@ import com.openisle.service.ImageUploader; import com.openisle.service.UserService; import com.openisle.service.PostService; import com.openisle.service.CommentService; +import com.openisle.service.SubscriptionService; import org.springframework.beans.factory.annotation.Value; import lombok.Data; import lombok.RequiredArgsConstructor; @@ -24,6 +25,7 @@ public class UserController { private final ImageUploader imageUploader; private final PostService postService; private final CommentService commentService; + private final SubscriptionService subscriptionService; @Value("${app.upload.check-type:true}") private boolean checkImageType; @@ -86,6 +88,20 @@ public class UserController { .collect(java.util.stream.Collectors.toList()); } + @GetMapping("/{username}/following") + public java.util.List following(@PathVariable String username) { + return subscriptionService.getSubscribedUsers(username).stream() + .map(this::toDto) + .collect(java.util.stream.Collectors.toList()); + } + + @GetMapping("/{username}/followers") + public java.util.List followers(@PathVariable String username) { + return subscriptionService.getSubscribers(username).stream() + .map(this::toDto) + .collect(java.util.stream.Collectors.toList()); + } + @GetMapping("/{username}/all") public ResponseEntity userAggregate(@PathVariable String username, @RequestParam(value = "postsLimit", required = false) Integer postsLimit, @@ -112,6 +128,8 @@ public class UserController { dto.setUsername(user.getUsername()); dto.setEmail(user.getEmail()); dto.setAvatar(user.getAvatar()); + dto.setFollowers(subscriptionService.countSubscribers(user.getUsername())); + dto.setFollowing(subscriptionService.countSubscribed(user.getUsername())); return dto; } @@ -140,6 +158,8 @@ public class UserController { private String username; private String email; private String avatar; + private long followers; + private long following; } @Data diff --git a/src/main/java/com/openisle/model/CommentSubscription.java b/src/main/java/com/openisle/model/CommentSubscription.java new file mode 100644 index 000000000..a301da175 --- /dev/null +++ b/src/main/java/com/openisle/model/CommentSubscription.java @@ -0,0 +1,27 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** Subscription to a comment for replies notifications. */ +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "comment_subscriptions", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "comment_id"})) +public class CommentSubscription { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "comment_id") + private Comment comment; +} diff --git a/src/main/java/com/openisle/model/NotificationType.java b/src/main/java/com/openisle/model/NotificationType.java index d1abfbbbe..1771fff28 100644 --- a/src/main/java/com/openisle/model/NotificationType.java +++ b/src/main/java/com/openisle/model/NotificationType.java @@ -11,5 +11,9 @@ public enum NotificationType { /** Someone reacted to your post or comment */ REACTION, /** Your post under review was approved or rejected */ - POST_REVIEWED + POST_REVIEWED, + /** A subscribed post received a new comment */ + POST_UPDATED, + /** A user you subscribe to created a post or comment */ + USER_ACTIVITY } diff --git a/src/main/java/com/openisle/model/PostSubscription.java b/src/main/java/com/openisle/model/PostSubscription.java new file mode 100644 index 000000000..c993c359e --- /dev/null +++ b/src/main/java/com/openisle/model/PostSubscription.java @@ -0,0 +1,27 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** Subscription to a post for update notifications. */ +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "post_subscriptions", + uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "post_id"})) +public class PostSubscription { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "post_id") + private Post post; +} diff --git a/src/main/java/com/openisle/model/UserSubscription.java b/src/main/java/com/openisle/model/UserSubscription.java new file mode 100644 index 000000000..7f45d2ae1 --- /dev/null +++ b/src/main/java/com/openisle/model/UserSubscription.java @@ -0,0 +1,27 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** Following relationship between users. */ +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "user_subscriptions", + uniqueConstraints = @UniqueConstraint(columnNames = {"subscriber_id", "target_id"})) +public class UserSubscription { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "subscriber_id") + private User subscriber; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "target_id") + private User target; +} diff --git a/src/main/java/com/openisle/repository/CommentSubscriptionRepository.java b/src/main/java/com/openisle/repository/CommentSubscriptionRepository.java new file mode 100644 index 000000000..8d2308cc1 --- /dev/null +++ b/src/main/java/com/openisle/repository/CommentSubscriptionRepository.java @@ -0,0 +1,15 @@ +package com.openisle.repository; + +import com.openisle.model.Comment; +import com.openisle.model.CommentSubscription; +import com.openisle.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CommentSubscriptionRepository extends JpaRepository { + List findByComment(Comment comment); + List findByUser(User user); + Optional findByUserAndComment(User user, Comment comment); +} diff --git a/src/main/java/com/openisle/repository/PostSubscriptionRepository.java b/src/main/java/com/openisle/repository/PostSubscriptionRepository.java new file mode 100644 index 000000000..8bb66a3a7 --- /dev/null +++ b/src/main/java/com/openisle/repository/PostSubscriptionRepository.java @@ -0,0 +1,15 @@ +package com.openisle.repository; + +import com.openisle.model.Post; +import com.openisle.model.PostSubscription; +import com.openisle.model.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PostSubscriptionRepository extends JpaRepository { + List findByPost(Post post); + List findByUser(User user); + Optional findByUserAndPost(User user, Post post); +} diff --git a/src/main/java/com/openisle/repository/UserSubscriptionRepository.java b/src/main/java/com/openisle/repository/UserSubscriptionRepository.java new file mode 100644 index 000000000..d186fdcd6 --- /dev/null +++ b/src/main/java/com/openisle/repository/UserSubscriptionRepository.java @@ -0,0 +1,16 @@ +package com.openisle.repository; + +import com.openisle.model.User; +import com.openisle.model.UserSubscription; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface UserSubscriptionRepository extends JpaRepository { + List findBySubscriber(User subscriber); + List findByTarget(User target); + Optional findBySubscriberAndTarget(User subscriber, User target); + long countByTarget(User target); + long countBySubscriber(User subscriber); +} diff --git a/src/main/java/com/openisle/service/CommentService.java b/src/main/java/com/openisle/service/CommentService.java index 10412b01f..af5b88674 100644 --- a/src/main/java/com/openisle/service/CommentService.java +++ b/src/main/java/com/openisle/service/CommentService.java @@ -8,6 +8,7 @@ import com.openisle.repository.CommentRepository; import com.openisle.repository.PostRepository; import com.openisle.repository.UserRepository; import com.openisle.service.NotificationService; +import com.openisle.service.SubscriptionService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -22,6 +23,7 @@ public class CommentService { private final PostRepository postRepository; private final UserRepository userRepository; private final NotificationService notificationService; + private final SubscriptionService subscriptionService; public Comment addComment(String username, Long postId, String content) { User author = userRepository.findByUsername(username) @@ -36,6 +38,16 @@ public class CommentService { if (!author.getId().equals(post.getAuthor().getId())) { notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null); } + for (User u : subscriptionService.getPostSubscribers(postId)) { + if (!u.getId().equals(author.getId())) { + notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null); + } + } + for (User u : subscriptionService.getSubscribers(author.getUsername())) { + if (!u.getId().equals(author.getId())) { + notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null); + } + } return comment; } @@ -53,6 +65,21 @@ public class CommentService { if (!author.getId().equals(parent.getAuthor().getId())) { notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), comment, null); } + for (User u : subscriptionService.getCommentSubscribers(parentId)) { + if (!u.getId().equals(author.getId())) { + notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment, null); + } + } + for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) { + if (!u.getId().equals(author.getId())) { + notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment, null); + } + } + for (User u : subscriptionService.getSubscribers(author.getUsername())) { + if (!u.getId().equals(author.getId())) { + notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null); + } + } return comment; } diff --git a/src/main/java/com/openisle/service/PostService.java b/src/main/java/com/openisle/service/PostService.java index 4edd887ec..3b6e82f39 100644 --- a/src/main/java/com/openisle/service/PostService.java +++ b/src/main/java/com/openisle/service/PostService.java @@ -10,6 +10,7 @@ import com.openisle.repository.PostRepository; import com.openisle.repository.UserRepository; import com.openisle.repository.CategoryRepository; import com.openisle.repository.TagRepository; +import com.openisle.service.SubscriptionService; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -25,6 +26,7 @@ public class PostService { private final TagRepository tagRepository; private final PublishMode publishMode; private final NotificationService notificationService; + private final SubscriptionService subscriptionService; @org.springframework.beans.factory.annotation.Autowired public PostService(PostRepository postRepository, @@ -32,12 +34,14 @@ public class PostService { CategoryRepository categoryRepository, TagRepository tagRepository, NotificationService notificationService, + SubscriptionService subscriptionService, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { this.postRepository = postRepository; this.userRepository = userRepository; this.categoryRepository = categoryRepository; this.tagRepository = tagRepository; this.notificationService = notificationService; + this.subscriptionService = subscriptionService; this.publishMode = publishMode; } @@ -64,7 +68,14 @@ public class PostService { post.setCategory(category); post.setTags(new java.util.HashSet<>(tags)); post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); - return postRepository.save(post); + post = postRepository.save(post); + // notify followers of author + for (User u : subscriptionService.getSubscribers(author.getUsername())) { + if (!u.getId().equals(author.getId())) { + notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, null, null); + } + } + return post; } public Post viewPost(Long id, String viewer) { diff --git a/src/main/java/com/openisle/service/SubscriptionService.java b/src/main/java/com/openisle/service/SubscriptionService.java new file mode 100644 index 000000000..303608b96 --- /dev/null +++ b/src/main/java/com/openisle/service/SubscriptionService.java @@ -0,0 +1,102 @@ +package com.openisle.service; + +import com.openisle.model.*; +import com.openisle.repository.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class SubscriptionService { + private final PostSubscriptionRepository postSubRepo; + private final CommentSubscriptionRepository commentSubRepo; + private final UserSubscriptionRepository userSubRepo; + private final UserRepository userRepo; + private final PostRepository postRepo; + private final CommentRepository commentRepo; + + public void subscribePost(String username, Long postId) { + User user = userRepo.findByUsername(username).orElseThrow(); + Post post = postRepo.findById(postId).orElseThrow(); + postSubRepo.findByUserAndPost(user, post).orElseGet(() -> { + PostSubscription ps = new PostSubscription(); + ps.setUser(user); + ps.setPost(post); + return postSubRepo.save(ps); + }); + } + + public void unsubscribePost(String username, Long postId) { + User user = userRepo.findByUsername(username).orElseThrow(); + Post post = postRepo.findById(postId).orElseThrow(); + postSubRepo.findByUserAndPost(user, post).ifPresent(postSubRepo::delete); + } + + public void subscribeComment(String username, Long commentId) { + User user = userRepo.findByUsername(username).orElseThrow(); + Comment comment = commentRepo.findById(commentId).orElseThrow(); + commentSubRepo.findByUserAndComment(user, comment).orElseGet(() -> { + CommentSubscription cs = new CommentSubscription(); + cs.setUser(user); + cs.setComment(comment); + return commentSubRepo.save(cs); + }); + } + + public void unsubscribeComment(String username, Long commentId) { + User user = userRepo.findByUsername(username).orElseThrow(); + Comment comment = commentRepo.findById(commentId).orElseThrow(); + commentSubRepo.findByUserAndComment(user, comment).ifPresent(commentSubRepo::delete); + } + + public void subscribeUser(String username, String targetName) { + if (username.equals(targetName)) return; + User subscriber = userRepo.findByUsername(username).orElseThrow(); + User target = userRepo.findByUsername(targetName).orElseThrow(); + userSubRepo.findBySubscriberAndTarget(subscriber, target).orElseGet(() -> { + UserSubscription us = new UserSubscription(); + us.setSubscriber(subscriber); + us.setTarget(target); + return userSubRepo.save(us); + }); + } + + public void unsubscribeUser(String username, String targetName) { + User subscriber = userRepo.findByUsername(username).orElseThrow(); + User target = userRepo.findByUsername(targetName).orElseThrow(); + userSubRepo.findBySubscriberAndTarget(subscriber, target).ifPresent(userSubRepo::delete); + } + + public List getSubscribedUsers(String username) { + User user = userRepo.findByUsername(username).orElseThrow(); + return userSubRepo.findBySubscriber(user).stream().map(UserSubscription::getTarget).toList(); + } + + public List getSubscribers(String username) { + User user = userRepo.findByUsername(username).orElseThrow(); + return userSubRepo.findByTarget(user).stream().map(UserSubscription::getSubscriber).toList(); + } + + public List getPostSubscribers(Long postId) { + Post post = postRepo.findById(postId).orElseThrow(); + return postSubRepo.findByPost(post).stream().map(PostSubscription::getUser).toList(); + } + + public List getCommentSubscribers(Long commentId) { + Comment c = commentRepo.findById(commentId).orElseThrow(); + return commentSubRepo.findByComment(c).stream().map(CommentSubscription::getUser).toList(); + } + + + public long countSubscribers(String username) { + User user = userRepo.findByUsername(username).orElseThrow(); + return userSubRepo.countByTarget(user); + } + + public long countSubscribed(String username) { + User user = userRepo.findByUsername(username).orElseThrow(); + return userSubRepo.countBySubscriber(user); + } +}